@jxsuite/studio 0.33.0 → 0.35.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.
Files changed (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,154 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * IframeChannel — the typed message boundary between the editor (parent) and the canvas iframe.
4
+ *
5
+ * Per the migration's cross-origin bridge decision, the parent never touches the iframe's
6
+ * `contentDocument`; everything (render commands, patches, geometry, selection, hit-tests) crosses
7
+ * as serializable messages through this one interface. Phases 1+ instantiate it with concrete
8
+ * message unions (`ParentToIframe`/`IframeToParent`); this module owns only the transport contract
9
+ * plus an in-memory `fakeChannelPair` so cross-frame logic is unit-testable without a live iframe.
10
+ */
11
+
12
+ /**
13
+ * A typed, bidirectional message channel. `TOut` is what this side sends; `TIn` is what it
14
+ * receives. Implementations: a real `postMessage` channel over an `<iframe>` (added with the iframe
15
+ * host), and {@link fakeChannelPair} for tests.
16
+ */
17
+ export interface IframeChannel<TOut, TIn> {
18
+ /** Send a message to the other side. */
19
+ post: (message: TOut) => void;
20
+ /** Subscribe to messages from the other side. Returns an unsubscribe function. */
21
+ onMessage: (handler: (message: TIn) => void) => () => void;
22
+ /** Detach all handlers and release the transport. */
23
+ dispose: () => void;
24
+ }
25
+
26
+ /** Envelope key that tags every cross-frame message so foreign `message` events are ignored. */
27
+ const ENVELOPE = "jx:canvas";
28
+
29
+ interface PostMessageTarget {
30
+ postMessage: (message: unknown, targetOrigin: string) => void;
31
+ }
32
+
33
+ interface MessageSource {
34
+ addEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
35
+ removeEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
36
+ }
37
+
38
+ /**
39
+ * A real `IframeChannel` over `window.postMessage`. Outbound messages are wrapped in an envelope
40
+ * tagged with a shared `token`; inbound messages are dropped unless their `event.origin` matches
41
+ * `acceptOrigin` (pass `"*"` to skip the origin check) AND they carry the same token. This is the
42
+ * origin + secret-token authentication that keeps other local pages from driving the canvas.
43
+ *
44
+ * Parent side: `target` = the iframe's `contentWindow`, `source` = `window`, origins = the iframe
45
+ * origin. Iframe side: `target` = `window.parent`, `source` = `window`, origins = the editor
46
+ * origin.
47
+ */
48
+ export function postMessageChannel<TOut, TIn>(opts: {
49
+ target: PostMessageTarget;
50
+ source: MessageSource;
51
+ targetOrigin: string;
52
+ acceptOrigin: string;
53
+ token: string;
54
+ }): IframeChannel<TOut, TIn> {
55
+ const { target, source, targetOrigin, acceptOrigin, token } = opts;
56
+ const handlers = new Set<(message: TIn) => void>();
57
+
58
+ const listener = (event: MessageEvent) => {
59
+ if (acceptOrigin !== "*" && event.origin !== acceptOrigin) {
60
+ return;
61
+ }
62
+ const data = event.data as { [ENVELOPE]?: string; payload?: TIn } | null;
63
+ if (!data || typeof data !== "object" || data[ENVELOPE] !== token) {
64
+ return;
65
+ }
66
+ for (const handler of handlers) {
67
+ handler(data.payload as TIn);
68
+ }
69
+ };
70
+ source.addEventListener("message", listener);
71
+
72
+ return {
73
+ dispose() {
74
+ handlers.clear();
75
+ source.removeEventListener("message", listener);
76
+ },
77
+ onMessage(handler) {
78
+ handlers.add(handler);
79
+ return () => handlers.delete(handler);
80
+ },
81
+ post(message) {
82
+ target.postMessage({ [ENVELOPE]: token, payload: message }, targetOrigin);
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Two in-memory channels wired to each other for tests. A message `parent.post(x)` is delivered to
89
+ * the `iframe` side's handlers (and vice versa) only when `flush()` is called — so tests drive the
90
+ * inherently-async postMessage ordering deterministically. Messages posted _during_ a flush are
91
+ * queued for the next flush, never delivered re-entrantly.
92
+ */
93
+ export function fakeChannelPair<ParentOut, IframeOut>(): {
94
+ parent: IframeChannel<ParentOut, IframeOut>;
95
+ iframe: IframeChannel<IframeOut, ParentOut>;
96
+ flush: () => void;
97
+ /** Number of messages waiting to be delivered (both directions). */
98
+ pending: () => number;
99
+ } {
100
+ let toIframe: ParentOut[] = [];
101
+ let toParent: IframeOut[] = [];
102
+ const iframeHandlers = new Set<(m: ParentOut) => void>();
103
+ const parentHandlers = new Set<(m: IframeOut) => void>();
104
+
105
+ const parent: IframeChannel<ParentOut, IframeOut> = {
106
+ dispose() {
107
+ parentHandlers.clear();
108
+ },
109
+ onMessage(handler) {
110
+ parentHandlers.add(handler);
111
+ return () => parentHandlers.delete(handler);
112
+ },
113
+ post(message) {
114
+ toIframe.push(message);
115
+ },
116
+ };
117
+
118
+ const iframe: IframeChannel<IframeOut, ParentOut> = {
119
+ dispose() {
120
+ iframeHandlers.clear();
121
+ },
122
+ onMessage(handler) {
123
+ iframeHandlers.add(handler);
124
+ return () => iframeHandlers.delete(handler);
125
+ },
126
+ post(message) {
127
+ toParent.push(message);
128
+ },
129
+ };
130
+
131
+ function flush() {
132
+ const forIframe = toIframe;
133
+ const forParent = toParent;
134
+ toIframe = [];
135
+ toParent = [];
136
+ for (const message of forIframe) {
137
+ for (const handler of iframeHandlers) {
138
+ handler(message);
139
+ }
140
+ }
141
+ for (const message of forParent) {
142
+ for (const handler of parentHandlers) {
143
+ handler(message);
144
+ }
145
+ }
146
+ }
147
+
148
+ return {
149
+ flush,
150
+ iframe,
151
+ parent,
152
+ pending: () => toIframe.length + toParent.length,
153
+ };
154
+ }
@@ -0,0 +1,484 @@
1
+ /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
3
+ /**
4
+ * In-iframe drop math (Phase 4c). Runs INSIDE the canvas iframe, in the iframe's own realm/coords.
5
+ *
6
+ * Split for testability: - {@link resolveDropTarget} is the thin DOM adapter (point hit-test →
7
+ * nearest `data-jx-path`); it is CDP-ONLY because happy-dom's `elementFromPoint` returns null (no
8
+ * layout), so it carries no branching logic worth unit-proving against a fake. -
9
+ * {@link computeDropInstruction} is PURE: it reads element rects through {@link rectOf} (stubbable)
10
+ * and the iframe's shadow doc, and resolves the structural placement. It ports the legacy
11
+ * `getCanvasDropResult`/`nearestChildEdge` math (canvas-dnd.ts) against IFRAME-realm geometry, and
12
+ * reproduces the EXACT `[...parentPath, "children", index]` targetPath shape so the parent's
13
+ * realm-agnostic `applyDropInstruction` resolves the same parent/index.
14
+ *
15
+ * The drop is computed FRESH in the iframe's `drop` handler from the live DOM — a `dragOver`
16
+ * preview is display-only and never the source of truth (patch-mid-drag safety).
17
+ */
18
+
19
+ import { parseJxPath } from "./path-mapping";
20
+ import { rectOf, elementAtPoint } from "../utils/geometry";
21
+ import { getNodeAtPath, isAncestor, pathsEqual } from "../state";
22
+ import { isEditing } from "../editor/inline-edit";
23
+ import type { IframeChannel } from "./iframe-channel";
24
+ import type { DragSrcKind, DropPreview, IframeToParent, ParentToIframe } from "./iframe-protocol";
25
+ import type { JxMutableNode } from "@jxsuite/schema/types";
26
+ import type { JxPath } from "../state";
27
+
28
+ /**
29
+ * Void (self-closing) HTML tags that cannot accept children — a leaf for drop purposes. COPIED from
30
+ * the store's `VOID_ELEMENTS` literal so this module stays dependency-light (the iframe bundle must
31
+ * not pull in the store). Keep in sync with store.ts:VOID_ELEMENTS.
32
+ */
33
+ const VOID_TAGS = new Set([
34
+ "area",
35
+ "base",
36
+ "br",
37
+ "col",
38
+ "embed",
39
+ "hr",
40
+ "img",
41
+ "input",
42
+ "link",
43
+ "meta",
44
+ "param",
45
+ "source",
46
+ "track",
47
+ "wbr",
48
+ ]);
49
+
50
+ /**
51
+ * Walk up from an element to the nearest ancestor carrying a `data-jx-path`. Refactored out of the
52
+ * `nearestHit` walk (iframe-interaction.ts) to take an element directly so the hit-test adapter can
53
+ * reuse it. Returns null when no addressable node is found.
54
+ */
55
+ function nearestPathEl(start: Element | null): HTMLElement | null {
56
+ let el = start instanceof Element ? (start as HTMLElement) : null;
57
+ while (el) {
58
+ if (el.dataset?.jxPath) {
59
+ return el;
60
+ }
61
+ el = el.parentElement;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Point hit-test in iframe-viewport coords (`x`,`y`): the topmost element, then walk to the nearest
68
+ * `[data-jx-path]` ancestor. CDP-ONLY — happy-dom's `elementFromPoint` returns null (no layout), so
69
+ * this thin adapter is exercised under a real browser, not a unit test.
70
+ */
71
+ export function resolveDropTarget(x: number, y: number, doc: Document): HTMLElement | null {
72
+ const hit = elementAtPoint(x, y, doc);
73
+ return nearestPathEl(hit);
74
+ }
75
+
76
+ /**
77
+ * Compute the structural drop placement for a cursor over `targetEl`, PURE against iframe-realm
78
+ * rects.
79
+ *
80
+ * `cursorY` is in iframe-viewport coords (same space as {@link rectOf}); `shadowDoc` is the
81
+ * iframe's non-reactive shadow doc (path coordinate space); `src` is the realm-agnostic drag
82
+ * source.
83
+ *
84
+ * Branching (ported from canvas-dnd.ts getCanvasDropResult/nearestChildEdge):
85
+ *
86
+ * - Root (path.length===0) with element children → nearest child edge (reorder-above/below that
87
+ * child).
88
+ * - Leaf (void tag, or no element children) → relY<0.5 above (edge top) else below (edge bottom).
89
+ * - Container → relY<0.25 above, >0.75 below, else make-child (edge inside). Returns null when the
90
+ * drop is disallowed (a tree-node onto its own ancestor or itself).
91
+ */
92
+ export function computeDropInstruction(
93
+ targetEl: HTMLElement,
94
+ cursorY: number,
95
+ shadowDoc: JxMutableNode,
96
+ src: DragSrcKind,
97
+ ): DropPreview | null {
98
+ const serialized = targetEl.dataset?.jxPath;
99
+ if (serialized == null) {
100
+ return null;
101
+ }
102
+ const targetPath = parseJxPath(serialized) as JxPath;
103
+
104
+ // Root: pick the nearest child edge among the element's element children.
105
+ if (targetPath.length === 0) {
106
+ const children = [...targetEl.children] as HTMLElement[];
107
+ if (children.length === 0) {
108
+ return canDrop(src, targetPath)
109
+ ? {
110
+ edge: "inside",
111
+ instruction: "make-child",
112
+ referenceRect: rectFor(targetEl),
113
+ targetPath,
114
+ }
115
+ : null;
116
+ }
117
+ return nearestChildEdge(children, cursorY, targetPath, src);
118
+ }
119
+
120
+ if (!canDrop(src, targetPath)) {
121
+ return null;
122
+ }
123
+
124
+ const node = getNodeAtPath(shadowDoc, targetPath) as JxMutableNode | undefined;
125
+ const tag = (node?.tagName || "div").toLowerCase();
126
+ const hasElementChildren =
127
+ Array.isArray(node?.children) &&
128
+ node.children.some((c: unknown) => c != null && typeof c === "object");
129
+ const isLeaf = VOID_TAGS.has(tag) || !hasElementChildren;
130
+
131
+ const rect = rectFor(targetEl);
132
+ const relY = rect.height === 0 ? 0 : (cursorY - rect.y) / rect.height;
133
+
134
+ if (isLeaf) {
135
+ return relY < 0.5
136
+ ? { edge: "top", instruction: "reorder-above", referenceRect: rect, targetPath }
137
+ : { edge: "bottom", instruction: "reorder-below", referenceRect: rect, targetPath };
138
+ }
139
+ if (relY < 0.25) {
140
+ return { edge: "top", instruction: "reorder-above", referenceRect: rect, targetPath };
141
+ }
142
+ if (relY > 0.75) {
143
+ return { edge: "bottom", instruction: "reorder-below", referenceRect: rect, targetPath };
144
+ }
145
+ return { edge: "inside", instruction: "make-child", referenceRect: rect, targetPath };
146
+ }
147
+
148
+ /**
149
+ * Resolve the nearest child-edge drop among `children` (ports nearestChildEdge, canvas-dnd.ts). The
150
+ * resulting `targetPath` is `[...parentPath, "children", closestIdx]` so the parent's
151
+ * parentElementPath(slice(0,-2)) / childIndex(at(-1)) read the same parent + index.
152
+ */
153
+ function nearestChildEdge(
154
+ children: HTMLElement[],
155
+ cursorY: number,
156
+ parentPath: JxPath,
157
+ src: DragSrcKind,
158
+ ): DropPreview | null {
159
+ let closestDist = Infinity;
160
+ let instruction: "reorder-above" | "reorder-below" = "reorder-below";
161
+ let closestIdx = children.length - 1;
162
+
163
+ for (let i = 0; i < children.length; i++) {
164
+ const rect = rectFor(children[i]!);
165
+ const topDist = Math.abs(cursorY - rect.y);
166
+ const bottomDist = Math.abs(cursorY - (rect.y + rect.height));
167
+ if (topDist < closestDist) {
168
+ closestDist = topDist;
169
+ instruction = "reorder-above";
170
+ closestIdx = i;
171
+ }
172
+ if (bottomDist < closestDist) {
173
+ closestDist = bottomDist;
174
+ instruction = "reorder-below";
175
+ closestIdx = i;
176
+ }
177
+ }
178
+
179
+ const childPath = [...parentPath, "children", closestIdx] as JxPath;
180
+ if (!canDrop(src, childPath)) {
181
+ return null;
182
+ }
183
+ return {
184
+ edge: instruction === "reorder-above" ? "top" : "bottom",
185
+ instruction,
186
+ referenceRect: rectFor(children[closestIdx]!),
187
+ targetPath: childPath,
188
+ };
189
+ }
190
+
191
+ /** A tree-node may not drop onto its own subtree (ancestor-or-self); a block may always drop. */
192
+ function canDrop(src: DragSrcKind, targetPath: JxPath): boolean {
193
+ if (src.type !== "tree-node") {
194
+ return true;
195
+ }
196
+ const srcPath = src.path as JxPath;
197
+ if (pathsEqual(srcPath, targetPath)) {
198
+ return false;
199
+ }
200
+ return !isAncestor(srcPath, targetPath);
201
+ }
202
+
203
+ /** Read an element's iframe-viewport rect as a {@link DropPreview} `referenceRect`. */
204
+ function rectFor(el: Element): DropPreview["referenceRect"] {
205
+ const r = rectOf(el);
206
+ return { height: r.height, width: r.width, x: r.x, y: r.y };
207
+ }
208
+
209
+ // ─── Flow 3: grab-anywhere drag detector (Phase 4c, commit 3; rebuilt 4c flow-3 fix) ──────────────
210
+ // The iframe owns no pragmatic-dnd, so an element-body drag is detected manually: a pointerdown on a
211
+ // `[data-jx-path]` arms a candidate, and the first pointermove past a small threshold posts
212
+ // `dragOriginate`. A drag that begins inside the iframe delivers its held-button pointermoves to the
213
+ // IFRAME document (implicit pointer capture), so the PARENT never sees them while the cursor is over
214
+ // The canvas. Flow 3 is therefore FULLY IFRAME-DRIVEN: after originating, the detector keeps driving
215
+ // — it computes the preview/drop LOCALLY from its own cursor (already iframe-viewport coords, no
216
+ // Parent->iframe round-trip) and posts dragOver/dropResult directly. The parent only adopts the
217
+ // Iframe's dragSeq, draws the indicator, positions the ghost from the posted cursor, and applies the
218
+ // Drop. (Flows 1/2/4 stay parent-originated: pragmatic's parent-document pointer stream does deliver
219
+ // Moves over the iframe for a drag that began in parent DOM, so those are sound and untouched.)
220
+
221
+ /** Pixels the pointer must travel from pointerdown before a body-grab counts as a drag. */
222
+ const GRAB_THRESHOLD = 4;
223
+
224
+ /**
225
+ * Whether an iframe-originated (flow 3) drag is currently active. Read by iframe-keys to suppress
226
+ * forwarding Escape to the parent during such a drag (the iframe owns cancel locally, see
227
+ * {@link cancelIframeDrag}), and by the entry's dragEnd/dragCancel handling.
228
+ */
229
+ let dragActive = false;
230
+
231
+ /** True while a flow-3 (iframe-originated) drag is live. */
232
+ export function isDragActive(): boolean {
233
+ return dragActive;
234
+ }
235
+
236
+ /** The local cancel hook (clears preview/auto-scroll + posts dragEnd) installed by the active drag. */
237
+ let cancelHook: (() => void) | null = null;
238
+
239
+ /**
240
+ * Cancel the active iframe-originated drag locally: clear any in-iframe drag state and run the
241
+ * installed cancel hook (the entry posts `dragEnd` so the parent tears down ghost/indicator). A
242
+ * no-op when no flow-3 drag is active. Single-sourced through here (and the parent's pragmatic
243
+ * cancel for the parent-source flows) so cancel never double-fires.
244
+ */
245
+ export function cancelIframeDrag(): void {
246
+ if (!dragActive) {
247
+ return;
248
+ }
249
+ const hook = cancelHook;
250
+ clearIframeDrag();
251
+ hook?.();
252
+ }
253
+
254
+ /** Reset the flow-3 drag state without firing the cancel hook (used on natural drop/dragEnd). */
255
+ export function clearIframeDrag(): void {
256
+ dragActive = false;
257
+ cancelHook = null;
258
+ }
259
+
260
+ /**
261
+ * Mark a flow-3 drag active and register its local cancel hook. Called by the entry when it accepts
262
+ * an iframe-originated session (after posting dragOriginate). Idempotent per drag.
263
+ */
264
+ export function beginIframeDrag(onCancel: () => void): void {
265
+ dragActive = true;
266
+ cancelHook = onCancel;
267
+ }
268
+
269
+ /**
270
+ * Whether a pointerdown on `target` may originate a body-grab drag: it must resolve to a
271
+ * `[data-jx-path]` element and NOT be inside an active inline-edit session (typing/selecting text
272
+ * must never start a reorder). Returns the resolved path, or null when no drag should originate.
273
+ */
274
+ export function grabCandidatePath(target: EventTarget | null): JxPath | null {
275
+ if (isEditing()) {
276
+ return null;
277
+ }
278
+ const el = nearestPathEl(target instanceof Element ? target : null);
279
+ if (!el) {
280
+ return null;
281
+ }
282
+ return parseJxPath(el.dataset.jxPath as string) as JxPath;
283
+ }
284
+
285
+ /** Whether the pointer has moved past the grab threshold from its pointerdown origin (PURE). */
286
+ export function passedGrabThreshold(
287
+ origin: { x: number; y: number },
288
+ now: { x: number; y: number },
289
+ ): boolean {
290
+ return (
291
+ Math.abs(now.x - origin.x) >= GRAB_THRESHOLD || Math.abs(now.y - origin.y) >= GRAB_THRESHOLD
292
+ );
293
+ }
294
+
295
+ // ─── Auto-scroll (Phase 4c, commit 6) ──────────────────────────────────────────
296
+
297
+ /** Edge band height (px from the top/bottom of the viewport) that triggers auto-scroll. */
298
+ export const AUTO_SCROLL_BAND = 40;
299
+
300
+ /** Pixels scrolled per auto-scroll frame. */
301
+ export const AUTO_SCROLL_STEP = 12;
302
+
303
+ /**
304
+ * The auto-scroll direction for a cursor `y` within a viewport of height `viewportH` (PURE).
305
+ * Returns `-1` (scroll up) when the cursor is in the top `band`, `+1` (scroll down) in the bottom
306
+ * `band`, or `0` outside both bands. The iframe's rAF loop multiplies this by
307
+ * {@link AUTO_SCROLL_STEP} and keeps scrolling while it stays non-zero (a stationary edge-hold
308
+ * self-sustains).
309
+ */
310
+ export function scrollDirection(y: number, viewportH: number, band = AUTO_SCROLL_BAND): -1 | 0 | 1 {
311
+ if (y < band) {
312
+ return -1;
313
+ }
314
+ if (y > viewportH - band) {
315
+ return 1;
316
+ }
317
+ return 0;
318
+ }
319
+
320
+ /**
321
+ * The iframe-side capabilities the flow-3 detector needs to DRIVE a drag locally. Injected from
322
+ * {@link file://./iframe-entry.ts} (the entry owns the render generation, the shadow doc, and the
323
+ * self-sustaining auto-scroll loop) rather than duplicated here, so the detector and the
324
+ * `dragMove`/`drop` message handlers share the EXACT same preview/drop math and auto-scroll state.
325
+ */
326
+ export interface GrabDetectorDeps {
327
+ /**
328
+ * Resolve the display preview / authoritative drop for an IFRAME-VIEWPORT cursor (point hit-test
329
+ * → nearest `[data-jx-path]` → pure {@link computeDropInstruction}); null when over no droppable.
330
+ * Identical to the function the `dragMove`/`drop` handlers use — flow 3 takes the iframe-local
331
+ * cursor DIRECTLY (no parentCursorToIframe; the iframe already owns its coordinate space).
332
+ */
333
+ previewAt: (cursor: { x: number; y: number }, src: DragSrcKind) => DropPreview | null;
334
+ /** The generation the iframe's DOM currently reflects, so replies can be stale-gated parent-side. */
335
+ gen: () => number;
336
+ /**
337
+ * The live render's canvas mode. Grab-anywhere drags are a document-editing affordance —
338
+ * suppressed for stylebook renders (specimens can't be reordered). Absent = permissive.
339
+ */
340
+ getMode?: () => string;
341
+ /**
342
+ * Arm the self-sustaining auto-scroll loop for a flow-3 cursor. `src` lets the loop recompute the
343
+ * preview during an edge-hold (the parent never posts a dragStart for flow 3, so the entry's own
344
+ * `dragSrc` is null); the loop also re-posts dragOver WITH the cursor so the ghost keeps
345
+ * tracking.
346
+ */
347
+ armAutoScroll: (cursor: { x: number; y: number }, dragSeq: number, src: DragSrcKind) => void;
348
+ /** Stop the auto-scroll loop (on drop / band-exit / cancel). */
349
+ stopAutoScroll: () => void;
350
+ }
351
+
352
+ /**
353
+ * Wire the flow-3 grab detector on the iframe document and DRIVE the whole gesture locally (the
354
+ * iframe owns the pointer it started — its held-button moves never reach the parent document).
355
+ *
356
+ * - Pointerdown on a node body (primary button, not while editing) arms a candidate.
357
+ * - The first pointermove past {@link GRAB_THRESHOLD} originates: bumps the local seq, marks the drag
358
+ * active (with a cancel hook that posts `dragEnd`), posts `dragOriginate{path,dragSeq}`, and then
359
+ * immediately drives the first move.
360
+ * - Every later pointermove computes the preview LOCALLY from its own (iframe-viewport) cursor and
361
+ * posts `dragOver{dragSeq, gen, preview, cursor}` (the parent positions the ghost from `cursor`)
362
+ * and updates auto-scroll.
363
+ * - Pointerup computes the drop FRESH and posts `dropResult`, then tears down.
364
+ *
365
+ * The parent adopts this seq ({@link file://../panels/canvas-dnd-bridge.ts}'s
366
+ * `startIframeOriginatedDrag` → `adoptDragSession`) so the dragOver/dropResult pass its seq gate;
367
+ * it attaches NO parent-document listeners for flow 3. Returns a teardown. `deps` injects the
368
+ * iframe-side preview/gen/auto-scroll capabilities (see {@link GrabDetectorDeps}).
369
+ */
370
+ export function startGrabDetector(
371
+ channel: IframeChannel<IframeToParent, ParentToIframe>,
372
+ doc: Document = document,
373
+ deps?: GrabDetectorDeps,
374
+ ): () => void {
375
+ let candidate: { path: JxPath; origin: { x: number; y: number } } | null = null;
376
+ let localSeq = 0;
377
+ // The realm-agnostic source kind for the live drag, kept in closure so previewAt's canDrop
378
+ // (isAncestor/self) sees the grabbed node's path for the whole gesture.
379
+ let src: DragSrcKind | null = null;
380
+
381
+ const onPointerDown = (e: PointerEvent) => {
382
+ // Only a primary-button drag originates; ignore right/middle and multi-touch gestures.
383
+ if (e.button !== 0) {
384
+ return;
385
+ }
386
+ if (deps?.getMode?.() === "stylebook") {
387
+ return;
388
+ }
389
+ const path = grabCandidatePath(e.target);
390
+ if (!path) {
391
+ return;
392
+ }
393
+ candidate = { origin: { x: e.clientX, y: e.clientY }, path };
394
+ };
395
+
396
+ // A REAL mouse press-drag over an <img> (draggable by default) or a text selection starts a
397
+ // NATIVE HTML5 drag, which fires pointercancel and hijacks the pointer stream — the grab dies
398
+ // Before it originates (synthetic/CDP input never triggers this, which is why only real-mouse
399
+ // Drags broke). While a grab candidate is armed or a drag is live, native drags are never wanted;
400
+ // Outside that (no path target, inline editing) native behavior is preserved.
401
+ const onDragStart = (e: Event) => {
402
+ if (candidate || dragActive) {
403
+ e.preventDefault();
404
+ }
405
+ };
406
+
407
+ // Suppress text selection only while the drag is LIVE (plain clicks and inline editing keep
408
+ // Native selection); the origination path below also clears any selection the pre-threshold
409
+ // Moves already started.
410
+ const onSelectStart = (e: Event) => {
411
+ if (dragActive) {
412
+ e.preventDefault();
413
+ }
414
+ };
415
+
416
+ /** Compute + post the dragOver preview for the live cursor, and (re)arm auto-scroll. */
417
+ const drive = (cursor: { x: number; y: number }) => {
418
+ if (!src) {
419
+ return;
420
+ }
421
+ const preview = deps ? deps.previewAt(cursor, src) : null;
422
+ channel.post({
423
+ cursor,
424
+ dragSeq: localSeq,
425
+ gen: deps ? deps.gen() : -1,
426
+ kind: "dragOver",
427
+ preview,
428
+ });
429
+ deps?.armAutoScroll(cursor, localSeq, src);
430
+ };
431
+
432
+ const onPointerMove = (e: PointerEvent) => {
433
+ const cursor = { x: e.clientX, y: e.clientY };
434
+ if (!dragActive) {
435
+ if (!candidate || !passedGrabThreshold(candidate.origin, cursor)) {
436
+ return;
437
+ }
438
+ localSeq += 1;
439
+ const { path } = candidate;
440
+ candidate = null;
441
+ src = { path: [...path], type: "tree-node" };
442
+ beginIframeDrag(() => channel.post({ dragSeq: localSeq, kind: "dragEnd" }));
443
+ // Drop any text selection the pre-threshold moves started (real-mouse drags select as they
444
+ // Go); onSelectStart keeps new selections suppressed for the rest of the gesture.
445
+ doc.getSelection?.()?.removeAllRanges();
446
+ channel.post({ dragSeq: localSeq, kind: "dragOriginate", path: [...path] });
447
+ // Fall through and drive the first move immediately (the originating move is also a move).
448
+ }
449
+ drive(cursor);
450
+ };
451
+
452
+ const onPointerUp = (e: PointerEvent) => {
453
+ candidate = null;
454
+ if (!dragActive || !src) {
455
+ return;
456
+ }
457
+ const cursor = { x: e.clientX, y: e.clientY };
458
+ const drop = deps ? deps.previewAt(cursor, src) : null;
459
+ channel.post({
460
+ dragSeq: localSeq,
461
+ gen: deps ? deps.gen() : -1,
462
+ instruction: drop ? drop.instruction : null,
463
+ kind: "dropResult",
464
+ targetPath: drop ? drop.targetPath : null,
465
+ });
466
+ src = null;
467
+ deps?.stopAutoScroll();
468
+ clearIframeDrag();
469
+ };
470
+
471
+ doc.addEventListener("pointerdown", onPointerDown, true);
472
+ doc.addEventListener("pointermove", onPointerMove, true);
473
+ doc.addEventListener("pointerup", onPointerUp, true);
474
+ doc.addEventListener("dragstart", onDragStart, true);
475
+ doc.addEventListener("selectstart", onSelectStart, true);
476
+
477
+ return () => {
478
+ doc.removeEventListener("pointerdown", onPointerDown, true);
479
+ doc.removeEventListener("pointermove", onPointerMove, true);
480
+ doc.removeEventListener("pointerup", onPointerUp, true);
481
+ doc.removeEventListener("dragstart", onDragStart, true);
482
+ doc.removeEventListener("selectstart", onSelectStart, true);
483
+ };
484
+ }