@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.
Files changed (51) hide show
  1. package/README.md +45 -0
  2. package/dist/canvas/Canvas.svelte +10678 -0
  3. package/dist/canvas/Canvas.svelte.d.ts +147 -0
  4. package/dist/canvas/CanvasToolbar.svelte +1422 -0
  5. package/dist/canvas/CanvasToolbar.svelte.d.ts +54 -0
  6. package/dist/canvas/ContextMenu.svelte +279 -0
  7. package/dist/canvas/ContextMenu.svelte.d.ts +36 -0
  8. package/dist/canvas/PanZoomPanel.svelte +315 -0
  9. package/dist/canvas/PanZoomPanel.svelte.d.ts +32 -0
  10. package/dist/canvas/canvasLogger.d.ts +5 -0
  11. package/dist/canvas/canvasLogger.js +19 -0
  12. package/dist/canvas/index.d.ts +13 -0
  13. package/dist/canvas/index.js +12 -0
  14. package/dist/canvas/props-panel/PropsPanel.svelte +1902 -0
  15. package/dist/canvas/props-panel/PropsPanel.svelte.d.ts +73 -0
  16. package/dist/canvas/props-panel/TextPropsICSection.svelte +238 -0
  17. package/dist/canvas/props-panel/TextPropsICSection.svelte.d.ts +36 -0
  18. package/dist/canvas/shapes/FrameShape.d.ts +97 -0
  19. package/dist/canvas/shapes/FrameShape.js +951 -0
  20. package/dist/canvas/shapes/ImageShape.d.ts +38 -0
  21. package/dist/canvas/shapes/ImageShape.js +245 -0
  22. package/dist/canvas/shapes/ShapeLibrary.d.ts +64 -0
  23. package/dist/canvas/shapes/ShapeLibrary.js +526 -0
  24. package/dist/canvas/shapes/WidgetShape.d.ts +21 -0
  25. package/dist/canvas/shapes/WidgetShape.js +132 -0
  26. package/dist/canvas/types.d.ts +26 -0
  27. package/dist/canvas/types.js +5 -0
  28. package/dist/components/Tooltip.svelte +179 -0
  29. package/dist/components/Tooltip.svelte.d.ts +21 -0
  30. package/dist/components/index.d.ts +11 -0
  31. package/dist/components/index.js +13 -0
  32. package/dist/components/input-controls/ColorIC.svelte +388 -0
  33. package/dist/components/input-controls/ColorIC.svelte.d.ts +22 -0
  34. package/dist/components/input-controls/FontSelectorIC.svelte +69 -0
  35. package/dist/components/input-controls/FontSelectorIC.svelte.d.ts +17 -0
  36. package/dist/components/input-controls/SliderUnitIC.svelte +217 -0
  37. package/dist/components/input-controls/SliderUnitIC.svelte.d.ts +24 -0
  38. package/dist/components/input-controls/TextAlignIC.svelte +84 -0
  39. package/dist/components/input-controls/TextAlignIC.svelte.d.ts +17 -0
  40. package/dist/components/input-controls/TextStyleIC.svelte +85 -0
  41. package/dist/components/input-controls/TextStyleIC.svelte.d.ts +21 -0
  42. package/dist/icons/arrow.svg +3 -0
  43. package/dist/icons/checkmark.svg +3 -0
  44. package/dist/icons/draw.svg +22 -0
  45. package/dist/icons/eraser.svg +23 -0
  46. package/dist/icons/hand.svg +6 -0
  47. package/dist/icons/select.svg +3 -0
  48. package/dist/icons/text.svg +5 -0
  49. package/dist/index.d.ts +11 -0
  50. package/dist/index.js +12 -0
  51. 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
+ }