@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,367 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * In-iframe inline editing — runs the contenteditable session INSIDE the canvas iframe (Selection /
4
+ * Range / execCommand are per-realm singletons bound to their document, so the editing must happen
5
+ * where the edited DOM lives) and posts the serializable results to the parent, which applies them
6
+ * via transactDoc. Double-click enters editing; the parent posts `enterEdit` to re-enter on the new
7
+ * element after a split/insert re-renders.
8
+ *
9
+ * Phase 4b-2 adds the format-toolbar bridge: the iframe owns the Selection and posts a serializable
10
+ * `selectionChanged` snapshot (active tags + caret rect + link state + seq); the parent toolbar
11
+ * renders pressed-state/position from it and posts `applyFormat` intents back, which this module
12
+ * applies to the iframe's cached range. The parent NEVER reads the iframe DOM/Selection.
13
+ *
14
+ * NOTE ON REALM ISOLATION (unit tests can't prove it): the session engine (`inline-edit.ts` +
15
+ * `inline-format.ts`) and `inline-link.ts` are bundled into the iframe and use ambient
16
+ * `window`/`document`, so at runtime `window === iframe.contentWindow`. Under happy-dom there is
17
+ * one shared global, so the cross-realm focus/Selection behavior (a parent-toolbar click blurring
18
+ * the iframe; CSS Custom Highlight painting) is STRUCTURAL, verified by CDP — not by this unit
19
+ * suite.
20
+ */
21
+
22
+ import {
23
+ getActiveElement,
24
+ isEditableBlock,
25
+ isEditing,
26
+ isSlashActive,
27
+ resumeBlurClose,
28
+ startEditing,
29
+ stopEditing,
30
+ suspendBlurClose,
31
+ } from "../editor/inline-edit";
32
+ import { isTagActiveInSelection, toggleInlineFormat } from "../editor/inline-format";
33
+ import { applyLink, insertTemplateToken, linkStateForSelection } from "../editor/inline-link";
34
+ import { restoreTemplateExpressions } from "../utils/edit-display";
35
+ import { rectOfRange } from "../utils/geometry";
36
+ import { parseJxPath, serializeJxPath } from "./path-mapping";
37
+ import type { IframeChannel } from "./iframe-channel";
38
+ import type {
39
+ ApplyFormatIntent,
40
+ IframeToParent,
41
+ ParentToIframe,
42
+ SelectionSnapshot,
43
+ SerializableRect,
44
+ } from "./iframe-protocol";
45
+ import type { JxPath } from "../state";
46
+
47
+ /** Toolbar command → inline tag (mirrors the parent's old `cmdToTag`, now iframe-side). */
48
+ const CMD_TO_TAG: Record<string, string> = {
49
+ bold: "strong",
50
+ code: "code",
51
+ italic: "em",
52
+ strikethrough: "del",
53
+ subscript: "sub",
54
+ superscript: "sup",
55
+ underline: "u",
56
+ };
57
+
58
+ /** Inline tags whose active state the snapshot reports (everything except `a`, handled via link). */
59
+ const ACTIVE_TAG_PROBES = ["strong", "em", "u", "del", "sub", "sup", "code"];
60
+
61
+ /** CSS Custom Highlight name + the id of the injected `::highlight()` style rule. */
62
+ const HIGHLIGHT_NAME = "jx-pending-format";
63
+ const HIGHLIGHT_STYLE_ID = "jx-pending-format-style";
64
+
65
+ /** Walk up from an event target to the nearest editable element carrying a `data-jx-path`. */
66
+ function findEditableTarget(target: EventTarget | null): { el: HTMLElement; path: JxPath } | null {
67
+ let el = target instanceof Element ? target : null;
68
+ while (el) {
69
+ if (el instanceof HTMLElement && el.dataset.jxPath && isEditableBlock(el)) {
70
+ return { el, path: parseJxPath(el.dataset.jxPath) };
71
+ }
72
+ el = el.parentElement;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Locate the rendered element for a document path via its stamped `data-jx-path`. */
78
+ function elementForPath(container: HTMLElement, path: JxPath): HTMLElement | null {
79
+ const serialized = serializeJxPath(path);
80
+ const esc = serialized.replaceAll("\\", String.raw`\\`).replaceAll("'", String.raw`\'`);
81
+ const el = container.querySelector(`[data-jx-path='${esc}']`);
82
+ return el instanceof HTMLElement ? el : null;
83
+ }
84
+
85
+ /** A DOMRect → the serializable rect shape the snapshot carries (iframe-viewport coords). */
86
+ function toSerializableRect(rect: DOMRect): SerializableRect {
87
+ return { height: rect.height, width: rect.width, x: rect.x, y: rect.y };
88
+ }
89
+
90
+ /**
91
+ * Wire double-click → inline editing on `container`'s editable elements, the parent `enterEdit`
92
+ * re-entry, the selection-snapshot post stream, and the `applyFormat` apply path. The session's
93
+ * onCommit/onSplit/onInsert/onEnd results are posted to the parent. Returns a teardown function.
94
+ */
95
+ export function startIframeInlineEdit(
96
+ channel: IframeChannel<IframeToParent, ParentToIframe>,
97
+ container: HTMLElement,
98
+ opts?: { getMode?: () => string },
99
+ ): () => void {
100
+ const doc = container.ownerDocument;
101
+
102
+ // Inline editing exists only in design/edit renders — a stylebook (or preview) render must not
103
+ // Start a contenteditable session on its nodes. An absent getMode is permissive (tests).
104
+ const editingAllowed = () => {
105
+ const mode = opts?.getMode?.();
106
+ return mode === undefined || mode === "design" || mode === "edit";
107
+ };
108
+
109
+ // The most recent non-empty range inside the active editable. Cached AGGRESSIVELY (not just on
110
+ // Selectionchange) because focus can collapse the live selection before the post-blur event runs.
111
+ let lastNonEmptyRange: Range | null = null;
112
+ // Monotonic per session; the parent drops stale snapshots.
113
+ let seq = 0;
114
+
115
+ /** The live selection's first range, if it sits inside the active editable. */
116
+ const liveRangeInEditable = (el: HTMLElement): Range | null => {
117
+ const sel = window.getSelection();
118
+ if (!sel || !sel.rangeCount) {
119
+ return null;
120
+ }
121
+ const range = sel.getRangeAt(0);
122
+ return el.contains(range.commonAncestorContainer) ? range : null;
123
+ };
124
+
125
+ /** Cache the live range when it's non-empty and inside the active editable. */
126
+ const cacheRange = () => {
127
+ const el = getActiveElement();
128
+ if (!el) {
129
+ return;
130
+ }
131
+ const range = liveRangeInEditable(el);
132
+ if (range && !range.collapsed) {
133
+ lastNonEmptyRange = range.cloneRange();
134
+ }
135
+ };
136
+
137
+ /**
138
+ * Inject the `::highlight()` rule once so the cached range is visible while the toolbar has
139
+ * focus.
140
+ */
141
+ const ensureHighlightStyle = () => {
142
+ if (doc.querySelector(`#${HIGHLIGHT_STYLE_ID}`)) {
143
+ return;
144
+ }
145
+ const style = doc.createElement("style");
146
+ style.id = HIGHLIGHT_STYLE_ID;
147
+ style.textContent = `::highlight(${HIGHLIGHT_NAME}) { background-color: Highlight; color: HighlightText; }`;
148
+ doc.head.append(style);
149
+ };
150
+
151
+ /**
152
+ * Paint (or clear) the cached range via the CSS Custom Highlight API so the selection stays
153
+ * visible while the parent link-URL field is focused. Feature-detected: happy-dom lacks
154
+ * `Highlight`/`CSS.highlights`, so this is a silent no-op there (and in non-Chromium engines).
155
+ */
156
+ const updateHighlight = () => {
157
+ const HighlightCtor = (globalThis as { Highlight?: unknown }).Highlight as
158
+ | (new (range: Range) => unknown)
159
+ | undefined;
160
+ const highlights = (globalThis.CSS as unknown as { highlights?: Map<string, unknown> })
161
+ ?.highlights;
162
+ if (!HighlightCtor || !highlights) {
163
+ return;
164
+ }
165
+ if (lastNonEmptyRange && lastNonEmptyRange.startContainer.isConnected) {
166
+ ensureHighlightStyle();
167
+ highlights.set(HIGHLIGHT_NAME, new HighlightCtor(lastNonEmptyRange.cloneRange()));
168
+ } else {
169
+ highlights.delete(HIGHLIGHT_NAME);
170
+ }
171
+ };
172
+
173
+ /** Tear down the Custom Highlight (on teardown / real editEnd). */
174
+ const clearHighlight = () => {
175
+ const highlights = (globalThis.CSS as unknown as { highlights?: Map<string, unknown> })
176
+ ?.highlights;
177
+ highlights?.delete(HIGHLIGHT_NAME);
178
+ };
179
+
180
+ /**
181
+ * Build the serializable selection snapshot from the live selection (falling back to the cached
182
+ * range for geometry when the live one collapsed). Returns null when no session is active.
183
+ */
184
+ const buildSnapshot = (): SelectionSnapshot | null => {
185
+ const el = getActiveElement();
186
+ if (!el || !el.dataset.jxPath) {
187
+ return null;
188
+ }
189
+ const liveRange = liveRangeInEditable(el);
190
+ const geomRange = liveRange ?? lastNonEmptyRange;
191
+ const rect = geomRange ? toSerializableRect(rectOfRange(geomRange)) : null;
192
+ const collapsed = liveRange ? liveRange.collapsed : true;
193
+
194
+ const activeTags = ACTIVE_TAG_PROBES.filter((tag) => isTagActiveInSelection(tag, el));
195
+ const link = linkStateForSelection(el);
196
+ if (link.active) {
197
+ activeTags.push("a");
198
+ }
199
+
200
+ seq += 1;
201
+ return {
202
+ activeTags,
203
+ collapsed,
204
+ kind: "selectionChanged",
205
+ link,
206
+ localScope: null,
207
+ path: parseJxPath(el.dataset.jxPath),
208
+ rect,
209
+ seq,
210
+ };
211
+ };
212
+
213
+ /** Recompute + post a snapshot (and repaint the highlight) when a session is live. */
214
+ const onSelectionChange = () => {
215
+ if (!isEditing()) {
216
+ return;
217
+ }
218
+ // Cache the (possibly just-changed) live range BEFORE painting/snapshotting so the highlight and
219
+ // The snapshot reflect the current selection — selectionchange is one of the aggressive triggers.
220
+ cacheRange();
221
+ updateHighlight();
222
+ const snapshot = buildSnapshot();
223
+ if (snapshot) {
224
+ channel.post(snapshot);
225
+ }
226
+ };
227
+
228
+ const enterEditAt = (el: HTMLElement, path: JxPath) => {
229
+ // Show raw `${expr}` syntax for editing (the render displays it as ❪ expr ❫).
230
+ restoreTemplateExpressions(el);
231
+ channel.post({ kind: "editStart", path });
232
+ // While the session is live the parent toolbar/popover may take focus — a blur must not tear
233
+ // The session down across the bridge (focus-loss BLOCKER fix).
234
+ suspendBlurClose();
235
+ startEditing(el, path, {
236
+ onCommit: (p, children, textContent) =>
237
+ channel.post({ children, kind: "editCommit", path: p, textContent }),
238
+ onEnd: () => {
239
+ // A real session end: stop suspending blur-close and drop the selection viz.
240
+ resumeBlurClose();
241
+ clearHighlight();
242
+ lastNonEmptyRange = null;
243
+ channel.post({ kind: "editEnd" });
244
+ },
245
+ onInsert: (p, cmd, commitData) =>
246
+ channel.post({ cmd, commitData, kind: "editInsert", path: p }),
247
+ onSplit: (p, before, after) => channel.post({ after, before, kind: "editSplit", path: p }),
248
+ });
249
+ // Post an initial (collapsed-caret) snapshot so the toolbar shows on entry.
250
+ cacheRange();
251
+ onSelectionChange();
252
+ };
253
+
254
+ /** Apply a format/link/insert intent to the iframe's cached selection range. */
255
+ const applyFormatIntent = (intent: ApplyFormatIntent) => {
256
+ const el = getActiveElement();
257
+ if (!el) {
258
+ return; // Session not active → no-op.
259
+ }
260
+ // Restore the cached range ONLY if it's still usable (the DOM may have re-rendered).
261
+ if (
262
+ lastNonEmptyRange &&
263
+ lastNonEmptyRange.startContainer.isConnected &&
264
+ el.contains(lastNonEmptyRange.commonAncestorContainer)
265
+ ) {
266
+ const sel = window.getSelection();
267
+ el.focus();
268
+ sel?.removeAllRanges();
269
+ sel?.addRange(lastNonEmptyRange);
270
+ }
271
+
272
+ if (intent.command === "link") {
273
+ applyLink(el, intent.href);
274
+ } else if (intent.command === "insertData") {
275
+ insertTemplateToken(el, intent.token);
276
+ } else {
277
+ const tag = CMD_TO_TAG[intent.command];
278
+ if (tag) {
279
+ toggleInlineFormat(tag, el);
280
+ }
281
+ }
282
+
283
+ // Re-emit so the parent's pressed-state/position updates. The parent guards re-render of an
284
+ // OPEN link popover (so this won't yank a mid-edit URL field).
285
+ cacheRange();
286
+ onSelectionChange();
287
+ };
288
+
289
+ const onDblClick = (e: Event) => {
290
+ if (!editingAllowed()) {
291
+ return;
292
+ }
293
+ const hit = findEditableTarget(e.target);
294
+ if (hit) {
295
+ enterEditAt(hit.el, hit.path);
296
+ }
297
+ };
298
+
299
+ const onMouseUp = () => cacheRange();
300
+ const onKeyUp = () => cacheRange();
301
+ // Capture-phase blur: cache the range before focus moves out (the bubbling blur fires too late).
302
+ const onBlurCapture = () => cacheRange();
303
+
304
+ /**
305
+ * Commit-on-click-away: a pointerdown INSIDE the iframe but OUTSIDE the active editable ends the
306
+ * session (committing via the engine's stopEditing → onCommit). Blur-close stays suspended for
307
+ * the whole session (a parent-toolbar click across the bridge must not kill it), so without this
308
+ * an in-canvas click-away would never commit — text edits would never reach the document.
309
+ */
310
+ const onPointerDownCapture = (e: Event) => {
311
+ if (!isEditing() || isSlashActive()) {
312
+ return;
313
+ }
314
+ const el = getActiveElement();
315
+ if (el && e.target instanceof Node && !el.contains(e.target)) {
316
+ stopEditing();
317
+ }
318
+ };
319
+
320
+ doc.addEventListener("dblclick", onDblClick, true);
321
+ doc.addEventListener("selectionchange", onSelectionChange);
322
+ doc.addEventListener("mouseup", onMouseUp, true);
323
+ doc.addEventListener("keyup", onKeyUp, true);
324
+ doc.addEventListener("blur", onBlurCapture, true);
325
+ doc.addEventListener("pointerdown", onPointerDownCapture, true);
326
+
327
+ const off = channel.onMessage((msg) => {
328
+ if (msg.kind === "applyFormat") {
329
+ applyFormatIntent(msg.intent);
330
+ return;
331
+ }
332
+ if (msg.kind === "endEdit") {
333
+ // The parent detected intent leaving the edit surface in ITS realm (tab switch, chrome click
334
+ // Outside the edit toolbars) — commit and end, a no-op when no session is live.
335
+ if (isEditing()) {
336
+ stopEditing();
337
+ }
338
+ return;
339
+ }
340
+ if (msg.kind !== "enterEdit") {
341
+ return;
342
+ }
343
+ if (!editingAllowed()) {
344
+ return;
345
+ }
346
+ const el = elementForPath(container, msg.path);
347
+ if (el && isEditableBlock(el)) {
348
+ enterEditAt(el, msg.path);
349
+ }
350
+ });
351
+
352
+ return () => {
353
+ doc.removeEventListener("dblclick", onDblClick, true);
354
+ doc.removeEventListener("selectionchange", onSelectionChange);
355
+ doc.removeEventListener("mouseup", onMouseUp, true);
356
+ doc.removeEventListener("keyup", onKeyUp, true);
357
+ doc.removeEventListener("blur", onBlurCapture, true);
358
+ doc.removeEventListener("pointerdown", onPointerDownCapture, true);
359
+ off();
360
+ clearHighlight();
361
+ lastNonEmptyRange = null;
362
+ resumeBlurClose();
363
+ if (isEditing()) {
364
+ stopEditing();
365
+ }
366
+ };
367
+ }
@@ -0,0 +1,164 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * In-iframe insertion-zone math (cross-origin "+" affordance). Runs INSIDE the canvas iframe, in
4
+ * the iframe's own realm/coords, and is the structural cousin of {@link file://./iframe-drop.ts}:
5
+ * where iframe-drop resolves a DRAG's placement, this resolves where a fresh element could be
6
+ * INSERTED at the gaps around the hovered node, so the parent can float a clickable "+" there.
7
+ *
8
+ * It SALVAGES the pure edge/index math from the orphaned parent-realm
9
+ * {@link file://../editor/insertion-helper.ts} (empty-container detection, row-vs-column layout via
10
+ * getComputedStyle(parent), {@link EDGE_THRESHOLD}, leading/trailing edge → insert index) but
11
+ * re-expresses it against IFRAME-realm rects ({@link rectOf}, stubbable) and the iframe's shadow
12
+ * doc, and reproduces the parent's `parentElementPath`/`childIndex` resolution so the posted
13
+ * `insertParentPath` + `index` feed the unchanged `mutateInsertNode(t, parentPath, index, def)`.
14
+ *
15
+ * PURE: it reads element rects through {@link rectOf} and `data-jx-path`, so it is unit-proven
16
+ * against fake rects. The DOM adapter that resolves the hovered element from a point is CDP-only
17
+ * (happy-dom's `elementFromPoint` returns null) and lives in iframe-interaction.
18
+ */
19
+
20
+ import { parseJxPath } from "./path-mapping";
21
+ import { rectOf } from "../utils/geometry";
22
+ import { childIndex, parentElementPath } from "../state";
23
+ import type { InsertZone, SerializableRect } from "./iframe-protocol";
24
+ import type { JxMutableNode } from "@jxsuite/schema/types";
25
+ import type { JxPath } from "../state";
26
+
27
+ /** Edge detection threshold in pixels (SALVAGED from insertion-helper.ts). */
28
+ export const EDGE_THRESHOLD = 14;
29
+
30
+ /** The CSS class the renderer stamps on an empty container's placeholder (SALVAGED). */
31
+ const EMPTY_CONTAINER_CLASS = "empty-container-placeholder";
32
+
33
+ /** Read an element's iframe-viewport rect as a {@link SerializableRect}. */
34
+ function rectFor(el: Element): SerializableRect {
35
+ const r = rectOf(el);
36
+ return { height: r.height, width: r.width, x: r.x, y: r.y };
37
+ }
38
+
39
+ /**
40
+ * Whether the hovered element's parent lays its children out in a ROW (so leading/trailing edges
41
+ * are LEFT/RIGHT rather than TOP/BOTTOM). Ports the legacy direction test: flex `row*` or grid
42
+ * `grid-auto-flow: column*`. Pure-ish — reads getComputedStyle, which the unit test stubs.
43
+ */
44
+ function isRowLayout(parent: HTMLElement): boolean {
45
+ const style = getComputedStyle(parent);
46
+ const { display } = style;
47
+ const isFlex = display === "flex" || display === "inline-flex";
48
+ const isGrid = display === "grid" || display === "inline-grid";
49
+ return (
50
+ (isFlex && style.flexDirection.startsWith("row")) ||
51
+ (isGrid && (style.gridAutoFlow ?? "").startsWith("column"))
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Compute the candidate insertion "+" zone(s) for a cursor over `targetEl`, PURE against
57
+ * iframe-realm rects.
58
+ *
59
+ * `cursor` is in iframe-viewport coords (same space as {@link rectOf}); `shadowDoc` is the iframe's
60
+ * non-reactive shadow doc (carried for parity with iframe-drop — the structural decision here needs
61
+ * only the stamped path, but keeping the signature aligned lets the entry thread the same
62
+ * accessor).
63
+ *
64
+ * Branching (SALVAGED from insertion-helper.ts onMouseMove):
65
+ *
66
+ * - Empty container (`empty-container-placeholder` class) → ONE centered zone that inserts as the
67
+ * container's first child (`insertParentPath = targetPath`, `index = 0`).
68
+ * - Otherwise resolve the parent's layout (row/grid-column vs column); when the cursor is within
69
+ * {@link EDGE_THRESHOLD} of the leading edge → a sibling-before zone (`index = childIndex`); of
70
+ * the trailing edge → a sibling-after zone (`index = childIndex + 1`); mid-element → null.
71
+ *
72
+ * Returns null for the root/degenerate cases (no addressable parent to insert a sibling into) and
73
+ * when the cursor is not near an edge.
74
+ */
75
+ export function computeInsertZones(
76
+ targetEl: HTMLElement,
77
+ cursor: { x: number; y: number },
78
+ shadowDoc: JxMutableNode,
79
+ ): InsertZone[] | null {
80
+ void shadowDoc;
81
+ const serialized = targetEl.dataset?.jxPath;
82
+ if (serialized == null) {
83
+ return null;
84
+ }
85
+ const targetPath = parseJxPath(serialized) as JxPath;
86
+
87
+ // Empty container: one centered "+" that inserts as the container's first child.
88
+ if (targetEl.classList.contains(EMPTY_CONTAINER_CLASS)) {
89
+ return [{ edge: "center", index: 0, insertParentPath: targetPath, rect: rectFor(targetEl) }];
90
+ }
91
+
92
+ // A sibling insert needs an addressable parent element path; the root (path.length < 2) has none.
93
+ const parentPath = parentElementPath(targetPath);
94
+ if (!parentPath) {
95
+ return null;
96
+ }
97
+ const parent = targetEl.parentElement;
98
+ if (!parent) {
99
+ return null;
100
+ }
101
+ const childIdx = childIndex(targetPath);
102
+ if (typeof childIdx !== "number") {
103
+ return null;
104
+ }
105
+
106
+ const rect = rectFor(targetEl);
107
+ if (isRowLayout(parent)) {
108
+ const relX = cursor.x - rect.x;
109
+ if (relX < EDGE_THRESHOLD) {
110
+ return [edgeZone("left", parentPath, childIdx, leadingEdgeRect(rect, true))];
111
+ }
112
+ if (rect.width - relX < EDGE_THRESHOLD) {
113
+ return [edgeZone("right", parentPath, childIdx + 1, trailingEdgeRect(rect, true))];
114
+ }
115
+ return null;
116
+ }
117
+
118
+ const relY = cursor.y - rect.y;
119
+ if (relY < EDGE_THRESHOLD) {
120
+ return [edgeZone("top", parentPath, childIdx, leadingEdgeRect(rect, false))];
121
+ }
122
+ if (rect.height - relY < EDGE_THRESHOLD) {
123
+ return [edgeZone("bottom", parentPath, childIdx + 1, trailingEdgeRect(rect, false))];
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** Build a sibling-edge zone. */
129
+ function edgeZone(
130
+ edge: InsertZone["edge"],
131
+ insertParentPath: JxPath,
132
+ index: number,
133
+ rect: SerializableRect,
134
+ ): InsertZone {
135
+ return { edge, index, insertParentPath, rect };
136
+ }
137
+
138
+ /**
139
+ * A zero-thickness anchor rect at the target's LEADING edge — top edge (column) or left edge (row).
140
+ * The parent centers the "+" on this box, so it collapses the relevant dimension to the edge.
141
+ */
142
+ function leadingEdgeRect(rect: SerializableRect, row: boolean): SerializableRect {
143
+ return row
144
+ ? { height: rect.height, width: 0, x: rect.x, y: rect.y }
145
+ : { height: 0, width: rect.width, x: rect.x, y: rect.y };
146
+ }
147
+
148
+ /** A zero-thickness anchor rect at the target's TRAILING edge — bottom edge or right edge. */
149
+ function trailingEdgeRect(rect: SerializableRect, row: boolean): SerializableRect {
150
+ return row
151
+ ? { height: rect.height, width: 0, x: rect.x + rect.width, y: rect.y }
152
+ : { height: 0, width: rect.width, x: rect.x, y: rect.y + rect.height };
153
+ }
154
+
155
+ /**
156
+ * A stable key for a zone set, so the iframe only re-posts when the affordance actually changes
157
+ * (mirrors the hover de-dupe in startInteraction). Null/empty collapse to a single sentinel.
158
+ */
159
+ export function insertZonesKey(zones: InsertZone[] | null): string {
160
+ if (!zones || zones.length === 0) {
161
+ return "none";
162
+ }
163
+ return zones.map((z) => `${z.edge}:${z.insertParentPath.join("/")}:${z.index}`).join("|");
164
+ }