@widgetic/canvas 0.5.4 → 0.5.6

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.
@@ -213,6 +213,9 @@
213
213
  // Callback: fires when the user requests a screenshot refresh for the linked widget.
214
214
  export let onRefreshScreenshot: ((widgetId: string) => void) | null = null;
215
215
 
216
+ /** Host persists canvases.primary_widget_id after Canvas paints the red stroke. */
217
+ export let onSetPrimaryWidget: ((widgetId: string) => void) | null = null;
218
+
216
219
  // Widget name resolver: given a widgetId, returns the widget name (or null).
217
220
  // Used by PropsPanel to display the linked widget's name.
218
221
  export let getWidgetName: ((widgetId: string) => string | null) | null = null;
@@ -305,7 +308,7 @@
305
308
  let activeEditingTextbox: Textbox | null = null;
306
309
 
307
310
  const MIN_DRAW_DISTANCE = 4;
308
- const MIN_TEXTBOX_WIDTH = 120;
311
+ const MIN_TEXTBOX_WIDTH = 15;
309
312
 
310
313
  // Clipboard for copy/paste functionality
311
314
  let clipboard: any = null;
@@ -361,6 +364,237 @@
361
364
  return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
362
365
  }
363
366
 
367
+ /** Regular Fabric Group currently in double-click edit mode (children selectable). */
368
+ let activeEditingGroup: any = null;
369
+ let isExitingGroupEdit = false;
370
+ let isRedirectingGroupSelection = false;
371
+
372
+ function isRegularGroup(obj: any): boolean {
373
+ if (!obj || isFrameShape(obj)) return false;
374
+ if ((obj as any)._isWidgetImage) return false;
375
+ return String(obj.type || '').toLowerCase() === 'group';
376
+ }
377
+
378
+ const GROUP_IDLE_STROKE = '#64748b';
379
+ const GROUP_IDLE_DASH = [8, 5];
380
+ const GROUP_IDLE_STROKE_WIDTH = 1.5;
381
+ /** Hide the idle dashed outline during toDataURL / convert screenshots. */
382
+ let skipGroupIdleOutline = false;
383
+
384
+ /** Stamp top-level paint order before ActiveSelection swallows children. */
385
+ function stampTopLevelPaintOrder() {
386
+ if (!canvas) return;
387
+ canvas.getObjects().forEach((obj: any, i: number) => {
388
+ const t = String(obj.type || '').toLowerCase();
389
+ if (t === 'activeselection' || t === 'activeSelection') return;
390
+ obj._canvasPaintOrder = i;
391
+ });
392
+ }
393
+
394
+ function isGroupActivelySelected(group: any): boolean {
395
+ if (!canvas || !group) return false;
396
+ const active = canvas.getActiveObject() as any;
397
+ if (!active) return false;
398
+ if (active === group) return true;
399
+ const type = String(active.type || '').toLowerCase();
400
+ if ((type === 'activeselection' || type === 'activeSelection') && typeof active.getObjects === 'function') {
401
+ return active.getObjects().includes(group);
402
+ }
403
+ return false;
404
+ }
405
+
406
+ /** Group chrome (fill/stroke behind-or-around children) + idle dashed outline.
407
+ * Idle dashes are skipped while the group is selected and during screenshots. */
408
+ function applyGroupIdleOutlineRender(group: any) {
409
+ if (!isRegularGroup(group) || (group as any)._hasGroupOutlineRender) return;
410
+ (group as any)._hasGroupOutlineRender = true;
411
+ const originalDrawObject = group.drawObject.bind(group);
412
+ group.drawObject = function (ctx: CanvasRenderingContext2D, forClipping?: boolean, context?: any) {
413
+ const w = this.width;
414
+ const h = this.height;
415
+ const scale = Math.max(this.scaleX || 1, this.scaleY || 1);
416
+ const groupFill = (this as any)._groupFill;
417
+ const fillOpacity = (this as any)._groupFillOpacity ?? 1;
418
+ if (w && h && groupFill && groupFill !== '' && groupFill !== 'transparent') {
419
+ ctx.save();
420
+ ctx.fillStyle = fillOpacity < 1 ? colorToRgba(groupFill, fillOpacity) : groupFill;
421
+ ctx.fillRect(-w / 2, -h / 2, w, h);
422
+ ctx.restore();
423
+ }
424
+ originalDrawObject(ctx, forClipping, context);
425
+ if (forClipping || !w || !h) return;
426
+
427
+ const groupStroke = (this as any)._groupStroke;
428
+ const groupStrokeWidth = (this as any)._groupStrokeWidth ?? 0;
429
+ if (groupStroke && groupStrokeWidth > 0) {
430
+ ctx.save();
431
+ ctx.strokeStyle = groupStroke;
432
+ ctx.lineWidth = groupStrokeWidth / scale;
433
+ const dash = (this as any)._groupStrokeDashArray;
434
+ ctx.setLineDash(Array.isArray(dash) && dash.length ? dash : []);
435
+ ctx.beginPath();
436
+ ctx.rect(-w / 2, -h / 2, w, h);
437
+ ctx.stroke();
438
+ ctx.restore();
439
+ return;
440
+ }
441
+
442
+ if (skipGroupIdleOutline || isGroupActivelySelected(this)) return;
443
+ ctx.save();
444
+ ctx.strokeStyle = GROUP_IDLE_STROKE;
445
+ ctx.lineWidth = GROUP_IDLE_STROKE_WIDTH / scale;
446
+ ctx.setLineDash(GROUP_IDLE_DASH);
447
+ ctx.beginPath();
448
+ ctx.rect(-w / 2, -h / 2, w, h);
449
+ ctx.stroke();
450
+ ctx.restore();
451
+ };
452
+ }
453
+
454
+ /** Regular Groups stay uncached so children remain visible. `interactive` is
455
+ * false by default (click selects the group). Double-click enters edit mode. */
456
+ function applyInteractiveGroupFlags(group: any) {
457
+ if (!group || isFrameShape(group)) return;
458
+ if (!isRegularGroup(group)) return;
459
+ group.subTargetCheck = true;
460
+ group.objectCaching = false;
461
+ if (!(group as any)._groupEditMode) {
462
+ group.interactive = false;
463
+ }
464
+ applyGroupIdleOutlineRender(group);
465
+ group.getObjects?.()?.forEach((child: any) => applyInteractiveGroupFlags(child));
466
+ }
467
+
468
+ function snapshotGroupChildrenWorld(group: any): Array<{ obj: any; world: number[] }> {
469
+ return (group.getObjects?.() || []).map((obj: any) => ({
470
+ obj,
471
+ world: obj.calcTransformMatrix().slice(),
472
+ }));
473
+ }
474
+
475
+ function copyRegularGroupIdentity(fromGroup: any, toGroup: any) {
476
+ toGroup._objectId = fromGroup._objectId;
477
+ toGroup._customType = fromGroup._customType || 'group';
478
+ toGroup._groupFill = fromGroup._groupFill;
479
+ toGroup._groupStroke = fromGroup._groupStroke;
480
+ toGroup._groupStrokeWidth = fromGroup._groupStrokeWidth;
481
+ toGroup._groupFillOpacity = fromGroup._groupFillOpacity;
482
+ toGroup._groupStrokeDashArray = fromGroup._groupStrokeDashArray;
483
+ toGroup._canvasPaintOrder = fromGroup._canvasPaintOrder;
484
+ toGroup.set({ fill: '', stroke: '', strokeWidth: 0, backgroundColor: '' });
485
+ applyInteractiveGroupFlags(toGroup);
486
+ }
487
+
488
+ /**
489
+ * Leave children in canvas-absolute (world) space without FitContentLayout
490
+ * re-offsetting them. `removeAll()` would apply the *old* group matrix again.
491
+ */
492
+ function detachGroupChildrenAtWorld(
493
+ group: any,
494
+ snapshots: Array<{ obj: any; world: number[] }>,
495
+ ) {
496
+ const applyTransformToObject = (fabricUtil as any).applyTransformToObject;
497
+ group._activeObjects = [];
498
+ for (const { obj, world } of snapshots) {
499
+ if (typeof group._watchObject === 'function') {
500
+ group._watchObject(false, obj);
501
+ }
502
+ // Unset parent first — applyTransformToObject writes the object's
503
+ // own matrix; if `group` is still set, world = group * own (double).
504
+ obj._set?.('group', undefined);
505
+ obj._set?.('parent', undefined);
506
+ obj.group = undefined;
507
+ obj.parent = undefined;
508
+ applyTransformToObject(obj, world);
509
+ obj.setCoords?.();
510
+ }
511
+ group._objects = [];
512
+ }
513
+
514
+ /**
515
+ * Ungroup at current world positions, then Group() again (same path as
516
+ * onGroupSelection). Avoids the interactive-exit snap-back.
517
+ */
518
+ function rebuildRegularGroupFromWorld(
519
+ group: any,
520
+ snapshots = snapshotGroupChildrenWorld(group),
521
+ ): any {
522
+ if (!canvas || !group) return group;
523
+ if (!snapshots.length) return group;
524
+ const children = snapshots.map((entry) => entry.obj);
525
+ if (group.group) {
526
+ canvasLog('Canvas: skip group rebuild inside parent', group.group._customType);
527
+ return group;
528
+ }
529
+ const stackIndex = canvas.getObjects().indexOf(group);
530
+
531
+ detachGroupChildrenAtWorld(group, snapshots);
532
+ canvas.remove(group);
533
+
534
+ const newGroup = new Group(children, {
535
+ subTargetCheck: true,
536
+ interactive: false,
537
+ objectCaching: false,
538
+ fill: '',
539
+ stroke: '',
540
+ strokeWidth: 0,
541
+ backgroundColor: '',
542
+ });
543
+ copyRegularGroupIdentity(group, newGroup);
544
+ canvas.add(newGroup);
545
+ if (stackIndex >= 0 && typeof (canvas as any).moveObjectTo === 'function') {
546
+ (canvas as any).moveObjectTo(newGroup, stackIndex);
547
+ }
548
+ stampTopLevelPaintOrder();
549
+ canvasLog('Canvas: rebuilt group after edit', {
550
+ children: children.length,
551
+ objectId: newGroup._objectId,
552
+ });
553
+ return newGroup;
554
+ }
555
+
556
+ function exitGroupEdit(reselectGroup = false) {
557
+ const group = activeEditingGroup;
558
+ if (!group || isExitingGroupEdit) return;
559
+ isExitingGroupEdit = true;
560
+ // World matrices BEFORE discard — deselect remaps against the old box.
561
+ const snapshots = snapshotGroupChildrenWorld(group);
562
+ // Clear before discard: selection:cleared would otherwise re-enter
563
+ // exitGroupEdit and snapshot already-remapped (snapped) coords.
564
+ activeEditingGroup = null;
565
+ (group as any)._groupEditMode = false;
566
+ try {
567
+ if (canvas) {
568
+ const active = canvas.getActiveObject();
569
+ if (active && active !== group) {
570
+ canvas.discardActiveObject();
571
+ }
572
+ }
573
+ group.interactive = false;
574
+ const rebuilt = rebuildRegularGroupFromWorld(group, snapshots);
575
+ canvasLog('Canvas: exited group edit');
576
+ if (reselectGroup && canvas && rebuilt) {
577
+ canvas.setActiveObject(rebuilt);
578
+ }
579
+ canvas?.requestRenderAll();
580
+ } finally {
581
+ isExitingGroupEdit = false;
582
+ }
583
+ }
584
+
585
+ function enterGroupEdit(group: any) {
586
+ if (!group || !isRegularGroup(group)) return;
587
+ if (activeEditingGroup && activeEditingGroup !== group) {
588
+ exitGroupEdit(false);
589
+ }
590
+ (group as any)._groupEditMode = true;
591
+ group.interactive = true;
592
+ group.subTargetCheck = true;
593
+ group.objectCaching = false;
594
+ activeEditingGroup = group;
595
+ canvasLog('Canvas: entered group edit', { children: group.getObjects?.()?.length });
596
+ }
597
+
364
598
  // Text settings
