@widgetic/canvas 0.5.4 → 0.5.7

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.
@@ -153,6 +153,10 @@
153
153
  // (white fill, dark stroke) on all newly created objects
154
154
  export let wireframeMode: boolean = true;
155
155
 
156
+ // Extra canvas chrome (undo/redo/lock, frame, image, shape library).
157
+ // Default off for the sketch MVP. Same pattern as wireframeMode: keep the code, hide the UI.
158
+ export let advancedFeatures: boolean = false;
159
+
156
160
  // Callback fired once when Fabric canvas is fully initialized and ready
157
161
  export let onReady: (() => void) | null = null;
158
162
 
@@ -213,6 +217,9 @@
213
217
  // Callback: fires when the user requests a screenshot refresh for the linked widget.
214
218
  export let onRefreshScreenshot: ((widgetId: string) => void) | null = null;
215
219
 
220
+ /** Host persists canvases.primary_widget_id after Canvas paints the red stroke. */
221
+ export let onSetPrimaryWidget: ((widgetId: string) => void) | null = null;
222
+
216
223
  // Widget name resolver: given a widgetId, returns the widget name (or null).
217
224
  // Used by PropsPanel to display the linked widget's name.
218
225
  export let getWidgetName: ((widgetId: string) => string | null) | null = null;
@@ -305,7 +312,7 @@
305
312
  let activeEditingTextbox: Textbox | null = null;
306
313
 
307
314
  const MIN_DRAW_DISTANCE = 4;
308
- const MIN_TEXTBOX_WIDTH = 120;
315
+ const MIN_TEXTBOX_WIDTH = 15;
309
316
 
310
317
  // Clipboard for copy/paste functionality
311
318
  let clipboard: any = null;
@@ -361,6 +368,237 @@
361
368
  return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
362
369
  }
363
370
 
371
+ /** Regular Fabric Group currently in double-click edit mode (children selectable). */
372
+ let activeEditingGroup: any = null;
373
+ let isExitingGroupEdit = false;
374
+ let isRedirectingGroupSelection = false;
375
+
376
+ function isRegularGroup(obj: any): boolean {
377
+ if (!obj || isFrameShape(obj)) return false;
378
+ if ((obj as any)._isWidgetImage) return false;
379
+ return String(obj.type || '').toLowerCase() === 'group';
380
+ }
381
+
382
+ const GROUP_IDLE_STROKE = '#64748b';
383
+ const GROUP_IDLE_DASH = [8, 5];
384
+ const GROUP_IDLE_STROKE_WIDTH = 1.5;
385
+ /** Hide the idle dashed outline during toDataURL / convert screenshots. */
386
+ let skipGroupIdleOutline = false;
387
+
388
+ /** Stamp top-level paint order before ActiveSelection swallows children. */
389
+ function stampTopLevelPaintOrder() {
390
+ if (!canvas) return;
391
+ canvas.getObjects().forEach((obj: any, i: number) => {
392
+ const t = String(obj.type || '').toLowerCase();
393
+ if (t === 'activeselection' || t === 'activeSelection') return;
394
+ obj._canvasPaintOrder = i;
395
+ });
396
+ }
397
+
398
+ function isGroupActivelySelected(group: any): boolean {
399
+ if (!canvas || !group) return false;
400
+ const active = canvas.getActiveObject() as any;
401
+ if (!active) return false;
402
+ if (active === group) return true;
403
+ const type = String(active.type || '').toLowerCase();
404
+ if ((type === 'activeselection' || type === 'activeSelection') && typeof active.getObjects === 'function') {
405
+ return active.getObjects().includes(group);
406
+ }
407
+ return false;
408
+ }
409
+
410
+ /** Group chrome (fill/stroke behind-or-around children) + idle dashed outline.
411
+ * Idle dashes are skipped while the group is selected and during screenshots. */
412
+ function applyGroupIdleOutlineRender(group: any) {
413
+ if (!isRegularGroup(group) || (group as any)._hasGroupOutlineRender) return;
414
+ (group as any)._hasGroupOutlineRender = true;
415
+ const originalDrawObject = group.drawObject.bind(group);
416
+ group.drawObject = function (ctx: CanvasRenderingContext2D, forClipping?: boolean, context?: any) {
417
+ const w = this.width;
418
+ const h = this.height;
419
+ const scale = Math.max(this.scaleX || 1, this.scaleY || 1);
420
+ const groupFill = (this as any)._groupFill;
421
+ const fillOpacity = (this as any)._groupFillOpacity ?? 1;
422
+ if (w && h && groupFill && groupFill !== '' && groupFill !== 'transparent') {
423
+ ctx.save();
424
+ ctx.fillStyle = fillOpacity < 1 ? colorToRgba(groupFill, fillOpacity) : groupFill;
425
+ ctx.fillRect(-w / 2, -h / 2, w, h);
426
+ ctx.restore();
427
+ }
428
+ originalDrawObject(ctx, forClipping, context);
429
+ if (forClipping || !w || !h) return;
430
+
431
+ const groupStroke = (this as any)._groupStroke;
432
+ const groupStrokeWidth = (this as any)._groupStrokeWidth ?? 0;
433
+ if (groupStroke && groupStrokeWidth > 0) {
434
+ ctx.save();
435
+ ctx.strokeStyle = groupStroke;
436
+ ctx.lineWidth = groupStrokeWidth / scale;
437
+ const dash = (this as any)._groupStrokeDashArray;
438
+ ctx.setLineDash(Array.isArray(dash) && dash.length ? dash : []);
439
+ ctx.beginPath();
440
+ ctx.rect(-w / 2, -h / 2, w, h);
441
+ ctx.stroke();
442
+ ctx.restore();
443
+ return;
444
+ }
445
+
446
+ if (skipGroupIdleOutline || isGroupActivelySelected(this)) return;
447
+ ctx.save();
448
+ ctx.strokeStyle = GROUP_IDLE_STROKE;
449
+ ctx.lineWidth = GROUP_IDLE_STROKE_WIDTH / scale;
450
+ ctx.setLineDash(GROUP_IDLE_DASH);
451
+ ctx.beginPath();
452
+ ctx.rect(-w / 2, -h / 2, w, h);
453
+ ctx.stroke();
454
+ ctx.restore();
455
+ };
456
+ }
457
+
458
+ /** Regular Groups stay uncached so children remain visible. `interactive` is
459
+ * false by default (click selects the group). Double-click enters edit mode. */
460
+ function applyInteractiveGroupFlags(group: any) {
461
+ if (!group || isFrameShape(group)) return;
462
+ if (!isRegularGroup(group)) return;
463
+ group.subTargetCheck = true;
464
+ group.objectCaching = false;
465
+ if (!(group as any)._groupEditMode) {
466
+ group.interactive = false;
467
+ }
468
+ applyGroupIdleOutlineRender(group);
469
+ group.getObjects?.()?.forEach((child: any) => applyInteractiveGroupFlags(child));
470
+ }
471
+
472
+ function snapshotGroupChildrenWorld(group: any): Array<{ obj: any; world: number[] }> {
473
+ return (group.getObjects?.() || []).map((obj: any) => ({
474
+ obj,
475
+ world: obj.calcTransformMatrix().slice(),
476
+ }));
477
+ }
478
+
479
+ function copyRegularGroupIdentity(fromGroup: any, toGroup: any) {
480
+ toGroup._objectId = fromGroup._objectId;
481
+ toGroup._customType = fromGroup._customType || 'group';
482
+ toGroup._groupFill = fromGroup._groupFill;
483
+ toGroup._groupStroke = fromGroup._groupStroke;
484
+ toGroup._groupStrokeWidth = fromGroup._groupStrokeWidth;
485
+ toGroup._groupFillOpacity = fromGroup._groupFillOpacity;
486
+ toGroup._groupStrokeDashArray = fromGroup._groupStrokeDashArray;
487
+ toGroup._canvasPaintOrder = fromGroup._canvasPaintOrder;
488
+ toGroup.set({ fill: '', stroke: '', strokeWidth: 0, backgroundColor: '' });
489
+ applyInteractiveGroupFlags(toGroup);
490
+ }
491
+
492
+ /**
493
+ * Leave children in canvas-absolute (world) space without FitContentLayout
494
+ * re-offsetting them. `removeAll()` would apply the *old* group matrix again.
495
+ */
496
+ function detachGroupChildrenAtWorld(
497
+ group: any,
498
+ snapshots: Array<{ obj: any; world: number[] }>,
499
+ ) {
500
+ const applyTransformToObject = (fabricUtil as any).applyTransformToObject;
501
+ group._activeObjects = [];
502
+ for (const { obj, world } of snapshots) {
503
+ if (typeof group._watchObject === 'function') {
504
+ group._watchObject(false, obj);
505
+ }
506
+ // Unset parent first — applyTransformToObject writes the object's
507
+ // own matrix; if `group` is still set, world = group * own (double).
508
+ obj._set?.('group', undefined);
509
+ obj._set?.('parent', undefined);
510
+ obj.group = undefined;
511
+ obj.parent = undefined;
512
+ applyTransformToObject(obj, world);
513
+ obj.setCoords?.();
514
+ }
515
+ group._objects = [];
516
+ }
517
+
518
+ /**
519
+ * Ungroup at current world positions, then Group() again (same path as
520
+ * onGroupSelection). Avoids the interactive-exit snap-back.
521
+ */
522
+ function rebuildRegularGroupFromWorld(
523
+ group: any,
524
+ snapshots = snapshotGroupChildrenWorld(group),
525
+ ): any {
526
+ if (!canvas || !group) return group;
527
+ if (!snapshots.length) return group;
528
+ const children = snapshots.map((entry) => entry.obj);
529
+ if (group.group) {
530
+ canvasLog('Canvas: skip group rebuild inside parent', group.group._customType);
531
+ return group;
532
+ }
533
+ const stackIndex = canvas.getObjects().indexOf(group);
534
+
535
+ detachGroupChildrenAtWorld(group, snapshots);
536
+ canvas.remove(group);
537
+
538
+ const newGroup = new Group(children, {
539
+ subTargetCheck: true,
540
+ interactive: false,
541
+ objectCaching: false,
542
+ fill: '',
543
+ stroke: '',
544
+ strokeWidth: 0,
545
+ backgroundColor: '',
546
+ });
547
+ copyRegularGroupIdentity(group, newGroup);
548
+ canvas.add(newGroup);
549
+ if (stackIndex >= 0 && typeof (canvas as any).moveObjectTo === 'function') {
550
+ (canvas as any).moveObjectTo(newGroup, stackIndex);
551
+ }
552
+ stampTopLevelPaintOrder();
553
+ canvasLog('Canvas: rebuilt group after edit', {
554
+ children: children.length,
555
+ objectId: newGroup._objectId,
556
+ });
557
+ return newGroup;
558
+ }
559
+
560
+ function exitGroupEdit(reselectGroup = false) {
561
+ const group = activeEditingGroup;
562
+ if (!group || isExitingGroupEdit) return;
563
+ isExitingGroupEdit = true;
564
+ // World matrices BEFORE discard — deselect remaps against the old box.
565
+ const snapshots = snapshotGroupChildrenWorld(group);
566
+ // Clear before discard: selection:cleared would otherwise re-enter
567
+ // exitGroupEdit and snapshot already-remapped (snapped) coords.
568
+ activeEditingGroup = null;
569
+ (group as any)._groupEditMode = false;
570
+ try {
571
+ if (canvas) {
572
+ const active = canvas.getActiveObject();
573
+ if (active && active !== group) {
574
+ canvas.discardActiveObject();
575
+ }
576
+ }
577
+ group.interactive = false;
578
+ const rebuilt = rebuildRegularGroupFromWorld(group, snapshots);
579
+ canvasLog('Canvas: exited group edit');
580
+ if (reselectGroup && canvas && rebuilt) {
581
+ canvas.setActiveObject(rebuilt);
582
+ }
583
+ canvas?.requestRenderAll();
584
+ } finally {
585
+ isExitingGroupEdit = false;
586
+ }
587
+ }
588
+
589
+ function enterGroupEdit(group: any) {
590
+ if (!group || !isRegularGroup(group)) return;
591
+ if (activeEditingGroup && activeEditingGroup !== group) {
592
+ exitGroupEdit(false);
593
+ }
594
+ (group as any)._groupEditMode = true;
595
+ group.interactive = true;
596
+ group.subTargetCheck = true;
597
+ group.objectCaching = false;
598
+ activeEditingGroup = group;
599
+ canvasLog('Canvas: entered group edit', { children: group.getObjects?.()?.length });
600
+ }
601
+
364
602
  // Text settings
