@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.
- package/dist/canvas/Canvas.svelte +854 -168
- package/dist/canvas/Canvas.svelte.d.ts +54 -2
- package/dist/canvas/CanvasToolbar.svelte +18 -6
- package/dist/canvas/CanvasToolbar.svelte.d.ts +1 -0
- package/dist/canvas/PanZoomPanel.svelte +90 -3
- package/dist/canvas/PanZoomPanel.svelte.d.ts +1 -0
- package/dist/canvas/props-panel/PropsPanel.svelte +148 -4
- package/dist/canvas/props-panel/PropsPanel.svelte.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
+
for (const [o, wasVisible] of isHidden) {
|
|
696
|
+
if (wasVisible) o.visible = true;
|
|
697
|
+
}
|
|
698
|
+
canvas.requestRenderAll();
|
|
445
699
|
} else {
|
|
446
|
-
// Single object
|
|
447
|
-
//
|
|
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
|
-
|
|
656
|
-
|
|
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
|
|
663
|
-
const
|
|
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)
|
|
667
|
-
const originalStrokeWidth = (obj as any)
|
|
668
|
-
const originalStrokeDashArray = (obj as any)
|
|
669
|
-
const originalRx = (obj as any)
|
|
670
|
-
const originalRy = (obj as any)
|
|
671
|
-
const originalOpacity = (obj as any)
|
|
672
|
-
const originalFill = (obj as any)
|
|
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
|
-
|
|
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
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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('
|
|
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:
|
|
1775
|
-
tr: { x:
|
|
1776
|
-
bl: { x:
|
|
1777
|
-
br: { x:
|
|
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
|
-
|
|
4174
|
-
|
|
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
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
6294
|
-
|
|
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.
|
|
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
|
-
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7626
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
10470
|
-
|
|
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.
|
|
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>
|