365
599
  let textBoxPlaceholder = 'Type here';
366
600
  let textFontSize = 20;
@@ -399,6 +633,18 @@
399
633
  scaled: fabric.IEventHandler<Event>;
400
634
  } | null = null;
401
635
 
636
+ /**
637
+ * Pending multi-selection conversions, keyed by the selection's _objectId.
638
+ * convertObjectToWidget snapshots children + bounds here; the later
639
+ * replaceObjectWithWidgetImage consumes the entry. Needed because Fabric 6
640
+ * ActiveSelection empties itself on deselect (onDeselect → removeAll), so
641
+ * the selection state cannot be relied on after the convert click.
642
+ */
643
+ const pendingSelectionConversions = new Map<
644
+ string,
645
+ { children: any[]; bounds: { left: number; top: number; width: number; height: number } }
646
+ >();
647
+
402
648
  /**
403
649
  * HELPER FUNCTIONS
404
650
  */
@@ -406,45 +652,49 @@
406
652
  // Capture screenshot of selected object(s)
407
653
  async function captureObjectScreenshot(target: any): Promise<string> {
408
654
  if (!canvas) return '';
409
-
655
+
656
+ // Fabric 6 hazard: ActiveSelection.onDeselect() calls removeAll(), which
657
+ // EMPTIES the selection. Read everything we need from the selection
658
+ // BEFORE any discardActiveObject(), and never discard mid-capture.
659
+ const isMultiSelection =
660
+ target.type === 'activeSelection' || target.type === 'activeselection';
661
+ const selectionChildren = isMultiSelection ? target.getObjects() : [];
662
+ const selectionBounds = target.getBoundingRect();
663
+
664
+ skipGroupIdleOutline = true;
410
665
  try {
411
- // Deselect object temporarily to capture without selection controls
412
- const activeObject = canvas.getActiveObject();
413
- canvas.discardActiveObject();
414
- canvas.renderAll();
415
-
416
666
  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);
667
+
668
+ if (isMultiSelection) {
669
+ // Multi-selection: capture on the REAL canvas hide everything
670
+ // outside the selection, render, crop the selection's bounds.
671
+ // Do NOT discard the selection: in Fabric 6 discarding empties it
672
+ // (onDeselect removeAll) and the children would vanish. Selection
673
+ // controls live on the upper canvas, so toDataURL never shows them.
674
+ const childSet = new Set(selectionChildren);
675
+ const isHidden: Array<[any, boolean]> = [];
676
+ for (const o of canvas.getObjects()) {
677
+ if (!childSet.has(o) && o.visible) {
678
+ o.visible = false;
679
+ isHidden.push([o, true]);
680
+ }
437
681
  }
438
-
439
- tempCanvas.renderAll();
440
- dataUrl = tempCanvas.toDataURL({
682
+ canvas.renderAll();
683
+ dataUrl = canvas.toDataURL({
441
684
  format: 'png',
442
- multiplier: 2
685
+ multiplier: 2,
686
+ left: Math.floor(selectionBounds.left),
687
+ top: Math.floor(selectionBounds.top),
688
+ width: Math.max(1, Math.ceil(selectionBounds.width)),
689
+ height: Math.max(1, Math.ceil(selectionBounds.height))
443
690
  });
444
- tempCanvas.dispose();
691
+ for (const [o, wasVisible] of isHidden) {
692
+ if (wasVisible) o.visible = true;
693
+ }
694
+ canvas.requestRenderAll();
445
695
  } else {
446
- // Single object - use toDataURL directly on the object
447
- // This exports only the object with transparency preserved
696
+ // Single object toDataURL exports only the object with
697
+ // transparency preserved, no deselect needed either.
448
698
  dataUrl = target.toDataURL({
449
699
  format: 'png',
450
700
  multiplier: 2,
@@ -452,19 +702,16 @@
452
702
  withoutShadow: false
453
703
  });
454
704
  }
455
-
456
- canvasLog('Capture completed for:', target.type);
457
-
458
- // Restore selection
459
- if (activeObject) {
460
- canvas.setActiveObject(activeObject);
461
- canvas.renderAll();
462
- }
463
-
705
+
706
+ canvasLog('Capture completed for:', target.type, '| dataUrl length:', dataUrl.length);
707
+
464
708
  return dataUrl;
465
709
  } catch (e) {
466
710
  console.error('Error capturing screenshot:', e);
467
711
  return '';
712
+ } finally {
713
+ skipGroupIdleOutline = false;
714
+ canvas.requestRenderAll();
468
715
  }
469
716
  }
470
717
 
@@ -539,8 +786,33 @@
539
786
  const isFrame = isFrameShape(obj);
540
787
  const children = isFrame ? getFrameChildren(obj) : [];
541
788
  const bounds = obj.getBoundingRect();
789
+ const isMultiSelection =
790
+ obj.type === 'activeSelection' || obj.type === 'activeselection';
791
+
792
+ // A multi-selection has no stable _objectId (it is a transient container).
793
+ // Allocate one so the host's replace step can resolve what to remove —
794
+ // without it the payload carried an invented obj_<timestamp> id that
795
+ // matched nothing and the original sketch shapes stayed on the canvas.
796
+ if (isMultiSelection && !(obj as any)._objectId) {
797
+ (obj as any)._objectId = generateObjectId();
798
+ }
542
799
 
543
800
  try {
801
+ // Snapshot multi-selection children + bounds BEFORE anything can
802
+ // deselect the selection (onDeselect empties it in Fabric 6).
803
+ // replaceObjectWithWidgetImage consumes this by selection id.
804
+ if (isMultiSelection) {
805
+ pendingSelectionConversions.set((obj as any)._objectId, {
806
+ children: [...obj.getObjects()],
807
+ bounds: {
808
+ left: bounds.left,
809
+ top: bounds.top,
810
+ width: bounds.width,
811
+ height: bounds.height
812
+ }
813
+ });
814
+ }
815
+
544
816
  const screenshot = await captureObjectScreenshot(obj);
545
817
 
546
818
  const objectName = isFrame
@@ -579,8 +851,6 @@
579
851
  ShapeTypes: payload.metadata.shapeTypes,
580
852
  });
