@1agh/maude 0.37.0 → 0.38.1
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/README.md +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +316 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +3 -3
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
type EditSourcePayload,
|
|
55
55
|
} from './commands/edit-source-command.ts';
|
|
56
56
|
import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
|
|
57
|
+
import { buildReorderRecord, type ReorderRevertFn } from './commands/reorder-command.ts';
|
|
57
58
|
import {
|
|
58
59
|
ContextMenuProvider,
|
|
59
60
|
type ContextRegistry,
|
|
@@ -77,7 +78,12 @@ import {
|
|
|
77
78
|
} from './dom-selection.ts';
|
|
78
79
|
import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
|
|
79
80
|
import { ExportDialogProvider } from './export-dialog.tsx';
|
|
80
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
crossedDragThreshold,
|
|
83
|
+
type HoverTarget,
|
|
84
|
+
resolveHoverTarget,
|
|
85
|
+
useInputRouter,
|
|
86
|
+
} from './input-router.tsx';
|
|
81
87
|
import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
|
|
82
88
|
import { ParticipantsChrome } from './participants-chrome.tsx';
|
|
83
89
|
import { ToolPalette } from './tool-palette.tsx';
|
|
@@ -317,6 +323,30 @@ const HALO_CSS = `
|
|
|
317
323
|
border-radius: 1px;
|
|
318
324
|
cursor: text;
|
|
319
325
|
}
|
|
326
|
+
/* Phase 12.1 (DDR-138) — in-canvas drag-to-reorder (Figma-style). Dragging a
|
|
327
|
+
SELECTED element floats it with the cursor (via transform, applied inline —
|
|
328
|
+
its box stays reserved so the origin shows empty space, and it stays in the
|
|
329
|
+
zoomed world so styling + scale are preserved). A blue DIVIDER shows the
|
|
330
|
+
insertion point between siblings; a dashed RING shows a container it will nest
|
|
331
|
+
into. The DOM only changes on drop (no mid-drag reflow → no jank).
|
|
332
|
+
NOTE: keep this comment backtick-free — it is inside the HALO_CSS template
|
|
333
|
+
literal and a stray backtick closes it (DDR-067 §6). */
|
|
334
|
+
.dc-cv-reorder-divider {
|
|
335
|
+
position: fixed;
|
|
336
|
+
z-index: 8;
|
|
337
|
+
pointer-events: none;
|
|
338
|
+
display: none;
|
|
339
|
+
background: var(--maude-hud-accent, #0d99ff);
|
|
340
|
+
border-radius: 2px;
|
|
341
|
+
box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
|
|
342
|
+
}
|
|
343
|
+
/* Drop-target container ring — shown while the pointer is over the middle of a
|
|
344
|
+
container the dragged node would nest INTO. Outline only — no layout shift. */
|
|
345
|
+
[data-cd-id].dc-cv-reorder-into {
|
|
346
|
+
outline: 2px dashed var(--maude-hud-accent, #0d99ff);
|
|
347
|
+
outline-offset: -2px;
|
|
348
|
+
border-radius: 2px;
|
|
349
|
+
}
|
|
320
350
|
`.trim();
|
|
321
351
|
|
|
322
352
|
function ensureHaloStyles(): void {
|
|
@@ -421,10 +451,36 @@ function serializeArtboardTree(root: Element): LayerNode[] {
|
|
|
421
451
|
return walk(root);
|
|
422
452
|
}
|
|
423
453
|
|
|
454
|
+
// The artboard whose Layers tree the shell is currently showing. Set every time
|
|
455
|
+
// we post a tree (request-layers / selection); the live MutationObserver
|
|
456
|
+
// (LayersLiveSync) re-posts THIS artboard whenever its DOM structure changes, so
|
|
457
|
+
// the panel mirrors the canvas both ways (drag/keyboard reorder + HMR reload)
|
|
458
|
+
// with FRESH data-cd-ids — no stale-optimistic-tree drift.
|
|
459
|
+
//
|
|
460
|
+
// Stored on `window` (NOT a module `let`): a source edit triggers a SOFT HMR
|
|
461
|
+
// that re-evaluates this module — a module-scoped var would reset to null and
|
|
462
|
+
// the observer would bail right when it matters (post-reorder). The iframe
|
|
463
|
+
// window survives the soft HMR, so the tracked artboard persists across it.
|
|
464
|
+
function getLastLayersArtboardId(): string | null {
|
|
465
|
+
try {
|
|
466
|
+
return (
|
|
467
|
+
(window as unknown as { __maudeLastLayersArt?: string | null }).__maudeLastLayersArt ?? null
|
|
468
|
+
);
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
424
474
|
function postLayersTree(artboardId: string | null | undefined): void {
|
|
425
475
|
if (typeof document === 'undefined' || !artboardId) return;
|
|
426
476
|
const root = document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"]`);
|
|
427
477
|
if (!root) return;
|
|
478
|
+
try {
|
|
479
|
+
(window as unknown as { __maudeLastLayersArt?: string | null }).__maudeLastLayersArt =
|
|
480
|
+
artboardId;
|
|
481
|
+
} catch {
|
|
482
|
+
/* no window */
|
|
483
|
+
}
|
|
428
484
|
try {
|
|
429
485
|
window.parent.postMessage(
|
|
430
486
|
{ dgn: 'layers-tree', artboardId, tree: serializeArtboardTree(root) },
|
|
@@ -435,6 +491,45 @@ function postLayersTree(artboardId: string | null | undefined): void {
|
|
|
435
491
|
}
|
|
436
492
|
}
|
|
437
493
|
|
|
494
|
+
// Live-sync the Layers panel to the canvas DOM. A structural change anywhere in
|
|
495
|
+
// the artboard (an in-canvas reorder's live reflow, a drop, or the HMR remount
|
|
496
|
+
// after ANY source write) re-serializes + re-posts the tracked artboard's tree.
|
|
497
|
+
// Chrome-only churn (halo / divider / pins / activity rim, all appended to body)
|
|
498
|
+
// is ignored so we don't thrash. Debounced; the tree walk skips dev-server chrome
|
|
499
|
+
// already (serializeArtboardTree).
|
|
500
|
+
const LAYERS_CHROME_SEL =
|
|
501
|
+
'.dc-cv-halo, .dc-cv-group-bbox, .dgn-pin, .dc-cv-reorder-divider, .dc-cv-reorder-into, .dc-activity-rim, .dc-activity-scan, .dc-cv-reorder-lift';
|
|
502
|
+
|
|
503
|
+
function LayersLiveSync() {
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return;
|
|
506
|
+
const isChrome = (n: Node): boolean =>
|
|
507
|
+
n.nodeType === 1 && !!(n as Element).closest?.(LAYERS_CHROME_SEL);
|
|
508
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
509
|
+
const obs = new MutationObserver((records) => {
|
|
510
|
+
let structural = false;
|
|
511
|
+
for (const r of records) {
|
|
512
|
+
if (r.type !== 'childList') continue;
|
|
513
|
+
if (r.target.nodeType === 1 && (r.target as Element).closest?.(LAYERS_CHROME_SEL)) continue;
|
|
514
|
+
const touched = [...r.addedNodes, ...r.removedNodes];
|
|
515
|
+
if (touched.length && touched.every(isChrome)) continue; // pure chrome churn
|
|
516
|
+
structural = true;
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
const art = getLastLayersArtboardId();
|
|
520
|
+
if (!structural || !art) return;
|
|
521
|
+
if (timer) clearTimeout(timer);
|
|
522
|
+
timer = setTimeout(() => postLayersTree(art), 90);
|
|
523
|
+
});
|
|
524
|
+
obs.observe(document.body, { childList: true, subtree: true });
|
|
525
|
+
return () => {
|
|
526
|
+
if (timer) clearTimeout(timer);
|
|
527
|
+
obs.disconnect();
|
|
528
|
+
};
|
|
529
|
+
}, []);
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
438
533
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
439
534
|
// Shell
|
|
440
535
|
|
|
@@ -837,14 +932,23 @@ function CanvasCore({
|
|
|
837
932
|
// inverse — artboards kept their own DS theme and following was opt-in — which
|
|
838
933
|
// produced the recurring "canvas dark / chrome light" mismatch.)
|
|
839
934
|
//
|
|
840
|
-
// DS theme-wrapper conventions vary (`.
|
|
841
|
-
// bare `[data-theme]`, …) and there's no reliable config
|
|
842
|
-
// with a hidden computed-style probe: stamp a throwaway
|
|
843
|
-
// candidate `<class>[data-theme=light|dark]` and keep the
|
|
844
|
-
// `--bg-0` differs between light and dark. Because the probe
|
|
845
|
-
// element, "supported" is exactly "stamping one artboard will
|
|
846
|
-
// only themes `:root[data-theme]` correctly reports unsupported
|
|
847
|
-
// can't theme a single artboard there).
|
|
935
|
+
// DS theme-wrapper conventions vary (`.maude[data-theme]`, `.mdcc[data-theme]`,
|
|
936
|
+
// `.app[data-theme]`, bare `[data-theme]`, …) and there's no reliable config
|
|
937
|
+
// flag, so we DETECT it with a hidden computed-style probe: stamp a throwaway
|
|
938
|
+
// nested <div> with each candidate `<class>[data-theme=light|dark]` and keep the
|
|
939
|
+
// first whose resolved `--bg-0` differs between light and dark. Because the probe
|
|
940
|
+
// tests a NON-root element, "supported" is exactly "stamping one artboard will
|
|
941
|
+
// work" — a DS that only themes `:root[data-theme]` correctly reports unsupported
|
|
942
|
+
// (you genuinely can't theme a single artboard there).
|
|
943
|
+
//
|
|
944
|
+
// Candidate classes are DERIVED FROM THE LIVE DOM (2026-07-02): the DS's own
|
|
945
|
+
// rootClass wrapper is already in the artboard content carrying `[data-theme]`
|
|
946
|
+
// (e.g. `<div class="maude" data-theme="dark">`), so we read those class strings
|
|
947
|
+
// straight off the page instead of guessing. A hardcoded guess list (`['', 'mdcc',
|
|
948
|
+
// 'app']`) missed EVERY DS whose rootClass wasn't in it — including this repo's own
|
|
949
|
+
// `.maude` — which greyed out Light/Dark on a DS that fully ships both themes. The
|
|
950
|
+
// historical guesses stay as a trailing fallback for a canvas whose wrapper hasn't
|
|
951
|
+
// mounted yet at probe time.
|
|
848
952
|
|
|
849
953
|
interface DsThemeSupport {
|
|
850
954
|
supported: boolean;
|
|
@@ -873,9 +977,23 @@ function detectDsThemeSupport(): DsThemeSupport {
|
|
|
873
977
|
host.removeChild(el);
|
|
874
978
|
return v;
|
|
875
979
|
};
|
|
876
|
-
//
|
|
980
|
+
// Derive candidate wrapper classes from the DS wrappers already in the DOM
|
|
981
|
+
// (elements carrying `data-theme` are the DS rootClass wrappers), trying both
|
|
982
|
+
// the full className ("maude di-root") and each individual token ("maude",
|
|
983
|
+
// "di-root"). Bare `''` first (cleanest), historical guesses last as a
|
|
984
|
+
// pre-mount fallback. A Set de-dupes; insertion order keeps DOM-derived first.
|
|
985
|
+
const candidates = new Set<string>(['']);
|
|
986
|
+
for (const el of Array.from(document.querySelectorAll('[data-theme]'))) {
|
|
987
|
+
const cn = (el as HTMLElement).className;
|
|
988
|
+
if (typeof cn === 'string' && cn.trim()) {
|
|
989
|
+
candidates.add(cn.trim());
|
|
990
|
+
for (const tok of cn.trim().split(/\s+/)) candidates.add(tok);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
candidates.add('mdcc');
|
|
994
|
+
candidates.add('app');
|
|
877
995
|
let found = fallback;
|
|
878
|
-
for (const cls of
|
|
996
|
+
for (const cls of candidates) {
|
|
879
997
|
const light = read(cls, 'light');
|
|
880
998
|
const dark = read(cls, 'dark');
|
|
881
999
|
if (light && dark && light !== dark) {
|
|
@@ -1400,6 +1518,21 @@ function CanvasRouter({
|
|
|
1400
1518
|
return () => undoSinks.setSink('editSourceApplyFn', undefined);
|
|
1401
1519
|
}, [undoSinks]);
|
|
1402
1520
|
|
|
1521
|
+
// Phase 12.1 — reorder undo. Same origin-split shape as editSourceApplyFn:
|
|
1522
|
+
// the reorder-revert write is main-origin-only, so the command's do()/undo()
|
|
1523
|
+
// posts `dgn:'reorder-revert'` and the shell calls /_api/reorder-revert.
|
|
1524
|
+
useEffect(() => {
|
|
1525
|
+
const revertFn: ReorderRevertFn = (revert) => {
|
|
1526
|
+
try {
|
|
1527
|
+
window.parent.postMessage({ dgn: 'reorder-revert', ...revert }, '*');
|
|
1528
|
+
} catch {
|
|
1529
|
+
/* detached / cross-origin teardown — nothing to revert */
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
undoSinks.setSink('reorderRevertFn', revertFn);
|
|
1533
|
+
return () => undoSinks.setSink('reorderRevertFn', undefined);
|
|
1534
|
+
}, [undoSinks]);
|
|
1535
|
+
|
|
1403
1536
|
// Shell View-menu zoom bridge (dgn:'zoom') — same controller the zoom pill uses.
|
|
1404
1537
|
const zoomController = useViewportControllerContext();
|
|
1405
1538
|
// Shell View-menu chrome-visibility bridge (dgn:'view-chrome') — minimap /
|
|
@@ -1557,7 +1690,21 @@ function CanvasRouter({
|
|
|
1557
1690
|
// a hostile canvas self-post must not be able to plant fake undo entries.
|
|
1558
1691
|
if (m.dgn === 'record-edit') {
|
|
1559
1692
|
if (e.source !== window.parent) return;
|
|
1560
|
-
const p = (m as { payload?: Partial<EditSourcePayload> })
|
|
1693
|
+
const p = (m as { payload?: Partial<EditSourcePayload> & { seq?: number; label?: string } })
|
|
1694
|
+
.payload;
|
|
1695
|
+
// Phase 12.1 — a landed reorder (drag / keyboard move). The shell POSTed
|
|
1696
|
+
// /_api/reorder already; record so Cmd+Z reverts via the server's log.
|
|
1697
|
+
if (p && (p as { op?: string }).op === 'reorder') {
|
|
1698
|
+
if (typeof p.canvas === 'string' && typeof p.seq === 'number') {
|
|
1699
|
+
undoStack.record(
|
|
1700
|
+
buildReorderRecord(
|
|
1701
|
+
{ canvas: p.canvas, seq: p.seq },
|
|
1702
|
+
typeof p.label === 'string' ? p.label : undefined
|
|
1703
|
+
)
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1561
1708
|
if (
|
|
1562
1709
|
p &&
|
|
1563
1710
|
(p.op === 'css' || p.op === 'text' || p.op === 'attr') &&
|
|
@@ -1808,6 +1955,8 @@ function CanvasRouter({
|
|
|
1808
1955
|
<ElementMarqueeOverlay />
|
|
1809
1956
|
<HoverHalo el={hoverEl} />
|
|
1810
1957
|
<SelectionHalos />
|
|
1958
|
+
<ReorderDrag />
|
|
1959
|
+
<LayersLiveSync />
|
|
1811
1960
|
<GroupBbox />
|
|
1812
1961
|
<EqualSpacingHandles />
|
|
1813
1962
|
<ContextualToolbar />
|
|
@@ -2269,6 +2418,529 @@ function SelectionHalos() {
|
|
|
2269
2418
|
return <div ref={containerRef} aria-hidden="true" />;
|
|
2270
2419
|
}
|
|
2271
2420
|
|
|
2421
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2422
|
+
// ReorderDrag (Phase 12.1, DDR-138) — in-canvas drag-to-reorder, Figma-style.
|
|
2423
|
+
// Grab the SELECTED element and drag: it FLOATS with the cursor (a `transform`
|
|
2424
|
+
// applied inline, so its layout box stays reserved — the origin shows empty
|
|
2425
|
+
// space — and it stays inside the zoomed world, keeping its styling + scale; we
|
|
2426
|
+
// divide the screen delta by the element's own zoom so it tracks 1:1). The drop
|
|
2427
|
+
// zone is the WHOLE hovered element split 50/50 — top/left half inserts BEFORE,
|
|
2428
|
+
// bottom/right half AFTER (axis = parent flex direction) — so there's a wide hit
|
|
2429
|
+
// area, not a thin divider to aim at; a blue DIVIDER marks the resulting seam, a
|
|
2430
|
+
// dashed RING marks an EMPTY container it would nest INTO. Hover a stable target
|
|
2431
|
+
// for SETTLE_MS and the layout reflows LIVE
|
|
2432
|
+
// (the node moves there, still floating — you see the result before committing);
|
|
2433
|
+
// per-mousemove the DOM never changes, so there's no jank / ping-pong. DROP
|
|
2434
|
+
// commits via `dgn:'reorder-request'` to the shell (DDR-054: untrusted canvas
|
|
2435
|
+
// REQUESTS, main-origin shell WRITES → source write → HMR remount renders the
|
|
2436
|
+
// committed order); ESC aborts and restores the origin.
|
|
2437
|
+
//
|
|
2438
|
+
// Headless: a document-capture pointerdown that only ACTS when a bare drag
|
|
2439
|
+
// starts on the single selected element with the move tool — a plain click and
|
|
2440
|
+
// every other gesture (Cmd-select, marquee, pan, annotation, native button
|
|
2441
|
+
// clicks) pass through untouched. Threshold-gated so a click is never a drag.
|
|
2442
|
+
|
|
2443
|
+
/** Read a usable `data-cd-id` off a live node, skipping dev-server chrome. */
|
|
2444
|
+
function reorderCdId(node: Node | null): string | null {
|
|
2445
|
+
if (node?.nodeType !== 1) return null;
|
|
2446
|
+
const el = node as Element;
|
|
2447
|
+
if (el.closest('.dc-cv-halo, .dc-cv-group-bbox, .dgn-pin')) return null;
|
|
2448
|
+
return el.getAttribute('data-cd-id');
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/** Suppress the synthetic click that fires after a drag so the drag doesn't
|
|
2452
|
+
* also activate a button / link inside the dragged element. */
|
|
2453
|
+
function suppressNextCanvasClick(): void {
|
|
2454
|
+
const onClick = (e: MouseEvent) => {
|
|
2455
|
+
e.preventDefault();
|
|
2456
|
+
e.stopImmediatePropagation();
|
|
2457
|
+
cleanup();
|
|
2458
|
+
};
|
|
2459
|
+
const cleanup = () => {
|
|
2460
|
+
document.removeEventListener('click', onClick, true);
|
|
2461
|
+
clearTimeout(t);
|
|
2462
|
+
};
|
|
2463
|
+
const t = setTimeout(cleanup, 350);
|
|
2464
|
+
document.addEventListener('click', onClick, true);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function ReorderDrag() {
|
|
2468
|
+
const { selected } = useSelectionSet();
|
|
2469
|
+
const { tool } = useToolMode();
|
|
2470
|
+
// The document pointerdown listener is stable; it reads the latest selection +
|
|
2471
|
+
// tool from refs so it doesn't need re-attaching on every selection change.
|
|
2472
|
+
const selRef = useRef(selected);
|
|
2473
|
+
selRef.current = selected;
|
|
2474
|
+
const toolRef = useRef(tool);
|
|
2475
|
+
toolRef.current = tool;
|
|
2476
|
+
|
|
2477
|
+
useEffect(() => {
|
|
2478
|
+
type Target = { kind: 'before' | 'after' | 'inside'; el: HTMLElement; container: HTMLElement };
|
|
2479
|
+
type Drag = {
|
|
2480
|
+
pointerId: number;
|
|
2481
|
+
startX: number;
|
|
2482
|
+
startY: number;
|
|
2483
|
+
lastX: number;
|
|
2484
|
+
lastY: number;
|
|
2485
|
+
grabDX: number;
|
|
2486
|
+
grabDY: number;
|
|
2487
|
+
el: HTMLElement;
|
|
2488
|
+
cdId: string;
|
|
2489
|
+
zoom: number;
|
|
2490
|
+
prevStyle: string | null;
|
|
2491
|
+
dragging: boolean;
|
|
2492
|
+
target: Target | null;
|
|
2493
|
+
/** Where the node lived at lift — for the Esc restore. */
|
|
2494
|
+
origin: { parent: HTMLElement; next: Element | null } | null;
|
|
2495
|
+
/** Pending settle timer — fires the live-reflow preview. */
|
|
2496
|
+
settle: ReturnType<typeof setTimeout> | null;
|
|
2497
|
+
settleKey: string | null;
|
|
2498
|
+
/** The last PREVIEWED (already reflowed) drop, committed on release. */
|
|
2499
|
+
applied: { refId: string; position: 'before' | 'after' | 'inside-end' } | null;
|
|
2500
|
+
/** Stamped [data-cd-id] nodes in their ORIGINAL order — for computing a
|
|
2501
|
+
* reused instance's occurrence index even after the DOM reflowed. */
|
|
2502
|
+
snapshot: Element[] | null;
|
|
2503
|
+
};
|
|
2504
|
+
// Hover a stable spot this long and the layout reflows LIVE (the node moves
|
|
2505
|
+
// there while still floating with the cursor) — you see the result before
|
|
2506
|
+
// committing. Drop commits; Esc restores the origin.
|
|
2507
|
+
const SETTLE_MS = 500;
|
|
2508
|
+
let drag: Drag | null = null;
|
|
2509
|
+
let highlightEl: HTMLElement | null = null;
|
|
2510
|
+
let dividerEl: HTMLDivElement | null = null;
|
|
2511
|
+
// The last committed drop, kept so a shell 'reorder-failed' can undo the
|
|
2512
|
+
// optimistic DOM move (the write was rejected → nothing persisted).
|
|
2513
|
+
let lastCommit: { el: HTMLElement; parent: HTMLElement; next: Element | null } | null = null;
|
|
2514
|
+
|
|
2515
|
+
const setHighlight = (el: HTMLElement | null) => {
|
|
2516
|
+
if (highlightEl === el) return;
|
|
2517
|
+
if (highlightEl) highlightEl.classList.remove('dc-cv-reorder-into');
|
|
2518
|
+
highlightEl = el;
|
|
2519
|
+
if (highlightEl) highlightEl.classList.add('dc-cv-reorder-into');
|
|
2520
|
+
};
|
|
2521
|
+
|
|
2522
|
+
const hideDivider = () => {
|
|
2523
|
+
if (dividerEl) dividerEl.style.display = 'none';
|
|
2524
|
+
};
|
|
2525
|
+
const showDivider = (t: Target) => {
|
|
2526
|
+
if (!dividerEl) {
|
|
2527
|
+
dividerEl = document.createElement('div');
|
|
2528
|
+
dividerEl.className = 'dc-cv-reorder-divider';
|
|
2529
|
+
dividerEl.setAttribute('aria-hidden', 'true');
|
|
2530
|
+
document.body.appendChild(dividerEl);
|
|
2531
|
+
}
|
|
2532
|
+
const r = t.el.getBoundingClientRect();
|
|
2533
|
+
const pcs = getComputedStyle(t.container);
|
|
2534
|
+
const isRow = pcs.display.includes('flex') && pcs.flexDirection.startsWith('row');
|
|
2535
|
+
const el = dividerEl;
|
|
2536
|
+
el.style.display = 'block';
|
|
2537
|
+
if (isRow) {
|
|
2538
|
+
const cx = t.kind === 'before' ? r.left : r.right;
|
|
2539
|
+
el.style.left = `${Math.round(cx) - 1}px`;
|
|
2540
|
+
el.style.top = `${Math.round(r.top)}px`;
|
|
2541
|
+
el.style.width = '2px';
|
|
2542
|
+
el.style.height = `${Math.round(r.height)}px`;
|
|
2543
|
+
} else {
|
|
2544
|
+
const cy = t.kind === 'before' ? r.top : r.bottom;
|
|
2545
|
+
el.style.left = `${Math.round(r.left)}px`;
|
|
2546
|
+
el.style.top = `${Math.round(cy) - 1}px`;
|
|
2547
|
+
el.style.width = `${Math.round(r.width)}px`;
|
|
2548
|
+
el.style.height = '2px';
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
|
|
2552
|
+
// A genuinely EMPTY container (no element children, no text) is the only case
|
|
2553
|
+
// where before/after can't express "put it in here" — so the whole box nests
|
|
2554
|
+
// inside. Everything else splits 50/50 (see computeTarget).
|
|
2555
|
+
const isEmptyContainer = (el: HTMLElement): boolean =>
|
|
2556
|
+
el.childElementCount === 0 && !el.textContent?.trim();
|
|
2557
|
+
|
|
2558
|
+
// Where would the node drop right now? Pure read — never mutates the DOM
|
|
2559
|
+
// (that only happens on drop), which is what keeps the drag jank-free.
|
|
2560
|
+
// The drop zone is the WHOLE element split 50/50: top/left half → before,
|
|
2561
|
+
// bottom/right half → after (axis follows the parent's flex direction). No
|
|
2562
|
+
// thin central "nest" band eating the target — the entire element is an easy,
|
|
2563
|
+
// wide hit. (Nesting into a container that HAS content = drop onto one of its
|
|
2564
|
+
// children; nesting into an EMPTY one = drop anywhere on it; deep nesting =
|
|
2565
|
+
// the Layers panel's Alt+Shift+↑/↓.)
|
|
2566
|
+
const computeTarget = (x: number, y: number, d: Drag): Target | null => {
|
|
2567
|
+
let E = document.elementFromPoint(x, y)?.closest('[data-cd-id]') as HTMLElement | null;
|
|
2568
|
+
while (E && (E === d.el || d.el.contains(E))) {
|
|
2569
|
+
E = E.parentElement
|
|
2570
|
+
? (E.parentElement.closest('[data-cd-id]') as HTMLElement | null)
|
|
2571
|
+
: null;
|
|
2572
|
+
}
|
|
2573
|
+
if (!E || !reorderCdId(E)) return null;
|
|
2574
|
+
// While inside the dragged node's own parent, climb to that parent's direct
|
|
2575
|
+
// child so hovering a sibling's inner content still targets the sibling
|
|
2576
|
+
// (no deep-hit). Outside it, E stays deep → reparent OUT / INTO others.
|
|
2577
|
+
let anchor: HTMLElement = E;
|
|
2578
|
+
const sib = d.el.parentElement;
|
|
2579
|
+
if (sib?.contains(E)) {
|
|
2580
|
+
let s: HTMLElement | null = E;
|
|
2581
|
+
while (s && s.parentElement !== sib) s = s.parentElement as HTMLElement | null;
|
|
2582
|
+
if (s && s !== d.el && reorderCdId(s)) anchor = s;
|
|
2583
|
+
}
|
|
2584
|
+
if (isEmptyContainer(anchor) && !d.el.contains(anchor)) {
|
|
2585
|
+
return { kind: 'inside', el: anchor, container: anchor };
|
|
2586
|
+
}
|
|
2587
|
+
const parent = anchor.parentElement;
|
|
2588
|
+
const r = anchor.getBoundingClientRect();
|
|
2589
|
+
const pcs = parent ? getComputedStyle(parent) : null;
|
|
2590
|
+
const isRow = !!pcs && pcs.display.includes('flex') && pcs.flexDirection.startsWith('row');
|
|
2591
|
+
const frac = isRow ? (x - r.left) / (r.width || 1) : (y - r.top) / (r.height || 1);
|
|
2592
|
+
// Nest zone: the middle third of a CONTAINER (element that holds children)
|
|
2593
|
+
// nests INTO it. This is the only way to drop a node into a non-empty
|
|
2594
|
+
// container — before/after only ever inserts as a SIBLING, so without it a
|
|
2595
|
+
// node accidentally lifted to the root could never be dragged back into a
|
|
2596
|
+
// child div. Leaves keep the full 50/50 (no nest band eating the target).
|
|
2597
|
+
if (anchor.childElementCount > 0 && frac >= 0.34 && frac <= 0.66 && !d.el.contains(anchor)) {
|
|
2598
|
+
return { kind: 'inside', el: anchor, container: anchor };
|
|
2599
|
+
}
|
|
2600
|
+
if (!parent || parent === d.el || d.el.contains(parent)) return null;
|
|
2601
|
+
return { kind: frac < 0.5 ? 'before' : 'after', el: anchor, container: parent };
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
const targetToDrop = (
|
|
2605
|
+
t: Target
|
|
2606
|
+
): { refId: string; position: 'before' | 'after' | 'inside-end' } | null => {
|
|
2607
|
+
if (t.kind === 'inside') {
|
|
2608
|
+
const id = reorderCdId(t.container);
|
|
2609
|
+
return id ? { refId: id, position: 'inside-end' } : null;
|
|
2610
|
+
}
|
|
2611
|
+
const id = reorderCdId(t.el);
|
|
2612
|
+
return id ? { refId: id, position: t.kind } : null;
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
// FLIP the siblings a reflow displaces so they GLIDE to their new slots
|
|
2616
|
+
// instead of jumping: record first-positions, run the DOM move, invert via
|
|
2617
|
+
// transform, then play to zero. Scoped to direct children of the affected
|
|
2618
|
+
// parent(s); descendants ride along. The dragged node is excluded — it has
|
|
2619
|
+
// its own cursor-following transform. Deltas divide by each element's own
|
|
2620
|
+
// zoom (screen px → world px), same math as the float. Respects
|
|
2621
|
+
// prefers-reduced-motion (skips straight to the end state).
|
|
2622
|
+
const REFLOW_MS = 180;
|
|
2623
|
+
const animateReflow = (moved: HTMLElement, act: () => void) => {
|
|
2624
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
2625
|
+
act();
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
const first = new Map<HTMLElement, DOMRect>();
|
|
2629
|
+
const collect = (parent: Element | null) => {
|
|
2630
|
+
if (!parent) return;
|
|
2631
|
+
for (const c of Array.from(parent.children)) {
|
|
2632
|
+
const el = c as HTMLElement;
|
|
2633
|
+
if (el === moved || first.has(el)) continue;
|
|
2634
|
+
first.set(el, el.getBoundingClientRect());
|
|
2635
|
+
}
|
|
2636
|
+
};
|
|
2637
|
+
collect(moved.parentElement);
|
|
2638
|
+
act();
|
|
2639
|
+
collect(moved.parentElement); // reparent case — new siblings shift too
|
|
2640
|
+
const anims: Array<{ el: HTMLElement; prevTransform: string; prevTransition: string }> = [];
|
|
2641
|
+
for (const [el, r0] of first) {
|
|
2642
|
+
if (!el.isConnected) continue;
|
|
2643
|
+
const r1 = el.getBoundingClientRect();
|
|
2644
|
+
const dx = r0.left - r1.left;
|
|
2645
|
+
const dy = r0.top - r1.top;
|
|
2646
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
|
|
2647
|
+
const zoom = el.offsetWidth ? r1.width / el.offsetWidth : 1;
|
|
2648
|
+
anims.push({ el, prevTransform: el.style.transform, prevTransition: el.style.transition });
|
|
2649
|
+
el.style.transition = 'none';
|
|
2650
|
+
el.style.transform = `translate(${dx / zoom}px, ${dy / zoom}px)`;
|
|
2651
|
+
}
|
|
2652
|
+
if (!anims.length) return;
|
|
2653
|
+
void document.body.offsetWidth; // flush the inverted state
|
|
2654
|
+
for (const a of anims) {
|
|
2655
|
+
a.el.style.transition = `transform ${REFLOW_MS}ms ease`;
|
|
2656
|
+
a.el.style.transform = 'translate(0px, 0px)';
|
|
2657
|
+
}
|
|
2658
|
+
setTimeout(() => {
|
|
2659
|
+
for (const a of anims) {
|
|
2660
|
+
if (!a.el.isConnected) continue;
|
|
2661
|
+
a.el.style.transition = a.prevTransition;
|
|
2662
|
+
a.el.style.transform = a.prevTransform;
|
|
2663
|
+
}
|
|
2664
|
+
}, REFLOW_MS + 40);
|
|
2665
|
+
};
|
|
2666
|
+
|
|
2667
|
+
// Move the node to match the target (used by the settle preview AND the
|
|
2668
|
+
// final drop); the source write + HMR remount then re-render the committed
|
|
2669
|
+
// order.
|
|
2670
|
+
const applyDrop = (el: HTMLElement, t: Target) => {
|
|
2671
|
+
try {
|
|
2672
|
+
if (t.kind === 'inside') t.container.appendChild(el);
|
|
2673
|
+
else if (t.kind === 'before') t.container.insertBefore(el, t.el);
|
|
2674
|
+
else {
|
|
2675
|
+
let ref = t.el.nextElementSibling;
|
|
2676
|
+
if (ref === el) ref = el.nextElementSibling;
|
|
2677
|
+
t.container.insertBefore(el, ref);
|
|
2678
|
+
}
|
|
2679
|
+
} catch {
|
|
2680
|
+
/* detached — remount re-syncs */
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
|
|
2684
|
+
// Is the target where the node ALREADY sits? True after a settle preview
|
|
2685
|
+
// moved it — without this the timer would re-fire forever and the divider
|
|
2686
|
+
// would point at the node's own slot.
|
|
2687
|
+
const isCurrentPosition = (t: Target, d: Drag): boolean => {
|
|
2688
|
+
if (t.kind === 'before')
|
|
2689
|
+
return t.container === d.el.parentElement && t.el === d.el.nextElementSibling;
|
|
2690
|
+
if (t.kind === 'after')
|
|
2691
|
+
return t.container === d.el.parentElement && t.el === d.el.previousElementSibling;
|
|
2692
|
+
return t.container === d.el.parentElement && t.container.lastElementChild === d.el;
|
|
2693
|
+
};
|
|
2694
|
+
|
|
2695
|
+
const clearSettle = (d: Drag) => {
|
|
2696
|
+
if (d.settle != null) clearTimeout(d.settle);
|
|
2697
|
+
d.settle = null;
|
|
2698
|
+
d.settleKey = null;
|
|
2699
|
+
};
|
|
2700
|
+
|
|
2701
|
+
// The live reflow: move the node to the hovered target NOW (the origin gap
|
|
2702
|
+
// closes, a gap opens at the target) while it keeps floating under the
|
|
2703
|
+
// cursor — re-anchor the transform to the node's new layout slot so it
|
|
2704
|
+
// doesn't visually jump.
|
|
2705
|
+
const applyPreview = (d: Drag) => {
|
|
2706
|
+
d.settle = null;
|
|
2707
|
+
d.settleKey = null;
|
|
2708
|
+
const t = d.target;
|
|
2709
|
+
if (!t?.el.isConnected) return;
|
|
2710
|
+
const drop = targetToDrop(t);
|
|
2711
|
+
if (!drop) return;
|
|
2712
|
+
animateReflow(d.el, () => applyDrop(d.el, t));
|
|
2713
|
+
d.applied = drop;
|
|
2714
|
+
d.el.style.transform = 'none';
|
|
2715
|
+
const rect = d.el.getBoundingClientRect();
|
|
2716
|
+
d.zoom = d.el.offsetWidth ? rect.width / d.el.offsetWidth : 1;
|
|
2717
|
+
const tx = (d.lastX - d.grabDX - rect.left) / d.zoom;
|
|
2718
|
+
const ty = (d.lastY - d.grabDY - rect.top) / d.zoom;
|
|
2719
|
+
d.el.style.transform = `translate(${tx}px, ${ty}px)`;
|
|
2720
|
+
// Keep onMove's (client - start)/zoom math consistent from the new anchor.
|
|
2721
|
+
d.startX = d.lastX - tx * d.zoom;
|
|
2722
|
+
d.startY = d.lastY - ty * d.zoom;
|
|
2723
|
+
setHighlight(null);
|
|
2724
|
+
hideDivider();
|
|
2725
|
+
};
|
|
2726
|
+
|
|
2727
|
+
// Esc — abort the drag: put the node back where it was lifted from and
|
|
2728
|
+
// restore its exact inline style. Nothing is committed.
|
|
2729
|
+
const cancelDrag = (d: Drag) => {
|
|
2730
|
+
clearSettle(d);
|
|
2731
|
+
const origin = d.origin;
|
|
2732
|
+
if (d.applied && origin) {
|
|
2733
|
+
try {
|
|
2734
|
+
animateReflow(d.el, () => origin.parent.insertBefore(d.el, origin.next));
|
|
2735
|
+
} catch {
|
|
2736
|
+
/* origin gone — remount re-syncs */
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
if (d.prevStyle == null) d.el.removeAttribute('style');
|
|
2740
|
+
else d.el.setAttribute('style', d.prevStyle);
|
|
2741
|
+
setHighlight(null);
|
|
2742
|
+
hideDivider();
|
|
2743
|
+
suppressNextCanvasClick();
|
|
2744
|
+
drag = null;
|
|
2745
|
+
};
|
|
2746
|
+
|
|
2747
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
2748
|
+
if (e.key !== 'Escape' || !drag || !drag.dragging) return;
|
|
2749
|
+
e.preventDefault();
|
|
2750
|
+
e.stopImmediatePropagation(); // don't also clear the selection
|
|
2751
|
+
cancelDrag(drag);
|
|
2752
|
+
};
|
|
2753
|
+
|
|
2754
|
+
const onDown = (e: PointerEvent) => {
|
|
2755
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
2756
|
+
if (toolRef.current !== 'move') return;
|
|
2757
|
+
const sel = selRef.current;
|
|
2758
|
+
const one = sel && sel.length === 1 ? sel[0] : null;
|
|
2759
|
+
const cdId = one && typeof one.id === 'string' ? one.id : null;
|
|
2760
|
+
if (!cdId || !one) return;
|
|
2761
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
2762
|
+
if (!el) return;
|
|
2763
|
+
const t = e.target;
|
|
2764
|
+
if (!(t instanceof Node) || !el.contains(t)) return;
|
|
2765
|
+
// Candidate — do NOT claim yet (a plain click must still work). We only
|
|
2766
|
+
// become a drag once the pointer crosses the threshold in onMove.
|
|
2767
|
+
drag = {
|
|
2768
|
+
pointerId: e.pointerId,
|
|
2769
|
+
startX: e.clientX,
|
|
2770
|
+
startY: e.clientY,
|
|
2771
|
+
lastX: e.clientX,
|
|
2772
|
+
lastY: e.clientY,
|
|
2773
|
+
grabDX: 0,
|
|
2774
|
+
grabDY: 0,
|
|
2775
|
+
el,
|
|
2776
|
+
cdId,
|
|
2777
|
+
zoom: 1,
|
|
2778
|
+
prevStyle: null,
|
|
2779
|
+
dragging: false,
|
|
2780
|
+
target: null,
|
|
2781
|
+
origin: null,
|
|
2782
|
+
settle: null,
|
|
2783
|
+
settleKey: null,
|
|
2784
|
+
applied: null,
|
|
2785
|
+
snapshot: null,
|
|
2786
|
+
};
|
|
2787
|
+
};
|
|
2788
|
+
|
|
2789
|
+
const onMove = (e: PointerEvent) => {
|
|
2790
|
+
const d = drag;
|
|
2791
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
2792
|
+
if (!d.dragging) {
|
|
2793
|
+
if (!crossedDragThreshold(d.startX, d.startY, e.clientX, e.clientY)) return;
|
|
2794
|
+
d.dragging = true;
|
|
2795
|
+
// Start floating: the element tracks the cursor via `transform`, so its
|
|
2796
|
+
// layout box stays reserved (origin shows empty space) and it stays in
|
|
2797
|
+
// the zoomed world (styling + scale intact). Divide the screen delta by
|
|
2798
|
+
// the element's own zoom (screen size / natural size) so it tracks 1:1.
|
|
2799
|
+
const rect = d.el.getBoundingClientRect();
|
|
2800
|
+
d.zoom = d.el.offsetWidth ? rect.width / d.el.offsetWidth : 1;
|
|
2801
|
+
d.grabDX = d.startX - rect.left;
|
|
2802
|
+
d.grabDY = d.startY - rect.top;
|
|
2803
|
+
d.origin = d.el.parentElement
|
|
2804
|
+
? { parent: d.el.parentElement, next: d.el.nextElementSibling }
|
|
2805
|
+
: null;
|
|
2806
|
+
// Snapshot the original stamped-node order NOW — occurrence indices must
|
|
2807
|
+
// reflect the pre-drag layout, not the reflowed one.
|
|
2808
|
+
d.snapshot = [...document.querySelectorAll('[data-cd-id]')];
|
|
2809
|
+
d.prevStyle = d.el.getAttribute('style');
|
|
2810
|
+
if (getComputedStyle(d.el).position === 'static') d.el.style.position = 'relative';
|
|
2811
|
+
d.el.style.zIndex = '9990';
|
|
2812
|
+
d.el.style.pointerEvents = 'none'; // so elementFromPoint sees what's beneath
|
|
2813
|
+
d.el.style.opacity = '0.9';
|
|
2814
|
+
d.el.style.cursor = 'grabbing';
|
|
2815
|
+
d.el.style.boxShadow = '0 10px 28px rgba(0, 0, 0, 0.28)';
|
|
2816
|
+
d.el.style.willChange = 'transform';
|
|
2817
|
+
d.el.style.transition = 'none';
|
|
2818
|
+
}
|
|
2819
|
+
d.lastX = e.clientX;
|
|
2820
|
+
d.lastY = e.clientY;
|
|
2821
|
+
const tx = (e.clientX - d.startX) / d.zoom;
|
|
2822
|
+
const ty = (e.clientY - d.startY) / d.zoom;
|
|
2823
|
+
d.el.style.transform = `translate(${tx}px, ${ty}px)`;
|
|
2824
|
+
// Indicator + settle scheduling only — the DOM moves when a target stays
|
|
2825
|
+
// stable for SETTLE_MS (the live-reflow preview), never per-mousemove.
|
|
2826
|
+
const target = computeTarget(e.clientX, e.clientY, d);
|
|
2827
|
+
const current = target ? isCurrentPosition(target, d) : false;
|
|
2828
|
+
d.target = target && !current ? target : null;
|
|
2829
|
+
if (!d.target) {
|
|
2830
|
+
clearSettle(d);
|
|
2831
|
+
setHighlight(null);
|
|
2832
|
+
hideDivider();
|
|
2833
|
+
} else {
|
|
2834
|
+
const t = d.target;
|
|
2835
|
+
if (t.kind === 'inside') {
|
|
2836
|
+
hideDivider();
|
|
2837
|
+
setHighlight(t.container);
|
|
2838
|
+
} else {
|
|
2839
|
+
setHighlight(null);
|
|
2840
|
+
showDivider(t);
|
|
2841
|
+
}
|
|
2842
|
+
const key = `${t.kind}:${reorderCdId(t.kind === 'inside' ? t.container : t.el) ?? ''}`;
|
|
2843
|
+
if (key !== d.settleKey) {
|
|
2844
|
+
if (d.settle != null) clearTimeout(d.settle);
|
|
2845
|
+
d.settleKey = key;
|
|
2846
|
+
d.settle = setTimeout(() => {
|
|
2847
|
+
if (drag === d && d.dragging) applyPreview(d);
|
|
2848
|
+
}, SETTLE_MS);
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
};
|
|
2852
|
+
|
|
2853
|
+
const onUp = (e: PointerEvent) => {
|
|
2854
|
+
const d = drag;
|
|
2855
|
+
drag = null;
|
|
2856
|
+
setHighlight(null);
|
|
2857
|
+
hideDivider();
|
|
2858
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
2859
|
+
if (!d.dragging) return; // a plain click — leave it to native handlers
|
|
2860
|
+
clearSettle(d);
|
|
2861
|
+
// Un-float: restore the element's exact original inline style.
|
|
2862
|
+
if (d.prevStyle == null) d.el.removeAttribute('style');
|
|
2863
|
+
else d.el.setAttribute('style', d.prevStyle);
|
|
2864
|
+
// Commit priority: a fresh (un-settled) target under the cursor wins;
|
|
2865
|
+
// otherwise the last settled preview (the layout the user is LOOKING at).
|
|
2866
|
+
let drop = d.target ? targetToDrop(d.target) : null;
|
|
2867
|
+
if (drop && d.target) {
|
|
2868
|
+
const t = d.target;
|
|
2869
|
+
animateReflow(d.el, () => applyDrop(d.el, t)); // immediate result; the remount confirms it
|
|
2870
|
+
} else if (d.applied) {
|
|
2871
|
+
drop = d.applied; // DOM already sits there from the preview
|
|
2872
|
+
}
|
|
2873
|
+
if (!drop) return; // dropped on empty space, never previewed — no move
|
|
2874
|
+
// Remember where this node came FROM: if the shell reports the write was
|
|
2875
|
+
// rejected (dgn:'reorder-failed'), we put it back so a phantom move that
|
|
2876
|
+
// never persisted doesn't linger until the next canvas switch.
|
|
2877
|
+
lastCommit = d.origin ? { el: d.el, parent: d.origin.parent, next: d.origin.next } : null;
|
|
2878
|
+
suppressNextCanvasClick();
|
|
2879
|
+
// Occurrence index of each node among its like-id siblings, read from the
|
|
2880
|
+
// PRE-drag snapshot (the live DOM already reflowed). For a reused component
|
|
2881
|
+
// (many DOM nodes share one internal id) the server maps this to the
|
|
2882
|
+
// parent's distinct <Component> usage; 0 for a normal element.
|
|
2883
|
+
const occ = (node: Element | null): number => {
|
|
2884
|
+
if (!node || !d.snapshot) return 0;
|
|
2885
|
+
const id = node.getAttribute('data-cd-id');
|
|
2886
|
+
const at = d.snapshot.indexOf(node);
|
|
2887
|
+
if (!id || at < 0) return 0;
|
|
2888
|
+
let c = 0;
|
|
2889
|
+
for (let k = 0; k < at; k++) {
|
|
2890
|
+
if (d.snapshot[k]?.getAttribute('data-cd-id') === id) c++;
|
|
2891
|
+
}
|
|
2892
|
+
return c;
|
|
2893
|
+
};
|
|
2894
|
+
const refNode = d.target?.kind === 'inside' ? d.target.container : (d.target?.el ?? null);
|
|
2895
|
+
window.parent.postMessage(
|
|
2896
|
+
{
|
|
2897
|
+
dgn: 'reorder-request',
|
|
2898
|
+
id: d.cdId,
|
|
2899
|
+
refId: drop.refId,
|
|
2900
|
+
position: drop.position,
|
|
2901
|
+
idIndex: occ(d.el),
|
|
2902
|
+
refIndex: occ(refNode),
|
|
2903
|
+
},
|
|
2904
|
+
'*'
|
|
2905
|
+
);
|
|
2906
|
+
};
|
|
2907
|
+
|
|
2908
|
+
// The shell rejected the last reorder — revert the optimistic DOM move.
|
|
2909
|
+
const onReorderFailed = (e: MessageEvent) => {
|
|
2910
|
+
if (e.source !== window.parent) return;
|
|
2911
|
+
const m = e.data as { dgn?: string } | null;
|
|
2912
|
+
if (m?.dgn !== 'reorder-failed' || !lastCommit) return;
|
|
2913
|
+
try {
|
|
2914
|
+
lastCommit.parent.insertBefore(lastCommit.el, lastCommit.next);
|
|
2915
|
+
} catch {
|
|
2916
|
+
/* origin detached — a subsequent load re-syncs */
|
|
2917
|
+
}
|
|
2918
|
+
lastCommit = null;
|
|
2919
|
+
};
|
|
2920
|
+
|
|
2921
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2922
|
+
window.addEventListener('pointermove', onMove);
|
|
2923
|
+
window.addEventListener('pointerup', onUp);
|
|
2924
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
2925
|
+
window.addEventListener('message', onReorderFailed);
|
|
2926
|
+
return () => {
|
|
2927
|
+
if (drag) clearSettle(drag);
|
|
2928
|
+
setHighlight(null);
|
|
2929
|
+
window.removeEventListener('message', onReorderFailed);
|
|
2930
|
+
if (dividerEl) {
|
|
2931
|
+
dividerEl.remove();
|
|
2932
|
+
dividerEl = null;
|
|
2933
|
+
}
|
|
2934
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
2935
|
+
window.removeEventListener('pointermove', onMove);
|
|
2936
|
+
window.removeEventListener('pointerup', onUp);
|
|
2937
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
2938
|
+
};
|
|
2939
|
+
}, []);
|
|
2940
|
+
|
|
2941
|
+
return null;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2272
2944
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2273
2945
|
// GroupBbox — dashed outline around the union of selected elements when N > 1.
|
|
2274
2946
|
|