365
603
  let textBoxPlaceholder = 'Type here';
366
604
  let textFontSize = 20;
@@ -399,6 +637,18 @@
399
637
  scaled: fabric.IEventHandler<Event>;
400
638
  } | null = null;
401
639
 
640
+ /**
641
+ * Pending multi-selection conversions, keyed by the selection's _objectId.
642
+ * convertObjectToWidget snapshots children + bounds here; the later
643
+ * replaceObjectWithWidgetImage consumes the entry. Needed because Fabric 6
644
+ * ActiveSelection empties itself on deselect (onDeselect → removeAll), so
645
+ * the selection state cannot be relied on after the convert click.
646
+ */
647
+ const pendingSelectionConversions = new Map<
648
+ string,
649
+ { children: any[]; bounds: { left: number; top: number; width: number; height: number } }
650
+ >();
651
+
402
652
  /**
403
653
  * HELPER FUNCTIONS
404
654
  */
@@ -406,45 +656,49 @@
406
656
  // Capture screenshot of selected object(s)
407
657
  async function captureObjectScreenshot(target: any): Promise<string> {
408
658
  if (!canvas) return '';
409
-
659
+
660
+ // Fabric 6 hazard: ActiveSelection.onDeselect() calls removeAll(), which
661
+ // EMPTIES the selection. Read everything we need from the selection
662
+ // BEFORE any discardActiveObject(), and never discard mid-capture.
663
+ const isMultiSelection =
664
+ target.type === 'activeSelection' || target.type === 'activeselection';
665
+ const selectionChildren = isMultiSelection ? target.getObjects() : [];
666
+ const selectionBounds = target.getBoundingRect();
667
+
668
+ skipGroupIdleOutline = true;
410
669
  try {
411
- // Deselect object temporarily to capture without selection controls
412
- const activeObject = canvas.getActiveObject();
413
- canvas.discardActiveObject();
414
- canvas.renderAll();
415
-
416
670
  let dataUrl = '';
417
-
418
- // For activeSelection (multiple objects), we need to handle differently
419
- if (target.type === 'activeSelection' || target.type === 'activeselection') {
420
- // Clone objects to a temporary canvas
421
- const objects = target.getObjects();
422
- const tempCanvas = new fabric.Canvas(null, {
423
- width: target.width + 40,
424
- height: target.height + 40,
425
- backgroundColor: null
426
- });
427
-
428
- // Clone and add each object
429
- for (const obj of objects) {
430
- const cloned = await obj.clone();
431
- // Adjust position relative to group center
432
- cloned.set({
433
- left: (tempCanvas.width / 2) + (obj.left - target.left),
434
- top: (tempCanvas.height / 2) + (obj.top - target.top)
435
- });
436
- tempCanvas.add(cloned);
671
+
672
+ if (isMultiSelection) {
673
+ // Multi-selection: capture on the REAL canvas hide everything
674
+ // outside the selection, render, crop the selection's bounds.
675
+ // Do NOT discard the selection: in Fabric 6 discarding empties it
676
+ // (onDeselect removeAll) and the children would vanish. Selection
677
+ // controls live on the upper canvas, so toDataURL never shows them.
678
+ const childSet = new Set(selectionChildren);
679
+ const isHidden: Array<[any, boolean]> = [];
680
+ for (const o of canvas.getObjects()) {
681
+ if (!childSet.has(o) && o.visible) {
682
+ o.visible = false;
683
+ isHidden.push([o, true]);
684
+ }
437
685
  }
438
-
439
- tempCanvas.renderAll();
440
- dataUrl = tempCanvas.toDataURL({
686
+ canvas.renderAll();
687
+ dataUrl = canvas.toDataURL({
441
688
  format: 'png',
442
- multiplier: 2
689
+ multiplier: 2,
690
+ left: Math.floor(selectionBounds.left),
691
+ top: Math.floor(selectionBounds.top),
692
+ width: Math.max(1, Math.ceil(selectionBounds.width)),
693
+ height: Math.max(1, Math.ceil(selectionBounds.height))
443
694
  });
444
- tempCanvas.dispose();
695
+ for (const [o, wasVisible] of isHidden) {
696
+ if (wasVisible) o.visible = true;
697
+ }
698
+ canvas.requestRenderAll();
445
699
  } else {
446
- // Single object - use toDataURL directly on the object
447
- // This exports only the object with transparency preserved
700
+ // Single object toDataURL exports only the object with
701
+ // transparency preserved, no deselect needed either.
448
702
  dataUrl = target.toDataURL({
449
703
  format: 'png',
450
704
  multiplier: 2,
@@ -452,19 +706,16 @@
452
706
  withoutShadow: false
453
707
  });
454
708
  }
455
-
456
- canvasLog('Capture completed for:', target.type);
457
-
458
- // Restore selection
459
- if (activeObject) {
460
- canvas.setActiveObject(activeObject);
461
- canvas.renderAll();
462
- }
463
-
709
+
710
+ canvasLog('Capture completed for:', target.type, '| dataUrl length:', dataUrl.length);
711
+
464
712
  return dataUrl;
465
713
  } catch (e) {
466
714
  console.error('Error capturing screenshot:', e);
467
715
  return '';
716
+ } finally {
717
+ skipGroupIdleOutline = false;
718
+ canvas.requestRenderAll();
468
719
  }
469
720
  }
470
721
 
@@ -539,8 +790,33 @@
539
790
  const isFrame = isFrameShape(obj);
540
791
  const children = isFrame ? getFrameChildren(obj) : [];
541
792
  const bounds = obj.getBoundingRect();
793
+ const isMultiSelection =
794
+ obj.type === 'activeSelection' || obj.type === 'activeselection';
795
+
796
+ // A multi-selection has no stable _objectId (it is a transient container).
797
+ // Allocate one so the host's replace step can resolve what to remove —
798
+ // without it the payload carried an invented obj_<timestamp> id that
799
+ // matched nothing and the original sketch shapes stayed on the canvas.
800
+ if (isMultiSelection && !(obj as any)._objectId) {
801
+ (obj as any)._objectId = generateObjectId();
802
+ }
542
803
 
543
804
  try {
805
+ // Snapshot multi-selection children + bounds BEFORE anything can
806
+ // deselect the selection (onDeselect empties it in Fabric 6).
807
+ // replaceObjectWithWidgetImage consumes this by selection id.
808
+ if (isMultiSelection) {
809
+ pendingSelectionConversions.set((obj as any)._objectId, {
810
+ children: [...obj.getObjects()],
811
+ bounds: {
812
+ left: bounds.left,
813
+ top: bounds.top,
814
+ width: bounds.width,
815
+ height: bounds.height
816
+ }
817
+ });
818
+ }
819
+
544
820
  const screenshot = await captureObjectScreenshot(obj);
545
821
 
546
822
  const objectName = isFrame
@@ -579,8 +855,6 @@
579
855
  ShapeTypes: payload.metadata.shapeTypes,
580
856
  });
581
857
 
582
- obj._isConverting = true;
583
- selectedObject = selectedObject;
584
858
  onConvertToWidget(payload);
585
859
  } catch (err) {
586
860
  console.error('Canvas: convertObjectToWidget failed', err);
@@ -595,6 +869,16 @@
595
869
  selectedObject = null;
596
870
  }
597
871
 
872
+ /** Same as Ctrl+D / context Duplicate — clones selection (widget shapes notify host). */
873
+ export function duplicateSelectedObjects() {
874
+ return _duplicateSelected();
875
+ }
876
+
877
+ /** Same as Delete key / context Delete. */
878
+ export function deleteSelectedObjects() {
879
+ return _deleteActiveObjects();
880
+ }
881
+
598
882
  /** Re-link a cloned object to a new widget (called by host after duplicateWidget API).
599
883
  * Selects the object on canvas so Props Panel updates with the correct widget info. */
600
884
  /** Mark the currently selected object as "duplicating in progress" so PropsPanel shows a loader. */
@@ -652,32 +936,77 @@
652
936
  ): Promise<string | null> {
653
937
  if (!canvas) return null;
654
938
 
655
- const obj = canvas.getObjects().find((o: any) => o._objectId === objectId);
656
- if (!obj) {
939
+ // Convert registry convertObjectToWidget snapshots the selection's
940
+ // children + bounds under the selection's _objectId BEFORE anything can
941
+ // deselect it (Fabric 6 ActiveSelection.onDeselect() → removeAll() empties
942
+ // the selection, so live lookups are unreliable by replace time).
943
+ const pendingConversion = pendingSelectionConversions.get(objectId);
944
+ pendingSelectionConversions.delete(objectId);
945
+
946
+ // Resolve a single (non-selection) source object by id, if any.
947
+ let obj = canvas.getObjects().find((o: any) => o._objectId === objectId);
948
+ let selectionChildren: any[] = [];
949
+ if (pendingConversion) {
950
+ // Registry path: still-present children are removed below.
951
+ selectionChildren = pendingConversion.children.filter((c: any) =>
952
+ canvas!.getObjects().includes(c)
953
+ );
954
+ } else if (!obj) {
955
+ const active = canvas.getActiveObject();
956
+ if (
957
+ active &&
958
+ (active.type === 'activeSelection' || active.type === 'activeselection') &&
959
+ (active as any)._objectId === objectId
960
+ ) {
961
+ selectionChildren = (active as any).getObjects();
962
+ } else {
963
+ // Last resort: a top-level object whose children match (group)
964
+ const byChild = canvas.getObjects().find((o: any) =>
965
+ typeof o.getObjects === 'function' &&
966
+ o.getObjects().some((c: any) => c._objectId === objectId)
967
+ );
968
+ obj = byChild;
969
+ }
970
+ }
971
+ if (!obj && selectionChildren.length === 0) {
657
972
  canvasWarn('Canvas: replaceObjectWithWidgetImage — object not found', objectId);
658
973
  return null;
659
974
  }
660
975
 
661
976
  try {
662
- const bounds = obj.getBoundingRect();
663
- const isFrame = isFrameShape(obj);
977
+ const isFrame = obj ? isFrameShape(obj) : false;
978
+ const bounds = pendingConversion?.bounds ??
979
+ (obj
980
+ ? obj.getBoundingRect()
981
+ : (() => {
982
+ const a = canvas.getActiveObject() as any;
983
+ return a ? a.getBoundingRect() : { left: 0, top: 0, width: 200, height: 200 };
984
+ })());
664
985
 
665
986
  // Preserve original visual properties before removing
666
- const originalStroke = (obj as any).stroke || '#94a3b8';
667
- const originalStrokeWidth = (obj as any).strokeWidth ?? 2;
668
- const originalStrokeDashArray = (obj as any).strokeDashArray || null;
669
- const originalRx = (obj as any).rx || 0;
670
- const originalRy = (obj as any).ry || 0;
671
- const originalOpacity = (obj as any).opacity ?? 1;
672
- const originalFill = (obj as any).fill || null;
673
-
674
- if (isFrame) {
675
- const children = getFrameChildren(obj);
676
- const bg = getFrameBackground(obj);
987
+ const originalStroke = (obj as any)?.stroke || '#94a3b8';
988
+ const originalStrokeWidth = (obj as any)?.strokeWidth ?? 2;
989
+ const originalStrokeDashArray = (obj as any)?.strokeDashArray || null;
990
+ const originalRx = (obj as any)?.rx || 0;
991
+ const originalRy = (obj as any)?.ry || 0;
992
+ const originalOpacity = (obj as any)?.opacity ?? 1;
993
+ const originalFill = (obj as any)?.fill || null;
994
+
995
+ if (isFrame && obj) {
996
+ const children = getFrameChildren(obj as any);
997
+ const bg = getFrameBackground(obj as any);
677
998
  for (const child of children) canvas.remove(child);
678
999
  if (bg) canvas.remove(bg);
679
1000
  }
680
- canvas.remove(obj);
1001
+ // Multi-selection: remove every child shape so the sketch is absorbed
1002
+ // into the single WidgetShape (F4 — no duplicate shapes left).
1003
+ if (selectionChildren.length > 0) {
1004
+ // Deselect first so Fabric doesn't re-add children on discard.
1005
+ // After this point nothing reads from the (now emptied) selection.
1006
+ canvas.discardActiveObject();
1007
+ for (const child of selectionChildren) canvas.remove(child);
1008
+ }
1009
+ if (obj) canvas.remove(obj);
681
1010
 
682
1011
  const widgetImage = createImagePlaceholder({
683
1012
  left: bounds.left,
@@ -708,12 +1037,16 @@
708
1037
 
709
1038
  canvas.add(widgetImage);
710
1039
 
1040
+ // Set image fields SYNCHRONOUSLY: an autosave that runs before the
1041
+ // <img> loads must still persist the screenshot URL (previously the
1042
+ // JSON saved a bare placeholder and the sketch was lost on reload).
1043
+ widgetImage._imageUrl = screenshotDataUrl;
1044
+ widgetImage._hasImage = true;
1045
+ widgetImage._originalFilename = 'widget-screenshot.png';
1046
+
711
1047
  const img = new Image();
712
1048
  img.onload = () => {
713
1049
  widgetImage._imageElement = img;
714
- widgetImage._hasImage = true;
715
- widgetImage._imageUrl = screenshotDataUrl;
716
- widgetImage._originalFilename = 'widget-screenshot.png';
717
1050
  widgetImage._originalAspectRatio = img.width / img.height;
718
1051
  widgetImage.dirty = true;
719
1052
  canvas.requestRenderAll();
@@ -786,6 +1119,71 @@
786
1119
  return false;
787
1120
  }
788
1121
 
1122
+ /**
1123
+ * Screen-space box of a widget shape plus the canvas element viewport.
1124
+ * Used to park Widget Details left/right of the shape.
1125
+ */
1126
+ export function getWidgetShapeScreenRect(widgetId: string): {
1127
+ left: number;
1128
+ top: number;
1129
+ width: number;
1130
+ height: number;
1131
+ viewportLeft: number;
1132
+ viewportTop: number;
1133
+ viewportWidth: number;
1134
+ viewportHeight: number;
1135
+ } | null {
1136
+ if (!canvas || !canvasEl || !widgetId) return null;
1137
+ const obj = canvas.getObjects().find((o: any) => o._widgetId === widgetId);
1138
+ if (!obj) {
1139
+ canvasLog('Canvas: getWidgetShapeScreenRect — no shape for', widgetId);
1140
+ return null;
1141
+ }
1142
+ const bbox = obj.getBoundingRect();
1143
+ const canvasRect = canvasEl.getBoundingClientRect();
1144
+ return {
1145
+ left: canvasRect.left + bbox.left,
1146
+ top: canvasRect.top + bbox.top,
1147
+ width: bbox.width,
1148
+ height: bbox.height,
1149
+ viewportLeft: canvasRect.left,
1150
+ viewportTop: canvasRect.top,
1151
+ viewportWidth: canvasRect.width,
1152
+ viewportHeight: canvasRect.height,
1153
+ };
1154
+ }
1155
+
1156
+ /** Screen-space box for any canvas object id (convert drafts use the grouped sketch). */
1157
+ export function getObjectScreenRect(objectId: string): {
1158
+ left: number;
1159
+ top: number;
1160
+ width: number;
1161
+ height: number;
1162
+ viewportLeft: number;
1163
+ viewportTop: number;
1164
+ viewportWidth: number;
1165
+ viewportHeight: number;
1166
+ } | null {
1167
+ if (!canvas || !canvasEl || !objectId) return null;
1168
+ const obj = canvas.getObjects().find((o: any) => o._objectId === objectId);
1169
+ if (!obj) {
1170
+ canvasLog('Canvas: getObjectScreenRect — no object for', objectId);
1171
+ return null;
1172
+ }
1173
+ const bbox = obj.getBoundingRect();
1174
+ const canvasRect = canvasEl.getBoundingClientRect();
1175
+ return {
1176
+ left: canvasRect.left + bbox.left,
1177
+ top: canvasRect.top + bbox.top,
1178
+ width: bbox.width,
1179
+ height: bbox.height,
1180
+ viewportLeft: canvasRect.left,
1181
+ viewportTop: canvasRect.top,
1182
+ viewportWidth: canvasRect.width,
1183
+ viewportHeight: canvasRect.height,
1184
+ };
1185
+ }
1186
+
789
1187
  /**
790
1188
  * Get the current image data URL of a widget shape on the canvas.
791
1189
  * Returns the base64 data URL or null if the shape is not found or has no image.
@@ -1043,12 +1441,12 @@
1043
1441
  function getPanZoomPositionClass(position: string): string {
1044
1442
  canvasLog('getPanZoomPositionClass: ', position);
1045
1443
  const positions: Record<string, string> = {
1046
- 'TL': 'top-4 left-4',
1047
- 'TC': 'top-4 left-1/2 -translate-x-1/2',
1048
- 'TR': 'top-4 right-4',
1049
- 'BL': 'bottom-20 left-4',
1050
- 'BC': 'bottom-20 left-1/2 -translate-x-1/2',
1051
- 'BR': 'bottom-20 right-4'
1444
+ 'TL': 'absolute z-10 top-4 left-4',
1445
+ 'TC': 'absolute z-10 top-4 left-1/2 -translate-x-1/2',
1446
+ 'TR': 'absolute z-10 top-4 right-4',
1447
+ 'BL': 'absolute z-10 bottom-20 left-4',
1448
+ 'BC': 'absolute z-10 bottom-20 left-1/2 -translate-x-1/2',
1449
+ 'BR': 'absolute z-10 bottom-20 right-4'
1052
1450
  };
1053
1451
  return positions[position] || positions['TL'];
1054
1452
  }
@@ -1084,6 +1482,10 @@
1084
1482
  // Let Fabric handle Shift+corner = proportional resize natively.
1085
1483
  // For _lockAspectRatio shapes, before:transform sets uniformScaling=true.
1086
1484
  (canvas as any).uniScaleKey = 'shiftKey';
1485
+ // Free corner resize (not from center). Keep Alt off Fabric's centeredKey —
1486
+ // Option is used for frame children, and a stuck altKey used to explode scales.
1487
+ canvas.centeredScaling = false;
1488
+ (canvas as any).centeredKey = null;
1087
1489
 
1088
1490
  // Set 'move' cursor as default hover cursor on ALL objects.
1089
1491
  // Set on both canvas level and prototype level for full coverage.
@@ -1693,8 +2095,10 @@
1693
2095
  let altKeyCurrentlyHeld = false;
1694
2096
  const trackAltKeyDown = (e: KeyboardEvent) => { if (e.altKey) altKeyCurrentlyHeld = true; };
1695
2097
  const trackAltKeyUp = (e: KeyboardEvent) => { if (!e.altKey) altKeyCurrentlyHeld = false; };
2098
+ const trackAltKeyBlur = () => { altKeyCurrentlyHeld = false; };
1696
2099
  window.addEventListener('keydown', trackAltKeyDown);
1697
2100
  window.addEventListener('keyup', trackAltKeyUp);
2101
+ window.addEventListener('blur', trackAltKeyBlur);
1698
2102
 
1699
2103
  // ── Custom right-click context menu ───────────────────────────────────
1700
2104
  // Prevent the browser default context menu on the canvas and show ours.
@@ -1718,12 +2122,17 @@
1718
2122
  const transform = opt.transform;
1719
2123
  if (!transform) return;
1720
2124
 
1721
- // Disable Alt/Option-triggered centered scaling for Frame objects.
1722
- // Alt is used for "preserve children" resize on frames, not center-scale.
1723
- // The centeredKey check happens BEFORE before:transform, so we can't prevent it.
1724
- // Instead, override the transform origin from 'center' back to the correct corner.
1725
- if (isFrameShape(transform.target) && transform.originX === 'center' && transform.originY === 'center') {
1726
- // Map corner handle to the correct opposite origin
2125
+ // Only remap when Fabric forced origin=center on an object that is NOT
2126
+ // natively center-origin (library icons / groups already use origin center).
2127
+ // Remapping those made corner resize look like Alt-deflate.
2128
+ const targetObj = transform.target as any;
2129
+ const nativeCenterOrigin =
2130
+ targetObj?.originX === 'center' && targetObj?.originY === 'center';
2131
+ if (
2132
+ !nativeCenterOrigin &&
2133
+ transform.originX === 'center' &&
2134
+ transform.originY === 'center'
2135
+ ) {
1727
2136
  const cornerOriginMap: Record<string, { x: string; y: string }> = {
1728
2137
  tl: { x: 'right', y: 'bottom' },
1729
2138
  tr: { x: 'left', y: 'bottom' },
@@ -1739,7 +2148,7 @@
1739
2148
  if (origin) {
1740
2149
  transform.originX = origin.x;
1741
2150
  transform.originY = origin.y;
1742
- canvasLog('Frame: overrode centered scaling origin for corner', corner, '→', origin);
2151
+ canvasLog('Canvas: overrode accidental centered origin for corner', corner, '→', origin);
1743
2152
  }
1744
2153
  }
1745
2154
 
@@ -1768,13 +2177,19 @@
1768
2177
  // Store the original corners BEFORE any transformation
1769
2178
  // This ensures we have accurate positions to work with
1770
2179
  const target = scalingTarget as any;
1771
- const w = target.width * target.scaleX;
1772
- const h = target.height * target.scaleY;
2180
+ const w = (target.width ?? 0) * (target.scaleX ?? 1);
2181
+ const h = (target.height ?? 0) * (target.scaleY ?? 1);
2182
+ const originX = target.originX ?? 'left';
2183
+ const originY = target.originY ?? 'top';
2184
+ const left = target.left ?? 0;
2185
+ const top = target.top ?? 0;
2186
+ const tlX = originX === 'center' ? left - w / 2 : originX === 'right' ? left - w : left;
2187
+ const tlY = originY === 'center' ? top - h / 2 : originY === 'bottom' ? top - h : top;
1773
2188
  target._originalCorners = {
1774
- tl: { x: target.left, y: target.top },
1775
- tr: { x: target.left + w, y: target.top },
1776
- bl: { x: target.left, y: target.top + h },
1777
- br: { x: target.left + w, y: target.top + h }
2189
+ tl: { x: tlX, y: tlY },
2190
+ tr: { x: tlX + w, y: tlY },
2191
+ bl: { x: tlX, y: tlY + h },
2192
+ br: { x: tlX + w, y: tlY + h }
1778
2193
  };
1779
2194
 
1780
2195
  // For frames: store original children metrics for Alt/Option key resize.
@@ -1917,6 +2332,15 @@
1917
2332
  if ((obj as any)._customType === 'image') {
1918
2333
  // Upgrade old Rect-based image placeholders to ImageShape (backward compat)
1919
2334
  upgradeToImageShape(obj);
2335
+ if ((obj as any)._isWidgetImage) {
2336
+ if ((obj as any)._isPrimaryWidget) {
2337
+ obj.set({
2338
+ stroke: PRIMARY_WIDGET_STROKE,
2339
+ strokeWidth: PRIMARY_WIDGET_STROKE_WIDTH,
2340
+ strokeDashArray: null,
2341
+ });
2342
+ }
2343
+ }
1920
2344
  canvasLog('Canvas: object:added — upgraded image shape for', (obj as any)._objectId || 'image');
1921
2345
  }
1922
2346
 
@@ -2054,7 +2478,7 @@
2054
2478
  // Textboxes use backgroundColor for their box fill.
2055
2479
  // Default to white so the box fill opacity slider is visible.
2056
2480
  obj.set({ backgroundColor: '#ffffff' });
2057
- } else {
2481
+ } else if (String(obj.type || '').toLowerCase() !== 'group') {
2058
2482
  obj.set({
2059
2483
  fill: WIREFRAME_FILL,
2060
2484
  stroke: WIREFRAME_STROKE,
@@ -2065,8 +2489,11 @@
2065
2489
  canvasLog('Canvas: Wireframe defaults applied to new object', obj._objectId);
2066
2490
  }
2067
2491
  }
2492
+ stampTopLevelPaintOrder();
2068
2493
  });
2069
2494
 
2495
+ canvas.on('mouse:down:before', stampTopLevelPaintOrder);
2496
+
2070
2497
  // ── Proportional resize lock: handled via canvas.uniformScaling in before:transform ──
2071
2498
  // Edge handles (ml/mr/mt/mb) are hidden for _lockAspectRatio shapes (see object:added).
2072
2499
  // canvas.uniformScaling=true is set in before:transform for the drag duration, which
@@ -2698,6 +3125,10 @@
2698
3125
  isAltHeld = false;
2699
3126
  }
2700
3127
  };
3128
+ const handleGlobalAltBlur = () => {
3129
+ isAltHeld = false;
3130
+ };
3131
+ window.addEventListener('blur', handleGlobalAltBlur);
2701
3132
 
2702
3133
  // Shared helper: delete whatever is currently active on the canvas.
2703
3134
  // Used by both the Delete key handler and the Cut (Ctrl+X) handler.
@@ -3012,6 +3443,13 @@
3012
3443
  if (activeTool !== 'select') {
3013
3444
  activateSelectTool();
3014
3445
  }
3446
+
3447
+ // Escape while inside a group exits edit mode and keeps the group selected
3448
+ if (activeEditingGroup) {
3449
+ exitGroupEdit(true);
3450
+ canvasLog('Canvas: Escape - Exited group edit');
3451
+ return;
3452
+ }
3015
3453
 
3016
3454
  // Deselect all objects
3017
3455
  canvas.discardActiveObject();
@@ -3042,6 +3480,11 @@
3042
3480
  '_textPadding',
3043
3481
  // Text stroke properties
3044
3482
  '_textStrokeColor', '_textStrokeWidth',
3483
+ // Group chrome (fill/stroke around children, like Frame)
3484
+ '_groupFill', '_groupStroke', '_groupStrokeWidth',
3485
+ '_groupFillOpacity', '_groupStrokeDashArray',
3486
+ '_canvasPaintOrder',
3487
+ '_isPrimaryWidget',
3045
3488
  ];
3046
3489
 
3047
3490
  // Apply custom Image render to a cloned object
@@ -3982,6 +4425,8 @@
3982
4425
  (canvas as any)._globalShiftKeyupHandler = handleGlobalShiftKeyup;
3983
4426
  (canvas as any)._globalAltKeydownHandler = handleGlobalAltKeydown;
3984
4427
  (canvas as any)._globalAltKeyupHandler = handleGlobalAltKeyup;
4428
+ (canvas as any)._globalAltBlurHandler = handleGlobalAltBlur;
4429
+ (canvas as any)._altTrackBlur = trackAltKeyBlur;
3985
4430
  (canvas as any)._deleteKeyHandler = handleDeleteKeydown;
3986
4431
  (canvas as any)._arrowKeyHandler = handleArrowKeydown;
3987
4432
  (canvas as any)._selectAllHandler = handleSelectAllKeydown;
@@ -4084,6 +4529,28 @@
4084
4529
  canvas.setActiveObject(activeObj.group);
4085
4530
  return activeObj.group;
4086
4531
  }
4532
+
4533
+ // Regular group children: only selectable in double-click edit mode.
4534
+ // Otherwise promote the click to the parent group (tldraw-like).
4535
+ const parent = activeObj.group;
4536
+ if (parent && isRegularGroup(parent)) {
4537
+ if (activeEditingGroup === parent && parent.interactive) {
4538
+ return activeObj;
4539
+ }
4540
+ if (!isRedirectingGroupSelection) {
4541
+ isRedirectingGroupSelection = true;
4542
+ try {
4543
+ canvas.setActiveObject(parent);
4544
+ } finally {
4545
+ isRedirectingGroupSelection = false;
4546
+ }
4547
+ }
4548
+ return parent;
4549
+ }
4550
+
4551
+ if (activeEditingGroup && activeObj !== activeEditingGroup) {
4552
+ exitGroupEdit(false);
4553
+ }
4087
4554
 
4088
4555
  return activeObj;
4089
4556
  };
@@ -4170,10 +4637,12 @@
4170
4637
 
4171
4638
  const handleSelectionCleared = () => {
4172
4639
  selectedObject = null;
4173
- canvasLog('Canvas: Selection cleared');
4174
- if (onNonWidgetSelected) {
4175
- onNonWidgetSelected();
4640
+ if (activeEditingGroup) {
4641
+ exitGroupEdit(false);
4176
4642
  }
4643
+ canvasLog('Canvas: Selection cleared');
4644
+ // Do not close Widget Details here — Fabric also clears selection when
4645
+ // switching from widget A to widget B, which reopened the previous panel.
4177
4646
  };
4178
4647
 
4179
4648
  canvas.on('selection:created', handleSelectionCreated);
@@ -4200,6 +4669,27 @@
4200
4669
  return;
4201
4670
  }
4202
4671
 
4672
+ // Regular Group: double-click enters edit mode (tldraw-like).
4673
+ // Single click always selects the group; children are only
4674
+ // selectable after this enter.
4675
+ if (
4676
+ String(target.type || '').toLowerCase() === 'group' &&
4677
+ !isFrameShape(target) &&
4678
+ !(target as any)._isWidgetImage
4679
+ ) {
4680
+ enterGroupEdit(target);
4681
+ const sub = Array.isArray(e.subTargets) ? e.subTargets[0] : null;
4682
+ if (sub && sub !== target) {
4683
+ canvas.setActiveObject(sub);
4684
+ }
4685
+ canvas.requestRenderAll();
4686
+ canvasLog('Canvas: entered group via double-click', {
4687
+ children: target.getObjects?.()?.length,
4688
+ subType: sub?.type,
4689
+ });
4690
+ return;
4691
+ }
4692
+
4203
4693
  // Widget Image (linked to a widget): open WidgetDetails panel
4204
4694
  // Must be checked BEFORE generic WidgetShape — widget shapes also have _customType === 'image'
4205
4695
  if (onWidgetImageClick && (target as any)?._isWidgetImage && (target as any)?._widgetId) {
@@ -5028,6 +5518,12 @@
5028
5518
  if (canvas && (canvas as any)._altTrackUp) {
5029
5519
  window.removeEventListener('keyup', (canvas as any)._altTrackUp);
5030
5520
  }
5521
+ if (canvas && (canvas as any)._altTrackBlur) {
5522
+ window.removeEventListener('blur', (canvas as any)._altTrackBlur);
5523
+ }
5524
+ if (canvas && (canvas as any)._globalAltBlurHandler) {
5525
+ window.removeEventListener('blur', (canvas as any)._globalAltBlurHandler);
5526
+ }
5031
5527
  if (canvas && (canvas as any)._contextMenuHandler && (canvas as any)._contextMenuTarget) {
5032
5528
  (canvas as any)._contextMenuTarget.removeEventListener('contextmenu', (canvas as any)._contextMenuHandler);
5033
5529
  }
@@ -5061,6 +5557,9 @@
5061
5557
  if (canvas && (canvas as any)._frameInteriorMouseDownHandler) {
5062
5558
  canvas.off('mouse:down:before', (canvas as any)._frameInteriorMouseDownHandler);
5063
5559
  }
5560
+ if (canvas) {
5561
+ canvas.off('mouse:down:before', stampTopLevelPaintOrder);
5562
+ }
5064
5563
  if (canvas && (canvas as any)._frameInteriorMouseUpHandler) {
5065
5564
  canvas.off('mouse:up', (canvas as any)._frameInteriorMouseUpHandler);
5066
5565
  }
@@ -5203,13 +5702,25 @@
5203
5702
  };
5204
5703
  };
5205
5704
 
5206
- const estimatePlaceholderWidth = () => {
5207
- if (!textBoxPlaceholder) {
5208
- return MIN_TEXTBOX_WIDTH;
5705
+ /** Width that fits the current default/placeholder text on one line.
5706
+ * measureText is the wrap width; padding is drawn outside that, so only a
5707
+ * small extra is needed (avoid double-counting create padding). */
5708
+ const measureDefaultTextboxWidth = () => {
5709
+ const text = textBoxPlaceholder || '';
5710
+ const extra = 2;
5711
+ if (!text) return MIN_TEXTBOX_WIDTH;
5712
+ try {
5713
+ const fabricCtx = canvas && typeof canvas.getContext === 'function' ? canvas.getContext() : null;
5714
+ const ctx = fabricCtx ?? document.createElement('canvas').getContext('2d');
5715
+ if (ctx) {
5716
+ ctx.font = `${textFontSize}px ${textFontFamily}`;
5717
+ const measured = ctx.measureText(text).width;
5718
+ return Math.max(MIN_TEXTBOX_WIDTH, Math.ceil(measured + extra));
5719
+ }
5720
+ } catch {
5721
+ /* fall through to heuristic */
5209
5722
  }
5210
-
5211
- const estimatedWidth = textBoxPlaceholder.length * textFontSize * 0.6;
5212
- return Math.max(MIN_TEXTBOX_WIDTH, estimatedWidth);
5723
+ return Math.max(MIN_TEXTBOX_WIDTH, Math.ceil(text.length * textFontSize * 0.6 + extra));
5213
5724
  };
5214
5725
 
5215
5726
  const createArrowGeometry = (
@@ -6238,7 +6749,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
6238
6749
  previewObject = new Textbox(textBoxPlaceholder, {
6239
6750
  left: drawingStartPoint.x,
6240
6751
  top: drawingStartPoint.y,
6241
- width: estimatePlaceholderWidth() + 12,
6752
+ width: measureDefaultTextboxWidth(),
6242
6753
  fontSize: textFontSize,
6243
6754
  fontFamily: textFontFamily,
6244
6755
  fill: textFillColor,
@@ -6262,7 +6773,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
6262
6773
  }
6263
6774
  }
6264
6775
 
6265
- const minWidth = 50;
6776
+ const minWidth = MIN_TEXTBOX_WIDTH;
6266
6777
  const desiredWidth = Math.max(width, minWidth);
6267
6778
  const left = Math.min(pointer.x, drawingStartPoint.x);
6268
6779
  const top = Math.min(pointer.y, drawingStartPoint.y);
@@ -6290,8 +6801,9 @@ function checkAndAddNewShapeToFrame(shape: any) {
6290
6801
  // On quick click (no drag), use the estimated placeholder width
6291
6802
  // so the text fits nicely without wrapping.
6292
6803
  const width = Math.abs(pointer.x - drawingStartPoint.x);
6293
- const placeholderWidth = estimatePlaceholderWidth() + 12;
6294
- const textboxWidth = width < MIN_DRAW_DISTANCE ? placeholderWidth : Math.max(width, 50);
6804
+ const textboxWidth = width < MIN_DRAW_DISTANCE
6805
+ ? measureDefaultTextboxWidth()
6806
+ : Math.max(width, MIN_TEXTBOX_WIDTH);
6295
6807
 
6296
6808
  // Calculate position (support drag in any direction)
6297
6809
  const left = Math.min(pointer.x, drawingStartPoint.x);
@@ -6787,8 +7299,61 @@ function checkAndAddNewShapeToFrame(shape: any) {
6787
7299
  }
6788
7300
 
6789
7301
  /** No visible Fabric stroke on widget shapes — preview image may supply its own border. */
7302
+ const PRIMARY_WIDGET_STROKE = '#DC2626';
7303
+ const PRIMARY_WIDGET_STROKE_WIDTH = 2;
7304
+
7305
+ /** Red stroke marks the canvas primary widget; others stay borderless. */
7306
+ function applyPrimaryWidgetStrokes(primaryWidgetId: string | null) {
7307
+ if (!canvas) return;
7308
+ for (const obj of canvas.getObjects()) {
7309
+ const anyObj = obj as any;
7310
+ if (!anyObj._isWidgetImage) continue;
7311
+ const isPrimary = !!(primaryWidgetId && anyObj._widgetId === primaryWidgetId);
7312
+ anyObj._isPrimaryWidget = isPrimary;
7313
+ if (isPrimary) {
7314
+ obj.set({
7315
+ stroke: PRIMARY_WIDGET_STROKE,
7316
+ strokeWidth: PRIMARY_WIDGET_STROKE_WIDTH,
7317
+ strokeDashArray: null,
7318
+ });
7319
+ } else {
7320
+ obj.set({ stroke: 'transparent', strokeWidth: 0, strokeDashArray: null });
7321
+ }
7322
+ obj.dirty = true;
7323
+ }
7324
+ canvas.requestRenderAll();
7325
+ canvasLog('Canvas: applied primary widget stroke', primaryWidgetId);
7326
+ }
7327
+
7328
+ export function setPrimaryWidgetShape(widgetId: string): boolean {
7329
+ if (!widgetId) return false;
7330
+ applyPrimaryWidgetStrokes(widgetId);
7331
+ selectedObject = selectedObject;
7332
+ return true;
7333
+ }
7334
+
7335
+ function handleSetPrimaryFromPanel(widgetId: string) {
7336
+ setPrimaryWidgetShape(widgetId);
7337
+ onSetPrimaryWidget?.(widgetId);
7338
+ }
7339
+
7340
+ export function getPrimaryWidgetId(): string | null {
7341
+ if (!canvas) return null;
7342
+ const primary = canvas.getObjects().find((o: any) => o._isWidgetImage && o._isPrimaryWidget);
7343
+ return (primary as any)?._widgetId ?? null;
7344
+ }
7345
+
7346
+ /** No visible Fabric stroke on widget shapes unless this one is primary. */
6790
7347
  function clearWidgetShapeDefaultBorder(obj: any): void {
6791
- obj.set({ stroke: 'transparent', strokeWidth: 0, strokeDashArray: null });
7348
+ if (obj._isPrimaryWidget) {
7349
+ obj.set({
7350
+ stroke: PRIMARY_WIDGET_STROKE,
7351
+ strokeWidth: PRIMARY_WIDGET_STROKE_WIDTH,
7352
+ strokeDashArray: null,
7353
+ });
7354
+ } else {
7355
+ obj.set({ stroke: 'transparent', strokeWidth: 0, strokeDashArray: null });
7356
+ }
6792
7357
  obj.dirty = true;
6793
7358
  }
6794
7359
 
@@ -7567,63 +8132,76 @@ function checkAndAddNewShapeToFrame(shape: any) {
7567
8132
  * They are used to test the canvas and the fabric.js library.
7568
8133
  * */
7569
8134
  // Group selection method
7570
- export function onGroupSelection() {
8135
+ export function onGroupSelection(): string | null {
7571
8136
  if (!canvas) {
7572
- return;
8137
+ return null;
7573
8138
  }
7574
8139
 
7575
8140
  const activeObject = canvas.getActiveObject();
7576
8141
  if (!activeObject) {
7577
- return;
8142
+ return null;
7578
8143
  }
7579
8144
 
7580
8145
  // check if the active object is an active selection
7581
- if (activeObject.type !== 'activeSelection' &&
8146
+ if (activeObject.type !== 'activeSelection' &&
7582
8147
  activeObject.type !== 'activeselection') {
7583
- return;
7584
- }
7585
-
7586
- // Get objects from selection
7587
- const objects = activeObject.getObjects();
8148
+ // Already a single object (e.g. re-convert of an existing group) —
8149
+ // return its id so the host can replace it later.
8150
+ const single = activeObject as any;
8151
+ if (!single._objectId) single._objectId = generateObjectId();
8152
+ return single._objectId as string;
8153
+ }
8154
+
8155
+ // Copy — getObjects() returns the live _objects array.
8156
+ // Sort by canvas paint order (bottom → top) so grouping does not reverse z-index.
8157
+ const objects = [...activeObject.getObjects()].sort((a: any, b: any) => {
8158
+ const ia = a._canvasPaintOrder;
8159
+ const ib = b._canvasPaintOrder;
8160
+ if (typeof ia === 'number' && typeof ib === 'number') return ia - ib;
8161
+ return 0;
8162
+ });
7588
8163
  if (objects.length === 0) {
7589
- return;
8164
+ return null;
7590
8165
  }
7591
8166
 
7592
- // Remove objects from canvas
8167
+ // Fabric 6: ActiveSelection.onDeselect() → removeAll() applies the
8168
+ // selection transform so children return to canvas-absolute coords.
8169
+ // This MUST run while objects are still on the canvas. Removing them
8170
+ // first, then discarding, double-applies the transform — children
8171
+ // collapse behind the bounding rect (the Sep 2025 grouping bug).
8172
+ canvas.discardActiveObject();
8173
+
7593
8174
  objects.forEach((obj: any) => {
7594
8175
  canvas.remove(obj);
8176
+ if (!obj._objectId) obj._objectId = generateObjectId();
7595
8177
  });
7596
8178
 
7597
- // Discard the active selection
7598
- canvas.discardActiveObject();
7599
-
7600
- // Create group with all objects
8179
+ // Group constructor + FitContentLayout converts canvas-absolute coords
8180
+ // into group-local and places the group at the former selection bounds.
7601
8181
  const group = new Group(objects, {
7602
- canvas: canvas
8182
+ subTargetCheck: true,
8183
+ interactive: false,
8184
+ objectCaching: false,
8185
+ fill: '',
8186
+ stroke: '',
8187
+ strokeWidth: 0,
8188
+ backgroundColor: '',
7603
8189
  });
8190
+ applyInteractiveGroupFlags(group);
8191
+ (group as any)._objectId = generateObjectId();
8192
+ (group as any)._customType = 'group';
8193
+ group.set({ fill: '', stroke: '', strokeWidth: 0, backgroundColor: '' });
7604
8194
 
7605
- // Disable object caching if group contains children with custom render overrides.
7606
- // Frame labels are drawn outside the bounding box (clipped by cache).
7607
- // Image placeholders use manual transforms that conflict with cache context.
7608
- const hasCustomRenderChild = objects.some((obj: any) =>
7609
- isFrameShape(obj) || (obj as any)._customType === 'image'
7610
- );
7611
- if (hasCustomRenderChild) {
7612
- group.objectCaching = false;
7613
- }
7614
-
7615
- // add the group to the canvas
7616
8195
  canvas.add(group);
7617
-
7618
- // set the group as the active object
7619
8196
  canvas.setActiveObject(group);
7620
-
7621
- // request a render all
7622
8197
  canvas.requestRenderAll();
7623
8198
  takeHistorySnapshot();
7624
8199
 
7625
- // log the group creation
7626
- canvasLog('Group selection created', { Group: group, objectCount: objects.length, hasFrameChild });
8200
+ canvasLog('Group selection created', {
8201
+ objectCount: objects.length,
8202
+ objectId: (group as any)._objectId,
8203
+ });
8204
+ return (group as any)._objectId as string;
7627
8205
  }
7628
8206
 
7629
8207
  // Ungroup selection method - using native Fabric.js approach
@@ -7651,6 +8229,9 @@ function checkAndAddNewShapeToFrame(shape: any) {
7651
8229
  }
7652
8230
 
7653
8231
  const group = activeObject as Group;
8232
+ if (activeEditingGroup === group) {
8233
+ exitGroupEdit(false);
8234
+ }
7654
8235
 
7655
8236
  canvasLog('Ungroup: Starting with', group.getObjects().length, 'objects');
7656
8237
 
@@ -7769,6 +8350,10 @@ function checkAndAddNewShapeToFrame(shape: any) {
7769
8350
  '_widgetId',
7770
8351
  // Widget Image: converted widget placeholder on canvas
7771
8352
  '_isWidgetImage',
8353
+ '_groupFill', '_groupStroke', '_groupStrokeWidth',
8354
+ '_groupFillOpacity', '_groupStrokeDashArray',
8355
+ '_canvasPaintOrder',
8356
+ '_isPrimaryWidget',
7772
8357
  ];
7773
8358
 
7774
8359
  // ── History: capture / restore ───────────────────────────────────────────
@@ -7830,8 +8415,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
7830
8415
  }, autosaveDelayMs);
7831
8416
  }
7832
8417
 
7833
- /** Force an immediate save (used by Cmd+S and the save button). */
7834
- function saveNow() {
8418
+ /** Force an immediate save (used by Cmd+S, the save button, and host post-replace flushes). */
8419
+ export function saveNow() {
7835
8420
  if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
7836
8421
  _autoSaveTimer = null;
7837
8422
  const json = saveCanvas();
@@ -7880,6 +8465,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
7880
8465
  syncTextboxFabricPadding(obj);
7881
8466
  }
7882
8467
  }
8468
+ applyInteractiveGroupFlags(obj);
7883
8469
  } catch (reviverErr) {
7884
8470
  canvasWarn('Canvas: restoreHistorySnapshot reviver error', obj?.type, reviverErr);
7885
8471
  }
@@ -7896,6 +8482,13 @@ function checkAndAddNewShapeToFrame(shape: any) {
7896
8482
  });
7897
8483
  } else if (obj._customType === 'image' || obj._hasImage || obj._imageUrl) {
7898
8484
  upgradeToImageShape(obj);
8485
+ } else {
8486
+ applyInteractiveGroupFlags(obj);
8487
+ obj.getObjects?.()?.forEach((child: any) => {
8488
+ if (child._customType === 'image' || child._hasImage || child._imageUrl) {
8489
+ upgradeToImageShape(child);
8490
+ }
8491
+ });
7899
8492
  }
7900
8493
  });
7901
8494
  canvas.discardActiveObject();
@@ -8021,6 +8614,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
8021
8614
  syncTextboxFabricPadding(obj);
8022
8615
  }
8023
8616
  }
8617
+ applyInteractiveGroupFlags(obj);
8024
8618
  } catch (reviverErr) {
8025
8619
  canvasWarn('Canvas: loadCanvas reviver error for object', obj?.type, reviverErr);
8026
8620
  }
