@1agh/maude 0.40.0 → 0.42.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/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -44
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/server.ts +3 -1
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
selectorIndex,
|
|
77
77
|
shortText,
|
|
78
78
|
} from './dom-selection.ts';
|
|
79
|
+
import { setElementDragActive } from './drag-state.ts';
|
|
79
80
|
import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
|
|
80
81
|
import { ExportDialogProvider } from './export-dialog.tsx';
|
|
81
82
|
import {
|
|
@@ -85,6 +86,7 @@ import {
|
|
|
85
86
|
useInputRouter,
|
|
86
87
|
} from './input-router.tsx';
|
|
87
88
|
import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
|
|
89
|
+
import { MeasureOverlay } from './measure-overlay.tsx';
|
|
88
90
|
import { ParticipantsChrome } from './participants-chrome.tsx';
|
|
89
91
|
import { ToolPalette } from './tool-palette.tsx';
|
|
90
92
|
import { UndoHud } from './undo-hud.tsx';
|
|
@@ -98,12 +100,14 @@ import { showCanvasToast } from './use-canvas-media-drop.tsx';
|
|
|
98
100
|
import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
99
101
|
import { useCollab } from './use-collab.tsx';
|
|
100
102
|
import { useCursorModifiers } from './use-cursor-modifiers.tsx';
|
|
103
|
+
import { ElementResizeOverlay } from './use-element-resize.tsx';
|
|
101
104
|
import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
|
|
102
105
|
import {
|
|
103
106
|
MaybeSelectionSetProvider,
|
|
104
107
|
type Selection,
|
|
105
108
|
useSelectionSet,
|
|
106
109
|
} from './use-selection-set.tsx';
|
|
110
|
+
import { SpacingHandlesOverlay } from './use-spacing-handles.tsx';
|
|
107
111
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
108
112
|
import { useUndoSinks, useUndoStack } from './use-undo-stack.tsx';
|
|
109
113
|
|
|
@@ -382,6 +386,11 @@ interface LayerNode {
|
|
|
382
386
|
/** Occurrence index of this id within the artboard — matches the resolver's
|
|
383
387
|
* `querySelectorAll([data-dc-screen=…] [data-cd-id=…])[index]`. */
|
|
384
388
|
index: number;
|
|
389
|
+
/** Authored `style.display === 'none'` — the Layers-panel eye icon's source
|
|
390
|
+
* of truth. The panel's toggle persists through `/_api/edit-css` (same
|
|
391
|
+
* route CssKnobs uses), so this must reflect the SOURCE value, not a
|
|
392
|
+
* client-only preview flag (see app.jsx `toggleVisibility`). */
|
|
393
|
+
hidden: boolean;
|
|
385
394
|
children: LayerNode[];
|
|
386
395
|
}
|
|
387
396
|
|
|
@@ -423,6 +432,73 @@ function layerType(el: Element): string {
|
|
|
423
432
|
return 'box';
|
|
424
433
|
}
|
|
425
434
|
|
|
435
|
+
// Task L6 — the stamped-ancestor chain from an artboard body down to `el`
|
|
436
|
+
// (outermost first), mirroring `resolveHoverTarget`'s "top mode" climb
|
|
437
|
+
// (input-router.tsx) but returning every rung instead of only the outermost —
|
|
438
|
+
// deep-select needs the whole ladder to know what "one level deeper" means.
|
|
439
|
+
function stampedChainToBody(el: Element): Element[] {
|
|
440
|
+
const bodyEl = el.closest('.dc-artboard-body');
|
|
441
|
+
const chain: Element[] = [];
|
|
442
|
+
let cur: Element | null = el;
|
|
443
|
+
while (cur && cur !== bodyEl) {
|
|
444
|
+
if (cur.hasAttribute('data-cd-id')) chain.push(cur);
|
|
445
|
+
cur = cur.parentElement;
|
|
446
|
+
}
|
|
447
|
+
return chain.reverse();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Task L6 — "Select layer" submenu: every DISTINCT `[data-cd-id]` ancestor in
|
|
451
|
+
// the full hit-stack under the cursor (topmost/frontmost first, matching
|
|
452
|
+
// Layers-panel order), so overlapping/deeply-nested elements a single
|
|
453
|
+
// elementFromPoint pick can't reach become individually selectable.
|
|
454
|
+
// `elementsFromPoint` (unlike `elementFromPoint`) walks the WHOLE z-order
|
|
455
|
+
// stack — including non-ancestor siblings visually underneath the frontmost
|
|
456
|
+
// hit — so this also disambiguates true overlaps, not just nesting depth.
|
|
457
|
+
function buildSelectLayerSubmenu(
|
|
458
|
+
target: ContextTarget,
|
|
459
|
+
selSet: { replace: (s: Selection | Selection[]) => void }
|
|
460
|
+
): MenuItem[] {
|
|
461
|
+
if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
const hits = document.elementsFromPoint(target.clientX, target.clientY);
|
|
465
|
+
const seen = new Set<Element>();
|
|
466
|
+
const candidates: Element[] = [];
|
|
467
|
+
for (const hit of hits) {
|
|
468
|
+
const stamped = hit.closest?.('[data-cd-id]') ?? null;
|
|
469
|
+
if (!stamped || seen.has(stamped)) continue;
|
|
470
|
+
seen.add(stamped);
|
|
471
|
+
candidates.push(stamped);
|
|
472
|
+
}
|
|
473
|
+
if (candidates.length === 0) {
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
id: 'select-layer-empty',
|
|
477
|
+
label: 'No stamped elements here',
|
|
478
|
+
disabled: true,
|
|
479
|
+
onSelect: () => {},
|
|
480
|
+
},
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
return candidates.map((el, i) => {
|
|
484
|
+
const cdId = el.getAttribute('data-cd-id') ?? '';
|
|
485
|
+
const artboardId =
|
|
486
|
+
(el.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute('data-dc-screen') ??
|
|
487
|
+
null;
|
|
488
|
+
return {
|
|
489
|
+
id: `select-layer-${i}`,
|
|
490
|
+
label: `${layerLabel(el)} · ${layerType(el)}`,
|
|
491
|
+
onSelect: () => {
|
|
492
|
+
// `hoverTargetToSelection` computes the occurrence index itself
|
|
493
|
+
// (`selectorIndex` against the scoped selector) — no need to pass one.
|
|
494
|
+
selSet.replace(
|
|
495
|
+
hoverTargetToSelection({ el, cdId: cdId || null, artboardId } as HoverTarget)
|
|
496
|
+
);
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
426
502
|
function serializeArtboardTree(root: Element): LayerNode[] {
|
|
427
503
|
const seen = new Map<string, number>();
|
|
428
504
|
function walk(el: Element): LayerNode[] {
|
|
@@ -440,6 +516,7 @@ function serializeArtboardTree(root: Element): LayerNode[] {
|
|
|
440
516
|
label: layerLabel(child),
|
|
441
517
|
type: layerType(child),
|
|
442
518
|
index: idx,
|
|
519
|
+
hidden: (child as HTMLElement).style.display === 'none',
|
|
443
520
|
children: walk(child),
|
|
444
521
|
});
|
|
445
522
|
} else {
|
|
@@ -1363,9 +1440,168 @@ function buildRegistry(deps: {
|
|
|
1363
1440
|
}
|
|
1364
1441
|
},
|
|
1365
1442
|
},
|
|
1443
|
+
{
|
|
1444
|
+
id: 'select-layer',
|
|
1445
|
+
label: 'Select layer',
|
|
1446
|
+
// Task L6 — every DISTINCT stamped ancestor in the cursor's full hit
|
|
1447
|
+
// stack (elementsFromPoint sees through overlapping/nested elements a
|
|
1448
|
+
// single elementFromPoint pick can't reach), Layers-order (topmost
|
|
1449
|
+
// first), so overlapping/deeply-nested TSX becomes grabbable. A
|
|
1450
|
+
// function submenu — resolved per-click against `target`, since the
|
|
1451
|
+
// candidate set depends on where the user right-clicked.
|
|
1452
|
+
//
|
|
1453
|
+
// v1 scope: no live Stage-H local/shared badge on each candidate —
|
|
1454
|
+
// that resolver is server-side/main-origin-only (`GET /_api/edit-scope`,
|
|
1455
|
+
// canvas-edit.ts), unreachable from this iframe without a new
|
|
1456
|
+
// request/reply round-trip; deferred rather than blocking this task.
|
|
1457
|
+
onSelect: () => {
|
|
1458
|
+
/* parent of a submenu — never invoked directly */
|
|
1459
|
+
},
|
|
1460
|
+
submenu: (t) => buildSelectLayerSubmenu(t, selSet),
|
|
1461
|
+
},
|
|
1462
|
+
],
|
|
1463
|
+
[
|
|
1464
|
+
{
|
|
1465
|
+
id: 'insert-element',
|
|
1466
|
+
label: 'Insert',
|
|
1467
|
+
// feature-element-editing-robustness Stage I3 — insert a synthesized
|
|
1468
|
+
// element AFTER the right-clicked one (main-origin write: request it
|
|
1469
|
+
// from the parent shell). Image insert needs an asset src (the Stage-F
|
|
1470
|
+
// AssetPicker), so only Div/Text are offered here for now.
|
|
1471
|
+
submenu: [
|
|
1472
|
+
{
|
|
1473
|
+
id: 'insert-div',
|
|
1474
|
+
label: 'Div',
|
|
1475
|
+
onSelect: (target) => {
|
|
1476
|
+
if (!target.cdId) return;
|
|
1477
|
+
try {
|
|
1478
|
+
window.parent.postMessage(
|
|
1479
|
+
{ dgn: 'insert-request', refId: target.cdId, position: 'after', kind: 'div' },
|
|
1480
|
+
'*'
|
|
1481
|
+
);
|
|
1482
|
+
} catch {
|
|
1483
|
+
/* detached */
|
|
1484
|
+
}
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
{
|
|
1488
|
+
id: 'insert-text',
|
|
1489
|
+
label: 'Text',
|
|
1490
|
+
onSelect: (target) => {
|
|
1491
|
+
if (!target.cdId) return;
|
|
1492
|
+
try {
|
|
1493
|
+
window.parent.postMessage(
|
|
1494
|
+
{ dgn: 'insert-request', refId: target.cdId, position: 'after', kind: 'text' },
|
|
1495
|
+
'*'
|
|
1496
|
+
);
|
|
1497
|
+
} catch {
|
|
1498
|
+
/* detached */
|
|
1499
|
+
}
|
|
1500
|
+
},
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
id: 'insert-image',
|
|
1504
|
+
label: 'Image…',
|
|
1505
|
+
// Image needs a contained asset src — the shell opens the AssetPicker.
|
|
1506
|
+
onSelect: (target) => {
|
|
1507
|
+
if (!target.cdId) return;
|
|
1508
|
+
try {
|
|
1509
|
+
window.parent.postMessage(
|
|
1510
|
+
{ dgn: 'insert-image-request', refId: target.cdId, position: 'after' },
|
|
1511
|
+
'*'
|
|
1512
|
+
);
|
|
1513
|
+
} catch {
|
|
1514
|
+
/* detached */
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
],
|
|
1519
|
+
onSelect: () => {},
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
id: 'replace-media',
|
|
1523
|
+
label: 'Replace image…',
|
|
1524
|
+
// Stage F2 — re-point an authored <img>/<video> src. Only meaningful on
|
|
1525
|
+
// a media element; on anything else it's a no-op (the shell's edit-attr
|
|
1526
|
+
// will refuse a non-media / expression src). The Media inspector section
|
|
1527
|
+
// carries the primary, always-gated "Replace…" button.
|
|
1528
|
+
onSelect: (target) => {
|
|
1529
|
+
const tag = target.el?.tagName?.toLowerCase();
|
|
1530
|
+
if (!target.cdId || (tag !== 'img' && tag !== 'video')) return;
|
|
1531
|
+
// Read the authored src straight off the element so the shell has the
|
|
1532
|
+
// undo before-value (getAttribute is the literal content attr, not the
|
|
1533
|
+
// resolved URL).
|
|
1534
|
+
const before = target.el?.getAttribute('src') ?? null;
|
|
1535
|
+
try {
|
|
1536
|
+
window.parent.postMessage(
|
|
1537
|
+
{ dgn: 'replace-media-request', id: target.cdId, before },
|
|
1538
|
+
'*'
|
|
1539
|
+
);
|
|
1540
|
+
} catch {
|
|
1541
|
+
/* detached */
|
|
1542
|
+
}
|
|
1543
|
+
},
|
|
1544
|
+
},
|
|
1366
1545
|
],
|
|
1367
1546
|
[exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
|
|
1368
1547
|
[
|
|
1548
|
+
{
|
|
1549
|
+
id: 'duplicate-element',
|
|
1550
|
+
label: 'Duplicate',
|
|
1551
|
+
shortcut: '⌘D',
|
|
1552
|
+
// Task L3 — main-origin source duplicate; post to the parent shell.
|
|
1553
|
+
onSelect: (target) => {
|
|
1554
|
+
if (!target.cdId) return;
|
|
1555
|
+
try {
|
|
1556
|
+
window.parent.postMessage({ dgn: 'duplicate-request', id: target.cdId }, '*');
|
|
1557
|
+
} catch {
|
|
1558
|
+
/* detached / cross-origin */
|
|
1559
|
+
}
|
|
1560
|
+
},
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
id: 'copy-style',
|
|
1564
|
+
label: 'Copy style',
|
|
1565
|
+
shortcut: '⌘⌥C',
|
|
1566
|
+
// Task L4 — the shell captures the current selection's authored style.
|
|
1567
|
+
onSelect: () => {
|
|
1568
|
+
try {
|
|
1569
|
+
window.parent.postMessage({ dgn: 'copy-style' }, '*');
|
|
1570
|
+
} catch {
|
|
1571
|
+
/* detached / cross-origin */
|
|
1572
|
+
}
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
id: 'paste-style',
|
|
1577
|
+
label: 'Paste style',
|
|
1578
|
+
shortcut: '⌘⌥V',
|
|
1579
|
+
onSelect: (target) => {
|
|
1580
|
+
if (!target.cdId) return;
|
|
1581
|
+
try {
|
|
1582
|
+
window.parent.postMessage({ dgn: 'paste-style', id: target.cdId }, '*');
|
|
1583
|
+
} catch {
|
|
1584
|
+
/* detached / cross-origin */
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
id: 'delete-element',
|
|
1590
|
+
label: 'Delete',
|
|
1591
|
+
shortcut: '⌫',
|
|
1592
|
+
destructive: true,
|
|
1593
|
+
// feature-element-editing-robustness Stage I — request a source delete.
|
|
1594
|
+
// The write is main-origin-only (DDR-054): post to the parent shell,
|
|
1595
|
+
// which performs it (pinned to the active canvas) + records undo.
|
|
1596
|
+
onSelect: (target) => {
|
|
1597
|
+
if (!target.cdId) return;
|
|
1598
|
+
try {
|
|
1599
|
+
window.parent.postMessage({ dgn: 'delete-request', id: target.cdId }, '*');
|
|
1600
|
+
} catch {
|
|
1601
|
+
/* detached / cross-origin */
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1369
1605
|
{
|
|
1370
1606
|
id: 'hide',
|
|
1371
1607
|
label: 'Hide',
|
|
@@ -1455,6 +1691,28 @@ function buildRegistry(deps: {
|
|
|
1455
1691
|
},
|
|
1456
1692
|
],
|
|
1457
1693
|
[themeItem],
|
|
1694
|
+
[
|
|
1695
|
+
{
|
|
1696
|
+
id: 'delete-artboard',
|
|
1697
|
+
label: 'Delete artboard',
|
|
1698
|
+
shortcut: '⌫',
|
|
1699
|
+
destructive: true,
|
|
1700
|
+
// Main-origin-only source delete (DDR-054): post to the parent shell,
|
|
1701
|
+
// which performs it (pinned to the active canvas) + records undo. The
|
|
1702
|
+
// engine refuses to delete the last artboard.
|
|
1703
|
+
onSelect: (target) => {
|
|
1704
|
+
if (!target.artboardId) return;
|
|
1705
|
+
try {
|
|
1706
|
+
window.parent.postMessage(
|
|
1707
|
+
{ dgn: 'delete-artboard-request', artboardId: target.artboardId },
|
|
1708
|
+
'*'
|
|
1709
|
+
);
|
|
1710
|
+
} catch {
|
|
1711
|
+
/* detached / cross-origin */
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
],
|
|
1458
1716
|
[exportItem('export-artboard', 'Export this artboard…', 'artboard')],
|
|
1459
1717
|
],
|
|
1460
1718
|
world: [
|
|
@@ -1478,6 +1736,63 @@ function boundsOf(el: HTMLElement) {
|
|
|
1478
1736
|
};
|
|
1479
1737
|
}
|
|
1480
1738
|
|
|
1739
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1740
|
+
// Task A1 (feature-element-editing-robustness) — camera-aware reveal.
|
|
1741
|
+
//
|
|
1742
|
+
// The `select-by-id` channel (Layers-panel click, restored/echoed selection,
|
|
1743
|
+
// post-edit reselect) used to `scrollIntoView({block:'nearest'})` to reveal the
|
|
1744
|
+
// selected element. But `.dc-canvas` / `.dc-artboard-body` are `overflow:hidden`
|
|
1745
|
+
// engine clip layers, and the camera is the `.dc-world` transform — so a host
|
|
1746
|
+
// scroll shifted the layout out from under the camera model (a phantom right
|
|
1747
|
+
// strip, and the pan reset after an absolute-element move: Bugs A + B). Reveal
|
|
1748
|
+
// THROUGH the camera instead: measure the element's live screen box and, only
|
|
1749
|
+
// when it's off-screen, pan the world transform the minimal amount to bring the
|
|
1750
|
+
// nearest out-of-view edge in. Screen-space deltas → `controller.panBy` (which
|
|
1751
|
+
// reads the exact live viewport), so it's self-correcting regardless of any
|
|
1752
|
+
// model lag and never touches host scroll.
|
|
1753
|
+
|
|
1754
|
+
/** Distance (screen px) a canvas element is allowed to sit from the viewport
|
|
1755
|
+
* edge after a reveal — a small inset so a just-revealed element isn't flush
|
|
1756
|
+
* against the frame. */
|
|
1757
|
+
export const REVEAL_MARGIN_PX = 40;
|
|
1758
|
+
|
|
1759
|
+
/** Minimal 1-axis delta (screen px) to bring `[elStart, elEnd]` inside
|
|
1760
|
+
* `[viewStart + margin, viewEnd - margin]`. Returns 0 when already visible OR
|
|
1761
|
+
* when the element spans the viewport (bigger than the view — don't move,
|
|
1762
|
+
* matching `scrollIntoView({block:'nearest'})`). Sign matches `panBy`: a
|
|
1763
|
+
* positive delta pans the world content toward the end (down / right). */
|
|
1764
|
+
export function revealAxisDelta(
|
|
1765
|
+
elStart: number,
|
|
1766
|
+
elEnd: number,
|
|
1767
|
+
viewStart: number,
|
|
1768
|
+
viewEnd: number,
|
|
1769
|
+
margin: number
|
|
1770
|
+
): number {
|
|
1771
|
+
const vs = viewStart + margin;
|
|
1772
|
+
const ve = viewEnd - margin;
|
|
1773
|
+
if (elStart >= vs && elEnd <= ve) return 0; // fully visible
|
|
1774
|
+
if (elStart <= vs && elEnd >= ve) return 0; // spans the viewport — leave it
|
|
1775
|
+
if (elStart < vs) return vs - elStart; // off the start edge → pan toward end
|
|
1776
|
+
if (elEnd > ve) return ve - elEnd; // off the end edge → pan toward start
|
|
1777
|
+
return 0;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/** Pan the camera the minimal amount so `target` is visible inside `host`,
|
|
1781
|
+
* entirely through the world transform (never host scroll). No-op when the
|
|
1782
|
+
* element is already on-screen. Exported for the Task A3 regression test. */
|
|
1783
|
+
export function revealElementViaCamera(
|
|
1784
|
+
host: HTMLElement,
|
|
1785
|
+
target: HTMLElement,
|
|
1786
|
+
controller: ViewportControllerHandle
|
|
1787
|
+
): void {
|
|
1788
|
+
const hostRect = host.getBoundingClientRect();
|
|
1789
|
+
const r = target.getBoundingClientRect();
|
|
1790
|
+
if (r.width <= 0 && r.height <= 0) return; // detached / display:none — nothing to reveal
|
|
1791
|
+
const dx = revealAxisDelta(r.left, r.right, hostRect.left, hostRect.right, REVEAL_MARGIN_PX);
|
|
1792
|
+
const dy = revealAxisDelta(r.top, r.bottom, hostRect.top, hostRect.bottom, REVEAL_MARGIN_PX);
|
|
1793
|
+
if (dx !== 0 || dy !== 0) controller.panBy(dx, dy);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1481
1796
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1482
1797
|
// Router wire-up
|
|
1483
1798
|
|
|
@@ -1542,6 +1857,36 @@ function CanvasRouter({
|
|
|
1542
1857
|
// consumes.
|
|
1543
1858
|
const chromeCtx = useChromeVisibility();
|
|
1544
1859
|
|
|
1860
|
+
// Task A2 (feature-element-editing-robustness) — host-scroll-0 invariant.
|
|
1861
|
+
// `.dc-canvas` (overflow:hidden) and each `.dc-artboard-body` are the engine's
|
|
1862
|
+
// clip layers; the camera lives entirely in the `.dc-world` transform, and
|
|
1863
|
+
// both `writeTransform` and `__maudeCanvasRects` assume these hosts sit at
|
|
1864
|
+
// scroll 0. A stray programmatic scroll (a residual scrollIntoView, focus()
|
|
1865
|
+
// auto-scroll of an overflow:hidden ancestor) would shift the layout and
|
|
1866
|
+
// desync the camera model — the Bug A/B class. Clamp any scroll on an ENGINE
|
|
1867
|
+
// clip layer straight back to 0. Author scroll containers deeper inside a mock
|
|
1868
|
+
// are left alone (the target check excludes them). `scroll` events don't
|
|
1869
|
+
// bubble, so capture:true is what lets the host observe a descendant clip's
|
|
1870
|
+
// scroll without also intercepting author-content scrolls (those fail the
|
|
1871
|
+
// engine-clip test and pass through).
|
|
1872
|
+
useEffect(() => {
|
|
1873
|
+
const host = hostRef.current;
|
|
1874
|
+
if (!host) return;
|
|
1875
|
+
const clamp = (e: Event) => {
|
|
1876
|
+
const t = e.target;
|
|
1877
|
+
if (!(t instanceof HTMLElement)) return;
|
|
1878
|
+
const isEngineClip =
|
|
1879
|
+
t === host ||
|
|
1880
|
+
t.classList.contains('dc-artboard-body') ||
|
|
1881
|
+
t.classList.contains('dc-artboard');
|
|
1882
|
+
if (!isEngineClip) return;
|
|
1883
|
+
if (t.scrollLeft !== 0) t.scrollLeft = 0;
|
|
1884
|
+
if (t.scrollTop !== 0) t.scrollTop = 0;
|
|
1885
|
+
};
|
|
1886
|
+
host.addEventListener('scroll', clamp, true);
|
|
1887
|
+
return () => host.removeEventListener('scroll', clamp, true);
|
|
1888
|
+
}, [hostRef]);
|
|
1889
|
+
|
|
1545
1890
|
// Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
|
|
1546
1891
|
// itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
|
|
1547
1892
|
const [hoverEl, setHoverEl] = useState<Element | null>(null);
|
|
@@ -1625,7 +1970,15 @@ function CanvasRouter({
|
|
|
1625
1970
|
artboardId: mm.artboardId ?? null,
|
|
1626
1971
|
} as HoverTarget)
|
|
1627
1972
|
);
|
|
1628
|
-
|
|
1973
|
+
// Task A1 — reveal THROUGH the camera, not host scroll. The old
|
|
1974
|
+
// `scrollIntoView` scrolled the overflow:hidden `.dc-canvas` /
|
|
1975
|
+
// `.dc-artboard-body` clip layers, desyncing the `.dc-world`
|
|
1976
|
+
// transform (Bugs A + B). `revealElementViaCamera` pans the world
|
|
1977
|
+
// the minimal amount only when the element is off-screen.
|
|
1978
|
+
const host = hostRef.current;
|
|
1979
|
+
if (host && zoomController) {
|
|
1980
|
+
revealElementViaCamera(host, target as HTMLElement, zoomController);
|
|
1981
|
+
}
|
|
1629
1982
|
}
|
|
1630
1983
|
}
|
|
1631
1984
|
return;
|
|
@@ -1767,7 +2120,7 @@ function CanvasRouter({
|
|
|
1767
2120
|
};
|
|
1768
2121
|
window.addEventListener('message', onMessage);
|
|
1769
2122
|
return () => window.removeEventListener('message', onMessage);
|
|
1770
|
-
}, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx]);
|
|
2123
|
+
}, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx, hostRef]);
|
|
1771
2124
|
|
|
1772
2125
|
// Phase 12 (DDR-103) — double-click a LEAF-TEXT element (children all text
|
|
1773
2126
|
// nodes) to edit its copy in place. Commit (blur / Enter) posts `dgn:edit-text`
|
|
@@ -1843,7 +2196,37 @@ function CanvasRouter({
|
|
|
1843
2196
|
const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
|
|
1844
2197
|
if (!stamped) return;
|
|
1845
2198
|
const kids = Array.from(stamped.childNodes);
|
|
1846
|
-
|
|
2199
|
+
const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
|
|
2200
|
+
if (!isLeafText) {
|
|
2201
|
+
// Task L6 — deep-select: a container (anything that isn't leaf-text)
|
|
2202
|
+
// double-clicked here used to just bail — this is the exact gap the
|
|
2203
|
+
// plan's deep-select task targets. Drill ONE level past whatever's
|
|
2204
|
+
// currently selected along this click's own stamped-ancestor chain
|
|
2205
|
+
// (Figma parity: repeated double-clicks walk progressively deeper);
|
|
2206
|
+
// default to the outermost rung when nothing in the chain is
|
|
2207
|
+
// currently selected. Mutually exclusive with text-edit below by
|
|
2208
|
+
// construction — same capture-phase listener, same branch gate.
|
|
2209
|
+
const chain = stampedChainToBody(stamped);
|
|
2210
|
+
if (chain.length > 0) {
|
|
2211
|
+
const sel = selSet.selected;
|
|
2212
|
+
const currentId = sel.length === 1 ? sel[0]?.id : undefined;
|
|
2213
|
+
const curIdx = currentId
|
|
2214
|
+
? chain.findIndex((n) => n.getAttribute('data-cd-id') === currentId)
|
|
2215
|
+
: -1;
|
|
2216
|
+
const next = curIdx >= 0 && curIdx + 1 < chain.length ? chain[curIdx + 1] : chain[0];
|
|
2217
|
+
if (next) {
|
|
2218
|
+
e.preventDefault();
|
|
2219
|
+
e.stopPropagation();
|
|
2220
|
+
const cdId = next.getAttribute('data-cd-id');
|
|
2221
|
+
const artboardId =
|
|
2222
|
+
(next.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
|
|
2223
|
+
'data-dc-screen'
|
|
2224
|
+
) ?? null;
|
|
2225
|
+
selSet.replace(hoverTargetToSelection({ el: next, cdId, artboardId } as HoverTarget));
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
1847
2230
|
e.preventDefault();
|
|
1848
2231
|
e.stopPropagation();
|
|
1849
2232
|
editing = stamped;
|
|
@@ -1888,7 +2271,7 @@ function CanvasRouter({
|
|
|
1888
2271
|
window.removeEventListener('message', onEditReverted);
|
|
1889
2272
|
if (editing) teardown(editing);
|
|
1890
2273
|
};
|
|
1891
|
-
}, [hostRef]);
|
|
2274
|
+
}, [hostRef, selSet]);
|
|
1892
2275
|
|
|
1893
2276
|
// Cleanup any pending rAF on unmount.
|
|
1894
2277
|
useEffect(
|
|
@@ -1982,10 +2365,13 @@ function CanvasRouter({
|
|
|
1982
2365
|
<ElementMarqueeOverlay />
|
|
1983
2366
|
<HoverHalo el={hoverEl} />
|
|
1984
2367
|
<SelectionHalos />
|
|
2368
|
+
<ElementResizeOverlay />
|
|
2369
|
+
<SpacingHandlesOverlay />
|
|
1985
2370
|
<ReorderDrag />
|
|
1986
2371
|
<LayersLiveSync />
|
|
1987
2372
|
<GroupBbox />
|
|
1988
2373
|
<EqualSpacingHandles />
|
|
2374
|
+
<MeasureOverlay />
|
|
1989
2375
|
<ContextualToolbar />
|
|
1990
2376
|
<MultiArtboardToolbar
|
|
1991
2377
|
distributeArtboards={distributeArtboards}
|
|
@@ -2527,6 +2913,15 @@ function ReorderDrag() {
|
|
|
2527
2913
|
/** Stamped [data-cd-id] nodes in their ORIGINAL order — for computing a
|
|
2528
2914
|
* reused instance's occurrence index even after the DOM reflowed. */
|
|
2529
2915
|
snapshot: Element[] | null;
|
|
2916
|
+
/** True when the dragged element is `position: absolute|fixed` — reorder
|
|
2917
|
+
* (sibling order) never changes where an out-of-flow element paints, so
|
|
2918
|
+
* these drags commit `left`/`top` instead of a tree move (see the
|
|
2919
|
+
* "absolute element snaps back" RCA). */
|
|
2920
|
+
outOfFlow: boolean;
|
|
2921
|
+
/** Pre-drag `left`/`top` (px, resolved via computed style) — only
|
|
2922
|
+
* meaningful when `outOfFlow`. */
|
|
2923
|
+
originLeft: number;
|
|
2924
|
+
originTop: number;
|
|
2530
2925
|
};
|
|
2531
2926
|
// Hover a stable spot this long and the layout reflows LIVE (the node moves
|
|
2532
2927
|
// there while still floating with the cursor) — you see the result before
|
|
@@ -2538,6 +2933,9 @@ function ReorderDrag() {
|
|
|
2538
2933
|
// The last committed drop, kept so a shell 'reorder-failed' can undo the
|
|
2539
2934
|
// optimistic DOM move (the write was rejected → nothing persisted).
|
|
2540
2935
|
let lastCommit: { el: HTMLElement; parent: HTMLElement; next: Element | null } | null = null;
|
|
2936
|
+
// The last committed out-of-flow reposition, kept so a shell
|
|
2937
|
+
// 'reposition-failed' can restore the pre-drag inline style.
|
|
2938
|
+
let lastPosition: { el: HTMLElement; prevStyle: string | null } | null = null;
|
|
2541
2939
|
|
|
2542
2940
|
const setHighlight = (el: HTMLElement | null) => {
|
|
2543
2941
|
if (highlightEl === el) return;
|
|
@@ -2769,6 +3167,7 @@ function ReorderDrag() {
|
|
|
2769
3167
|
hideDivider();
|
|
2770
3168
|
suppressNextCanvasClick();
|
|
2771
3169
|
drag = null;
|
|
3170
|
+
setElementDragActive(false);
|
|
2772
3171
|
};
|
|
2773
3172
|
|
|
2774
3173
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
@@ -2791,6 +3190,7 @@ function ReorderDrag() {
|
|
|
2791
3190
|
if (!(t instanceof Node) || !el.contains(t)) return;
|
|
2792
3191
|
// Candidate — do NOT claim yet (a plain click must still work). We only
|
|
2793
3192
|
// become a drag once the pointer crosses the threshold in onMove.
|
|
3193
|
+
const outOfFlowPos = getComputedStyle(el).position;
|
|
2794
3194
|
drag = {
|
|
2795
3195
|
pointerId: e.pointerId,
|
|
2796
3196
|
startX: e.clientX,
|
|
@@ -2810,6 +3210,9 @@ function ReorderDrag() {
|
|
|
2810
3210
|
settleKey: null,
|
|
2811
3211
|
applied: null,
|
|
2812
3212
|
snapshot: null,
|
|
3213
|
+
outOfFlow: outOfFlowPos === 'absolute' || outOfFlowPos === 'fixed',
|
|
3214
|
+
originLeft: 0,
|
|
3215
|
+
originTop: 0,
|
|
2813
3216
|
};
|
|
2814
3217
|
};
|
|
2815
3218
|
|
|
@@ -2819,6 +3222,7 @@ function ReorderDrag() {
|
|
|
2819
3222
|
if (!d.dragging) {
|
|
2820
3223
|
if (!crossedDragThreshold(d.startX, d.startY, e.clientX, e.clientY)) return;
|
|
2821
3224
|
d.dragging = true;
|
|
3225
|
+
setElementDragActive(true);
|
|
2822
3226
|
// Start floating: the element tracks the cursor via `transform`, so its
|
|
2823
3227
|
// layout box stays reserved (origin shows empty space) and it stays in
|
|
2824
3228
|
// the zoomed world (styling + scale intact). Divide the screen delta by
|
|
@@ -2834,7 +3238,18 @@ function ReorderDrag() {
|
|
|
2834
3238
|
// reflect the pre-drag layout, not the reflowed one.
|
|
2835
3239
|
d.snapshot = [...document.querySelectorAll('[data-cd-id]')];
|
|
2836
3240
|
d.prevStyle = d.el.getAttribute('style');
|
|
2837
|
-
if (
|
|
3241
|
+
if (d.outOfFlow) {
|
|
3242
|
+
// Resolved (used) value in px — matches the unit space `left`/`top`
|
|
3243
|
+
// are written back in, regardless of an ancestor's zoom transform
|
|
3244
|
+
// (a CSS `transform: scale()` on the world doesn't change a
|
|
3245
|
+
// descendant's own box-model values). `auto` (e.g. positioned via
|
|
3246
|
+
// `right`/`bottom` instead) falls back to 0 — a known v1 limit.
|
|
3247
|
+
const cs = getComputedStyle(d.el);
|
|
3248
|
+
d.originLeft = Number.parseFloat(cs.left) || 0;
|
|
3249
|
+
d.originTop = Number.parseFloat(cs.top) || 0;
|
|
3250
|
+
} else if (getComputedStyle(d.el).position === 'static') {
|
|
3251
|
+
d.el.style.position = 'relative';
|
|
3252
|
+
}
|
|
2838
3253
|
d.el.style.zIndex = '9990';
|
|
2839
3254
|
d.el.style.pointerEvents = 'none'; // so elementFromPoint sees what's beneath
|
|
2840
3255
|
d.el.style.opacity = '0.9';
|
|
@@ -2848,6 +3263,11 @@ function ReorderDrag() {
|
|
|
2848
3263
|
const tx = (e.clientX - d.startX) / d.zoom;
|
|
2849
3264
|
const ty = (e.clientY - d.startY) / d.zoom;
|
|
2850
3265
|
d.el.style.transform = `translate(${tx}px, ${ty}px)`;
|
|
3266
|
+
// Out-of-flow elements free-move with the cursor — there's no sibling
|
|
3267
|
+
// "before/after" to hunt for (reordering an absolute/fixed element's
|
|
3268
|
+
// JSX siblings wouldn't change where it paints), so skip the reorder
|
|
3269
|
+
// target/divider/settle machinery entirely; onUp commits `left`/`top`.
|
|
3270
|
+
if (d.outOfFlow) return;
|
|
2851
3271
|
// Indicator + settle scheduling only — the DOM moves when a target stays
|
|
2852
3272
|
// stable for SETTLE_MS (the live-reflow preview), never per-mousemove.
|
|
2853
3273
|
const target = computeTarget(e.clientX, e.clientY, d);
|
|
@@ -2880,11 +3300,59 @@ function ReorderDrag() {
|
|
|
2880
3300
|
const onUp = (e: PointerEvent) => {
|
|
2881
3301
|
const d = drag;
|
|
2882
3302
|
drag = null;
|
|
3303
|
+
setElementDragActive(false);
|
|
2883
3304
|
setHighlight(null);
|
|
2884
3305
|
hideDivider();
|
|
2885
3306
|
if (!d || e.pointerId !== d.pointerId) return;
|
|
2886
3307
|
if (!d.dragging) return; // a plain click — leave it to native handlers
|
|
2887
3308
|
clearSettle(d);
|
|
3309
|
+
// Occurrence index of a node among its like-id siblings, read from the
|
|
3310
|
+
// PRE-drag snapshot (the live DOM already reflowed for an in-flow drop). For
|
|
3311
|
+
// a reused component (many DOM nodes share one internal id) the server maps
|
|
3312
|
+
// this to the parent's distinct <Component> USAGE; 0 for a normal element.
|
|
3313
|
+
// Shared by the reposition (Stage H3 — instance move stays local) + reorder
|
|
3314
|
+
// posts below, so both anchor to the same instance the user dragged.
|
|
3315
|
+
const occ = (node: Element | null): number => {
|
|
3316
|
+
if (!node || !d.snapshot) return 0;
|
|
3317
|
+
const nid = node.getAttribute('data-cd-id');
|
|
3318
|
+
const at = d.snapshot.indexOf(node);
|
|
3319
|
+
if (!nid || at < 0) return 0;
|
|
3320
|
+
let c = 0;
|
|
3321
|
+
for (let k = 0; k < at; k++) {
|
|
3322
|
+
if (d.snapshot[k]?.getAttribute('data-cd-id') === nid) c++;
|
|
3323
|
+
}
|
|
3324
|
+
return c;
|
|
3325
|
+
};
|
|
3326
|
+
if (d.outOfFlow) {
|
|
3327
|
+
// Un-float: restore the pre-drag inline style (drops the drag-only
|
|
3328
|
+
// props AND the cursor-follow transform), then overlay the new
|
|
3329
|
+
// left/top — same "unfloat" step as reorder, but the position
|
|
3330
|
+
// sticks instead of reverting to `prevStyle`'s original left/top.
|
|
3331
|
+
if (d.prevStyle == null) d.el.removeAttribute('style');
|
|
3332
|
+
else d.el.setAttribute('style', d.prevStyle);
|
|
3333
|
+
const dx = (d.lastX - d.startX) / d.zoom;
|
|
3334
|
+
const dy = (d.lastY - d.startY) / d.zoom;
|
|
3335
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; // negligible — nothing to persist
|
|
3336
|
+
const left = Math.round(d.originLeft + dx);
|
|
3337
|
+
const top = Math.round(d.originTop + dy);
|
|
3338
|
+
d.el.style.left = `${left}px`;
|
|
3339
|
+
d.el.style.top = `${top}px`;
|
|
3340
|
+
lastPosition = { el: d.el, prevStyle: d.prevStyle };
|
|
3341
|
+
suppressNextCanvasClick();
|
|
3342
|
+
window.parent.postMessage(
|
|
3343
|
+
{
|
|
3344
|
+
dgn: 'reposition-request',
|
|
3345
|
+
id: d.cdId,
|
|
3346
|
+
left,
|
|
3347
|
+
top,
|
|
3348
|
+
beforeLeft: Math.round(d.originLeft),
|
|
3349
|
+
beforeTop: Math.round(d.originTop),
|
|
3350
|
+
idIndex: occ(d.el),
|
|
3351
|
+
},
|
|
3352
|
+
'*'
|
|
3353
|
+
);
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
2888
3356
|
// Un-float: restore the element's exact original inline style.
|
|
2889
3357
|
if (d.prevStyle == null) d.el.removeAttribute('style');
|
|
2890
3358
|
else d.el.setAttribute('style', d.prevStyle);
|
|
@@ -2903,21 +3371,6 @@ function ReorderDrag() {
|
|
|
2903
3371
|
// never persisted doesn't linger until the next canvas switch.
|
|
2904
3372
|
lastCommit = d.origin ? { el: d.el, parent: d.origin.parent, next: d.origin.next } : null;
|
|
2905
3373
|
suppressNextCanvasClick();
|
|
2906
|
-
// Occurrence index of each node among its like-id siblings, read from the
|
|
2907
|
-
// PRE-drag snapshot (the live DOM already reflowed). For a reused component
|
|
2908
|
-
// (many DOM nodes share one internal id) the server maps this to the
|
|
2909
|
-
// parent's distinct <Component> usage; 0 for a normal element.
|
|
2910
|
-
const occ = (node: Element | null): number => {
|
|
2911
|
-
if (!node || !d.snapshot) return 0;
|
|
2912
|
-
const id = node.getAttribute('data-cd-id');
|
|
2913
|
-
const at = d.snapshot.indexOf(node);
|
|
2914
|
-
if (!id || at < 0) return 0;
|
|
2915
|
-
let c = 0;
|
|
2916
|
-
for (let k = 0; k < at; k++) {
|
|
2917
|
-
if (d.snapshot[k]?.getAttribute('data-cd-id') === id) c++;
|
|
2918
|
-
}
|
|
2919
|
-
return c;
|
|
2920
|
-
};
|
|
2921
3374
|
const refNode = d.target?.kind === 'inside' ? d.target.container : (d.target?.el ?? null);
|
|
2922
3375
|
window.parent.postMessage(
|
|
2923
3376
|
{
|
|
@@ -2945,15 +3398,29 @@ function ReorderDrag() {
|
|
|
2945
3398
|
lastCommit = null;
|
|
2946
3399
|
};
|
|
2947
3400
|
|
|
3401
|
+
// The shell rejected the last reposition write — revert the optimistic
|
|
3402
|
+
// left/top back to the pre-drag inline style.
|
|
3403
|
+
const onRepositionFailed = (e: MessageEvent) => {
|
|
3404
|
+
if (e.source !== window.parent) return;
|
|
3405
|
+
const m = e.data as { dgn?: string } | null;
|
|
3406
|
+
if (m?.dgn !== 'reposition-failed' || !lastPosition) return;
|
|
3407
|
+
if (lastPosition.prevStyle == null) lastPosition.el.removeAttribute('style');
|
|
3408
|
+
else lastPosition.el.setAttribute('style', lastPosition.prevStyle);
|
|
3409
|
+
lastPosition = null;
|
|
3410
|
+
};
|
|
3411
|
+
|
|
2948
3412
|
document.addEventListener('pointerdown', onDown, true);
|
|
2949
3413
|
window.addEventListener('pointermove', onMove);
|
|
2950
3414
|
window.addEventListener('pointerup', onUp);
|
|
2951
3415
|
window.addEventListener('keydown', onKeyDown, true);
|
|
2952
3416
|
window.addEventListener('message', onReorderFailed);
|
|
3417
|
+
window.addEventListener('message', onRepositionFailed);
|
|
2953
3418
|
return () => {
|
|
2954
3419
|
if (drag) clearSettle(drag);
|
|
2955
3420
|
setHighlight(null);
|
|
3421
|
+
setElementDragActive(false);
|
|
2956
3422
|
window.removeEventListener('message', onReorderFailed);
|
|
3423
|
+
window.removeEventListener('message', onRepositionFailed);
|
|
2957
3424
|
if (dividerEl) {
|
|
2958
3425
|
dividerEl.remove();
|
|
2959
3426
|
dividerEl = null;
|