581
853
 
582
- obj._isConverting = true;
583
- selectedObject = selectedObject;
584
854
  onConvertToWidget(payload);
585
855
  } catch (err) {
586
856
  console.error('Canvas: convertObjectToWidget failed', err);
@@ -595,6 +865,16 @@
595
865
  selectedObject = null;
596
866
  }
597
867
 
868
+ /** Same as Ctrl+D / context Duplicate — clones selection (widget shapes notify host). */
869
+ export function duplicateSelectedObjects() {
870
+ return _duplicateSelected();
871
+ }
872
+
873
+ /** Same as Delete key / context Delete. */
874
+ export function deleteSelectedObjects() {
875
+ return _deleteActiveObjects();
876
+ }
877
+
598
878
  /** Re-link a cloned object to a new widget (called by host after duplicateWidget API).
599
879
  * Selects the object on canvas so Props Panel updates with the correct widget info. */
600
880
  /** Mark the currently selected object as "duplicating in progress" so PropsPanel shows a loader. */
@@ -652,32 +932,77 @@
652
932
  ): Promise<string | null> {
653
933
  if (!canvas) return null;
654
934
 
655
- const obj = canvas.getObjects().find((o: any) => o._objectId === objectId);
656
- if (!obj) {
935
+ // Convert registry convertObjectToWidget snapshots the selection's
936
+ // children + bounds under the selection's _objectId BEFORE anything can
937
+ // deselect it (Fabric 6 ActiveSelection.onDeselect() → removeAll() empties
938
+ // the selection, so live lookups are unreliable by replace time).
939
+ const pendingConversion = pendingSelectionConversions.get(objectId);
940
+ pendingSelectionConversions.delete(objectId);
941
+
942
+ // Resolve a single (non-selection) source object by id, if any.
943
+ let obj = canvas.getObjects().find((o: any) => o._objectId === objectId);
944
+ let selectionChildren: any[] = [];
945
+ if (pendingConversion) {
946
+ // Registry path: still-present children are removed below.
947
+ selectionChildren = pendingConversion.children.filter((c: any) =>
948
+ canvas!.getObjects().includes(c)
949
+ );
950
+ } else if (!obj) {
951
+ const active = canvas.getActiveObject();
952
+ if (
953
+ active &&
954
+ (active.type === 'activeSelection' || active.type === 'activeselection') &&
955
+ (active as any)._objectId === objectId
956
+ ) {
957
+ selectionChildren = (active as any).getObjects();
958
+ } else {
959
+ // Last resort: a top-level object whose children match (group)
960
+ const byChild = canvas.getObjects().find((o: any) =>
961
+ typeof o.getObjects === 'function' &&
962
+ o.getObjects().some((c: any) => c._objectId === objectId)
963
+ );
964
+ obj = byChild;
965
+ }
966
+ }
967
+ if (!obj && selectionChildren.length === 0) {
657
968
  canvasWarn('Canvas: replaceObjectWithWidgetImage — object not found', objectId);
658
969
  return null;
659
970
  }
660
971
 
661
972
  try {
662
- const bounds = obj.getBoundingRect();
663
- const isFrame = isFrameShape(obj);
973
+ const isFrame = obj ? isFrameShape(obj) : false;
974
+ const bounds = pendingConversion?.bounds ??
975
+ (obj
976
+ ? obj.getBoundingRect()
977
+ : (() => {
978
+ const a = canvas.getActiveObject() as any;
979
+ return a ? a.getBoundingRect() : { left: 0, top: 0, width: 200, height: 200 };
980
+ })());
664
981
 
665
982
  // 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);
983
+ const originalStroke = (obj as any)?.stroke || '#94a3b8';
984
+ const originalStrokeWidth = (obj as any)?.strokeWidth ?? 2;
985
+ const originalStrokeDashArray = (obj as any)?.strokeDashArray || null;
986
+ const originalRx = (obj as any)?.rx || 0;
987
+ const originalRy = (obj as any)?.ry || 0;
988
+ const originalOpacity = (obj as any)?.opacity ?? 1;
989
+ const originalFill = (obj as any)?.fill || null;
990
+
991
+ if (isFrame && obj) {
992
+ const children = getFrameChildren(obj as any);
993
+ const bg = getFrameBackground(obj as any);
677
994
  for (const child of children) canvas.remove(child);
678
995
  if (bg) canvas.remove(bg);
679
996
  }
680
- canvas.remove(obj);
997
+ // Multi-selection: remove every child shape so the sketch is absorbed
998
+ // into the single WidgetShape (F4 — no duplicate shapes left).
999
+ if (selectionChildren.length > 0) {
1000
+ // Deselect first so Fabric doesn't re-add children on discard.
1001
+ // After this point nothing reads from the (now emptied) selection.
1002
+ canvas.discardActiveObject();
1003
+ for (const child of selectionChildren) canvas.remove(child);
1004
+ }
1005
+ if (obj) canvas.remove(obj);
681
1006
 
682
1007
  const widgetImage = createImagePlaceholder({
683
1008
  left: bounds.left,
@@ -708,12 +1033,16 @@
708
1033
 
709
1034
  canvas.add(widgetImage);
710
1035
 
1036
+ // Set image fields SYNCHRONOUSLY: an autosave that runs before the
1037
+ // <img> loads must still persist the screenshot URL (previously the
1038
+ // JSON saved a bare placeholder and the sketch was lost on reload).
1039
+ widgetImage._imageUrl = screenshotDataUrl;
1040
+ widgetImage._hasImage = true;
1041
+ widgetImage._originalFilename = 'widget-screenshot.png';
1042
+
711
1043
  const img = new Image();
712
1044
  img.onload = () => {
713
1045
  widgetImage._imageElement = img;
714
- widgetImage._hasImage = true;
715
- widgetImage._imageUrl = screenshotDataUrl;
716
- widgetImage._originalFilename = 'widget-screenshot.png';
717
1046
  widgetImage._originalAspectRatio = img.width / img.height;
718
1047
  widgetImage.dirty = true;
719
1048
  canvas.requestRenderAll();
@@ -786,6 +1115,40 @@
786
1115
  return false;
787
1116
  }
788
1117
 
1118
+ /**
1119
+ * Screen-space box of a widget shape plus the canvas element viewport.
1120
+ * Used to park Widget Details left/right of the shape.
1121
+ */
1122
+ export function getWidgetShapeScreenRect(widgetId: string): {
1123
+ left: number;
1124
+ top: number;
1125
+ width: number;
1126
+ height: number;
1127
+ viewportLeft: number;
1128
+ viewportTop: number;
1129
+ viewportWidth: number;
1130
+ viewportHeight: number;
1131
+ } | null {
1132
+ if (!canvas || !canvasEl || !widgetId) return null;
1133
+ const obj = canvas.getObjects().find((o: any) => o._widgetId === widgetId);
1134
+ if (!obj) {
1135
+ canvasLog('Canvas: getWidgetShapeScreenRect — no shape for', widgetId);
1136
+ return null;
1137
+ }
1138
+ const bbox = obj.getBoundingRect();
1139
+ const canvasRect = canvasEl.getBoundingClientRect();
1140
+ return {
1141
+ left: canvasRect.left + bbox.left,
1142
+ top: canvasRect.top + bbox.top,
1143
+ width: bbox.width,
1144
+ height: bbox.height,
1145
+ viewportLeft: canvasRect.left,
1146
+ viewportTop: canvasRect.top,
1147
+ viewportWidth: canvasRect.width,
1148
+ viewportHeight: canvasRect.height,
1149
+ };
1150
+ }
1151
+
789
1152
  /**
790
1153
  * Get the current image data URL of a widget shape on the canvas.
791
1154
  * Returns the base64 data URL or null if the shape is not found or has no image.
@@ -1917,6 +2280,15 @@
1917
2280
  if ((obj as any)._customType === 'image') {
1918
2281
  // Upgrade old Rect-based image placeholders to ImageShape (backward compat)
1919
2282
  upgradeToImageShape(obj);
2283
+ if ((obj as any)._isWidgetImage) {
2284
+ if ((obj as any)._isPrimaryWidget) {
2285
+ obj.set({
2286
+ stroke: PRIMARY_WIDGET_STROKE,
2287
+ strokeWidth: PRIMARY_WIDGET_STROKE_WIDTH,
2288
+ strokeDashArray: null,
2289
+ });
2290
+ }
2291
+ }
1920
2292
  canvasLog('Canvas: object:added — upgraded image shape for', (obj as any)._objectId || 'image');
1921
2293
  }
1922
2294
 
@@ -2054,7 +2426,7 @@
2054
2426
  // Textboxes use backgroundColor for their box fill.
2055
2427
  // Default to white so the box fill opacity slider is visible.
2056
2428
  obj.set({ backgroundColor: '#ffffff' });
2057
- } else {
2429
+ } else if (String(obj.type || '').toLowerCase() !== 'group') {
2058
2430
  obj.set({
2059
2431
  fill: WIREFRAME_FILL,
2060
2432
  stroke: WIREFRAME_STROKE,
@@ -2065,8 +2437,11 @@
2065
2437
  canvasLog('Canvas: Wireframe defaults applied to new object', obj._objectId);
2066
2438
  }
2067
2439
  }
2440
+ stampTopLevelPaintOrder();
2068
2441
  });
2069
2442
 
2443
+ canvas.on('mouse:down:before', stampTopLevelPaintOrder);
2444
+
2070
2445
  // ── Proportional resize lock: handled via canvas.uniformScaling in before:transform ──
2071
2446
  // Edge handles (ml/mr/mt/mb) are hidden for _lockAspectRatio shapes (see object:added).
2072
2447
  // canvas.uniformScaling=true is set in before:transform for the drag duration, which
@@ -3012,6 +3387,13 @@
3012
3387
  if (activeTool !== 'select') {
3013
3388
  activateSelectTool();
3014
3389
  }
3390
+
3391
+ // Escape while inside a group exits edit mode and keeps the group selected
3392
+ if (activeEditingGroup) {
3393
+ exitGroupEdit(true);
3394
+ canvasLog('Canvas: Escape - Exited group edit');
3395
+ return;
3396
+ }
3015
3397
 
3016
3398
  // Deselect all objects
3017
3399
  canvas.discardActiveObject();
@@ -3042,6 +3424,11 @@
3042
3424
  '_textPadding',
3043
3425
  // Text stroke properties
3044
3426
  '_textStrokeColor', '_textStrokeWidth',
3427
+ // Group chrome (fill/stroke around children, like Frame)
3428
+ '_groupFill', '_groupStroke', '_groupStrokeWidth',
3429
+ '_groupFillOpacity', '_groupStrokeDashArray',
3430
+ '_canvasPaintOrder',
3431
+ '_isPrimaryWidget',
3045
3432
  ];
3046
3433
 
3047
3434
  // Apply custom Image render to a cloned object
@@ -4084,6 +4471,28 @@
4084
4471
  canvas.setActiveObject(activeObj.group);
4085
4472
  return activeObj.group;
4086
4473
  }
4474
+
4475
+ // Regular group children: only selectable in double-click edit mode.
4476
+ // Otherwise promote the click to the parent group (tldraw-like).
4477
+ const parent = activeObj.group;
4478
+ if (parent && isRegularGroup(parent)) {
4479
+ if (activeEditingGroup === parent && parent.interactive) {
4480
+ return activeObj;
4481
+ }
4482
+ if (!isRedirectingGroupSelection) {
4483
+ isRedirectingGroupSelection = true;
4484
+ try {
4485
+ canvas.setActiveObject(parent);
4486
+ } finally {
4487
+ isRedirectingGroupSelection = false;
4488
+ }
4489
+ }
4490
+ return parent;
4491
+ }
4492
+
4493
+ if (activeEditingGroup && activeObj !== activeEditingGroup) {
4494
+ exitGroupEdit(false);
4495
+ }
4087
4496
 
4088
4497
  return activeObj;
4089
4498
  };
@@ -4170,6 +4579,9 @@
4170
4579
 
4171
4580
  const handleSelectionCleared = () => {
4172
4581
  selectedObject = null;
4582
+ if (activeEditingGroup) {
4583
+ exitGroupEdit(false);
4584
+ }
4173
4585
  canvasLog('Canvas: Selection cleared');
4174
4586
  if (onNonWidgetSelected) {
4175
4587
  onNonWidgetSelected();
@@ -4200,6 +4612,27 @@
4200
4612
  return;
4201
4613
  }
4202
4614
 
4615
+ // Regular Group: double-click enters edit mode (tldraw-like).
4616
+ // Single click always selects the group; children are only
4617
+ // selectable after this enter.
4618
+ if (
4619
+ String(target.type || '').toLowerCase() === 'group' &&
4620
+ !isFrameShape(target) &&
4621
+ !(target as any)._isWidgetImage
4622
+ ) {
4623
+ enterGroupEdit(target);
4624
+ const sub = Array.isArray(e.subTargets) ? e.subTargets[0] : null;
4625
+ if (sub && sub !== target) {
4626
+ canvas.setActiveObject(sub);
4627
+ }
4628
+ canvas.requestRenderAll();
4629
+ canvasLog('Canvas: entered group via double-click', {
4630
+ children: target.getObjects?.()?.length,
4631
+ subType: sub?.type,
4632
+ });
4633
+ return;
4634
+ }
4635
+
4203
4636
  // Widget Image (linked to a widget): open WidgetDetails panel
4204
4637
  // Must be checked BEFORE generic WidgetShape — widget shapes also have _customType === 'image'
4205
4638
  if (onWidgetImageClick && (target as any)?._isWidgetImage && (target as any)?._widgetId) {
@@ -5061,6 +5494,9 @@
5061
5494
  if (canvas && (canvas as any)._frameInteriorMouseDownHandler) {
5062
5495
  canvas.off('mouse:down:before', (canvas as any)._frameInteriorMouseDownHandler);
5063
5496
  }
5497
+ if (canvas) {
5498
+ canvas.off('mouse:down:before', stampTopLevelPaintOrder);
5499
+ }
5064
5500
  if (canvas && (canvas as any)._frameInteriorMouseUpHandler) {
5065
5501
  canvas.off('mouse:up', (canvas as any)._frameInteriorMouseUpHandler);
5066
5502
  }
@@ -5203,13 +5639,25 @@
5203
5639
  };
5204
5640
  };
5205
5641
 
5206
- const estimatePlaceholderWidth = () => {
5207
- if (!textBoxPlaceholder) {
5208
- return MIN_TEXTBOX_WIDTH;
5642
+ /** Width that fits the current default/placeholder text on one line.
5643
+ * measureText is the wrap width; padding is drawn outside that, so only a
5644
+ * small extra is needed (avoid double-counting create padding). */
5645
+ const measureDefaultTextboxWidth = () => {
5646
+ const text = textBoxPlaceholder || '';
5647
+ const extra = 2;
5648
+ if (!text) return MIN_TEXTBOX_WIDTH;
5649
+ try {
5650
+ const fabricCtx = canvas && typeof canvas.getContext === 'function' ? canvas.getContext() : null;
5651
+ const ctx = fabricCtx ?? document.createElement('canvas').getContext('2d');
5652
+ if (ctx) {
5653
+ ctx.font = `${textFontSize}px ${textFontFamily}`;
5654
+ const measured = ctx.measureText(text).width;
5655
+ return Math.max(MIN_TEXTBOX_WIDTH, Math.ceil(measured + extra));
5656
+ }
5657
+ } catch {
5658
+ /* fall through to heuristic */
5209
5659
  }
5210
-
5211
- const estimatedWidth = textBoxPlaceholder.length * textFontSize * 0.6;
5212
- return Math.max(MIN_TEXTBOX_WIDTH, estimatedWidth);
5660
+ return Math.max(MIN_TEXTBOX_WIDTH, Math.ceil(text.length * textFontSize * 0.6 + extra));
5213
5661
  };
5214
5662
 
5215
5663
  const createArrowGeometry = (
@@ -6238,7 +6686,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
6238
6686
  previewObject = new Textbox(textBoxPlaceholder, {
6239
6687
  left: drawingStartPoint.x,
6240
6688
  top: drawingStartPoint.y,
6241
- width: estimatePlaceholderWidth() + 12,
6689
+ width: measureDefaultTextboxWidth(),
6242
6690
  fontSize: textFontSize,
6243
6691
  fontFamily: textFontFamily,
6244
6692
  fill: textFillColor,
@@ -6262,7 +6710,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
6262
6710
  }
6263
6711
  }
6264
6712
 
6265
- const minWidth = 50;
6713
+ const minWidth = MIN_TEXTBOX_WIDTH;
6266
6714
  const desiredWidth = Math.max(width, minWidth);
6267
6715
  const left = Math.min(pointer.x, drawingStartPoint.x);
6268
6716
  const top = Math.min(pointer.y, drawingStartPoint.y);
@@ -6290,8 +6738,9 @@ function checkAndAddNewShapeToFrame(shape: any) {
6290
6738
  // On quick click (no drag), use the estimated placeholder width
6291
6739
  // so the text fits nicely without wrapping.
6292
6740
  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);
6741
+ const textboxWidth = width < MIN_DRAW_DISTANCE
6742
+ ? measureDefaultTextboxWidth()
6743
+ : Math.max(width, MIN_TEXTBOX_WIDTH);
6295
6744
 
6296
6745
  // Calculate position (support drag in any direction)
6297
6746
  const left = Math.min(pointer.x, drawingStartPoint.x);
@@ -6787,8 +7236,61 @@ function checkAndAddNewShapeToFrame(shape: any) {
6787
7236
  }
6788
7237
 
6789
7238
  /** No visible Fabric stroke on widget shapes — preview image may supply its own border. */
7239
+ const PRIMARY_WIDGET_STROKE = '#DC2626';
7240
+ const PRIMARY_WIDGET_STROKE_WIDTH = 2;
7241
+
7242
+ /** Red stroke marks the canvas primary widget; others stay borderless. */
7243
+ function applyPrimaryWidgetStrokes(primaryWidgetId: string | null) {
7244
+ if (!canvas) return;
7245
+ for (const obj of canvas.getObjects()) {
7246
+ const anyObj = obj as any;
7247
+ if (!anyObj._isWidgetImage) continue;
7248
+ const isPrimary = !!(primaryWidgetId && anyObj._widgetId === primaryWidgetId);
7249
+ anyObj._isPrimaryWidget = isPrimary;
7250
+ if (isPrimary) {
7251
+ obj.set({
7252
+ stroke: PRIMARY_WIDGET_STROKE,
7253
+ strokeWidth: PRIMARY_WIDGET_STROKE_WIDTH,
7254
+ strokeDashArray: null,
7255
+ });
7256
+ } else {
7257
+ obj.set({ stroke: 'transparent', strokeWidth: 0, strokeDashArray: null });
7258
+ }
7259
+ obj.dirty = true;
7260
+ }
7261
+ canvas.requestRenderAll();
7262
+ canvasLog('Canvas: applied primary widget stroke', primaryWidgetId);
7263
+ }
7264
+
7265
+ export function setPrimaryWidgetShape(widgetId: string): boolean {
7266
+ if (!widgetId) return false;
7267
+ applyPrimaryWidgetStrokes(widgetId);
7268
+ selectedObject = selectedObject;
7269
+ return true;
7270
+ }
7271
+
7272
+ function handleSetPrimaryFromPanel(widgetId: string) {
7273
+ setPrimaryWidgetShape(widgetId);
7274
+ onSetPrimaryWidget?.(widgetId);
7275
+ }
7276
+
7277
+ export function getPrimaryWidgetId(): string | null {
7278
+ if (!canvas) return null;
7279
+ const primary = canvas.getObjects().find((o: any) => o._isWidgetImage && o._isPrimaryWidget);
7280
+ return (primary as any)?._widgetId ?? null;
7281
+ }
7282
+
7283
+ /** No visible Fabric stroke on widget shapes unless this one is primary. */
6790
7284
  function clearWidgetShapeDefaultBorder(obj: any): void {
6791
- obj.set({ stroke: 'transparent', strokeWidth: 0, strokeDashArray: null });
7285
+ if (obj._isPrimaryWidget) {
7286
+ obj.set({
7287
+ stroke: PRIMARY_WIDGET_STROKE,
7288
+ strokeWidth: PRIMARY_WIDGET_STROKE_WIDTH,
7289
+ strokeDashArray: null,
7290
+ });
7291
+ } else {
7292
+ obj.set({ stroke: 'transparent', strokeWidth: 0, strokeDashArray: null });
7293
+ }
6792
7294
  obj.dirty = true;
6793
7295
  }
6794
7296
 
@@ -7567,63 +8069,76 @@ function checkAndAddNewShapeToFrame(shape: any) {
7567
8069
  * They are used to test the canvas and the fabric.js library.
7568
8070
  * */
7569
8071
  // Group selection method
7570
- export function onGroupSelection() {
8072
+ export function onGroupSelection(): string | null {
7571
8073
  if (!canvas) {
7572
- return;
8074
+ return null;
7573
8075
  }
7574
8076
 
7575
8077
  const activeObject = canvas.getActiveObject();
7576
8078
  if (!activeObject) {
7577
- return;
8079
+ return null;
7578
8080
  }
7579
8081
 
7580
8082
  // check if the active object is an active selection
7581
- if (activeObject.type !== 'activeSelection' &&
8083
+ if (activeObject.type !== 'activeSelection' &&
7582
8084
  activeObject.type !== 'activeselection') {
7583
- return;
7584
- }
7585
-
7586
- // Get objects from selection
7587
- const objects = activeObject.getObjects();
8085
+ // Already a single object (e.g. re-convert of an existing group) —
8086
+ // return its id so the host can replace it later.
8087
+ const single = activeObject as any;
8088
+ if (!single._objectId) single._objectId = generateObjectId();
8089
+ return single._objectId as string;
8090
+ }
8091
+
8092
+ // Copy — getObjects() returns the live _objects array.
8093
+ // Sort by canvas paint order (bottom → top) so grouping does not reverse z-index.
8094
+ const objects = [...activeObject.getObjects()].sort((a: any, b: any) => {
8095
+ const ia = a._canvasPaintOrder;
8096
+ const ib = b._canvasPaintOrder;
8097
+ if (typeof ia === 'number' && typeof ib === 'number') return ia - ib;
8098
+ return 0;
8099
+ });
7588
8100
  if (objects.length === 0) {
7589
- return;
8101
+ return null;
7590
8102
  }
7591
8103
 
7592
- // Remove objects from canvas
8104
+ // Fabric 6: ActiveSelection.onDeselect() → removeAll() applies the
8105
+ // selection transform so children return to canvas-absolute coords.
8106
+ // This MUST run while objects are still on the canvas. Removing them
8107
+ // first, then discarding, double-applies the transform — children
8108
+ // collapse behind the bounding rect (the Sep 2025 grouping bug).
8109
+ canvas.discardActiveObject();
8110
+
7593
8111
  objects.forEach((obj: any) => {
7594
8112
  canvas.remove(obj);
8113
+ if (!obj._objectId) obj._objectId = generateObjectId();
7595
8114
  });
7596
8115
 
7597
- // Discard the active selection
7598
- canvas.discardActiveObject();
7599
-
7600
- // Create group with all objects
8116
+ // Group constructor + FitContentLayout converts canvas-absolute coords
8117
+ // into group-local and places the group at the former selection bounds.
7601
8118
  const group = new Group(objects, {
7602
- canvas: canvas
8119
+ subTargetCheck: true,
8120
+ interactive: false,
8121
+ objectCaching: false,
8122
+ fill: '',
8123
+ stroke: '',
8124
+ strokeWidth: 0,
8125
+ backgroundColor: '',
7603
8126
  });
8127
+ applyInteractiveGroupFlags(group);
8128
+ (group as any)._objectId = generateObjectId();
8129
+ (group as any)._customType = 'group';
8130
+ group.set({ fill: '', stroke: '', strokeWidth: 0, backgroundColor: '' });
7604
8131
 
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
8132
  canvas.add(group);
7617
-
7618
- // set the group as the active object
7619
8133
  canvas.setActiveObject(group);
7620
-
7621
- // request a render all
7622
8134
  canvas.requestRenderAll();
7623
8135
  takeHistorySnapshot();
7624
8136
 
7625
- // log the group creation
7626
- canvasLog('Group selection created', { Group: group, objectCount: objects.length, hasFrameChild });
8137
+ canvasLog('Group selection created', {
8138
+ objectCount: objects.length,
8139
+ objectId: (group as any)._objectId,
8140
+ });
8141
+ return (group as any)._objectId as string;
7627
8142
  }
7628
8143
 
7629
8144
  // Ungroup selection method - using native Fabric.js approach
@@ -7651,6 +8166,9 @@ function checkAndAddNewShapeToFrame(shape: any) {
7651
8166
  }
7652
8167
 
7653
8168
  const group = activeObject as Group;
8169
+ if (activeEditingGroup === group) {
8170
+ exitGroupEdit(false);
8171
+ }
7654
8172
 
7655
8173
  canvasLog('Ungroup: Starting with', group.getObjects().length, 'objects');
7656
8174
 
@@ -7769,6 +8287,10 @@ function checkAndAddNewShapeToFrame(shape: any) {
7769
8287
  '_widgetId',
7770
8288
  // Widget Image: converted widget placeholder on canvas
7771
8289
  '_isWidgetImage',
8290
+ '_groupFill', '_groupStroke', '_groupStrokeWidth',
8291
+ '_groupFillOpacity', '_groupStrokeDashArray',
8292
+ '_canvasPaintOrder',
8293
+ '_isPrimaryWidget',
7772
8294
  ];
7773
8295
 
7774
8296
  // ── History: capture / restore ───────────────────────────────────────────
@@ -7830,8 +8352,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
7830
8352
  }, autosaveDelayMs);
7831
8353
  }
7832
8354
 
7833
- /** Force an immediate save (used by Cmd+S and the save button). */
7834
- function saveNow() {
8355
+ /** Force an immediate save (used by Cmd+S, the save button, and host post-replace flushes). */
8356
+ export function saveNow() {
7835
8357
  if (_autoSaveTimer) clearTimeout(_autoSaveTimer);
7836
8358
  _autoSaveTimer = null;
7837
8359
  const json = saveCanvas();
@@ -7880,6 +8402,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
7880
8402
  syncTextboxFabricPadding(obj);
7881
8403
  }
7882
8404
  }
8405
+ applyInteractiveGroupFlags(obj);
7883
8406
  } catch (reviverErr) {
7884
8407
  canvasWarn('Canvas: restoreHistorySnapshot reviver error', obj?.type, reviverErr);
7885
8408
  }
@@ -7896,6 +8419,13 @@ function checkAndAddNewShapeToFrame(shape: any) {
7896
8419
  });
7897
8420
  } else if (obj._customType === 'image' || obj._hasImage || obj._imageUrl) {
7898
8421
  upgradeToImageShape(obj);
8422
+ } else {
8423
+ applyInteractiveGroupFlags(obj);
8424
+ obj.getObjects?.()?.forEach((child: any) => {
8425
+ if (child._customType === 'image' || child._hasImage || child._imageUrl) {
8426
+ upgradeToImageShape(child);
8427
+ }
8428
+ });
7899
8429
  }
7900
8430
  });
