@1agh/maude 0.46.0 → 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 +113 -5
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- 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/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- 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 +70 -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 +113 -0
- 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 +54 -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-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/commands/design.mjs +16 -0
- package/package.json +8 -8
- 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
|
}
|
|
@@ -1450,15 +1509,45 @@ function buildRegistry(deps: {
|
|
|
1450
1509
|
{
|
|
1451
1510
|
id: 'kind-digital',
|
|
1452
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.
|
|
1453
1516
|
onSelect: (target) => {
|
|
1454
|
-
if (target.artboardId)
|
|
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
|
+
})();
|
|
1455
1531
|
},
|
|
1456
1532
|
},
|
|
1457
1533
|
{
|
|
1458
1534
|
id: 'kind-print',
|
|
1459
1535
|
label: 'Print',
|
|
1460
1536
|
onSelect: (target) => {
|
|
1461
|
-
if (target.artboardId)
|
|
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
|
+
})();
|
|
1462
1551
|
},
|
|
1463
1552
|
},
|
|
1464
1553
|
{
|
|
@@ -1478,6 +1567,311 @@ function buildRegistry(deps: {
|
|
|
1478
1567
|
],
|
|
1479
1568
|
};
|
|
1480
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
|
+
|
|
1481
1875
|
return {
|
|
1482
1876
|
element: [
|
|
1483
1877
|
[
|
|
@@ -1657,6 +2051,18 @@ function buildRegistry(deps: {
|
|
|
1657
2051
|
}
|
|
1658
2052
|
},
|
|
1659
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
|
+
},
|
|
1660
2066
|
],
|
|
1661
2067
|
[exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
|
|
1662
2068
|
[
|
|
@@ -1746,6 +2152,18 @@ function buildRegistry(deps: {
|
|
|
1746
2152
|
fitItem,
|
|
1747
2153
|
resetItem,
|
|
1748
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
|
+
],
|
|
1749
2167
|
[
|
|
1750
2168
|
// DDR-148 — only meaningful (and only enabled) on a video-comp
|
|
1751
2169
|
// artboard; scopes + opens the shell's Timeline dock.
|
|
@@ -1828,7 +2246,7 @@ function buildRegistry(deps: {
|
|
|
1828
2246
|
onSelect: () => distributeArtboards('y'),
|
|
1829
2247
|
},
|
|
1830
2248
|
],
|
|
1831
|
-
[themeItem, kindItem],
|
|
2249
|
+
[themeItem, kindItem, duplicateAtBreakpointItem],
|
|
1832
2250
|
[
|
|
1833
2251
|
{
|
|
1834
2252
|
id: 'delete-artboard',
|
|
@@ -1931,6 +2349,43 @@ export function revealElementViaCamera(
|
|
|
1931
2349
|
if (dx !== 0 || dy !== 0) controller.panBy(dx, dy);
|
|
1932
2350
|
}
|
|
1933
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
|
+
|
|
1934
2389
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1935
2390
|
// Router wire-up
|
|
1936
2391
|
|
|
@@ -2064,6 +2519,15 @@ function CanvasRouter({
|
|
|
2064
2519
|
|
|
2065
2520
|
const getActiveTool = useCallback(() => tool, [tool]);
|
|
2066
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
|
+
|
|
2067
2531
|
const applyHover = useCallback(() => {
|
|
2068
2532
|
hoverRafRef.current = null;
|
|
2069
2533
|
const pending = pendingHoverRef.current;
|
|
@@ -2107,6 +2571,83 @@ function CanvasRouter({
|
|
|
2107
2571
|
if (typeof t === 'string') setTool(t as never);
|
|
2108
2572
|
return;
|
|
2109
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
|
+
}
|
|
2110
2651
|
// Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
|
|
2111
2652
|
if (m.dgn === 'undo') {
|
|
2112
2653
|
void undoStack.undo();
|
|
@@ -2140,6 +2681,19 @@ function CanvasRouter({
|
|
|
2140
2681
|
})
|
|
2141
2682
|
: null;
|
|
2142
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);
|
|
2143
2697
|
selSet.replace(
|
|
2144
2698
|
hoverTargetToSelection({
|
|
2145
2699
|
el: target,
|
|
@@ -2153,7 +2707,7 @@ function CanvasRouter({
|
|
|
2153
2707
|
// transform (Bugs A + B). `revealElementViaCamera` pans the world
|
|
2154
2708
|
// the minimal amount only when the element is off-screen.
|
|
2155
2709
|
const host = hostRef.current;
|
|
2156
|
-
if (host && zoomController) {
|
|
2710
|
+
if (host && zoomController && !sameSelection) {
|
|
2157
2711
|
revealElementViaCamera(host, target as HTMLElement, zoomController);
|
|
2158
2712
|
}
|
|
2159
2713
|
}
|
|
@@ -2442,7 +2996,12 @@ function CanvasRouter({
|
|
|
2442
2996
|
// AND the annotation Text-tool's click-through (maude:enter-text-edit,
|
|
2443
2997
|
// dispatched from annotations-layer.tsx when its own click lands on an
|
|
2444
2998
|
// existing editable element) share one entry point.
|
|
2445
|
-
function enterEditModeAt(
|
|
2999
|
+
function enterEditModeAt(
|
|
3000
|
+
stamped: HTMLElement,
|
|
3001
|
+
clientX: number,
|
|
3002
|
+
clientY: number,
|
|
3003
|
+
opts?: { selectAll?: boolean }
|
|
3004
|
+
): void {
|
|
2446
3005
|
if (editing) return;
|
|
2447
3006
|
editing = stamped;
|
|
2448
3007
|
original = stamped.textContent ?? '';
|
|
@@ -2451,31 +3010,55 @@ function CanvasRouter({
|
|
|
2451
3010
|
stamped.addEventListener('blur', onBlur, true);
|
|
2452
3011
|
stamped.addEventListener('keydown', onKey, true);
|
|
2453
3012
|
stamped.focus();
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
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
|
+
}
|
|
2457
3033
|
caretDispose = mountCaret(stamped, window);
|
|
2458
3034
|
}
|
|
2459
3035
|
const onDbl = (e: MouseEvent): void => {
|
|
2460
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;
|
|
2461
3040
|
const t = e.target as HTMLElement | null;
|
|
2462
3041
|
const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
|
|
2463
3042
|
if (!stamped) return;
|
|
2464
3043
|
const kids = Array.from(stamped.childNodes);
|
|
2465
3044
|
const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
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.
|
|
2475
3060
|
const chain = stampedChainToBody(stamped);
|
|
2476
3061
|
if (chain.length > 0) {
|
|
2477
|
-
const sel = selSet.selected;
|
|
2478
|
-
const currentId = sel.length === 1 ? sel[0]?.id : undefined;
|
|
2479
3062
|
const curIdx = currentId
|
|
2480
3063
|
? chain.findIndex((n) => n.getAttribute('data-cd-id') === currentId)
|
|
2481
3064
|
: -1;
|
|
@@ -2507,7 +3090,7 @@ function CanvasRouter({
|
|
|
2507
3090
|
);
|
|
2508
3091
|
return;
|
|
2509
3092
|
}
|
|
2510
|
-
enterEditModeAt(stamped, e.clientX, e.clientY);
|
|
3093
|
+
enterEditModeAt(stamped, e.clientX, e.clientY, { selectAll: true });
|
|
2511
3094
|
};
|
|
2512
3095
|
// Dogfood fix — the annotation Text tool's click-through: clicking an
|
|
2513
3096
|
// existing editable element with the Text (T) tool active should edit
|
|
@@ -2581,6 +3164,39 @@ function CanvasRouter({
|
|
|
2581
3164
|
// it actually captured something.
|
|
2582
3165
|
return;
|
|
2583
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
|
+
}
|
|
2584
3200
|
const sel = hoverTargetToSelection(target);
|
|
2585
3201
|
if (mode === 'replace') selSet.replace(sel);
|
|
2586
3202
|
else selSet.add(sel);
|
|
@@ -2645,6 +3261,7 @@ function CanvasRouter({
|
|
|
2645
3261
|
<SelectionHalos />
|
|
2646
3262
|
<ElementResizeOverlay />
|
|
2647
3263
|
<SpacingHandlesOverlay />
|
|
3264
|
+
<GridTrackHandlesOverlay />
|
|
2648
3265
|
<ReorderDrag />
|
|
2649
3266
|
<LayersLiveSync />
|
|
2650
3267
|
<GroupBbox />
|
|
@@ -3465,6 +4082,9 @@ function ReorderDrag() {
|
|
|
3465
4082
|
if (!cdId || !one) return;
|
|
3466
4083
|
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
3467
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;
|
|
3468
4088
|
const t = e.target;
|
|
3469
4089
|
if (!(t instanceof Node) || !el.contains(t)) return;
|
|
3470
4090
|
// Candidate — do NOT claim yet (a plain click must still work). We only
|