@1agh/maude 0.45.2 → 0.47.0
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/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +237 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -98,11 +98,12 @@ import {
|
|
|
98
98
|
useAnnotationSelectionOptional,
|
|
99
99
|
} from './use-annotation-selection.tsx';
|
|
100
100
|
import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
|
|
101
|
-
import { showCanvasToast } from './use-canvas-media-drop.tsx';
|
|
101
|
+
import { canvasConfirm, showCanvasToast } from './use-canvas-media-drop.tsx';
|
|
102
102
|
import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
103
103
|
import { useCollab } from './use-collab.tsx';
|
|
104
104
|
import { useCursorModifiers } from './use-cursor-modifiers.tsx';
|
|
105
|
-
import { ElementResizeOverlay } from './use-element-resize.tsx';
|
|
105
|
+
import { ElementResizeOverlay, worldZoomFor } from './use-element-resize.tsx';
|
|
106
|
+
import { GridTrackHandlesOverlay } from './use-grid-track-handles.tsx';
|
|
106
107
|
import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
|
|
107
108
|
import {
|
|
108
109
|
MaybeSelectionSetProvider,
|
|
@@ -346,6 +347,15 @@ const HALO_CSS = `
|
|
|
346
347
|
[data-cd-id]:not(:empty):not(:has(> *)):hover {
|
|
347
348
|
cursor: text;
|
|
348
349
|
}
|
|
350
|
+
/* feature-4 text-gestures — the Text tool's hover affordance: the editable
|
|
351
|
+
leaf the click-through would edit gets a dashed accent outline (applied by
|
|
352
|
+
annotations-layer's hit-test; a plain :hover can't reach through the
|
|
353
|
+
tool's input-capture overlay). */
|
|
354
|
+
.dc-text-editable-hover {
|
|
355
|
+
outline: 1.5px dashed var(--maude-hud-accent, #4a63e7);
|
|
356
|
+
outline-offset: 2px;
|
|
357
|
+
border-radius: 2px;
|
|
358
|
+
}
|
|
349
359
|
/* Phase 12.1 (DDR-138) — in-canvas drag-to-reorder (Figma-style). Dragging a
|
|
350
360
|
SELECTED element floats it with the cursor (via transform, applied inline —
|
|
351
361
|
its box stays reserved so the origin shows empty space, and it stays in the
|
|
@@ -409,6 +419,16 @@ interface LayerNode {
|
|
|
409
419
|
* route CssKnobs uses), so this must reflect the SOURCE value, not a
|
|
410
420
|
* client-only preview flag (see app.jsx `toggleVisibility`). */
|
|
411
421
|
hidden: boolean;
|
|
422
|
+
/** feature-4 T7 — a SYNTHETIC group row for an unstamped wrapper that holds
|
|
423
|
+
* stamped descendants. It's a structural stand-in so the tree mirrors real
|
|
424
|
+
* nesting instead of hoisting descendants up a level (the "some elements
|
|
425
|
+
* don't show up / tree is flat" bug). Non-selectable + non-draggable (it
|
|
426
|
+
* has no `data-cd-id` to address in source); its `id` is a stable
|
|
427
|
+
* `__grp:<sig>#<n>` synthetic key, never a real cd-id. */
|
|
428
|
+
synthetic?: boolean;
|
|
429
|
+
/** feature-4 T7c — the raw authored `data-dc-element` attr (null when unset).
|
|
430
|
+
* The rename input's before-value; `layerLabel` renders its Title-Case form. */
|
|
431
|
+
dcElement?: string | null;
|
|
412
432
|
children: LayerNode[];
|
|
413
433
|
}
|
|
414
434
|
|
|
@@ -517,8 +537,18 @@ function buildSelectLayerSubmenu(
|
|
|
517
537
|
});
|
|
518
538
|
}
|
|
519
539
|
|
|
520
|
-
|
|
540
|
+
// feature-4 T7 — does this subtree contain ANY stamped element? Gates whether an
|
|
541
|
+
// unstamped wrapper is worth a synthetic group row (an empty/decorative wrapper
|
|
542
|
+
// with no addressable descendants stays omitted — no noise).
|
|
543
|
+
function hasStampedDescendant(el: Element): boolean {
|
|
544
|
+
return !!el.querySelector('[data-cd-id]');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function serializeArtboardTree(root: Element): LayerNode[] {
|
|
521
548
|
const seen = new Map<string, number>();
|
|
549
|
+
// Occurrence counter for synthetic group signatures so their keys are stable
|
|
550
|
+
// + unique across re-serializations (collapse state persists through HMR).
|
|
551
|
+
const groupSeen = new Map<string, number>();
|
|
522
552
|
function walk(el: Element): LayerNode[] {
|
|
523
553
|
const out: LayerNode[] = [];
|
|
524
554
|
for (const child of Array.from(el.children)) {
|
|
@@ -535,12 +565,41 @@ function serializeArtboardTree(root: Element): LayerNode[] {
|
|
|
535
565
|
type: layerType(child),
|
|
536
566
|
index: idx,
|
|
537
567
|
hidden: (child as HTMLElement).style.display === 'none',
|
|
568
|
+
dcElement: child.getAttribute('data-dc-element'),
|
|
569
|
+
children: walk(child),
|
|
570
|
+
});
|
|
571
|
+
} else if (hasStampedDescendant(child)) {
|
|
572
|
+
// ENGINE chrome wrappers (`.dc-artboard-body`, any `dc-*` layer) are
|
|
573
|
+
// not user structure — hoist THROUGH them (pre-feature-4 behavior) so
|
|
574
|
+
// the tree doesn't grow a phantom "dc-artboard-body GROUP" root row
|
|
575
|
+
// (live-dogfood finding, 2026-07-19).
|
|
576
|
+
const classes = Array.from(child.classList);
|
|
577
|
+
if (classes.some((c) => c.startsWith('dc-') || c.startsWith('dgn-'))) {
|
|
578
|
+
out.push(...walk(child));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// feature-4 T7 — unstamped wrapper WITH stamped descendants: emit a
|
|
582
|
+
// synthetic, non-selectable GROUP row so the tree mirrors real nesting
|
|
583
|
+
// instead of hoisting its descendants up a level. (Pre-feature-4 this
|
|
584
|
+
// branch did `out.push(...walk(child))`, flattening the hierarchy — the
|
|
585
|
+
// "layers are flat / an element is at the wrong depth" report.)
|
|
586
|
+
const tag = child.tagName.toLowerCase();
|
|
587
|
+
const cls = realClasses(child).split(/\s+/).filter(Boolean)[0];
|
|
588
|
+
const sig = cls ? `${tag}.${cls}` : tag;
|
|
589
|
+
const n = groupSeen.get(sig) ?? 0;
|
|
590
|
+
groupSeen.set(sig, n + 1);
|
|
591
|
+
out.push({
|
|
592
|
+
id: `__grp:${sig}#${n}`,
|
|
593
|
+
tag,
|
|
594
|
+
label: layerLabel(child),
|
|
595
|
+
type: 'group',
|
|
596
|
+
index: 0,
|
|
597
|
+
hidden: false,
|
|
598
|
+
synthetic: true,
|
|
538
599
|
children: walk(child),
|
|
539
600
|
});
|
|
540
|
-
} else {
|
|
541
|
-
// Unstamped wrapper — descend so its stamped descendants still surface.
|
|
542
|
-
out.push(...walk(child));
|
|
543
601
|
}
|
|
602
|
+
// Unstamped wrapper with NO stamped descendants → omitted (decorative).
|
|
544
603
|
}
|
|
545
604
|
return out;
|
|
546
605
|
}
|
|
@@ -570,10 +629,17 @@ function getLastLayersArtboardId(): string | null {
|
|
|
570
629
|
// DDR-148 — an artboard hosting a <VideoComp> stamps a `[data-comp-id]`
|
|
571
630
|
// element inside it (see video-comp.tsx). Gates the artboard-chrome context
|
|
572
631
|
// menu's "Open Timeline" entry to artboards that actually have a sequence.
|
|
632
|
+
// T2 (feature-1-artboard-kinds-foundation) — merged with the resolved `kind`:
|
|
633
|
+
// an explicit `kind="video"` (DCArtboard's `data-dc-kind` readback) also
|
|
634
|
+
// qualifies, even before any `<VideoComp>` content exists yet; the structural
|
|
635
|
+
// `[data-comp-id]` check stays as the fallback for unmigrated canvases.
|
|
573
636
|
function artboardHasVideo(artboardId: string | null | undefined): boolean {
|
|
574
637
|
if (typeof document === 'undefined' || !artboardId) return false;
|
|
575
638
|
try {
|
|
576
|
-
|
|
639
|
+
const root = document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"]`);
|
|
640
|
+
if (!root) return false;
|
|
641
|
+
if (root.getAttribute('data-dc-kind') === 'video') return true;
|
|
642
|
+
return !!root.querySelector('[data-comp-id]');
|
|
577
643
|
} catch {
|
|
578
644
|
return false;
|
|
579
645
|
}
|
|
@@ -1421,6 +1487,391 @@ function buildRegistry(deps: {
|
|
|
1421
1487
|
],
|
|
1422
1488
|
};
|
|
1423
1489
|
|
|
1490
|
+
// feature-1-artboard-kinds-foundation, T8 — kind-switch submenu, same
|
|
1491
|
+
// shape as themeItem above (a parent-only MenuItem whose onSelect never
|
|
1492
|
+
// fires). The write is main-origin-only (DDR-054): post to the parent
|
|
1493
|
+
// shell (dgn:'set-artboard-kind-request'), which calls
|
|
1494
|
+
// api.setArtboardKindOp and records undo — mirrors delete-artboard-request.
|
|
1495
|
+
const postArtboardKindRequest = (artboardId: string, kind: string | null): void => {
|
|
1496
|
+
try {
|
|
1497
|
+
window.parent.postMessage({ dgn: 'set-artboard-kind-request', artboardId, kind }, '*');
|
|
1498
|
+
} catch {
|
|
1499
|
+
/* detached / cross-origin */
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
const kindItem: MenuItem = {
|
|
1503
|
+
id: 'kind',
|
|
1504
|
+
label: 'Artboard kind',
|
|
1505
|
+
onSelect: () => {
|
|
1506
|
+
/* parent of a submenu — never invoked directly */
|
|
1507
|
+
},
|
|
1508
|
+
submenu: [
|
|
1509
|
+
{
|
|
1510
|
+
id: 'kind-digital',
|
|
1511
|
+
label: 'Digital (default)',
|
|
1512
|
+
// feature-4 (user steer 2026-07-19) — digital/print artboards are the
|
|
1513
|
+
// marketing-graphics kinds: on switching, offer to freeze the whole
|
|
1514
|
+
// layout to absolute so every element is freely draggable. Convert
|
|
1515
|
+
// FIRST (freeze at the current visual), then switch the kind.
|
|
1516
|
+
onSelect: (target) => {
|
|
1517
|
+
if (!target.artboardId) return;
|
|
1518
|
+
const artboardId = target.artboardId;
|
|
1519
|
+
void (async () => {
|
|
1520
|
+
const freeze = await canvasConfirm(
|
|
1521
|
+
'Digital artboards work best with freely movable elements.\nAlso freeze the current layout (convert everything to position: absolute)? This is a one-way change — ⌘Z right after still reverts it.',
|
|
1522
|
+
{
|
|
1523
|
+
title: 'Switch to Digital',
|
|
1524
|
+
confirmLabel: 'Switch + freeze',
|
|
1525
|
+
cancelLabel: 'Just switch',
|
|
1526
|
+
}
|
|
1527
|
+
);
|
|
1528
|
+
if (freeze) await postConvertArtboardToAbsolute(artboardId, { skipConfirm: true });
|
|
1529
|
+
postArtboardKindRequest(artboardId, null);
|
|
1530
|
+
})();
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
id: 'kind-print',
|
|
1535
|
+
label: 'Print',
|
|
1536
|
+
onSelect: (target) => {
|
|
1537
|
+
if (!target.artboardId) return;
|
|
1538
|
+
const artboardId = target.artboardId;
|
|
1539
|
+
void (async () => {
|
|
1540
|
+
const freeze = await canvasConfirm(
|
|
1541
|
+
'Print artboards work best with freely movable, absolutely positioned elements.\nAlso freeze the current layout (convert everything to position: absolute)? This is a one-way change — ⌘Z right after still reverts it.',
|
|
1542
|
+
{
|
|
1543
|
+
title: 'Switch to Print',
|
|
1544
|
+
confirmLabel: 'Switch + freeze',
|
|
1545
|
+
cancelLabel: 'Just switch',
|
|
1546
|
+
}
|
|
1547
|
+
);
|
|
1548
|
+
if (freeze) await postConvertArtboardToAbsolute(artboardId, { skipConfirm: true });
|
|
1549
|
+
postArtboardKindRequest(artboardId, 'print');
|
|
1550
|
+
})();
|
|
1551
|
+
},
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
id: 'kind-web',
|
|
1555
|
+
label: 'Web',
|
|
1556
|
+
onSelect: (target) => {
|
|
1557
|
+
if (target.artboardId) postArtboardKindRequest(target.artboardId, 'web');
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
id: 'kind-video',
|
|
1562
|
+
label: 'Video',
|
|
1563
|
+
onSelect: (target) => {
|
|
1564
|
+
if (target.artboardId) postArtboardKindRequest(target.artboardId, 'video');
|
|
1565
|
+
},
|
|
1566
|
+
},
|
|
1567
|
+
],
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
// feature-3-web-artboards T3 — "Duplicate at width…" submenu, same
|
|
1571
|
+
// parent-only-MenuItem shape as themeItem/kindItem above. Breakpoint
|
|
1572
|
+
// widths are the same 4 values as the studio shell's own SCREEN_PRESETS
|
|
1573
|
+
// (mobile/tablet/laptop/desktop) — hardcoded here rather than imported
|
|
1574
|
+
// since this module bundles into the canvas iframe, a separate bundle
|
|
1575
|
+
// graph from the studio shell's app.jsx (same reasoning as kindItem
|
|
1576
|
+
// duplicating the ArtboardKind union above).
|
|
1577
|
+
const postDuplicateArtboardRequest = (artboardId: string, width: number): void => {
|
|
1578
|
+
try {
|
|
1579
|
+
window.parent.postMessage({ dgn: 'duplicate-artboard-request', artboardId, width }, '*');
|
|
1580
|
+
} catch {
|
|
1581
|
+
/* detached / cross-origin */
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
const DUP_BREAKPOINTS: Array<{ id: string; label: string; width: number }> = [
|
|
1585
|
+
{ id: 'dup-bp-mobile', label: 'Mobile — 390px', width: 390 },
|
|
1586
|
+
{ id: 'dup-bp-tablet', label: 'Tablet — 834px', width: 834 },
|
|
1587
|
+
{ id: 'dup-bp-laptop', label: 'Laptop — 1280px', width: 1280 },
|
|
1588
|
+
{ id: 'dup-bp-desktop', label: 'Desktop — 1440px', width: 1440 },
|
|
1589
|
+
];
|
|
1590
|
+
const duplicateAtBreakpointItem: MenuItem = {
|
|
1591
|
+
id: 'duplicate-at-breakpoint',
|
|
1592
|
+
label: 'Duplicate at width…',
|
|
1593
|
+
onSelect: () => {
|
|
1594
|
+
/* parent of a submenu — never invoked directly */
|
|
1595
|
+
},
|
|
1596
|
+
submenu: DUP_BREAKPOINTS.map(
|
|
1597
|
+
({ id, label, width }): MenuItem => ({
|
|
1598
|
+
id,
|
|
1599
|
+
label,
|
|
1600
|
+
onSelect: (target) => {
|
|
1601
|
+
if (target.artboardId) postDuplicateArtboardRequest(target.artboardId, width);
|
|
1602
|
+
},
|
|
1603
|
+
})
|
|
1604
|
+
),
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
// feature-4 T8 (convert-to-absolute, DDR-188) — the DIRECT stamped children of
|
|
1608
|
+
// a container that are eligible for the "Remove auto layout" convert: skips
|
|
1609
|
+
// dev-server chrome, requires a data-cd-id. Returns null when the element
|
|
1610
|
+
// isn't a convertible container (no stamped direct children).
|
|
1611
|
+
const convertibleChildrenOf = (el: Element | null): HTMLElement[] | null => {
|
|
1612
|
+
if (!el) return null;
|
|
1613
|
+
const kids = Array.from(el.children).filter(
|
|
1614
|
+
(c) => !c.closest('.dgn-pin, .dc-cv-halo, .dc-cv-group-bbox')
|
|
1615
|
+
) as HTMLElement[];
|
|
1616
|
+
const stamped = kids.filter((c) => c.hasAttribute('data-cd-id'));
|
|
1617
|
+
return stamped.length > 0 ? stamped : null;
|
|
1618
|
+
};
|
|
1619
|
+
// Count how many elements in the artboard body carry `cdId` — >1 means a
|
|
1620
|
+
// repeated (`.map`) or shared-component instance, which convert-to-absolute
|
|
1621
|
+
// refuses (one source element can't hold N distinct absolute positions).
|
|
1622
|
+
const cdIdCountInArtboard = (el: Element, cdId: string): number => {
|
|
1623
|
+
const body = el.closest('.dc-artboard-body') ?? el.ownerDocument;
|
|
1624
|
+
try {
|
|
1625
|
+
return body.querySelectorAll(`[data-cd-id="${CSS.escape(cdId)}"]`).length;
|
|
1626
|
+
} catch {
|
|
1627
|
+
return 99;
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
// feature-4 unified subtree convert (dogfood round 4, 2026-07-20) — ONE
|
|
1631
|
+
// walk + ONE confirm for the whole subtree, whether rooted at a right-
|
|
1632
|
+
// clicked container (element context menu) or the artboard body. The
|
|
1633
|
+
// earlier per-container element action made the user confirm once PER
|
|
1634
|
+
// nested container ("modal vyskočí 10×"); now a single action converts the
|
|
1635
|
+
// entire subtree in one batch (one undo seq), dissolving unstyled layout
|
|
1636
|
+
// wrappers along the way.
|
|
1637
|
+
const isUnstyledWrapper = (el: HTMLElement): boolean => {
|
|
1638
|
+
const cs = getComputedStyle(el);
|
|
1639
|
+
const bg = cs.backgroundColor;
|
|
1640
|
+
const transparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)';
|
|
1641
|
+
return (
|
|
1642
|
+
transparentBg &&
|
|
1643
|
+
cs.backgroundImage === 'none' &&
|
|
1644
|
+
(Number.parseFloat(cs.borderTopWidth) || 0) === 0 &&
|
|
1645
|
+
(Number.parseFloat(cs.borderRightWidth) || 0) === 0 &&
|
|
1646
|
+
(Number.parseFloat(cs.borderBottomWidth) || 0) === 0 &&
|
|
1647
|
+
(Number.parseFloat(cs.borderLeftWidth) || 0) === 0 &&
|
|
1648
|
+
cs.boxShadow === 'none' &&
|
|
1649
|
+
(cs.borderRadius === '0px' || cs.borderRadius === '0') &&
|
|
1650
|
+
cs.overflow !== 'hidden' &&
|
|
1651
|
+
cs.overflowX !== 'hidden' &&
|
|
1652
|
+
cs.overflowY !== 'hidden' &&
|
|
1653
|
+
cs.opacity === '1' &&
|
|
1654
|
+
cs.filter === 'none' &&
|
|
1655
|
+
cs.transform === 'none'
|
|
1656
|
+
);
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
type ConvertChildBox = {
|
|
1660
|
+
id: string;
|
|
1661
|
+
idIndex?: number;
|
|
1662
|
+
left: number;
|
|
1663
|
+
top: number;
|
|
1664
|
+
width: number;
|
|
1665
|
+
height: number;
|
|
1666
|
+
};
|
|
1667
|
+
type ConvertEntry = {
|
|
1668
|
+
containerId?: string;
|
|
1669
|
+
containerSetRelative: boolean;
|
|
1670
|
+
freezeSize?: { width: number; height: number };
|
|
1671
|
+
children: ConvertChildBox[];
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Convert an entire SUBTREE to absolute in one batch: `rootEl` is either the
|
|
1676
|
+
* artboard body (artboard-level, `rootId` undefined) or a right-clicked
|
|
1677
|
+
* container (element-level, `rootId` = its cd-id — the root itself keeps its
|
|
1678
|
+
* flow position; only its descendants freeze). Exactly ONE confirm per
|
|
1679
|
+
* invocation (skippable), an in-flight guard so double-invocations can't
|
|
1680
|
+
* stack dialogs, and the rest happens in the background.
|
|
1681
|
+
*/
|
|
1682
|
+
const runSubtreeConvert = async (
|
|
1683
|
+
rootEl: HTMLElement,
|
|
1684
|
+
rootId: string | undefined,
|
|
1685
|
+
artboardId: string,
|
|
1686
|
+
opts?: { skipConfirm?: boolean }
|
|
1687
|
+
): Promise<void> => {
|
|
1688
|
+
const w = window as unknown as { __maudeConvertBusy?: boolean };
|
|
1689
|
+
if (w.__maudeConvertBusy) return;
|
|
1690
|
+
w.__maudeConvertBusy = true;
|
|
1691
|
+
try {
|
|
1692
|
+
const body = rootEl.closest('.dc-artboard-body') ?? rootEl;
|
|
1693
|
+
const zoom = worldZoomFor(rootEl) || 1;
|
|
1694
|
+
const containers: ConvertEntry[] = [];
|
|
1695
|
+
const dissolve: string[] = [];
|
|
1696
|
+
let unstamped = 0;
|
|
1697
|
+
let repeated = 0;
|
|
1698
|
+
const countIn = (cid: string): number => {
|
|
1699
|
+
try {
|
|
1700
|
+
return body.querySelectorAll(`[data-cd-id="${CSS.escape(cid)}"]`).length;
|
|
1701
|
+
} catch {
|
|
1702
|
+
return 99;
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
const measure = (el: HTMLElement, vs: HTMLElement): Omit<ConvertChildBox, 'id'> => {
|
|
1706
|
+
const r = el.getBoundingClientRect();
|
|
1707
|
+
const sRect = vs.getBoundingClientRect();
|
|
1708
|
+
const scs = getComputedStyle(vs);
|
|
1709
|
+
const borderL = Number.parseFloat(scs.borderLeftWidth) || 0;
|
|
1710
|
+
const borderT = Number.parseFloat(scs.borderTopWidth) || 0;
|
|
1711
|
+
return {
|
|
1712
|
+
left: Math.round((r.left - sRect.left) / zoom - borderL),
|
|
1713
|
+
top: Math.round((r.top - sRect.top) / zoom - borderT),
|
|
1714
|
+
width: Math.round(r.width / zoom),
|
|
1715
|
+
height: Math.round(r.height / zoom),
|
|
1716
|
+
};
|
|
1717
|
+
};
|
|
1718
|
+
const walk = (
|
|
1719
|
+
containerEl: HTMLElement,
|
|
1720
|
+
survivingEl: HTMLElement,
|
|
1721
|
+
survivingEntry: ConvertEntry
|
|
1722
|
+
): void => {
|
|
1723
|
+
for (const c of Array.from(containerEl.children) as HTMLElement[]) {
|
|
1724
|
+
if (c.closest('.dgn-pin, .dc-cv-halo, .dc-cv-group-bbox')) continue;
|
|
1725
|
+
const cid = c.getAttribute('data-cd-id');
|
|
1726
|
+
if (!cid) {
|
|
1727
|
+
unstamped += 1;
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
const count = countIn(cid);
|
|
1731
|
+
if (count > 1) repeated += 1;
|
|
1732
|
+
const hasStampedKids = !!c.querySelector('[data-cd-id]');
|
|
1733
|
+
const dissolvable = hasStampedKids && count === 1 && isUnstyledWrapper(c);
|
|
1734
|
+
if (dissolvable) {
|
|
1735
|
+
dissolve.push(cid);
|
|
1736
|
+
walk(c, survivingEl, survivingEntry);
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
const idIndex =
|
|
1740
|
+
count > 1
|
|
1741
|
+
? Math.max(
|
|
1742
|
+
0,
|
|
1743
|
+
Array.from(body.querySelectorAll(`[data-cd-id="${CSS.escape(cid)}"]`)).indexOf(c)
|
|
1744
|
+
)
|
|
1745
|
+
: undefined;
|
|
1746
|
+
survivingEntry.children.push({
|
|
1747
|
+
id: cid,
|
|
1748
|
+
...(idIndex !== undefined ? { idIndex } : {}),
|
|
1749
|
+
...measure(c, survivingEl),
|
|
1750
|
+
});
|
|
1751
|
+
if (hasStampedKids && count === 1) {
|
|
1752
|
+
const entry: ConvertEntry = {
|
|
1753
|
+
containerId: cid,
|
|
1754
|
+
containerSetRelative: getComputedStyle(c).position === 'static',
|
|
1755
|
+
children: [],
|
|
1756
|
+
};
|
|
1757
|
+
containers.push(entry);
|
|
1758
|
+
walk(c, c, entry);
|
|
1759
|
+
}
|
|
1760
|
+
// A repeated (component-instance) container freezes as a WRAPPED
|
|
1761
|
+
// unit — we don't descend into shared internals (per-instance inner
|
|
1762
|
+
// edits need detach; the whole instance is draggable via its box).
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
const rootEntry: ConvertEntry = {
|
|
1766
|
+
...(rootId !== undefined ? { containerId: rootId } : {}),
|
|
1767
|
+
containerSetRelative:
|
|
1768
|
+
rootId !== undefined && getComputedStyle(rootEl).position === 'static',
|
|
1769
|
+
// The element-level root keeps its FLOW position but its auto height
|
|
1770
|
+
// would collapse once all children go absolute — freeze its box.
|
|
1771
|
+
...(rootId !== undefined
|
|
1772
|
+
? {
|
|
1773
|
+
freezeSize: {
|
|
1774
|
+
width: Math.round(rootEl.getBoundingClientRect().width / zoom),
|
|
1775
|
+
height: Math.round(rootEl.getBoundingClientRect().height / zoom),
|
|
1776
|
+
},
|
|
1777
|
+
}
|
|
1778
|
+
: {}),
|
|
1779
|
+
children: [],
|
|
1780
|
+
};
|
|
1781
|
+
containers.push(rootEntry);
|
|
1782
|
+
walk(rootEl, rootEl, rootEntry);
|
|
1783
|
+
const nonEmpty = containers.filter((c) => c.children.length > 0 || c.containerSetRelative);
|
|
1784
|
+
const total = nonEmpty.reduce((n, c) => n + c.children.length, 0);
|
|
1785
|
+
if (total === 0) {
|
|
1786
|
+
showCanvasToast('Nothing to convert — no selectable elements here.');
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (unstamped > 0) {
|
|
1790
|
+
showCanvasToast('Some elements have no id — edit the canvas via chat first, then convert.');
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
// ONE confirm for the WHOLE subtree (user steer: "stačí jednou a zbytek
|
|
1794
|
+
// proveď na pozadí"). Skipped entirely for kind-switch (the shell/canvas
|
|
1795
|
+
// already asked there).
|
|
1796
|
+
if (opts?.skipConfirm !== true) {
|
|
1797
|
+
const parts = [
|
|
1798
|
+
`Freeze all ${total} elements at their current positions (position: absolute)?`,
|
|
1799
|
+
dissolve.length > 0
|
|
1800
|
+
? `${dissolve.length} invisible layout wrapper(s) will be removed — the tree flattens.`
|
|
1801
|
+
: 'Every element becomes freely draggable — the layout will no longer reflow.',
|
|
1802
|
+
'This is a one-way change (⌘Z right after still reverts it).',
|
|
1803
|
+
];
|
|
1804
|
+
if (repeated > 0) {
|
|
1805
|
+
parts.push(
|
|
1806
|
+
`${repeated} element(s) render from shared components — each instance keeps its own frozen position.`
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
const ok = await canvasConfirm(parts.join('\n'), {
|
|
1810
|
+
title:
|
|
1811
|
+
rootId !== undefined
|
|
1812
|
+
? 'Convert this container to absolute?'
|
|
1813
|
+
: 'Convert artboard layout to absolute?',
|
|
1814
|
+
confirmLabel: 'Convert all',
|
|
1815
|
+
});
|
|
1816
|
+
if (!ok) return;
|
|
1817
|
+
}
|
|
1818
|
+
try {
|
|
1819
|
+
window.parent.postMessage(
|
|
1820
|
+
{
|
|
1821
|
+
dgn: 'convert-to-absolute-request',
|
|
1822
|
+
artboardId,
|
|
1823
|
+
allowShared: repeated > 0,
|
|
1824
|
+
containers: nonEmpty,
|
|
1825
|
+
...(dissolve.length > 0 ? { dissolve } : {}),
|
|
1826
|
+
},
|
|
1827
|
+
'*'
|
|
1828
|
+
);
|
|
1829
|
+
} catch {
|
|
1830
|
+
/* detached / cross-origin */
|
|
1831
|
+
}
|
|
1832
|
+
} finally {
|
|
1833
|
+
w.__maudeConvertBusy = false;
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
// Element context menu — recursive over the clicked container's subtree.
|
|
1838
|
+
const postConvertToAbsolute = async (containerEl: HTMLElement): Promise<void> => {
|
|
1839
|
+
const containerId = containerEl.getAttribute('data-cd-id');
|
|
1840
|
+
if (!containerId) {
|
|
1841
|
+
showCanvasToast('That container has no id yet — edit it via chat first, then convert.');
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const artboardId =
|
|
1845
|
+
(containerEl.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
|
|
1846
|
+
'data-dc-screen'
|
|
1847
|
+
) ?? '';
|
|
1848
|
+
if (!artboardId) return;
|
|
1849
|
+
if (cdIdCountInArtboard(containerEl, containerId) > 1) {
|
|
1850
|
+
showCanvasToast(
|
|
1851
|
+
'This container is a repeated/component instance — convert isn\u2019t supported for it yet.'
|
|
1852
|
+
);
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
await runSubtreeConvert(containerEl, containerId, artboardId);
|
|
1856
|
+
};
|
|
1857
|
+
// Artboard-level (context menu + kind switch).
|
|
1858
|
+
const postConvertArtboardToAbsolute = async (
|
|
1859
|
+
artboardId: string,
|
|
1860
|
+
opts?: { skipConfirm?: boolean }
|
|
1861
|
+
): Promise<void> => {
|
|
1862
|
+
const body = document.querySelector(
|
|
1863
|
+
`[data-dc-screen="${CSS.escape(artboardId)}"] .dc-artboard-body`
|
|
1864
|
+
) as HTMLElement | null;
|
|
1865
|
+
if (!body) return;
|
|
1866
|
+
await runSubtreeConvert(body, undefined, artboardId, opts);
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
// Publish for the CanvasRouter's `freeze-and-set-kind` handler (different
|
|
1870
|
+
// scope; window-level so it survives soft HMR, same as __maudeLastLayersArt).
|
|
1871
|
+
(
|
|
1872
|
+
window as unknown as { __maudeConvertArtboard?: typeof postConvertArtboardToAbsolute }
|
|
1873
|
+
).__maudeConvertArtboard = postConvertArtboardToAbsolute;
|
|
1874
|
+
|
|
1424
1875
|
return {
|
|
1425
1876
|
element: [
|
|
1426
1877
|
[
|
|
@@ -1600,6 +2051,18 @@ function buildRegistry(deps: {
|
|
|
1600
2051
|
}
|
|
1601
2052
|
},
|
|
1602
2053
|
},
|
|
2054
|
+
{
|
|
2055
|
+
// feature-4 T8 (convert-to-absolute, DDR-188) — the Figma "Remove auto
|
|
2056
|
+
// layout" analogue. Only shown on a container with stamped children;
|
|
2057
|
+
// freezes each child's computed box and rewrites them to
|
|
2058
|
+
// position:absolute so they can be dragged freely.
|
|
2059
|
+
id: 'convert-to-absolute',
|
|
2060
|
+
label: 'Convert children to absolute position',
|
|
2061
|
+
hidden: (target) => !convertibleChildrenOf(target.el),
|
|
2062
|
+
onSelect: (target) => {
|
|
2063
|
+
if (target.el) postConvertToAbsolute(target.el as HTMLElement);
|
|
2064
|
+
},
|
|
2065
|
+
},
|
|
1603
2066
|
],
|
|
1604
2067
|
[exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
|
|
1605
2068
|
[
|
|
@@ -1689,6 +2152,18 @@ function buildRegistry(deps: {
|
|
|
1689
2152
|
fitItem,
|
|
1690
2153
|
resetItem,
|
|
1691
2154
|
],
|
|
2155
|
+
[
|
|
2156
|
+
{
|
|
2157
|
+
// feature-4 (user steer 2026-07-19) — freeze the WHOLE artboard's
|
|
2158
|
+
// layout: every element position:absolute at its current box, so the
|
|
2159
|
+
// composition is freely draggable (marketing-graphics flow).
|
|
2160
|
+
id: 'convert-artboard-absolute',
|
|
2161
|
+
label: 'Convert layout to absolute…',
|
|
2162
|
+
onSelect: (target) => {
|
|
2163
|
+
if (target.artboardId) void postConvertArtboardToAbsolute(target.artboardId);
|
|
2164
|
+
},
|
|
2165
|
+
},
|
|
2166
|
+
],
|
|
1692
2167
|
[
|
|
1693
2168
|
// DDR-148 — only meaningful (and only enabled) on a video-comp
|
|
1694
2169
|
// artboard; scopes + opens the shell's Timeline dock.
|
|
@@ -1771,7 +2246,7 @@ function buildRegistry(deps: {
|
|
|
1771
2246
|
onSelect: () => distributeArtboards('y'),
|
|
1772
2247
|
},
|
|
1773
2248
|
],
|
|
1774
|
-
[themeItem],
|
|
2249
|
+
[themeItem, kindItem, duplicateAtBreakpointItem],
|
|
1775
2250
|
[
|
|
1776
2251
|
{
|
|
1777
2252
|
id: 'delete-artboard',
|
|
@@ -1874,6 +2349,43 @@ export function revealElementViaCamera(
|
|
|
1874
2349
|
if (dx !== 0 || dy !== 0) controller.panBy(dx, dy);
|
|
1875
2350
|
}
|
|
1876
2351
|
|
|
2352
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2353
|
+
// feature-4 T7b — locked-layer enforcement. The shell owns the padlock UI +
|
|
2354
|
+
// view.json persistence; the canvas only needs the LIVE set to refuse
|
|
2355
|
+
// select/drag on locked elements. Window-level (not module `let`) so it
|
|
2356
|
+
// survives soft HMR re-evaluation, same reasoning as __maudeLastLayersArt.
|
|
2357
|
+
function lockedKeySet(): Set<string> {
|
|
2358
|
+
const w = window as unknown as { __maudeLockedKeys?: Set<string> };
|
|
2359
|
+
if (!w.__maudeLockedKeys) {
|
|
2360
|
+
w.__maudeLockedKeys = new Set(
|
|
2361
|
+
Array.isArray(
|
|
2362
|
+
(window as unknown as { __canvas_meta__?: { locked?: unknown } }).__canvas_meta__?.locked
|
|
2363
|
+
)
|
|
2364
|
+
? ((window as unknown as { __canvas_meta__?: { locked?: string[] } }).__canvas_meta__
|
|
2365
|
+
?.locked as string[])
|
|
2366
|
+
: []
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
return w.__maudeLockedKeys;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** Is the element a locked layer? Matches by `cdId:occurrenceIndex` within the
|
|
2373
|
+
* artboard body (same occurrence math the Layers rows use). */
|
|
2374
|
+
function isLockedElement(el: Element | null, cdId: string | null): boolean {
|
|
2375
|
+
if (!el || !cdId) return false;
|
|
2376
|
+
const locked = lockedKeySet();
|
|
2377
|
+
if (locked.size === 0) return false;
|
|
2378
|
+
const body = el.closest('.dc-artboard-body');
|
|
2379
|
+
if (!body) return false;
|
|
2380
|
+
try {
|
|
2381
|
+
const same = Array.from(body.querySelectorAll(`[data-cd-id="${CSS.escape(cdId)}"]`));
|
|
2382
|
+
const idx = same.indexOf(el);
|
|
2383
|
+
return locked.has(`${cdId}:${idx < 0 ? 0 : idx}`);
|
|
2384
|
+
} catch {
|
|
2385
|
+
return false;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
1877
2389
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1878
2390
|
// Router wire-up
|
|
1879
2391
|
|
|
@@ -1938,6 +2450,34 @@ function CanvasRouter({
|
|
|
1938
2450
|
// consumes.
|
|
1939
2451
|
const chromeCtx = useChromeVisibility();
|
|
1940
2452
|
|
|
2453
|
+
// Dogfood follow-up (print artboards) — self-seed the per-canvas persisted
|
|
2454
|
+
// overlay visibility (view.json `overlays.guides`/`overlays.print`, GET-
|
|
2455
|
+
// merged into `window.__canvas_meta__` by _shell.html before mount) into
|
|
2456
|
+
// the chrome store, once per mount. This seed originally lived in
|
|
2457
|
+
// DesignCanvasInner (canvas-lib.tsx) — but that component renders
|
|
2458
|
+
// CanvasShell as its CHILD, and ChromeVisibilityProvider mounts INSIDE
|
|
2459
|
+
// CanvasShell, so DesignCanvasInner's own useChromeVisibility() was always
|
|
2460
|
+
// null and the seed silently never ran (dead since the foundation feature;
|
|
2461
|
+
// the generic `guides` toggle had no UI yet, so nothing surfaced it —
|
|
2462
|
+
// "Show print guides" is its first real consumer). CanvasRouter sits
|
|
2463
|
+
// inside the provider (it's the view-chrome bridge), so the seed works
|
|
2464
|
+
// here. Absent keys stay HIDDEN (the T6 gotcha — an old view.json must
|
|
2465
|
+
// not suddenly paint guides nobody asked for).
|
|
2466
|
+
const overlaysSeededRef = useRef(false);
|
|
2467
|
+
useEffect(() => {
|
|
2468
|
+
if (overlaysSeededRef.current || !chromeCtx) return;
|
|
2469
|
+
overlaysSeededRef.current = true;
|
|
2470
|
+
const w = window as unknown as {
|
|
2471
|
+
__canvas_meta__?: { overlays?: Record<string, boolean> };
|
|
2472
|
+
};
|
|
2473
|
+
const overlays = w.__canvas_meta__?.overlays;
|
|
2474
|
+
if (!overlays) return;
|
|
2475
|
+
const patch: { guides?: boolean; print?: boolean } = {};
|
|
2476
|
+
if (overlays.guides === true) patch.guides = true;
|
|
2477
|
+
if (overlays.print === true) patch.print = true;
|
|
2478
|
+
if (Object.keys(patch).length > 0) chromeCtx.setChrome(patch);
|
|
2479
|
+
}, [chromeCtx]);
|
|
2480
|
+
|
|
1941
2481
|
// Task A2 (feature-element-editing-robustness) — host-scroll-0 invariant.
|
|
1942
2482
|
// `.dc-canvas` (overflow:hidden) and each `.dc-artboard-body` are the engine's
|
|
1943
2483
|
// clip layers; the camera lives entirely in the `.dc-world` transform, and
|
|
@@ -1979,6 +2519,15 @@ function CanvasRouter({
|
|
|
1979
2519
|
|
|
1980
2520
|
const getActiveTool = useCallback(() => tool, [tool]);
|
|
1981
2521
|
|
|
2522
|
+
// feature-4 (browse/move split) — a live ref to the active tool for the
|
|
2523
|
+
// long-lived capture listeners (dblclick) whose effects don't re-attach on a
|
|
2524
|
+
// tool change. In the `browse` tool every canvas gesture is a pure native
|
|
2525
|
+
// pass-through, so drill-select + inline text-edit must NOT fire — a
|
|
2526
|
+
// double-click there belongs to the mock (selecting text in a real input,
|
|
2527
|
+
// etc.). Press V for the Move (select) tool to edit.
|
|
2528
|
+
const dblToolRef = useRef(tool);
|
|
2529
|
+
dblToolRef.current = tool;
|
|
2530
|
+
|
|
1982
2531
|
const applyHover = useCallback(() => {
|
|
1983
2532
|
hoverRafRef.current = null;
|
|
1984
2533
|
const pending = pendingHoverRef.current;
|
|
@@ -2022,6 +2571,83 @@ function CanvasRouter({
|
|
|
2022
2571
|
if (typeof t === 'string') setTool(t as never);
|
|
2023
2572
|
return;
|
|
2024
2573
|
}
|
|
2574
|
+
// feature-4 (2026-07-20) — the Inspector's kind picker (shell-side) asks
|
|
2575
|
+
// the CANVAS to freeze-then-switch, because only the canvas can measure
|
|
2576
|
+
// boxes. Both outbound messages (convert request, kind request) leave in
|
|
2577
|
+
// order, so the shell's structuralWrite chain serializes them correctly
|
|
2578
|
+
// (freeze lands before a print-kind resize could move anything).
|
|
2579
|
+
if (m.dgn === 'freeze-and-set-kind') {
|
|
2580
|
+
// SECURITY (ethical-hacker) — accept ONLY from the parent shell, never
|
|
2581
|
+
// from a canvas self-post or a sibling canvas iframe (all canvases share
|
|
2582
|
+
// one canvasOrigin, so any frame can otherwise forge this cross-frame —
|
|
2583
|
+
// confused-deputy: it drives a privileged convert-to-absolute + artboard
|
|
2584
|
+
// kind write). Same gate as apply-style/record-edit above.
|
|
2585
|
+
if (e.source !== window.parent) return;
|
|
2586
|
+
const mm = m as {
|
|
2587
|
+
artboardId?: string;
|
|
2588
|
+
kind?: string | null;
|
|
2589
|
+
freeze?: boolean;
|
|
2590
|
+
ask?: boolean;
|
|
2591
|
+
};
|
|
2592
|
+
if (typeof mm.artboardId === 'string' && mm.artboardId) {
|
|
2593
|
+
const artboardId = mm.artboardId;
|
|
2594
|
+
const kind = typeof mm.kind === 'string' ? mm.kind : null;
|
|
2595
|
+
void (async () => {
|
|
2596
|
+
// Dogfood round 5 — `ask: true` = the CANVAS shows the freeze
|
|
2597
|
+
// offer itself (canvasConfirm; the shell's window.confirm is a
|
|
2598
|
+
// silent no-op in the Tauri WKWebView).
|
|
2599
|
+
let freeze = mm.freeze !== false;
|
|
2600
|
+
if (mm.ask === true) {
|
|
2601
|
+
freeze = await canvasConfirm(
|
|
2602
|
+
`${kind === 'print' ? 'Print' : 'Digital'} artboards work best with freely movable elements.\nAlso freeze the current layout (convert everything to position: absolute, removing invisible layout wrappers)? One-way change — ⌘Z right after still reverts it.`,
|
|
2603
|
+
{
|
|
2604
|
+
title: kind === 'print' ? 'Switch to Print' : 'Switch to Digital',
|
|
2605
|
+
confirmLabel: 'Switch + freeze',
|
|
2606
|
+
cancelLabel: 'Just switch',
|
|
2607
|
+
}
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
if (freeze) {
|
|
2611
|
+
const convert = (
|
|
2612
|
+
window as unknown as {
|
|
2613
|
+
__maudeConvertArtboard?: (
|
|
2614
|
+
id: string,
|
|
2615
|
+
o?: { skipConfirm?: boolean }
|
|
2616
|
+
) => Promise<void>;
|
|
2617
|
+
}
|
|
2618
|
+
).__maudeConvertArtboard;
|
|
2619
|
+
await convert?.(artboardId, { skipConfirm: true });
|
|
2620
|
+
}
|
|
2621
|
+
try {
|
|
2622
|
+
window.parent.postMessage(
|
|
2623
|
+
{ dgn: 'set-artboard-kind-request', artboardId, kind },
|
|
2624
|
+
'*'
|
|
2625
|
+
);
|
|
2626
|
+
} catch {
|
|
2627
|
+
/* detached */
|
|
2628
|
+
}
|
|
2629
|
+
})();
|
|
2630
|
+
}
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
// feature-4 (2026-07-19) — generic shell→canvas result toast (convert /
|
|
2634
|
+
// detach success + failure feedback; the zero-visual-delta convert
|
|
2635
|
+
// otherwise LOOKS like nothing happened).
|
|
2636
|
+
if (m.dgn === 'op-toast') {
|
|
2637
|
+
const msg = (m as { message?: string }).message;
|
|
2638
|
+
if (typeof msg === 'string' && msg) showCanvasToast(msg.slice(0, 300));
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
// feature-4 T7b — live locked-layer set from the shell's Layers panel.
|
|
2642
|
+
if (m.dgn === 'locked-set') {
|
|
2643
|
+
const arr = (m as { locked?: unknown }).locked;
|
|
2644
|
+
if (Array.isArray(arr)) {
|
|
2645
|
+
const s = lockedKeySet();
|
|
2646
|
+
s.clear();
|
|
2647
|
+
for (const k of arr) if (typeof k === 'string') s.add(k);
|
|
2648
|
+
}
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2025
2651
|
// Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
|
|
2026
2652
|
if (m.dgn === 'undo') {
|
|
2027
2653
|
void undoStack.undo();
|
|
@@ -2037,29 +2663,52 @@ function CanvasRouter({
|
|
|
2037
2663
|
// request-layers re-walks + posts the tree for an artboard.
|
|
2038
2664
|
if (m.dgn === 'select-by-id') {
|
|
2039
2665
|
const mm = m as { id?: string; artboardId?: string | null; index?: number };
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2666
|
+
// Dogfood follow-up — a bare ARTBOARD selection (no data-cd-id, the
|
|
2667
|
+
// ArtboardKnobs panel's own case) has no `mm.id` to resolve by, so
|
|
2668
|
+
// the element branch below never ran for it: after a structural
|
|
2669
|
+
// artboard write (kind/print/style/hug/resize) the Inspector kept
|
|
2670
|
+
// showing the PRE-edit attrs snapshot (e.g. "Digital" right after
|
|
2671
|
+
// switching to "Print") until the user manually re-clicked the
|
|
2672
|
+
// artboard. Resolve by the SAME `[data-dc-screen="<id>"]` selector
|
|
2673
|
+
// ArtboardKnobs itself reads its selection from (dom-selection.ts
|
|
2674
|
+
// `hoverTargetToSelection` "2. data-dc-screen" resolution order) when
|
|
2675
|
+
// there's no cdId to anchor on.
|
|
2676
|
+
const target = mm.id
|
|
2677
|
+
? resolveSelectionEl(document, { id: mm.id, artboardId: mm.artboardId, index: mm.index })
|
|
2678
|
+
: mm.artboardId
|
|
2679
|
+
? resolveSelectionEl(document, {
|
|
2680
|
+
selector: `[data-dc-screen="${CSS.escape(mm.artboardId)}"]`,
|
|
2681
|
+
})
|
|
2682
|
+
: null;
|
|
2683
|
+
if (target) {
|
|
2684
|
+
// feature-4 dogfood fix (2026-07-19) — an IDEMPOTENT re-select (the
|
|
2685
|
+
// shell's halo-restore ladder re-posts the same selection at 50…5000
|
|
2686
|
+
// ms; artboard-resync does the same) must NOT re-reveal: the user may
|
|
2687
|
+
// be panning elsewhere mid-ladder, and every re-post used to yank the
|
|
2688
|
+
// camera back to the selection ("pan skoči zpět na selected objekt").
|
|
2689
|
+
// Reveal only when the selection actually CHANGES.
|
|
2690
|
+
const cur = selSet.selected;
|
|
2691
|
+
const one = cur.length === 1 ? cur[0] : null;
|
|
2692
|
+
const sameSelection = mm.id
|
|
2693
|
+
? !!one &&
|
|
2694
|
+
one.id === mm.id &&
|
|
2695
|
+
(mm.index == null || one.index == null || one.index === mm.index)
|
|
2696
|
+
: !!one && !one.id && one.artboardId === (mm.artboardId ?? null);
|
|
2697
|
+
selSet.replace(
|
|
2698
|
+
hoverTargetToSelection({
|
|
2699
|
+
el: target,
|
|
2700
|
+
cdId: mm.id,
|
|
2701
|
+
artboardId: mm.artboardId ?? null,
|
|
2702
|
+
} as HoverTarget)
|
|
2703
|
+
);
|
|
2704
|
+
// Task A1 — reveal THROUGH the camera, not host scroll. The old
|
|
2705
|
+
// `scrollIntoView` scrolled the overflow:hidden `.dc-canvas` /
|
|
2706
|
+
// `.dc-artboard-body` clip layers, desyncing the `.dc-world`
|
|
2707
|
+
// transform (Bugs A + B). `revealElementViaCamera` pans the world
|
|
2708
|
+
// the minimal amount only when the element is off-screen.
|
|
2709
|
+
const host = hostRef.current;
|
|
2710
|
+
if (host && zoomController && !sameSelection) {
|
|
2711
|
+
revealElementViaCamera(host, target as HTMLElement, zoomController);
|
|
2063
2712
|
}
|
|
2064
2713
|
}
|
|
2065
2714
|
return;
|
|
@@ -2188,13 +2837,57 @@ function CanvasRouter({
|
|
|
2188
2837
|
}
|
|
2189
2838
|
// Shell View-menu chrome toggles + Presentation Mode. Only the fields the
|
|
2190
2839
|
// shell sends are merged, so a `{ present: true }` enter keeps the user's
|
|
2191
|
-
// individual minimap/zoom toggles for the eventual exit.
|
|
2840
|
+
// individual minimap/zoom toggles for the eventual exit. `guides` (T6) is
|
|
2841
|
+
// the odd one out — per-CANVAS persisted state, not a global pref — but
|
|
2842
|
+
// rides the same live-broadcast channel; the shell PATCHes view.json
|
|
2843
|
+
// separately (see setGuidesVisible in app.jsx).
|
|
2192
2844
|
if (m.dgn === 'view-chrome' && chromeCtx) {
|
|
2193
|
-
const mm = m as {
|
|
2194
|
-
|
|
2845
|
+
const mm = m as {
|
|
2846
|
+
minimap?: boolean;
|
|
2847
|
+
zoom?: boolean;
|
|
2848
|
+
present?: boolean;
|
|
2849
|
+
guides?: boolean;
|
|
2850
|
+
print?: boolean;
|
|
2851
|
+
};
|
|
2852
|
+
const patch: {
|
|
2853
|
+
minimap?: boolean;
|
|
2854
|
+
zoom?: boolean;
|
|
2855
|
+
present?: boolean;
|
|
2856
|
+
guides?: boolean;
|
|
2857
|
+
print?: boolean;
|
|
2858
|
+
} = {};
|
|
2195
2859
|
if (typeof mm.minimap === 'boolean') patch.minimap = mm.minimap;
|
|
2196
2860
|
if (typeof mm.zoom === 'boolean') patch.zoom = mm.zoom;
|
|
2197
2861
|
if (typeof mm.present === 'boolean') patch.present = mm.present;
|
|
2862
|
+
if (typeof mm.guides === 'boolean') patch.guides = mm.guides;
|
|
2863
|
+
// feature-2-print-artboards T3 — "Show print guides" (bleed/trim/
|
|
2864
|
+
// margin overlay). Same per-canvas-persisted shape as `guides`.
|
|
2865
|
+
if (typeof mm.print === 'boolean') patch.print = mm.print;
|
|
2866
|
+
// Dogfood follow-up — mirror `guides`/`print` into the in-iframe
|
|
2867
|
+
// `window.__canvas_meta__.overlays` snapshot, exactly like the
|
|
2868
|
+
// existing viewport/layout mirror in canvas-lib.tsx's onSettle (DDR-
|
|
2869
|
+
// 138 comment: "populated ONCE at page load — so after a settle, a
|
|
2870
|
+
// soft HMR remount (any module edit: text, reorder, agent edit)
|
|
2871
|
+
// would re-read the STALE page-load [value]"). Without this, a
|
|
2872
|
+
// structural edit AFTER toggling guides on (e.g. a kind switch,
|
|
2873
|
+
// which is exactly such an edit) remounts DesignCanvasInner, its
|
|
2874
|
+
// `guidesSeededRef` self-seed effect re-fires, and reads the stale
|
|
2875
|
+
// page-load overlays snapshot — silently reverting a live-toggled
|
|
2876
|
+
// guide back to hidden even though the View-menu checkbox still
|
|
2877
|
+
// shows it checked. Same class of bug DDR-138 fixed for the camera;
|
|
2878
|
+
// this is its `overlays` counterpart.
|
|
2879
|
+
if (patch.guides !== undefined || patch.print !== undefined) {
|
|
2880
|
+
const w = window as unknown as {
|
|
2881
|
+
__canvas_meta__?: { overlays?: Record<string, boolean> };
|
|
2882
|
+
};
|
|
2883
|
+
if (w.__canvas_meta__) {
|
|
2884
|
+
w.__canvas_meta__.overlays = {
|
|
2885
|
+
...w.__canvas_meta__.overlays,
|
|
2886
|
+
...(patch.guides !== undefined ? { guides: patch.guides } : {}),
|
|
2887
|
+
...(patch.print !== undefined ? { print: patch.print } : {}),
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2198
2891
|
chromeCtx.setChrome(patch);
|
|
2199
2892
|
return;
|
|
2200
2893
|
}
|
|
@@ -2303,7 +2996,12 @@ function CanvasRouter({
|
|
|
2303
2996
|
// AND the annotation Text-tool's click-through (maude:enter-text-edit,
|
|
2304
2997
|
// dispatched from annotations-layer.tsx when its own click lands on an
|
|
2305
2998
|
// existing editable element) share one entry point.
|
|
2306
|
-
function enterEditModeAt(
|
|
2999
|
+
function enterEditModeAt(
|
|
3000
|
+
stamped: HTMLElement,
|
|
3001
|
+
clientX: number,
|
|
3002
|
+
clientY: number,
|
|
3003
|
+
opts?: { selectAll?: boolean }
|
|
3004
|
+
): void {
|
|
2307
3005
|
if (editing) return;
|
|
2308
3006
|
editing = stamped;
|
|
2309
3007
|
original = stamped.textContent ?? '';
|
|
@@ -2312,31 +3010,55 @@ function CanvasRouter({
|
|
|
2312
3010
|
stamped.addEventListener('blur', onBlur, true);
|
|
2313
3011
|
stamped.addEventListener('keydown', onKey, true);
|
|
2314
3012
|
stamped.focus();
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
3013
|
+
if (opts?.selectAll) {
|
|
3014
|
+
// feature-4 text-gestures (user steer 2026-07-20) — the DBLCLICK entry
|
|
3015
|
+
// selects ALL text (Figma: reaching the leaf via the drill ladder puts
|
|
3016
|
+
// the whole string under your fingers, ready to overwrite). Subsequent
|
|
3017
|
+
// clicks/dblclicks inside the editor are native (the router bails on
|
|
3018
|
+
// editable targets): click = caret, dblclick = word, triple = all.
|
|
3019
|
+
try {
|
|
3020
|
+
const sel = window.getSelection();
|
|
3021
|
+
const range = document.createRange();
|
|
3022
|
+
range.selectNodeContents(stamped);
|
|
3023
|
+
sel?.removeAllRanges();
|
|
3024
|
+
sel?.addRange(range);
|
|
3025
|
+
} catch {
|
|
3026
|
+
placeCaretAt(stamped, window, { x: clientX, y: clientY });
|
|
3027
|
+
}
|
|
3028
|
+
} else {
|
|
3029
|
+
// Unified placement chain (text-caret.ts) — the annotation editors use
|
|
3030
|
+
// the exact same helper, so caret-at-click can never diverge per surface.
|
|
3031
|
+
placeCaretAt(stamped, window, { x: clientX, y: clientY });
|
|
3032
|
+
}
|
|
2318
3033
|
caretDispose = mountCaret(stamped, window);
|
|
2319
3034
|
}
|
|
2320
3035
|
const onDbl = (e: MouseEvent): void => {
|
|
2321
3036
|
if (editing) return;
|
|
3037
|
+
// feature-4 — browse is a pure pass-through: no drill-select, no inline
|
|
3038
|
+
// text-edit. The mock owns its own double-clicks. Press V to edit.
|
|
3039
|
+
if (dblToolRef.current === 'browse') return;
|
|
2322
3040
|
const t = e.target as HTMLElement | null;
|
|
2323
3041
|
const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
|
|
2324
3042
|
if (!stamped) return;
|
|
2325
3043
|
const kids = Array.from(stamped.childNodes);
|
|
2326
3044
|
const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
3045
|
+
// Task L6 + feature-4 dogfood fix (2026-07-19) — the Figma ordering: each
|
|
3046
|
+
// double-click drills ONE level along the clicked element's stamped
|
|
3047
|
+
// ancestor chain; text edit opens only when the LEAF ITSELF is already
|
|
3048
|
+
// selected (the drill has reached it). Previously a dblclick on leaf
|
|
3049
|
+
// text jumped straight to the editor from any depth, so the drill ladder
|
|
3050
|
+
// never ran on text-bearing targets.
|
|
3051
|
+
const sel0 = selSet.selected;
|
|
3052
|
+
const currentId = sel0.length === 1 ? sel0[0]?.id : undefined;
|
|
3053
|
+
const leafReached = isLeafText && currentId === stamped.getAttribute('data-cd-id');
|
|
3054
|
+
if (!leafReached) {
|
|
3055
|
+
// Drill ONE level past whatever's currently selected along this
|
|
3056
|
+
// click's own stamped-ancestor chain (repeated double-clicks walk
|
|
3057
|
+
// progressively deeper; the chain's last rung is the clicked element
|
|
3058
|
+
// itself); default to the outermost rung when nothing in the chain is
|
|
3059
|
+
// currently selected.
|
|
2336
3060
|
const chain = stampedChainToBody(stamped);
|
|
2337
3061
|
if (chain.length > 0) {
|
|
2338
|
-
const sel = selSet.selected;
|
|
2339
|
-
const currentId = sel.length === 1 ? sel[0]?.id : undefined;
|
|
2340
3062
|
const curIdx = currentId
|
|
2341
3063
|
? chain.findIndex((n) => n.getAttribute('data-cd-id') === currentId)
|
|
2342
3064
|
: -1;
|
|
@@ -2368,7 +3090,7 @@ function CanvasRouter({
|
|
|
2368
3090
|
);
|
|
2369
3091
|
return;
|
|
2370
3092
|
}
|
|
2371
|
-
enterEditModeAt(stamped, e.clientX, e.clientY);
|
|
3093
|
+
enterEditModeAt(stamped, e.clientX, e.clientY, { selectAll: true });
|
|
2372
3094
|
};
|
|
2373
3095
|
// Dogfood fix — the annotation Text tool's click-through: clicking an
|
|
2374
3096
|
// existing editable element with the Text (T) tool active should edit
|
|
@@ -2442,6 +3164,39 @@ function CanvasRouter({
|
|
|
2442
3164
|
// it actually captured something.
|
|
2443
3165
|
return;
|
|
2444
3166
|
}
|
|
3167
|
+
// feature-4 T7b — a locked layer can't be selected from the canvas
|
|
3168
|
+
// (the Layers panel row still can, so it stays reachable to unlock).
|
|
3169
|
+
if (isLockedElement(target.el, target.cdId)) return;
|
|
3170
|
+
// feature-4 escape hatch (user steer 2026-07-19) — a Cmd+click select
|
|
3171
|
+
// from BROWSE means "I want to edit now": flip to the Move (select)
|
|
3172
|
+
// tool so the follow-up gestures (drag, dblclick drill, keyboard)
|
|
3173
|
+
// work without a separate V press.
|
|
3174
|
+
if (tool === 'browse') setTool('move');
|
|
3175
|
+
// feature-4 dogfood fix (2026-07-19) — Figma's "entered group" context:
|
|
3176
|
+
// a bare (top-mode) click INSIDE the currently selected element keeps
|
|
3177
|
+
// that selection instead of resetting to the top-level ancestor.
|
|
3178
|
+
// Without this the dblclick drill ladder could never descend past one
|
|
3179
|
+
// level — the dblclick's own pointerdown(detail:2) re-selected the top
|
|
3180
|
+
// object right before the drill ran. Clicking OUTSIDE the current
|
|
3181
|
+
// selection (a sibling, empty space) still re-selects normally.
|
|
3182
|
+
if (!deep && mode === 'replace') {
|
|
3183
|
+
const cur = selSet.selected;
|
|
3184
|
+
const one = cur.length === 1 ? cur[0] : null;
|
|
3185
|
+
if (one?.id) {
|
|
3186
|
+
const curEl = resolveSelectionEl(document, one);
|
|
3187
|
+
const hitEl = document.elementFromPoint(clientX, clientY);
|
|
3188
|
+
if (
|
|
3189
|
+
curEl &&
|
|
3190
|
+
hitEl &&
|
|
3191
|
+
curEl !== target.el &&
|
|
3192
|
+
curEl.contains(hitEl) &&
|
|
3193
|
+
target.el &&
|
|
3194
|
+
target.el.contains(curEl)
|
|
3195
|
+
) {
|
|
3196
|
+
return; // stay in the entered-group context
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
2445
3200
|
const sel = hoverTargetToSelection(target);
|
|
2446
3201
|
if (mode === 'replace') selSet.replace(sel);
|
|
2447
3202
|
else selSet.add(sel);
|
|
@@ -2506,6 +3261,7 @@ function CanvasRouter({
|
|
|
2506
3261
|
<SelectionHalos />
|
|
2507
3262
|
<ElementResizeOverlay />
|
|
2508
3263
|
<SpacingHandlesOverlay />
|
|
3264
|
+
<GridTrackHandlesOverlay />
|
|
2509
3265
|
<ReorderDrag />
|
|
2510
3266
|
<LayersLiveSync />
|
|
2511
3267
|
<GroupBbox />
|
|
@@ -3326,6 +4082,9 @@ function ReorderDrag() {
|
|
|
3326
4082
|
if (!cdId || !one) return;
|
|
3327
4083
|
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
3328
4084
|
if (!el) return;
|
|
4085
|
+
// feature-4 T7b — a locked layer never drags (it can be selected from
|
|
4086
|
+
// the Layers panel, but the canvas won't move it).
|
|
4087
|
+
if (isLockedElement(el, cdId)) return;
|
|
3329
4088
|
const t = e.target;
|
|
3330
4089
|
if (!(t instanceof Node) || !el.contains(t)) return;
|
|
3331
4090
|
// Candidate — do NOT claim yet (a plain click must still work). We only
|