7901
8431
  canvas.discardActiveObject();
@@ -8021,6 +8551,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
8021
8551
  syncTextboxFabricPadding(obj);
8022
8552
  }
8023
8553
  }
8554
+ applyInteractiveGroupFlags(obj);
8024
8555
  } catch (reviverErr) {
8025
8556
  canvasWarn('Canvas: loadCanvas reviver error for object', obj?.type, reviverErr);
8026
8557
  }
@@ -8051,6 +8582,9 @@ function checkAndAddNewShapeToFrame(shape: any) {
8051
8582
  };
8052
8583
  img.src = obj._imageUrl;
8053
8584
  }
8585
+ } else if (String(obj.type || '').toLowerCase() === 'group') {
8586
+ applyInteractiveGroupFlags(obj);
8587
+ (obj.getObjects?.() || []).forEach(restoreObject);
8054
8588
  }
8055
8589
  };
8056
8590
  allObjects.forEach(restoreObject);
@@ -8090,7 +8624,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
8090
8624
  const spatialOrphans = canvas.getObjects().filter((obj: any) =>
8091
8625
  !isFrameShape(obj) &&
8092
8626
  !(obj as any)._isFrameBackground &&
8093
- !obj.group // not already inside any group
8627
+ !obj.group && // not already inside any group
8628
+ String(obj.type || '').toLowerCase() !== 'group'
8094
8629
  );