@@ -8051,6 +8645,9 @@ function checkAndAddNewShapeToFrame(shape: any) {
8051
8645
  };
8052
8646
  img.src = obj._imageUrl;
8053
8647
  }
8648
+ } else if (String(obj.type || '').toLowerCase() === 'group') {
8649
+ applyInteractiveGroupFlags(obj);
8650
+ (obj.getObjects?.() || []).forEach(restoreObject);
8054
8651
  }
8055
8652
  };
8056
8653
  allObjects.forEach(restoreObject);
@@ -8090,7 +8687,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
8090
8687
  const spatialOrphans = canvas.getObjects().filter((obj: any) =>
8091
8688
  !isFrameShape(obj) &&
8092
8689
  !(obj as any)._isFrameBackground &&
8093
- !obj.group // not already inside any group
8690
+ !obj.group && // not already inside any group
8691
+ String(obj.type || '').toLowerCase() !== 'group'
8094
8692
  );
8095
8693
  for (const obj of spatialOrphans) {
8096
8694
  const center = obj.getCenterPoint();
@@ -8430,7 +9028,6 @@ function checkAndAddNewShapeToFrame(shape: any) {
8430
9028
  }
8431
9029
 
8432
9030
  // If it's a plain group or activeSelection (NOT a Frame), apply to all children.
8433
- // Frames are caught above and returned early — this guard prevents accidental Group-path execution.
8434
9031
  if (!isFrameShape(selectedObject) && (selectedObject.type === 'group' || selectedObject.type === 'activeSelection' || selectedObject.type === 'activeselection')) {
8435
9032
  const group = selectedObject as Group;
8436
9033
  group.getObjects().forEach((obj: any) => {
@@ -8473,6 +9070,74 @@ function checkAndAddNewShapeToFrame(shape: any) {
8473
9070
  selectedObject = temp;
8474
9071
  canvasLog('Canvas: Updated fill color to', color);
8475
9072
  }
9073
+
9074
+ function refreshSelectedObjectUi() {
9075
+ const temp = selectedObject;
9076
+ selectedObject = null;
9077
+ selectedObject = temp;
9078
+ }
9079
+
9080
+ /** Group box chrome (behind/around children). Does not paint children. */
9081
+ function updateGroupFill(color: string) {
9082
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9083
+ (selectedObject as any)._groupFill = color;
9084
+ selectedObject.dirty = true;
9085
+ canvas.requestRenderAll();
9086
+ refreshSelectedObjectUi();
9087
+ canvasLog('Canvas: Updated group chrome fill to', color);
9088
+ }
9089
+
9090
+ function updateGroupFillOpacity(opacityPercent: number) {
9091
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9092
+ (selectedObject as any)._groupFillOpacity = opacityPercent / 100;
9093
+ selectedObject.dirty = true;
9094
+ canvas.requestRenderAll();
9095
+ refreshSelectedObjectUi();
9096
+ canvasLog('Canvas: Updated group chrome fill opacity to', opacityPercent + '%');
9097
+ }
9098
+
9099
+ function updateGroupStroke(color: string) {
9100
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9101
+ (selectedObject as any)._groupStroke = color;
9102
+ if (!(selectedObject as any)._groupStrokeWidth) {
9103
+ (selectedObject as any)._groupStrokeWidth = 2;
9104
+ }
9105
+ selectedObject.dirty = true;
9106
+ canvas.requestRenderAll();
9107
+ refreshSelectedObjectUi();
9108
+ canvasLog('Canvas: Updated group chrome stroke to', color);
9109
+ }
9110
+
9111
+ function updateGroupStrokeWidth(width: number) {
9112
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9113
+ (selectedObject as any)._groupStrokeWidth = width;
9114
+ if (width > 0 && !(selectedObject as any)._groupStroke) {
9115
+ (selectedObject as any)._groupStroke = '#000000';
9116
+ }
9117
+ selectedObject.dirty = true;
9118
+ canvas.requestRenderAll();
9119
+ refreshSelectedObjectUi();
9120
+ debouncedHistorySnapshot();
9121
+ canvasLog('Canvas: Updated group chrome stroke width to', width);
9122
+ }
9123
+
9124
+ function updateGroupStrokeType(type: 'solid' | 'dashed' | 'dotted') {
9125
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9126
+ let dashArray: number[] | null = null;
9127
+ if (type === 'dashed') dashArray = [10, 5];
9128
+ else if (type === 'dotted') dashArray = [2, 4];
9129
+ (selectedObject as any)._groupStrokeDashArray = dashArray;
9130
+ if (!(selectedObject as any)._groupStroke) {
9131
+ (selectedObject as any)._groupStroke = '#000000';
9132
+ }
9133
+ if (!(selectedObject as any)._groupStrokeWidth) {
9134
+ (selectedObject as any)._groupStrokeWidth = 2;
9135
+ }
9136
+ selectedObject.dirty = true;
9137
+ canvas.requestRenderAll();
9138
+ refreshSelectedObjectUi();
9139
+ canvasLog('Canvas: Updated group chrome stroke type to', type);
9140
+ }
8476
9141
 
8477
9142
  // Helper to convert any color to rgba with specified alpha
8478
9143
  function colorToRgba(color: string, alpha: number): string {
@@ -8567,7 +9232,6 @@ function checkAndAddNewShapeToFrame(shape: any) {
8567
9232
  }
8568
9233
  // Set on container too for UI reflection
8569
9234
  selectedObject._fillOpacity = alpha;
8570
- // If it's a regular group or activeSelection, apply to all children
8571
9235
  } else if (selectedObject.type === 'group' || selectedObject.type === 'activeSelection' || selectedObject.type === 'activeselection') {
8572
9236
  const group = selectedObject as Group;
8573
9237
  group.getObjects().forEach((obj: any) => {
@@ -9196,48 +9860,61 @@ function checkAndAddNewShapeToFrame(shape: any) {
9196
9860
  canvasLog('Canvas: Reordered in frame', { direction, from: idx, to: newIdx });
9197
9861
  }
9198
9862
 
9199
- function bringToFront() {
9863
+ function reorderInGroup(group: any, obj: any, direction: 'front' | 'forward' | 'backward' | 'back') {
9864
+ const objects = group._objects as any[];
9865
+ const idx = objects.indexOf(obj);
9866
+ if (idx === -1) return;
9867
+ const minIndex = 0;
9868
+ const maxIndex = objects.length - 1;
9869
+ objects.splice(idx, 1);
9870
+ let newIdx: number;
9871
+ switch (direction) {
9872
+ case 'front': newIdx = maxIndex; break;
9873
+ case 'forward': newIdx = Math.min(idx + 1, maxIndex); break;
9874
+ case 'backward': newIdx = Math.max(idx - 1, minIndex); break;
9875
+ case 'back': newIdx = minIndex; break;
9876
+ }
9877
+ objects.splice(newIdx, 0, obj);
9878
+ group.dirty = true;
9879
+ group.objectCaching = false;
9880
+ canvasLog('Canvas: Reordered in group', { direction, from: idx, to: newIdx });
9881
+ }
9882
+
9883
+ function applyLayerOrder(direction: 'front' | 'forward' | 'backward' | 'back') {
9200
9884
  if (!selectedObject || !canvas) return;
9201
- if (selectedObject.group && isFrameShape(selectedObject.group)) {
9202
- reorderInFrame(selectedObject.group, selectedObject, 'front');
9203
- } else {
9885
+ const parent = selectedObject.group;
9886
+ if (parent && isFrameShape(parent)) {
9887
+ reorderInFrame(parent, selectedObject, direction);
9888
+ } else if (parent && isRegularGroup(parent)) {
9889
+ reorderInGroup(parent, selectedObject, direction);
9890
+ } else if (direction === 'front') {
9204
9891
  canvas.bringObjectToFront(selectedObject);
9892
+ } else if (direction === 'forward') {
9893
+ canvas.bringObjectForward(selectedObject);
9894
+ } else if (direction === 'backward') {
9895
+ canvas.sendObjectBackwards(selectedObject);
9896
+ } else {
9897
+ canvas.sendObjectToBack(selectedObject);
9205
9898
  }
9899
+ stampTopLevelPaintOrder();
9206
9900
  canvas.requestRenderAll();
9207
9901
  takeHistorySnapshot();
9208
9902
  }
9903
+
9904
+ function bringToFront() {
9905
+ applyLayerOrder('front');
9906
+ }
9209
9907
 
9210
9908
  function bringForward() {
9211
- if (!selectedObject || !canvas) return;
9212
- if (selectedObject.group && isFrameShape(selectedObject.group)) {
9213
- reorderInFrame(selectedObject.group, selectedObject, 'forward');
9214
- } else {
9215
- canvas.bringObjectForward(selectedObject);
9216
- }
9217
- canvas.requestRenderAll();
9218
- takeHistorySnapshot();
9909
+ applyLayerOrder('forward');
9219
9910
  }
9220
9911
 
9221
9912
  function sendBackward() {
9222
- if (!selectedObject || !canvas) return;
9223
- if (selectedObject.group && isFrameShape(selectedObject.group)) {
9224
- reorderInFrame(selectedObject.group, selectedObject, 'backward');
9225
- } else {
9226
- canvas.sendObjectBackwards(selectedObject);
9227
- }
9228
- canvas.requestRenderAll();
9229
- takeHistorySnapshot();
9913
+ applyLayerOrder('backward');
9230
9914
  }
9231
9915
 
9232
9916
  function sendToBack() {
9233
- if (!selectedObject || !canvas) return;
9234
- if (selectedObject.group && isFrameShape(selectedObject.group)) {
9235
- reorderInFrame(selectedObject.group, selectedObject, 'back');
9236
- } else {
9237
- canvas.sendObjectToBack(selectedObject);
9238
- }
9239
- canvas.requestRenderAll();
9240
- takeHistorySnapshot();
9917
+ applyLayerOrder('back');
9241
9918
  }
9242
9919
 
9243
9920
  /**
@@ -10466,11 +11143,12 @@ function checkAndAddNewShapeToFrame(shape: any) {
10466
11143
 
10467
11144
  <!-- Pan/Zoom Panel: Position configurable via panZoomPosition prop -->
10468
11145
  {#if !isCanvasLoading}
10469
- <PanZoomPanel
10470
- class="absolute {getPanZoomPositionClass(panZoomPosition)} z-10"
11146
+ <div class="canvas-pan-zoom-overlay {getPanZoomPositionClass(panZoomPosition)}">
11147
+ <PanZoomPanel
10471
11148
  {zoomLevel}
10472
11149
  isPanActive={activeTool === 'pan'}
10473
11150
  {showGrid}
11151
+ {advancedFeatures}
10474
11152
  onPanToggle={() => activeTool === 'pan' ? onSelectSelectTool() : onSelectPanTool()}
10475
11153
  onZoomIn={zoomIn}
10476
11154
  onZoomOut={zoomOut}
@@ -10484,6 +11162,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
10484
11162
  hasUnsavedChanges={_hasUnsavedChanges}
10485
11163
  onSave={saveNow}
10486
11164
  />
11165
+ </div>
10487
11166
  {/if}
10488
11167
 
10489
11168
  <!-- Canvas -->
@@ -10542,6 +11221,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
10542
11221
  onToggleStickyTool={() => { stickyToolEnabled = !stickyToolEnabled; }}
10543
11222
  {onInsertLibraryShape}
10544
11223
  {onSelectLibraryShapeTool}
11224
+ {advancedFeatures}
10545
11225
  />
10546
11226
  {/if}
10547
11227
 
@@ -10552,6 +11232,11 @@ function checkAndAddNewShapeToFrame(shape: any) {
10552
11232
  {wireframeMode}
10553
11233
  onFillChange={updateSelectedFill}
10554
11234
  onFillOpacityChange={updateSelectedFillOpacity}
11235
+ onGroupFillChange={updateGroupFill}
11236
+ onGroupFillOpacityChange={updateGroupFillOpacity}
11237
+ onGroupStrokeChange={updateGroupStroke}
11238
+ onGroupStrokeWidthChange={updateGroupStrokeWidth}
11239
+ onGroupStrokeTypeChange={updateGroupStrokeType}
10555
11240
  onBackgroundColorChange={updateSelectedBackgroundColor}
10556
11241
  onBackgroundOpacityChange={updateSelectedBackgroundOpacity}
10557
11242
  onStrokeChange={updateSelectedStroke}
@@ -10589,6 +11274,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
10589
11274
  onWidgetClick={onWidgetImageClick}
10590
11275
  {onWidgetRename}
10591
11276
  {onRefreshScreenshot}
11277
+ onSetAsPrimary={handleSetPrimaryFromPanel}
10592
11278
  {getWidgetName}
10593
11279
  {isWidgetKnown}
10594
11280
  onRemoveOrphanedShape={() => { _deleteActiveObjects(); }}
@@ -10673,6 +11359,6 @@ function checkAndAddNewShapeToFrame(shape: any) {
10673
11359
  max-w-[calc(100%-1rem)] w-11/12
10674
11360
  -->
10675
11361
 
10676
- <style>/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
10677
- button{cursor:pointer}.canvas-container{-webkit-user-select:none;user-select:none}.canvas-container input,.canvas-container textarea{-webkit-user-select:text;user-select:text}.canvas-loader-spinner{border:3px solid #e5e7eb;border-top-color:#374151;border-radius:50%;width:36px;height:36px;animation:.8s linear infinite canvas-spin}@keyframes canvas-spin{to{transform:rotate(360deg)}}
11362
+ <style>/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
11363
+ button{cursor:pointer}.canvas-container{-webkit-user-select:none;user-select:none}.canvas-container input,.canvas-container textarea{-webkit-user-select:text;user-select:text}.canvas-loader-spinner{border:3px solid #e5e7eb;border-top-color:#374151;border-radius:50%;width:36px;height:36px;animation:.8s linear infinite canvas-spin}@keyframes canvas-spin{to{transform:rotate(360deg)}}.canvas-pan-zoom-overlay{width:max-content;max-width:calc(100% - 2rem);overflow:visible}
10678
11364
  </style>