@widgetic/canvas 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/dist/canvas/Canvas.svelte +10678 -0
- package/dist/canvas/Canvas.svelte.d.ts +147 -0
- package/dist/canvas/CanvasToolbar.svelte +1422 -0
- package/dist/canvas/CanvasToolbar.svelte.d.ts +54 -0
- package/dist/canvas/ContextMenu.svelte +279 -0
- package/dist/canvas/ContextMenu.svelte.d.ts +36 -0
- package/dist/canvas/PanZoomPanel.svelte +315 -0
- package/dist/canvas/PanZoomPanel.svelte.d.ts +32 -0
- package/dist/canvas/canvasLogger.d.ts +5 -0
- package/dist/canvas/canvasLogger.js +19 -0
- package/dist/canvas/index.d.ts +13 -0
- package/dist/canvas/index.js +12 -0
- package/dist/canvas/props-panel/PropsPanel.svelte +1902 -0
- package/dist/canvas/props-panel/PropsPanel.svelte.d.ts +73 -0
- package/dist/canvas/props-panel/TextPropsICSection.svelte +238 -0
- package/dist/canvas/props-panel/TextPropsICSection.svelte.d.ts +36 -0
- package/dist/canvas/shapes/FrameShape.d.ts +97 -0
- package/dist/canvas/shapes/FrameShape.js +951 -0
- package/dist/canvas/shapes/ImageShape.d.ts +38 -0
- package/dist/canvas/shapes/ImageShape.js +245 -0
- package/dist/canvas/shapes/ShapeLibrary.d.ts +64 -0
- package/dist/canvas/shapes/ShapeLibrary.js +526 -0
- package/dist/canvas/shapes/WidgetShape.d.ts +21 -0
- package/dist/canvas/shapes/WidgetShape.js +132 -0
- package/dist/canvas/types.d.ts +26 -0
- package/dist/canvas/types.js +5 -0
- package/dist/components/Tooltip.svelte +179 -0
- package/dist/components/Tooltip.svelte.d.ts +21 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.js +13 -0
- package/dist/components/input-controls/ColorIC.svelte +388 -0
- package/dist/components/input-controls/ColorIC.svelte.d.ts +22 -0
- package/dist/components/input-controls/FontSelectorIC.svelte +69 -0
- package/dist/components/input-controls/FontSelectorIC.svelte.d.ts +17 -0
- package/dist/components/input-controls/SliderUnitIC.svelte +217 -0
- package/dist/components/input-controls/SliderUnitIC.svelte.d.ts +24 -0
- package/dist/components/input-controls/TextAlignIC.svelte +84 -0
- package/dist/components/input-controls/TextAlignIC.svelte.d.ts +17 -0
- package/dist/components/input-controls/TextStyleIC.svelte +85 -0
- package/dist/components/input-controls/TextStyleIC.svelte.d.ts +21 -0
- package/dist/icons/arrow.svg +3 -0
- package/dist/icons/checkmark.svg +3 -0
- package/dist/icons/draw.svg +22 -0
- package/dist/icons/eraser.svg +23 -0
- package/dist/icons/hand.svg +6 -0
- package/dist/icons/select.svg +3 -0
- package/dist/icons/text.svg +5 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/package.json +101 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrameShape - A container shape extending Fabric.js Group
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Extends fabric.Group to act as a container for child objects
|
|
6
|
+
* - interactive: true — children can be selected/edited directly (tldraw-like)
|
|
7
|
+
* - Click on a child → selects that child for individual editing
|
|
8
|
+
* - Click on frame background → selects the frame itself
|
|
9
|
+
* - ClipPath for bounding/clipping content inside frame boundaries
|
|
10
|
+
* - Customizable label drawn above the frame (with text wrapping, rotation support)
|
|
11
|
+
* - Custom controls: corner resize only, no rotation handle
|
|
12
|
+
* - Minimum size enforcement
|
|
13
|
+
* - Serialization support with custom properties
|
|
14
|
+
* - addObjectToFrame / removeObjectFromFrame with automatic coordinate conversion
|
|
15
|
+
* (Fabric's native _enterGroup/_exitGroup handles absolute ↔ group-relative transforms)
|
|
16
|
+
*
|
|
17
|
+
* Containment detection (drag in/out) is handled by Canvas.svelte's
|
|
18
|
+
* object:modified handler since it requires canvas-level access.
|
|
19
|
+
*/
|
|
20
|
+
import { Group, Rect, Control, controlsUtils, util, LayoutManager, FixedLayout, classRegistry } from 'fabric';
|
|
21
|
+
import { canvasLog, canvasWarn } from '../canvasLogger';
|
|
22
|
+
// ─────────────────────────────────────────────
|
|
23
|
+
// Constants
|
|
24
|
+
// ─────────────────────────────────────────────
|
|
25
|
+
export const MIN_FRAME_WIDTH = 50;
|
|
26
|
+
export const MIN_FRAME_HEIGHT = 30;
|
|
27
|
+
const DEFAULT_LABEL_FONT_SIZE = 16;
|
|
28
|
+
const DEFAULT_LABEL_FONT_FAMILY = 'Inter, system-ui, sans-serif';
|
|
29
|
+
const DEFAULT_LABEL_COLOR = '#1f2937';
|
|
30
|
+
const DEFAULT_LABEL_BG_COLOR = '#ffffff';
|
|
31
|
+
const DEFAULT_FILL = '#ffffff';
|
|
32
|
+
export const DEFAULT_STROKE = '#2563eb';
|
|
33
|
+
// Slightly darker blue shown on border/label hover (replaces separate overlay border)
|
|
34
|
+
export const FRAME_HOVER_STROKE = '#1d4ed8';
|
|
35
|
+
const DEFAULT_STROKE_WIDTH = 3;
|
|
36
|
+
// ─────────────────────────────────────────────
|
|
37
|
+
// Helper: Wrap text into lines that fit a max width
|
|
38
|
+
// Handles both word-level and character-level wrapping for long words
|
|
39
|
+
// ─────────────────────────────────────────────
|
|
40
|
+
function wrapText(ctx, text, maxWidth) {
|
|
41
|
+
const words = text.split(' ');
|
|
42
|
+
const lines = [];
|
|
43
|
+
let currentLine = '';
|
|
44
|
+
// Break a single long word into character chunks
|
|
45
|
+
function breakWord(word) {
|
|
46
|
+
if (ctx.measureText(word).width <= maxWidth)
|
|
47
|
+
return [word];
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let chunk = '';
|
|
50
|
+
for (const char of word) {
|
|
51
|
+
const test = chunk + char;
|
|
52
|
+
if (ctx.measureText(test).width > maxWidth && chunk) {
|
|
53
|
+
chunks.push(chunk);
|
|
54
|
+
chunk = char;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
chunk = test;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (chunk)
|
|
61
|
+
chunks.push(chunk);
|
|
62
|
+
return chunks;
|
|
63
|
+
}
|
|
64
|
+
for (const word of words) {
|
|
65
|
+
if (ctx.measureText(word).width > maxWidth) {
|
|
66
|
+
// Word itself is too long - break by characters
|
|
67
|
+
if (currentLine) {
|
|
68
|
+
lines.push(currentLine);
|
|
69
|
+
currentLine = '';
|
|
70
|
+
}
|
|
71
|
+
const broken = breakWord(word);
|
|
72
|
+
for (let i = 0; i < broken.length - 1; i++) {
|
|
73
|
+
lines.push(broken[i]);
|
|
74
|
+
}
|
|
75
|
+
currentLine = broken[broken.length - 1] || '';
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
79
|
+
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
|
80
|
+
lines.push(currentLine);
|
|
81
|
+
currentLine = word;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
currentLine = testLine;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (currentLine)
|
|
89
|
+
lines.push(currentLine);
|
|
90
|
+
return lines.length > 0 ? lines : [text];
|
|
91
|
+
}
|
|
92
|
+
// ─────────────────────────────────────────────
|
|
93
|
+
// Helper: Draw the label above the frame
|
|
94
|
+
// ─────────────────────────────────────────────
|
|
95
|
+
function renderFrameLabel(ctx, frame) {
|
|
96
|
+
const text = frame._labelText || 'Frame';
|
|
97
|
+
const fontSize = frame._labelFontSize || DEFAULT_LABEL_FONT_SIZE;
|
|
98
|
+
const fontFamily = frame._labelFontFamily || DEFAULT_LABEL_FONT_FAMILY;
|
|
99
|
+
const textColor = frame._labelColor || DEFAULT_LABEL_COLOR;
|
|
100
|
+
const bgColor = frame._labelBackgroundColor || DEFAULT_LABEL_BG_COLOR;
|
|
101
|
+
const textAlign = frame._labelTextAlign || 'left';
|
|
102
|
+
const fontWeight = frame._labelFontWeight || 'normal';
|
|
103
|
+
const fontStyle = frame._labelFontStyle || 'normal';
|
|
104
|
+
const underline = frame._labelUnderline || false;
|
|
105
|
+
// Use FULL visual dimensions (including parent group scales).
|
|
106
|
+
// The calling code positions the context at the frame's center via this.transform()
|
|
107
|
+
// then counter-scales to 1:1 via getObjectScaling(). In this 1:1 context, the
|
|
108
|
+
// frame's visual extent from center is (width/2 * totalScale), not (width/2 * localScale).
|
|
109
|
+
const scaling = frame.getObjectScaling ? frame.getObjectScaling() : { x: frame.scaleX || 1, y: frame.scaleY || 1 };
|
|
110
|
+
const frameW = (frame.width || 100) * scaling.x;
|
|
111
|
+
const frameH = (frame.height || 100) * scaling.y;
|
|
112
|
+
ctx.save();
|
|
113
|
+
// Set font to measure text
|
|
114
|
+
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
115
|
+
const padding = 6;
|
|
116
|
+
const gap = 4;
|
|
117
|
+
const lineHeight = fontSize * 1.2;
|
|
118
|
+
// Label width matches the frame's visual width (clipPath boundary).
|
|
119
|
+
// The background rect's stroke is centered on the edge; the outward half is clipped,
|
|
120
|
+
// so the visible frame width is exactly frameW.
|
|
121
|
+
const minLabelWidth = 50;
|
|
122
|
+
const labelBgWidth = Math.max(frameW, minLabelWidth);
|
|
123
|
+
const maxTextWidth = labelBgWidth - padding * 2;
|
|
124
|
+
// Wrap text
|
|
125
|
+
const lines = wrapText(ctx, text, maxTextWidth);
|
|
126
|
+
const labelBgHeight = lines.length * lineHeight + padding * 2;
|
|
127
|
+
// Position: label sits above the frame
|
|
128
|
+
// In Group's local coordinate system, center is (0, 0)
|
|
129
|
+
// So top-left of frame content area is at (-frameW/2, -frameH/2)
|
|
130
|
+
const labelBgX = -frameW / 2;
|
|
131
|
+
const labelBgY = -frameH / 2 - labelBgHeight - gap;
|
|
132
|
+
// Draw label background
|
|
133
|
+
ctx.fillStyle = bgColor;
|
|
134
|
+
ctx.fillRect(labelBgX, labelBgY, labelBgWidth, labelBgHeight);
|
|
135
|
+
// Clip to label bounds (overflow hidden)
|
|
136
|
+
ctx.beginPath();
|
|
137
|
+
ctx.rect(labelBgX, labelBgY, labelBgWidth, labelBgHeight);
|
|
138
|
+
ctx.clip();
|
|
139
|
+
// Draw text lines
|
|
140
|
+
ctx.fillStyle = textColor;
|
|
141
|
+
ctx.textBaseline = 'top';
|
|
142
|
+
lines.forEach((line, index) => {
|
|
143
|
+
const lineWidth = ctx.measureText(line).width;
|
|
144
|
+
let textX;
|
|
145
|
+
switch (textAlign) {
|
|
146
|
+
case 'center':
|
|
147
|
+
textX = labelBgX + (labelBgWidth - lineWidth) / 2;
|
|
148
|
+
break;
|
|
149
|
+
case 'right':
|
|
150
|
+
textX = labelBgX + labelBgWidth - lineWidth - padding;
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
textX = labelBgX + padding;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
const textY = labelBgY + padding + index * lineHeight;
|
|
157
|
+
ctx.fillText(line, textX, textY);
|
|
158
|
+
// Draw underline if enabled
|
|
159
|
+
if (underline) {
|
|
160
|
+
ctx.beginPath();
|
|
161
|
+
ctx.strokeStyle = textColor;
|
|
162
|
+
ctx.lineWidth = 1;
|
|
163
|
+
ctx.moveTo(textX, textY + fontSize);
|
|
164
|
+
ctx.lineTo(textX + lineWidth, textY + fontSize);
|
|
165
|
+
ctx.stroke();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
ctx.restore();
|
|
169
|
+
}
|
|
170
|
+
// ─────────────────────────────────────────────
|
|
171
|
+
// FrameShape — registered Fabric class
|
|
172
|
+
// Extends Group with a unique type so Fabric's classRegistry
|
|
173
|
+
// dispatches loadFromJSON to FrameShape.fromObject, which calls
|
|
174
|
+
// reapplyFrameRender automatically. New saves use type: 'FrameShape'.
|
|
175
|
+
// Old saves (type: 'Group' with _isFrame: true) are handled by the
|
|
176
|
+
// object:added fallback in Canvas.svelte for backward compatibility.
|
|
177
|
+
// ─────────────────────────────────────────────
|
|
178
|
+
export class FrameShape extends Group {
|
|
179
|
+
static type = 'FrameShape';
|
|
180
|
+
/**
|
|
181
|
+
* Serialise the frame with type = 'FrameShape' plus all custom label/style
|
|
182
|
+
* props so they survive a canvas.toJSON() → loadFromJSON() round-trip.
|
|
183
|
+
* FRAME_CUSTOM_PROPS and _objectId are always included regardless of what
|
|
184
|
+
* the canvas passes as propertiesToInclude.
|
|
185
|
+
*/
|
|
186
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
187
|
+
toObject(propertiesToInclude) {
|
|
188
|
+
return {
|
|
189
|
+
// Merge FRAME_CUSTOM_PROPS + _objectId with whatever the canvas requests
|
|
190
|
+
...super.toObject([
|
|
191
|
+
...FRAME_CUSTOM_PROPS,
|
|
192
|
+
'_objectId',
|
|
193
|
+
...(propertiesToInclude || []),
|
|
194
|
+
]),
|
|
195
|
+
type: FrameShape.type,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Deserialise a saved FrameShape.
|
|
200
|
+
* Delegates to Group.fromObject (handles enlivenObjects, FixedLayout setup,
|
|
201
|
+
* and subscribeTargets) then upgrades the plain Group to a proper FrameShape
|
|
202
|
+
* instance via Object.setPrototypeOf before re-applying the custom render.
|
|
203
|
+
*
|
|
204
|
+
* WHY setPrototypeOf:
|
|
205
|
+
* Group.fromObject always returns `new Group(...)` whose prototype is Group.prototype.
|
|
206
|
+
* After setPrototypeOf → FrameShape.prototype, the instance:
|
|
207
|
+
* • reports type === 'FrameShape' (via FrameShape.prototype.type)
|
|
208
|
+
* • calls FrameShape.prototype.toObject() on save → emits type:'FrameShape' + custom props
|
|
209
|
+
* • classRegistry dispatches FrameShape.fromObject on next reload
|
|
210
|
+
*/
|
|
211
|
+
static fromObject(object, options) {
|
|
212
|
+
// Treat as a plain Group first so Group.fromObject handles all the
|
|
213
|
+
// heavy lifting (enlivenObjects, LayoutManager, coords, etc.)
|
|
214
|
+
const groupJson = { ...object, type: 'Group' };
|
|
215
|
+
canvasLog('FrameShape.fromObject: deserialising frame label=' + object._labelText
|
|
216
|
+
+ ' children=' + (object.objects || []).length);
|
|
217
|
+
return Group.fromObject(groupJson, options).then((group) => {
|
|
218
|
+
// Upgrade Group instance → FrameShape instance.
|
|
219
|
+
// This must happen BEFORE reapplyFrameRender so that
|
|
220
|
+
// reapplyFrameRender's prototype check (below) is a no-op.
|
|
221
|
+
Object.setPrototypeOf(group, FrameShape.prototype);
|
|
222
|
+
reapplyFrameRender(group);
|
|
223
|
+
canvasLog('FrameShape.fromObject: restored frame label=' + group._labelText
|
|
224
|
+
+ ' children=' + group.getObjects().length
|
|
225
|
+
+ ' type=' + group.type);
|
|
226
|
+
return group;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Register with Fabric so classRegistry.getClass('FrameShape') resolves in loadFromJSON.
|
|
231
|
+
// NOTE: Fabric's type getter reads this.constructor.type.toLowerCase().
|
|
232
|
+
// Object.setPrototypeOf(group, FrameShape.prototype) changes group.constructor to FrameShape,
|
|
233
|
+
// so group.type becomes 'frameshape' automatically — no prototype.type setter needed.
|
|
234
|
+
classRegistry.setClass(FrameShape);
|
|
235
|
+
// ─────────────────────────────────────────────
|
|
236
|
+
// Create a new FrameShape instance
|
|
237
|
+
// ─────────────────────────────────────────────
|
|
238
|
+
export function createFrameShape(options = {}) {
|
|
239
|
+
const { left = 0, top = 0, width = 200, height = 150, fill = DEFAULT_FILL, stroke = DEFAULT_STROKE, strokeWidth = DEFAULT_STROKE_WIDTH, labelText = 'Frame', labelFontSize = DEFAULT_LABEL_FONT_SIZE, labelFontFamily = DEFAULT_LABEL_FONT_FAMILY, labelColor = DEFAULT_LABEL_COLOR, labelBackgroundColor = DEFAULT_LABEL_BG_COLOR, labelTextAlign = 'left', labelFontWeight = 'normal', labelFontStyle = 'normal', labelUnderline = false, } = options;
|
|
240
|
+
// Enforce minimum dimensions
|
|
241
|
+
const finalWidth = Math.max(width, MIN_FRAME_WIDTH);
|
|
242
|
+
const finalHeight = Math.max(height, MIN_FRAME_HEIGHT);
|
|
243
|
+
// Create the background rectangle (visual frame body)
|
|
244
|
+
// In Group's local coords, origin is center, so rect goes from -w/2 to w/2
|
|
245
|
+
const backgroundRect = new Rect({
|
|
246
|
+
left: 0,
|
|
247
|
+
top: 0,
|
|
248
|
+
width: finalWidth,
|
|
249
|
+
height: finalHeight,
|
|
250
|
+
fill: fill,
|
|
251
|
+
stroke: stroke,
|
|
252
|
+
strokeWidth: strokeWidth,
|
|
253
|
+
strokeUniform: true,
|
|
254
|
+
originX: 'center',
|
|
255
|
+
originY: 'center',
|
|
256
|
+
// Background rect is NOT interactive - clicks on it pass through to the frame group
|
|
257
|
+
// This allows clicking on the frame's empty area to select the frame itself
|
|
258
|
+
selectable: false,
|
|
259
|
+
evented: false,
|
|
260
|
+
});
|
|
261
|
+
// Tag the background rect so we can identify it
|
|
262
|
+
backgroundRect._isFrameBackground = true;
|
|
263
|
+
// Use FrameShape (registered Fabric class) so canvas.toJSON() emits
|
|
264
|
+
// type: 'FrameShape', allowing loadFromJSON to call FrameShape.fromObject
|
|
265
|
+
// which restores the custom render automatically on reload.
|
|
266
|
+
const frame = new FrameShape([backgroundRect], {
|
|
267
|
+
left: left,
|
|
268
|
+
top: top,
|
|
269
|
+
originX: 'left',
|
|
270
|
+
originY: 'top',
|
|
271
|
+
// Sub-target check allows detecting clicks on children inside the group
|
|
272
|
+
subTargetCheck: true,
|
|
273
|
+
// Interactive: children can be selected/edited directly without entering edit mode
|
|
274
|
+
// Click on a child → selects that child for individual editing
|
|
275
|
+
// Click on frame background → selects the frame itself
|
|
276
|
+
interactive: true,
|
|
277
|
+
selectable: true,
|
|
278
|
+
// No rotation for frames
|
|
279
|
+
lockRotation: true,
|
|
280
|
+
// Minimum scale limit
|
|
281
|
+
minScaleLimit: 0.1,
|
|
282
|
+
// Disable object caching so children render directly on canvas.
|
|
283
|
+
// This ensures property changes on children are immediately visible.
|
|
284
|
+
objectCaching: false,
|
|
285
|
+
// Show move cursor when hovering over frame body
|
|
286
|
+
hoverCursor: 'move',
|
|
287
|
+
moveCursor: 'move',
|
|
288
|
+
// FixedLayout: frame keeps its explicit size, children don't expand selection border
|
|
289
|
+
layoutManager: new LayoutManager(new FixedLayout()),
|
|
290
|
+
});
|
|
291
|
+
// ─── Custom properties ───
|
|
292
|
+
const frameAny = frame;
|
|
293
|
+
frameAny._customType = 'frame';
|
|
294
|
+
frameAny._isFrame = true;
|
|
295
|
+
// Label properties (editable via PropsPanel)
|
|
296
|
+
frameAny._labelText = labelText;
|
|
297
|
+
frameAny._labelFontSize = labelFontSize;
|
|
298
|
+
frameAny._labelFontFamily = labelFontFamily;
|
|
299
|
+
frameAny._labelColor = labelColor;
|
|
300
|
+
frameAny._labelBackgroundColor = labelBackgroundColor;
|
|
301
|
+
frameAny._labelTextAlign = labelTextAlign;
|
|
302
|
+
frameAny._labelFontWeight = labelFontWeight;
|
|
303
|
+
frameAny._labelFontStyle = labelFontStyle;
|
|
304
|
+
frameAny._labelUnderline = labelUnderline;
|
|
305
|
+
// Frame visual properties (stored separately from Fabric's defaults)
|
|
306
|
+
frameAny._frameFill = fill;
|
|
307
|
+
frameAny._frameStroke = stroke;
|
|
308
|
+
frameAny._frameStrokeWidth = strokeWidth;
|
|
309
|
+
// ─── ClipPath: clips children to frame boundaries ───
|
|
310
|
+
setupClipPath(frame);
|
|
311
|
+
// ─── Custom controls: corner resize only, no rotation ───
|
|
312
|
+
setupFrameControls(frame);
|
|
313
|
+
// ─── Custom render: draw label above the frame ───
|
|
314
|
+
const originalRender = frame.render.bind(frame);
|
|
315
|
+
frame.render = function (ctx) {
|
|
316
|
+
// Render the group normally (background rect + any children)
|
|
317
|
+
originalRender(ctx);
|
|
318
|
+
// Draw the label on top.
|
|
319
|
+
// After originalRender (Group.render → FabricObject.render → save/transform/restore),
|
|
320
|
+
// the ctx is back to the parent's coordinate space. We use Fabric's own transform()
|
|
321
|
+
// method to apply the frame's transform correctly (handles _transformDone flag and
|
|
322
|
+
// group chain automatically). This works for both top-level and nested frames.
|
|
323
|
+
ctx.save();
|
|
324
|
+
this.transform(ctx);
|
|
325
|
+
// Counter-scale so label text stays crisp at 1:1 size.
|
|
326
|
+
// getObjectScaling() returns the total visual scale including parent groups.
|
|
327
|
+
const scaling = this.getObjectScaling();
|
|
328
|
+
ctx.scale(1 / scaling.x, 1 / scaling.y);
|
|
329
|
+
// Now context is at frame center, at 1:1 scale.
|
|
330
|
+
// renderFrameLabel uses local dimensions (frame.width * frame.scaleX).
|
|
331
|
+
renderFrameLabel(ctx, this);
|
|
332
|
+
ctx.restore();
|
|
333
|
+
};
|
|
334
|
+
// ─── Border-only hit test: frame is selectable only at its border/stroke ───
|
|
335
|
+
// or label — clicking the empty interior is a click-through so the user
|
|
336
|
+
// can start a rubber-band selection of frame children from there.
|
|
337
|
+
applyFrameHitTest(frame);
|
|
338
|
+
// Mark the frame as dirty so it renders immediately
|
|
339
|
+
frame.dirty = true;
|
|
340
|
+
canvasLog('FrameShape created', {
|
|
341
|
+
width: finalWidth,
|
|
342
|
+
height: finalHeight,
|
|
343
|
+
left,
|
|
344
|
+
top,
|
|
345
|
+
labelText
|
|
346
|
+
});
|
|
347
|
+
return frame;
|
|
348
|
+
}
|
|
349
|
+
// ─────────────────────────────────────────────
|
|
350
|
+
// Setup ClipPath on a frame Group
|
|
351
|
+
// Clips children to frame boundaries so content doesn't overflow
|
|
352
|
+
// ─────────────────────────────────────────────
|
|
353
|
+
export function setupClipPath(frame) {
|
|
354
|
+
const clipRect = new Rect({
|
|
355
|
+
left: 0,
|
|
356
|
+
top: 0,
|
|
357
|
+
width: frame.width,
|
|
358
|
+
height: frame.height,
|
|
359
|
+
originX: 'center',
|
|
360
|
+
originY: 'center',
|
|
361
|
+
absolutePositioned: false, // Relative to the group
|
|
362
|
+
});
|
|
363
|
+
frame.clipPath = clipRect;
|
|
364
|
+
}
|
|
365
|
+
// ─────────────────────────────────────────────
|
|
366
|
+
// Update ClipPath when frame is resized
|
|
367
|
+
// Call this after frame dimensions change
|
|
368
|
+
// ─────────────────────────────────────────────
|
|
369
|
+
export function updateClipPath(frame) {
|
|
370
|
+
// Preserve corner radius from the frame's custom property
|
|
371
|
+
const cornerRadius = frame._frameCornerRadius || 0;
|
|
372
|
+
if (frame.clipPath) {
|
|
373
|
+
frame.clipPath.set({
|
|
374
|
+
width: frame.width,
|
|
375
|
+
height: frame.height,
|
|
376
|
+
rx: cornerRadius,
|
|
377
|
+
ry: cornerRadius,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
setupClipPath(frame);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// ─────────────────────────────────────────────
|
|
385
|
+
// Update corner radius on frame's background rect + clip path
|
|
386
|
+
// Called from Canvas.svelte when the user changes the roundness slider
|
|
387
|
+
// ─────────────────────────────────────────────
|
|
388
|
+
export function updateFrameCornerRadius(frame, radiusPixels) {
|
|
389
|
+
const bg = getFrameBackground(frame);
|
|
390
|
+
if (bg) {
|
|
391
|
+
bg.set({ rx: radiusPixels, ry: radiusPixels });
|
|
392
|
+
bg.dirty = true;
|
|
393
|
+
}
|
|
394
|
+
// Also update the clip path so children are clipped to rounded bounds
|
|
395
|
+
if (frame.clipPath) {
|
|
396
|
+
frame.clipPath.set({ rx: radiusPixels, ry: radiusPixels });
|
|
397
|
+
}
|
|
398
|
+
// Store the pixel value on the frame for serialization/restoration
|
|
399
|
+
frame._frameCornerRadius = radiusPixels;
|
|
400
|
+
frame.dirty = true;
|
|
401
|
+
}
|
|
402
|
+
// ─────────────────────────────────────────────
|
|
403
|
+
// Setup custom controls for the frame
|
|
404
|
+
// Corner resize only (no rotation handle)
|
|
405
|
+
// ─────────────────────────────────────────────
|
|
406
|
+
function setupFrameControls(frame) {
|
|
407
|
+
// Remove the default rotation control (mtr)
|
|
408
|
+
// Keep corner controls for resize and middle controls for width/height
|
|
409
|
+
if (frame.controls && frame.controls.mtr) {
|
|
410
|
+
delete frame.controls.mtr;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ─────────────────────────────────────────────
|
|
414
|
+
// Add an object into the frame
|
|
415
|
+
//
|
|
416
|
+
// Uses frame.add() for correct coordinate conversion and internal state.
|
|
417
|
+
// The key challenge: Fabric's _enterGroup (line ~11795 in index.node.mjs)
|
|
418
|
+
// calls object.setCoords() BEFORE setting object.group and object.canvas,
|
|
419
|
+
// producing wrong aCoords (no group context) and no oCoords (no canvas).
|
|
420
|
+
//
|
|
421
|
+
// Solution: Temporarily replace object.setCoords with a no-op during
|
|
422
|
+
// frame.add(), then restore it and call setCoords() ourselves AFTER
|
|
423
|
+
// _enterGroup has set object.group and object.canvas.
|
|
424
|
+
//
|
|
425
|
+
// IMPORTANT: We do NOT touch frame._shouldSetNestedCoords — that would
|
|
426
|
+
// break the prototype chain and prevent Group.setCoords() from propagating
|
|
427
|
+
// to children when the frame is moved.
|
|
428
|
+
// ─────────────────────────────────────────────
|
|
429
|
+
export function addObjectToFrame(frame, object, canvas) {
|
|
430
|
+
try {
|
|
431
|
+
// Skip if object is already in this frame (by group ref)
|
|
432
|
+
if (object.group === frame) {
|
|
433
|
+
canvasLog('addObjectToFrame: Object already in this frame (group ref)');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Skip if object is already in frame._objects (safety for edge cases
|
|
437
|
+
// where group ref wasn't set due to a previous partial add)
|
|
438
|
+
if (frame._objects && frame._objects.includes(object)) {
|
|
439
|
+
canvasWarn('addObjectToFrame: Object already in frame._objects array, fixing state');
|
|
440
|
+
// Fix: manually set group and canvas if they weren't set
|
|
441
|
+
if (object.group !== frame) {
|
|
442
|
+
object._set('group', frame);
|
|
443
|
+
}
|
|
444
|
+
if (!object.canvas && frame.canvas) {
|
|
445
|
+
object._set('canvas', frame.canvas);
|
|
446
|
+
}
|
|
447
|
+
object.setCoords();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// Skip if object is the frame's background rect
|
|
451
|
+
if (object._isFrameBackground) {
|
|
452
|
+
canvasLog('addObjectToFrame: Cannot add frame background to itself');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// Prevent self-nesting and circular nesting
|
|
456
|
+
if (object === frame) {
|
|
457
|
+
canvasLog('addObjectToFrame: Cannot add frame to itself');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (object._isFrame && isFrameShape(object)) {
|
|
461
|
+
let parent = frame.group;
|
|
462
|
+
while (parent) {
|
|
463
|
+
if (parent === object) {
|
|
464
|
+
canvasLog('addObjectToFrame: Circular nesting prevented');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
parent = parent.group;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// ── 1. Clear ALL stale matrix caches ──
|
|
471
|
+
object.matrixCache = undefined;
|
|
472
|
+
object.ownMatrixCache = undefined;
|
|
473
|
+
frame.matrixCache = undefined;
|
|
474
|
+
frame.ownMatrixCache = undefined;
|
|
475
|
+
// ── 2. Save frame dimensions (frame.add can trigger layout side-effects) ──
|
|
476
|
+
const savedFrameWidth = frame.width;
|
|
477
|
+
const savedFrameHeight = frame.height;
|
|
478
|
+
const savedFrameLeft = frame.left;
|
|
479
|
+
const savedFrameTop = frame.top;
|
|
480
|
+
// ── 3. Remove from current location ──
|
|
481
|
+
if (object.group) {
|
|
482
|
+
object.group.remove(object);
|
|
483
|
+
}
|
|
484
|
+
else if (canvas) {
|
|
485
|
+
canvas.remove(object);
|
|
486
|
+
}
|
|
487
|
+
// Clear caches after removal
|
|
488
|
+
object.matrixCache = undefined;
|
|
489
|
+
object.ownMatrixCache = undefined;
|
|
490
|
+
// ── 4. Block premature setCoords inside _enterGroup ──
|
|
491
|
+
// Fabric's _enterGroup calls: this._shouldSetNestedCoords() && object.setCoords()
|
|
492
|
+
// At that point, object.group and object.canvas are NOT yet set, producing
|
|
493
|
+
// wrong coords. We patch object.setCoords to a no-op during frame.add().
|
|
494
|
+
// This is safer than patching frame._shouldSetNestedCoords which breaks
|
|
495
|
+
// the frame's prototype chain (Group.setCoords won't propagate to children).
|
|
496
|
+
object.setCoords = function () { }; // no-op during frame.add
|
|
497
|
+
// ── 5. Use frame.add() — handles coordinate conversion, events, layout ──
|
|
498
|
+
frame.add(object);
|
|
499
|
+
// ── 6. Restore object.setCoords from prototype ──
|
|
500
|
+
// Remove own property so it falls back to InteractiveObject.prototype.setCoords
|
|
501
|
+
delete object.setCoords;
|
|
502
|
+
// ── 7. Safety: ensure object.canvas is set ──
|
|
503
|
+
// _enterGroup should have set it, but verify as a safety net
|
|
504
|
+
if (!object.canvas && frame.canvas) {
|
|
505
|
+
canvasWarn('addObjectToFrame: canvas not set by _enterGroup, fixing manually');
|
|
506
|
+
object._set('canvas', frame.canvas);
|
|
507
|
+
}
|
|
508
|
+
// ── 8. Safety: ensure object.group is set ──
|
|
509
|
+
if (object.group !== frame) {
|
|
510
|
+
canvasWarn('addObjectToFrame: group not set by _enterGroup, fixing manually');
|
|
511
|
+
object._set('group', frame);
|
|
512
|
+
}
|
|
513
|
+
// ── 9. Restore frame dimensions if layout changed them ──
|
|
514
|
+
if (frame.width !== savedFrameWidth || frame.height !== savedFrameHeight ||
|
|
515
|
+
frame.left !== savedFrameLeft || frame.top !== savedFrameTop) {
|
|
516
|
+
frame.set({
|
|
517
|
+
width: savedFrameWidth,
|
|
518
|
+
height: savedFrameHeight,
|
|
519
|
+
left: savedFrameLeft,
|
|
520
|
+
top: savedFrameTop,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// ── 10. Clear stale matrix caches after group membership change ──
|
|
524
|
+
object.matrixCache = undefined;
|
|
525
|
+
object.ownMatrixCache = undefined;
|
|
526
|
+
frame.matrixCache = undefined;
|
|
527
|
+
frame.ownMatrixCache = undefined;
|
|
528
|
+
// ── 11. Clear stale _activeObjects ──
|
|
529
|
+
if (frame._activeObjects && frame._activeObjects.length > 0) {
|
|
530
|
+
frame._activeObjects = [];
|
|
531
|
+
}
|
|
532
|
+
// ── 12. NOW call setCoords — with object.group and object.canvas properly set ──
|
|
533
|
+
// This produces correct aCoords (corners in absolute space via
|
|
534
|
+
// calcTransformMatrix = frameMatrix * ownMatrix) and correct oCoords
|
|
535
|
+
// (control positions in viewport space, requires this.canvas).
|
|
536
|
+
object.setCoords();
|
|
537
|
+
// frame.setCoords() propagates to ALL children via _shouldSetNestedCoords()
|
|
538
|
+
// which returns this.subTargetCheck (true for our frame).
|
|
539
|
+
frame.setCoords();
|
|
540
|
+
frame.dirty = true;
|
|
541
|
+
// ── 13. Update clip path ──
|
|
542
|
+
updateClipPath(frame);
|
|
543
|
+
// ── 14. Track parent frame ID on child for orphan-repair on reload ──
|
|
544
|
+
// If the child is somehow not inside the frame group after a save/load cycle,
|
|
545
|
+
// loadCanvas will use _parentFrameId to re-attach it to the correct frame.
|
|
546
|
+
object._parentFrameId = frame._objectId || null;
|
|
547
|
+
// Debug: verify all state is correct
|
|
548
|
+
const hasGroup = object.group === frame;
|
|
549
|
+
const hasCanvas = !!object.canvas;
|
|
550
|
+
const hasOCoords = !!object.oCoords;
|
|
551
|
+
const nestedCoordsWorks = frame._shouldSetNestedCoords();
|
|
552
|
+
const isInObjects = frame._objects.includes(object);
|
|
553
|
+
canvasLog('addObjectToFrame: Object added to frame', {
|
|
554
|
+
objectType: object.type || object._customType,
|
|
555
|
+
frameLabel: frame._labelText,
|
|
556
|
+
hasGroup,
|
|
557
|
+
hasCanvas,
|
|
558
|
+
hasOCoords,
|
|
559
|
+
nestedCoordsWorks,
|
|
560
|
+
isInObjects,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
console.error('addObjectToFrame: Failed to add object', error);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ─────────────────────────────────────────────
|
|
568
|
+
// Remove an object from the frame
|
|
569
|
+
// Manual coordinate conversion that converts group-relative → canvas-absolute
|
|
570
|
+
// ─────────────────────────────────────────────
|
|
571
|
+
export function removeObjectFromFrame(frame, object, canvas) {
|
|
572
|
+
try {
|
|
573
|
+
// Skip if object is not in this frame
|
|
574
|
+
if (object.group !== frame) {
|
|
575
|
+
canvasLog('removeObjectFromFrame: Object not in this frame');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Skip the background rect
|
|
579
|
+
if (object._isFrameBackground) {
|
|
580
|
+
canvasLog('removeObjectFromFrame: Cannot remove frame background');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const { applyTransformToObject } = util;
|
|
584
|
+
// Calculate absolute transform BEFORE any removal
|
|
585
|
+
// (calcTransformMatrix chains through the parent group)
|
|
586
|
+
const absMatrix = object.calcTransformMatrix();
|
|
587
|
+
// Use Fabric's own Group.remove() for thorough internal cleanup.
|
|
588
|
+
// This handles _objects removal, _watchObject, canvas refs, events, etc.
|
|
589
|
+
frame.remove(object);
|
|
590
|
+
// Apply absolute coordinates (Group.remove may reset them)
|
|
591
|
+
applyTransformToObject(object, absMatrix);
|
|
592
|
+
// Explicitly clear any lingering group/parent references
|
|
593
|
+
object._set('group', undefined);
|
|
594
|
+
object._set('parent', undefined);
|
|
595
|
+
// Clear parent frame tracking — object is no longer in any frame
|
|
596
|
+
object._parentFrameId = undefined;
|
|
597
|
+
// Clear stale matrix caches
|
|
598
|
+
object.matrixCache = undefined;
|
|
599
|
+
object.ownMatrixCache = undefined;
|
|
600
|
+
frame.setCoords();
|
|
601
|
+
frame.dirty = true;
|
|
602
|
+
// Add back to canvas as a free-standing object with absolute coordinates
|
|
603
|
+
if (canvas) {
|
|
604
|
+
canvas.add(object);
|
|
605
|
+
object.setCoords();
|
|
606
|
+
}
|
|
607
|
+
canvasLog('removeObjectFromFrame: Object removed from frame', {
|
|
608
|
+
objectType: object.type || object._customType,
|
|
609
|
+
frameLabel: frame._labelText,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
console.error('removeObjectFromFrame: Failed to remove object', error);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// ─────────────────────────────────────────────
|
|
617
|
+
// Check if a canvas-space point is inside a frame's label area
|
|
618
|
+
// Used for extending the clickable/draggable area to include the label
|
|
619
|
+
// ─────────────────────────────────────────────
|
|
620
|
+
export function isPointInFrameLabel(frame, point) {
|
|
621
|
+
try {
|
|
622
|
+
// Use full transform to get visual dimensions (including parent groups)
|
|
623
|
+
const m = frame.calcTransformMatrix();
|
|
624
|
+
const totalScaleX = Math.sqrt(m[0] * m[0] + m[1] * m[1]);
|
|
625
|
+
const totalScaleY = Math.sqrt(m[2] * m[2] + m[3] * m[3]);
|
|
626
|
+
const frameW = (frame.width || 100) * totalScaleX;
|
|
627
|
+
const frameH = (frame.height || 100) * totalScaleY;
|
|
628
|
+
const center = frame.getCenterPoint();
|
|
629
|
+
// Label sits above the top of the frame
|
|
630
|
+
const fontSize = frame._labelFontSize || DEFAULT_LABEL_FONT_SIZE;
|
|
631
|
+
const lineHeight = fontSize * 1.2;
|
|
632
|
+
const padding = 6;
|
|
633
|
+
const gap = 4;
|
|
634
|
+
const labelHeight = lineHeight + padding * 2;
|
|
635
|
+
const frameLeft = center.x - frameW / 2;
|
|
636
|
+
const frameTop = center.y - frameH / 2;
|
|
637
|
+
const labelBgX = frameLeft;
|
|
638
|
+
const labelBgY = frameTop - labelHeight - gap;
|
|
639
|
+
return (point.x >= labelBgX && point.x <= labelBgX + frameW &&
|
|
640
|
+
point.y >= labelBgY && point.y <= frameTop);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// ─────────────────────────────────────────────
|
|
647
|
+
// Check if a canvas-absolute point is inside a frame's boundaries
|
|
648
|
+
// Used for containment detection during drag
|
|
649
|
+
// ─────────────────────────────────────────────
|
|
650
|
+
export function isPointInsideFrame(frame, point) {
|
|
651
|
+
try {
|
|
652
|
+
const frameCenter = frame.getCenterPoint();
|
|
653
|
+
// Use visual (scaled) dimensions for the hit-test
|
|
654
|
+
const halfW = ((frame.width || 0) * (frame.scaleX || 1)) / 2;
|
|
655
|
+
const halfH = ((frame.height || 0) * (frame.scaleY || 1)) / 2;
|
|
656
|
+
// Simple AABB check (ignoring rotation for now - frames have lockRotation)
|
|
657
|
+
return (point.x >= frameCenter.x - halfW &&
|
|
658
|
+
point.x <= frameCenter.x + halfW &&
|
|
659
|
+
point.y >= frameCenter.y - halfH &&
|
|
660
|
+
point.y <= frameCenter.y + halfH);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ─────────────────────────────────────────────
|
|
667
|
+
// Check if a canvas-space point is on a frame's border/stroke area.
|
|
668
|
+
// Returns true only for points near the frame edge (within hit thickness),
|
|
669
|
+
// not for points in the interior.
|
|
670
|
+
// ─────────────────────────────────────────────
|
|
671
|
+
export function isPointOnFrameBorder(frame, point) {
|
|
672
|
+
try {
|
|
673
|
+
const m = frame.calcTransformMatrix();
|
|
674
|
+
const totalScaleX = Math.sqrt(m[0] * m[0] + m[1] * m[1]);
|
|
675
|
+
const totalScaleY = Math.sqrt(m[2] * m[2] + m[3] * m[3]);
|
|
676
|
+
const frameW = (frame.width || 100) * totalScaleX;
|
|
677
|
+
const frameH = (frame.height || 100) * totalScaleY;
|
|
678
|
+
const center = frame.getCenterPoint();
|
|
679
|
+
const left = center.x - frameW / 2;
|
|
680
|
+
const right = center.x + frameW / 2;
|
|
681
|
+
const top = center.y - frameH / 2;
|
|
682
|
+
const bottom = center.y + frameH / 2;
|
|
683
|
+
// Hit zone: half the stroke width + a generous pointer buffer for easy grabbing
|
|
684
|
+
const strokeW = frame._frameStrokeWidth || DEFAULT_STROKE_WIDTH;
|
|
685
|
+
const hitZone = Math.max(strokeW / 2 + 4, 6);
|
|
686
|
+
// Must be within outer bounds (with tolerance)
|
|
687
|
+
if (point.x < left - hitZone || point.x > right + hitZone ||
|
|
688
|
+
point.y < top - hitZone || point.y > bottom + hitZone) {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
// If well inside the inner bounds, it's the interior — not the border
|
|
692
|
+
if (point.x > left + hitZone && point.x < right - hitZone &&
|
|
693
|
+
point.y > top + hitZone && point.y < bottom - hitZone) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// ─────────────────────────────────────────────
|
|
703
|
+
// Apply border-only hit test to a frame instance.
|
|
704
|
+
// Frame is selectable only by clicking its border/stroke or label area —
|
|
705
|
+
// clicking the interior is a click-through so rubber-band selection can start there.
|
|
706
|
+
// Must be called from both createFrameShape and reapplyFrameRender.
|
|
707
|
+
// ─────────────────────────────────────────────
|
|
708
|
+
export function applyFrameHitTest(frame) {
|
|
709
|
+
frame.containsPoint = function (point) {
|
|
710
|
+
const canvasRef = this.canvas;
|
|
711
|
+
if (!canvasRef)
|
|
712
|
+
return false;
|
|
713
|
+
// Convert viewport-space point to canvas (scene) space
|
|
714
|
+
const vpt = canvasRef.viewportTransform || [1, 0, 0, 1, 0, 0];
|
|
715
|
+
const inv = util.invertTransform(vpt);
|
|
716
|
+
const canvasX = inv[0] * point.x + inv[2] * point.y + inv[4];
|
|
717
|
+
const canvasY = inv[1] * point.x + inv[3] * point.y + inv[5];
|
|
718
|
+
const canvasPoint = { x: canvasX, y: canvasY };
|
|
719
|
+
// 1. Label area (above the frame) → selectable
|
|
720
|
+
if (isPointInFrameLabel(this, canvasPoint))
|
|
721
|
+
return true;
|
|
722
|
+
// 2. Border/stroke area → selectable
|
|
723
|
+
if (isPointOnFrameBorder(this, canvasPoint))
|
|
724
|
+
return true;
|
|
725
|
+
// 3. Interior → click-through (Fabric will start rubber-band selection)
|
|
726
|
+
return false;
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
// ─────────────────────────────────────────────
|
|
730
|
+
// Get all non-background children of a frame
|
|
731
|
+
// (Excludes the background rect)
|
|
732
|
+
// ─────────────────────────────────────────────
|
|
733
|
+
export function getFrameChildren(frame) {
|
|
734
|
+
return frame.getObjects().filter((obj) => !obj._isFrameBackground);
|
|
735
|
+
}
|
|
736
|
+
// ─────────────────────────────────────────────
|
|
737
|
+
// Get the background rect of a frame
|
|
738
|
+
// ─────────────────────────────────────────────
|
|
739
|
+
export function getFrameBackground(frame) {
|
|
740
|
+
const bg = frame.getObjects().find((obj) => obj._isFrameBackground);
|
|
741
|
+
if (bg)
|
|
742
|
+
return bg;
|
|
743
|
+
// Fallback: when _isFrameBackground flag is missing (e.g. after JSON round-trip with
|
|
744
|
+
// incomplete custom props), the first Rect in the group is the background rect.
|
|
745
|
+
const fallbackRect = frame.getObjects().find((obj) => (obj.type === 'rect' || obj.type === 'Rect') && !obj._isFrame);
|
|
746
|
+
if (fallbackRect) {
|
|
747
|
+
fallbackRect._isFrameBackground = true; // Re-mark so future calls skip this path
|
|
748
|
+
canvasLog('FrameShape: getFrameBackground — fallback, re-marked rect as background for frame', frame._labelText);
|
|
749
|
+
return fallbackRect;
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
// ─────────────────────────────────────────────
|
|
754
|
+
// Update the frame's background rect visual properties
|
|
755
|
+
// (fill, stroke, strokeWidth)
|
|
756
|
+
// ─────────────────────────────────────────────
|
|
757
|
+
export function updateFrameBackground(frame, props) {
|
|
758
|
+
const bg = getFrameBackground(frame);
|
|
759
|
+
if (bg) {
|
|
760
|
+
bg.set(props);
|
|
761
|
+
bg.dirty = true;
|
|
762
|
+
}
|
|
763
|
+
// Also store on frame custom props
|
|
764
|
+
const frameAny = frame;
|
|
765
|
+
if (props.fill !== undefined)
|
|
766
|
+
frameAny._frameFill = props.fill;
|
|
767
|
+
if (props.stroke !== undefined)
|
|
768
|
+
frameAny._frameStroke = props.stroke;
|
|
769
|
+
if (props.strokeWidth !== undefined)
|
|
770
|
+
frameAny._frameStrokeWidth = props.strokeWidth;
|
|
771
|
+
frame.dirty = true;
|
|
772
|
+
}
|
|
773
|
+
// ─────────────────────────────────────────────
|
|
774
|
+
// Normalize frame after scaling
|
|
775
|
+
// Converts scaleX/scaleY back to width/height so the label stays consistent.
|
|
776
|
+
// preserveChildren = false (default): children scale with frame
|
|
777
|
+
// preserveChildren = true (Option key): only frame resizes, children stay at position/size
|
|
778
|
+
// ─────────────────────────────────────────────
|
|
779
|
+
export function normalizeFrameAfterScaling(frame, preserveChildren = false) {
|
|
780
|
+
const scaleX = frame.scaleX || 1;
|
|
781
|
+
const scaleY = frame.scaleY || 1;
|
|
782
|
+
if (scaleX === 1 && scaleY === 1)
|
|
783
|
+
return;
|
|
784
|
+
let newWidth = (frame.width || 0) * scaleX;
|
|
785
|
+
let newHeight = (frame.height || 0) * scaleY;
|
|
786
|
+
// Enforce minimums
|
|
787
|
+
newWidth = Math.max(newWidth, MIN_FRAME_WIDTH);
|
|
788
|
+
newHeight = Math.max(newHeight, MIN_FRAME_HEIGHT);
|
|
789
|
+
// Also scale the background rect dimensions and reset its scale
|
|
790
|
+
const bg = getFrameBackground(frame);
|
|
791
|
+
if (bg) {
|
|
792
|
+
bg.set({
|
|
793
|
+
width: newWidth,
|
|
794
|
+
height: newHeight,
|
|
795
|
+
scaleX: 1,
|
|
796
|
+
scaleY: 1,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
// Adjust children based on mode
|
|
800
|
+
const children = getFrameChildren(frame);
|
|
801
|
+
if (!preserveChildren) {
|
|
802
|
+
// DEFAULT: Scale children's positions and sizes so they appear at the same
|
|
803
|
+
// visual position/size as during the scaling drag
|
|
804
|
+
for (const child of children) {
|
|
805
|
+
child.set({
|
|
806
|
+
left: (child.left || 0) * scaleX,
|
|
807
|
+
top: (child.top || 0) * scaleY,
|
|
808
|
+
scaleX: (child.scaleX || 1) * scaleX,
|
|
809
|
+
scaleY: (child.scaleY || 1) * scaleY,
|
|
810
|
+
});
|
|
811
|
+
child.setCoords();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// When preserveChildren = true: don't adjust children at all.
|
|
815
|
+
// After normalization (frame scaleX/Y → 1), children remain at their
|
|
816
|
+
// original group-relative positions and sizes. The frame's viewport expands
|
|
817
|
+
// but children stay put.
|
|
818
|
+
// Reset frame scale and set new dimensions
|
|
819
|
+
frame.set({
|
|
820
|
+
width: newWidth,
|
|
821
|
+
height: newHeight,
|
|
822
|
+
scaleX: 1,
|
|
823
|
+
scaleY: 1,
|
|
824
|
+
});
|
|
825
|
+
// Update clip path for new dimensions
|
|
826
|
+
updateClipPath(frame);
|
|
827
|
+
// Recalculate corner radius from percentage after resize
|
|
828
|
+
// (percentage stays constant, pixel value adapts to new dimensions)
|
|
829
|
+
const cornerRoundness = frame._cornerRoundness || 0;
|
|
830
|
+
if (cornerRoundness > 0) {
|
|
831
|
+
const minDim = Math.min(newWidth, newHeight);
|
|
832
|
+
const newRadius = (cornerRoundness / 100) * (minDim / 2);
|
|
833
|
+
updateFrameCornerRadius(frame, newRadius);
|
|
834
|
+
}
|
|
835
|
+
frame.setCoords();
|
|
836
|
+
frame.dirty = true;
|
|
837
|
+
}
|
|
838
|
+
// ─────────────────────────────────────────────
|
|
839
|
+
// Enforce minimum size during scaling (call from object:scaling event)
|
|
840
|
+
// ─────────────────────────────────────────────
|
|
841
|
+
export function enforceFrameMinSize(frame) {
|
|
842
|
+
const currentWidth = (frame.width || 0) * (frame.scaleX || 1);
|
|
843
|
+
const currentHeight = (frame.height || 0) * (frame.scaleY || 1);
|
|
844
|
+
if (currentWidth < MIN_FRAME_WIDTH) {
|
|
845
|
+
frame.scaleX = MIN_FRAME_WIDTH / (frame.width || 1);
|
|
846
|
+
}
|
|
847
|
+
if (currentHeight < MIN_FRAME_HEIGHT) {
|
|
848
|
+
frame.scaleY = MIN_FRAME_HEIGHT / (frame.height || 1);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// ─────────────────────────────────────────────
|
|
852
|
+
// List of custom properties for copy/paste serialization
|
|
853
|
+
// ─────────────────────────────────────────────
|
|
854
|
+
export const FRAME_CUSTOM_PROPS = [
|
|
855
|
+
'_customType', '_isFrame', '_isFrameBackground',
|
|
856
|
+
'_labelText', '_labelFontSize', '_labelFontFamily', '_labelColor',
|
|
857
|
+
'_labelBackgroundColor', '_labelTextAlign', '_labelFontWeight',
|
|
858
|
+
'_labelFontStyle', '_labelUnderline',
|
|
859
|
+
'_frameFill', '_frameStroke', '_frameStrokeWidth', '_frameStrokeDashArray',
|
|
860
|
+
'_cornerRoundness', '_frameCornerRadius',
|
|
861
|
+
// Tracks which frame a child belongs to; used to repair orphaned children on reload
|
|
862
|
+
'_parentFrameId',
|
|
863
|
+
];
|
|
864
|
+
// ─────────────────────────────────────────────
|
|
865
|
+
// Helper: Check if a Fabric object is a FrameShape
|
|
866
|
+
// ─────────────────────────────────────────────
|
|
867
|
+
export function isFrameShape(obj) {
|
|
868
|
+
return obj && (obj._isFrame === true || obj._customType === 'frame');
|
|
869
|
+
}
|
|
870
|
+
// ─────────────────────────────────────────────
|
|
871
|
+
// Reapply custom render to a cloned/deserialized frame
|
|
872
|
+
// Call this after copy/paste or JSON load to restore the label rendering
|
|
873
|
+
// ─────────────────────────────────────────────
|
|
874
|
+
export function reapplyFrameRender(frame) {
|
|
875
|
+
// ── Upgrade to FrameShape prototype if needed ──────────────────────────────
|
|
876
|
+
// Called on: (a) newly deserialized objects via FrameShape.fromObject (already upgraded),
|
|
877
|
+
// (b) old-format saves (type:'Group' + _isFrame:true) via loadCanvas reviver,
|
|
878
|
+
// (c) any backward-compat path that still passes plain Groups here.
|
|
879
|
+
// Fabric's type getter reads this.constructor.type.toLowerCase(), so after setPrototypeOf:
|
|
880
|
+
// frame.constructor === FrameShape → frame.type === 'frameshape'
|
|
881
|
+
// frame.toObject() calls FrameShape.prototype.toObject() → serializes type:'FrameShape'
|
|
882
|
+
if (Object.getPrototypeOf(frame) !== FrameShape.prototype) {
|
|
883
|
+
Object.setPrototypeOf(frame, FrameShape.prototype);
|
|
884
|
+
canvasLog('FrameShape: reapplyFrameRender — upgraded Group to FrameShape prototype for', frame._labelText || '(unlabelled)');
|
|
885
|
+
}
|
|
886
|
+
// Ensure backward-compat flags are always present
|
|
887
|
+
if (!frame._isFrame)
|
|
888
|
+
frame._isFrame = true;
|
|
889
|
+
if (!frame._customType)
|
|
890
|
+
frame._customType = 'frame';
|
|
891
|
+
const originalRender = Group.prototype.render.bind(frame);
|
|
892
|
+
frame.render = function (ctx) {
|
|
893
|
+
originalRender(ctx);
|
|
894
|
+
// Draw label using Fabric's this.transform() which automatically handles
|
|
895
|
+
// the parent chain (_transformDone flag), then counter-scale to 1:1.
|
|
896
|
+
// This approach works identically for top-level and nested frames.
|
|
897
|
+
ctx.save();
|
|
898
|
+
this.transform(ctx);
|
|
899
|
+
const scaling = this.getObjectScaling();
|
|
900
|
+
ctx.scale(1 / scaling.x, 1 / scaling.y);
|
|
901
|
+
renderFrameLabel(ctx, this);
|
|
902
|
+
ctx.restore();
|
|
903
|
+
};
|
|
904
|
+
// Remove rotation control if it exists
|
|
905
|
+
if (frame.controls && frame.controls.mtr) {
|
|
906
|
+
delete frame.controls.mtr;
|
|
907
|
+
}
|
|
908
|
+
// Restore interactive Group properties lost during clone/fromObject
|
|
909
|
+
// (Group.fromObject creates a plain Group without these)
|
|
910
|
+
frame.interactive = true;
|
|
911
|
+
frame.subTargetCheck = true;
|
|
912
|
+
frame.objectCaching = false;
|
|
913
|
+
frame.lockRotation = true;
|
|
914
|
+
frame.hoverCursor = 'move';
|
|
915
|
+
frame.moveCursor = 'move';
|
|
916
|
+
// Unsubscribe old LayoutManager from children before replacing it.
|
|
917
|
+
// Group.fromObject subscribes a FitContentLayout (or the saved one) to all
|
|
918
|
+
// children — if we just overwrite layoutManager without cleaning up, those
|
|
919
|
+
// event handlers keep pointing to the old manager and can cause stale layouts.
|
|
920
|
+
if (frame.layoutManager) {
|
|
921
|
+
try {
|
|
922
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
923
|
+
frame.layoutManager.unsubscribeTargets({ target: frame, targets: frame.getObjects() });
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
// Ignore errors if old manager has no subscriptions
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
frame.layoutManager = new LayoutManager(new FixedLayout());
|
|
930
|
+
// Restore background rect's non-interactive flags (not serialized by Fabric)
|
|
931
|
+
// Without this, the cloned background becomes selectable and "breaks" the Frame:
|
|
932
|
+
// clicking the frame selects the rect child instead of the Group.
|
|
933
|
+
const bg = getFrameBackground(frame);
|
|
934
|
+
if (bg) {
|
|
935
|
+
bg.selectable = false;
|
|
936
|
+
bg.evented = false;
|
|
937
|
+
}
|
|
938
|
+
// Ensure clip path is set
|
|
939
|
+
if (!frame.clipPath) {
|
|
940
|
+
setupClipPath(frame);
|
|
941
|
+
}
|
|
942
|
+
// Restore corner radius on background rect and clip path
|
|
943
|
+
// (after clipPath is ensured, so we can set rx/ry on it)
|
|
944
|
+
const cornerRadius = frame._frameCornerRadius || 0;
|
|
945
|
+
if (cornerRadius > 0) {
|
|
946
|
+
updateFrameCornerRadius(frame, cornerRadius);
|
|
947
|
+
}
|
|
948
|
+
// Restore border-only hit test (lost during clone/fromObject)
|
|
949
|
+
applyFrameHitTest(frame);
|
|
950
|
+
frame.dirty = true;
|
|
951
|
+
}
|