8095
8630
  for (const obj of spatialOrphans) {
8096
8631
  const center = obj.getCenterPoint();
@@ -8430,7 +8965,6 @@ function checkAndAddNewShapeToFrame(shape: any) {
8430
8965
  }
8431
8966
 
8432
8967
  // 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
8968
  if (!isFrameShape(selectedObject) && (selectedObject.type === 'group' || selectedObject.type === 'activeSelection' || selectedObject.type === 'activeselection')) {
8435
8969
  const group = selectedObject as Group;
8436
8970
  group.getObjects().forEach((obj: any) => {
@@ -8473,6 +9007,74 @@ function checkAndAddNewShapeToFrame(shape: any) {
8473
9007
  selectedObject = temp;
8474
9008
  canvasLog('Canvas: Updated fill color to', color);
8475
9009
  }
9010
+
9011
+ function refreshSelectedObjectUi() {
9012
+ const temp = selectedObject;
9013
+ selectedObject = null;
9014
+ selectedObject = temp;
9015
+ }
9016
+
9017
+ /** Group box chrome (behind/around children). Does not paint children. */
9018
+ function updateGroupFill(color: string) {
9019
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9020
+ (selectedObject as any)._groupFill = color;
9021
+ selectedObject.dirty = true;
9022
+ canvas.requestRenderAll();
9023
+ refreshSelectedObjectUi();
9024
+ canvasLog('Canvas: Updated group chrome fill to', color);
9025
+ }
9026
+
9027
+ function updateGroupFillOpacity(opacityPercent: number) {
9028
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9029
+ (selectedObject as any)._groupFillOpacity = opacityPercent / 100;
9030
+ selectedObject.dirty = true;
9031
+ canvas.requestRenderAll();
9032
+ refreshSelectedObjectUi();
9033
+ canvasLog('Canvas: Updated group chrome fill opacity to', opacityPercent + '%');
9034
+ }
9035
+
9036
+ function updateGroupStroke(color: string) {
9037
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9038
+ (selectedObject as any)._groupStroke = color;
9039
+ if (!(selectedObject as any)._groupStrokeWidth) {
9040
+ (selectedObject as any)._groupStrokeWidth = 2;
9041
+ }
9042
+ selectedObject.dirty = true;
9043
+ canvas.requestRenderAll();
9044
+ refreshSelectedObjectUi();
9045
+ canvasLog('Canvas: Updated group chrome stroke to', color);
9046
+ }
9047
+
9048
+ function updateGroupStrokeWidth(width: number) {
9049
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9050
+ (selectedObject as any)._groupStrokeWidth = width;
9051
+ if (width > 0 && !(selectedObject as any)._groupStroke) {
9052
+ (selectedObject as any)._groupStroke = '#000000';
9053
+ }
9054
+ selectedObject.dirty = true;
9055
+ canvas.requestRenderAll();
9056
+ refreshSelectedObjectUi();
9057
+ debouncedHistorySnapshot();
9058
+ canvasLog('Canvas: Updated group chrome stroke width to', width);
9059
+ }
9060
+
9061
+ function updateGroupStrokeType(type: 'solid' | 'dashed' | 'dotted') {
9062
+ if (!canvas || !isRegularGroup(selectedObject)) return;
9063
+ let dashArray: number[] | null = null;
9064
+ if (type === 'dashed') dashArray = [10, 5];
9065
+ else if (type === 'dotted') dashArray = [2, 4];
9066
+ (selectedObject as any)._groupStrokeDashArray = dashArray;
9067
+ if (!(selectedObject as any)._groupStroke) {
9068
+ (selectedObject as any)._groupStroke = '#000000';
9069
+ }
9070
+ if (!(selectedObject as any)._groupStrokeWidth) {
9071
+ (selectedObject as any)._groupStrokeWidth = 2;
9072
+ }
9073
+ selectedObject.dirty = true;
9074
+ canvas.requestRenderAll();
9075
+ refreshSelectedObjectUi();
9076
+ canvasLog('Canvas: Updated group chrome stroke type to', type);
9077
+ }
8476
9078
 
8477
9079
  // Helper to convert any color to rgba with specified alpha
8478
9080
  function colorToRgba(color: string, alpha: number): string {
@@ -8567,7 +9169,6 @@ function checkAndAddNewShapeToFrame(shape: any) {
8567
9169
  }
8568
9170
  // Set on container too for UI reflection
8569
9171
  selectedObject._fillOpacity = alpha;
8570
- // If it's a regular group or activeSelection, apply to all children
8571
9172
  } else if (selectedObject.type === 'group' || selectedObject.type === 'activeSelection' || selectedObject.type === 'activeselection') {
8572
9173
  const group = selectedObject as Group;
8573
9174
  group.getObjects().forEach((obj: any) => {
@@ -9196,48 +9797,61 @@ function checkAndAddNewShapeToFrame(shape: any) {
9196
9797
  canvasLog('Canvas: Reordered in frame', { direction, from: idx, to: newIdx });
9197
9798
  }
9198
9799
 
9199
- function bringToFront() {
9800
+ function reorderInGroup(group: any, obj: any, direction: 'front' | 'forward' | 'backward' | 'back') {
9801
+ const objects = group._objects as any[];
9802
+ const idx = objects.indexOf(obj);
9803
+ if (idx === -1) return;
9804
+ const minIndex = 0;
9805
+ const maxIndex = objects.length - 1;
9806
+ objects.splice(idx, 1);
9807
+ let newIdx: number;
9808
+ switch (direction) {
9809
+ case 'front': newIdx = maxIndex; break;
9810
+ case 'forward': newIdx = Math.min(idx + 1, maxIndex); break;
9811
+ case 'backward': newIdx = Math.max(idx - 1, minIndex); break;
9812
+ case 'back': newIdx = minIndex; break;
9813
+ }
9814
+ objects.splice(newIdx, 0, obj);
9815
+ group.dirty = true;
9816
+ group.objectCaching = false;
9817
+ canvasLog('Canvas: Reordered in group', { direction, from: idx, to: newIdx });
9818
+ }
9819
+
9820
+ function applyLayerOrder(direction: 'front' | 'forward' | 'backward' | 'back') {
9200
9821
  if (!selectedObject || !canvas) return;
9201
- if (selectedObject.group && isFrameShape(selectedObject.group)) {
9202
- reorderInFrame(selectedObject.group, selectedObject, 'front');
9203
- } else {
9822
+ const parent = selectedObject.group;
9823
+ if (parent && isFrameShape(parent)) {
9824
+ reorderInFrame(parent, selectedObject, direction);
9825
+ } else if (parent && isRegularGroup(parent)) {
9826
+ reorderInGroup(parent, selectedObject, direction);
9827
+ } else if (direction === 'front') {
9204
9828
  canvas.bringObjectToFront(selectedObject);
9829
+ } else if (direction === 'forward') {
9830
+ canvas.bringObjectForward(selectedObject);
9831
+ } else if (direction === 'backward') {
9832
+ canvas.sendObjectBackwards(selectedObject);
9833
+ } else {
9834
+ canvas.sendObjectToBack(selectedObject);
9205
9835
  }
9836
+ stampTopLevelPaintOrder();
9206
9837
  canvas.requestRenderAll();
9207
9838
  takeHistorySnapshot();
9208
9839
  }
9840
+
9841
+ function bringToFront() {
9842
+ applyLayerOrder('front');
9843
+ }
9209
9844
 
9210
9845
  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();
9846
+ applyLayerOrder('forward');
9219
9847
  }
9220
9848
 
9221
9849
  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();
9850
+ applyLayerOrder('backward');
9230
9851
  }
9231
9852
 
9232
9853
  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();
9854
+ applyLayerOrder('back');
9241
9855
  }
9242
9856
 
9243
9857
  /**
@@ -10552,6 +11166,11 @@ function checkAndAddNewShapeToFrame(shape: any) {
10552
11166
  {wireframeMode}
10553
11167
  onFillChange={updateSelectedFill}
10554
11168
  onFillOpacityChange={updateSelectedFillOpacity}
11169
+ onGroupFillChange={updateGroupFill}
11170
+ onGroupFillOpacityChange={updateGroupFillOpacity}
11171
+ onGroupStrokeChange={updateGroupStroke}
11172
+ onGroupStrokeWidthChange={updateGroupStrokeWidth}
11173
+ onGroupStrokeTypeChange={updateGroupStrokeType}
10555
11174
  onBackgroundColorChange={updateSelectedBackgroundColor}
10556
11175
  onBackgroundOpacityChange={updateSelectedBackgroundOpacity}
10557
11176
  onStrokeChange={updateSelectedStroke}
@@ -10589,6 +11208,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
10589
11208
  onWidgetClick={onWidgetImageClick}
10590
11209
  {onWidgetRename}
10591
11210
  {onRefreshScreenshot}
11211
+ onSetAsPrimary={handleSetPrimaryFromPanel}
10592
11212
  {getWidgetName}
10593
11213
  {isWidgetKnown}
10594
11214
  onRemoveOrphanedShape={() => { _deleteActiveObjects(); }}
@@ -10673,6 +11293,6 @@ function checkAndAddNewShapeToFrame(shape: any) {
10673
11293
  max-w-[calc(100%-1rem)] w-11/12
10674
11294
  -->
10675
11295
 
10676
- <style>/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
11296
+ <style>/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
10677
11297
  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)}}
10678
11298
  </style>