@jxsuite/studio 0.32.0 → 0.34.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 (94) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +22791 -13709
  4. package/dist/studio.js.map +181 -74
  5. package/package.json +12 -7
  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 +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +386 -328
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +18 -47
  58. package/src/services/ai-settings.ts +107 -0
  59. package/src/services/ai-system-prompt.ts +617 -0
  60. package/src/services/ai-tools.ts +854 -0
  61. package/src/services/context-manager.ts +200 -0
  62. package/src/services/document-assistant.ts +183 -0
  63. package/src/services/jx-validate.ts +65 -0
  64. package/src/services/monaco-setup.ts +12 -0
  65. package/src/services/render-critic.ts +75 -0
  66. package/src/services/token-lint.ts +140 -0
  67. package/src/services/tool-executor.ts +156 -0
  68. package/src/settings/css-vars-editor.ts +2 -2
  69. package/src/state.ts +29 -0
  70. package/src/store.ts +4 -62
  71. package/src/studio.ts +90 -40
  72. package/src/tabs/doc-op-apply.ts +89 -0
  73. package/src/tabs/patch-ops.ts +6 -2
  74. package/src/tabs/tab.ts +23 -4
  75. package/src/tabs/transact.ts +33 -69
  76. package/src/types.ts +16 -24
  77. package/src/ui/jx-theme.ts +63 -0
  78. package/src/ui/media-picker.ts +6 -4
  79. package/src/ui/spectrum.ts +5 -0
  80. package/src/utils/canvas-media.ts +0 -137
  81. package/src/utils/edit-display.ts +23 -3
  82. package/src/utils/geometry.ts +43 -0
  83. package/src/utils/link-target.ts +93 -0
  84. package/src/utils/strip-events.ts +54 -0
  85. package/src/view.ts +0 -23
  86. package/src/workspace/workspace.ts +14 -1
  87. package/src/canvas/canvas-subtree-render.ts +0 -113
  88. package/src/editor/component-inline-edit.ts +0 -349
  89. package/src/editor/content-inline-edit.ts +0 -207
  90. package/src/editor/insertion-helper.ts +0 -308
  91. package/src/panels/canvas-dnd.ts +0 -329
  92. package/src/panels/panel-events.ts +0 -306
  93. package/src/panels/preview-render.ts +0 -132
  94. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,86 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Render-path mapping — pure logic shared by the legacy in-realm path mapper (which stores paths in
4
+ * the `elToPath` WeakMap) and the iframe host (which stamps them as `data-jx-path` attributes).
5
+ *
6
+ * The runtime fires `onNodeCreated(el, renderPath, def, state)` for every node. The render path is
7
+ * not always the document path: layout-wrapped pages shift page content under a layout prefix, and
8
+ * `prepareForEditMode` renders mapped arrays (`$prototype: Array`) as a `repeater-perimeter` whose
9
+ * single child hop must be collapsed back to a `map` segment. {@link classifyRenderNode} resolves a
10
+ * render path to either a document path or a "layout" marker; {@link serializeJxPath} encodes the
11
+ * result for the `data-jx-path` attribute.
12
+ */
13
+
14
+ import type { JxPath } from "../state";
15
+
16
+ /** Context the mapper needs, computed once per render by whoever prepared the document. */
17
+ export interface PathMapCtx {
18
+ canvasMode: string;
19
+ layoutWrapped: boolean;
20
+ pageContentPrefix: JxPath | null;
21
+ pageContentOffset: number | null;
22
+ arrayPaths: Set<string>;
23
+ }
24
+
25
+ /** A render node either belongs to the layout chrome (no document path) or maps to a document path. */
26
+ export type RenderNodeClass = { kind: "layout" } | { kind: "path"; path: JxPath };
27
+
28
+ /**
29
+ * Resolve a runtime render path to a document path (or flag it as layout-originated). Pure — the
30
+ * caller decides whether to record the result in a WeakMap or a `data-jx-path` attribute.
31
+ */
32
+ export function classifyRenderNode(path: JxPath, def: unknown, ctx: PathMapCtx): RenderNodeClass {
33
+ // Layout-originated nodes have no page-document path; the caller marks them (data-jx-layout).
34
+ if (ctx.layoutWrapped && typeof def === "object" && (def as { $__layout?: unknown })?.$__layout) {
35
+ return { kind: "layout" };
36
+ }
37
+
38
+ let mappedPath = path;
39
+
40
+ // Strip the layout prefix so paths are relative to the original page document.
41
+ // Page children render at indices [offset, offset+1, …] when the layout places siblings before
42
+ // The <slot>, so subtract the offset to recover the page's 0-based child indices.
43
+ if (ctx.layoutWrapped && ctx.pageContentPrefix) {
44
+ const pfx = ctx.pageContentPrefix;
45
+ if (path.length >= pfx.length && pfx.every((seg, i) => path[i] === seg)) {
46
+ const rest = path.slice(pfx.length);
47
+ const [containerIdx] = rest;
48
+ mappedPath =
49
+ typeof containerIdx === "number"
50
+ ? ["children", containerIdx - (ctx.pageContentOffset ?? 0), ...rest.slice(1)]
51
+ : ["children", ...rest];
52
+ }
53
+ }
54
+
55
+ // Collapse repeater-perimeter template hops: for an array document path P, a render path of
56
+ // `[...P, "children", 0, ...rest]` maps to `[...P, "map", ...rest]`. Loop for nested repeaters.
57
+ if ((ctx.canvasMode === "design" || ctx.canvasMode === "edit") && ctx.arrayPaths.size > 0) {
58
+ let changed = true;
59
+ while (changed) {
60
+ changed = false;
61
+ for (let i = 1; i < mappedPath.length - 1; i++) {
62
+ if (
63
+ mappedPath[i] === "children" &&
64
+ mappedPath[i + 1] === 0 &&
65
+ ctx.arrayPaths.has(mappedPath.slice(0, i).join("/"))
66
+ ) {
67
+ mappedPath = [...mappedPath.slice(0, i), "map", ...mappedPath.slice(i + 2)];
68
+ changed = true;
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ return { kind: "path", path: mappedPath };
76
+ }
77
+
78
+ /** Encode a document path for the `data-jx-path` attribute (preserves string-vs-number segments). */
79
+ export function serializeJxPath(path: JxPath): string {
80
+ return JSON.stringify(path);
81
+ }
82
+
83
+ /** Decode a `data-jx-path` attribute back into a document path. */
84
+ export function parseJxPath(serialized: string): JxPath {
85
+ return JSON.parse(serialized) as JxPath;
86
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Serialize the iframe's resolved `$defs` scope (buildScope's output) into a structured-clone-safe
3
+ * plain object the iframe can postMessage to the parent editor. The data-explorer panel reads this
4
+ * from `S.canvas.scope` to show each data source's live value; the iframe (not the parent) resolves
5
+ * the scope since the iframe canvas migration moved buildScope into the iframe realm.
6
+ *
7
+ * The input is the resolved `$defs` — typically a Vue reactive proxy. Reading `defs[key]`
8
+ * auto-unwraps a top-level `ref`/`computed` to its current value; a JSON round-trip then reads
9
+ * THROUGH any nested reactive proxies and drops non-cloneable residue (nested functions, symbols,
10
+ * proxy artifacts) so the result crosses postMessage without a DataCloneError.
11
+ *
12
+ * Pure and DOM-free — no reactivity/runtime imports — so it stays trivially unit-testable.
13
+ */
14
+
15
+ /**
16
+ * Per-value size cap (in JSON-string chars). A single large content collection can be tens of KB;
17
+ * past this cap we store a small placeholder so one big data source can't bloat every render's
18
+ * postMessage. Generous on purpose — most scope values are far smaller.
19
+ */
20
+ const MAX_VALUE_CHARS = 256_000;
21
+
22
+ /** The placeholder stored in place of a value whose JSON string exceeds {@link MAX_VALUE_CHARS}. */
23
+ const LARGE_VALUE_PLACEHOLDER = "[large value omitted]";
24
+
25
+ /**
26
+ * Produce a structured-clone-safe snapshot of `defs` for the data-explorer.
27
+ *
28
+ * For each own enumerable key: - functions (`typeof v === "function"`) are SKIPPED entirely
29
+ * (handlers/server fns aren't data); - other values are JSON deep-cloned
30
+ * (`JSON.parse(JSON.stringify(v))`) inside try/catch — a throw (e.g. a computed getter that throws)
31
+ * or a circular/unserializable value maps the key to `null`; - a value whose JSON string exceeds
32
+ * the size cap is replaced with a short placeholder string.
33
+ *
34
+ * @param defs The resolved `$defs` scope (may be a Vue reactive proxy).
35
+ * @returns A plain object safe to `postMessage` (every value is JSON-round-trippable).
36
+ */
37
+ export function serializeDataScope(defs: Record<string, unknown>): Record<string, unknown> {
38
+ const out: Record<string, unknown> = {};
39
+ for (const key of Object.keys(defs)) {
40
+ try {
41
+ // Reading through the reactive proxy auto-unwraps a top-level ref/computed to its value, and
42
+ // Can THROW (a computed getter that throws) — done inside the try so that maps the key to null
43
+ // Rather than aborting the whole snapshot.
44
+ const value = defs[key];
45
+ if (typeof value === "function") {
46
+ continue; // Data sources only — a resolved server/handler fn isn't a "value".
47
+ }
48
+ const json = JSON.stringify(value);
49
+ if (json === undefined) {
50
+ // JSON.stringify returns undefined for a bare function/symbol/undefined at the top level.
51
+ out[key] = null;
52
+ continue;
53
+ }
54
+ if (json.length > MAX_VALUE_CHARS) {
55
+ out[key] = LARGE_VALUE_PLACEHOLDER;
56
+ continue;
57
+ }
58
+ out[key] = JSON.parse(json) as unknown;
59
+ } catch {
60
+ // A circular structure or a throwing getter → the key exists but carries no serializable value.
61
+ out[key] = null;
62
+ }
63
+ }
64
+ return out;
65
+ }
@@ -0,0 +1,40 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Parent-realm adapter for canvas right-clicks: the iframe suppresses the browser menu and posts
4
+ * `contextMenu` with the nearest element path + iframe-viewport coords; the host converts to
5
+ * parent-viewport coords and calls this handler, which shows the Jx element context menu (copy /
6
+ * paste / insert before/after / convert / delete…). Replaces the legacy panel-events canvas
7
+ * contextmenu handler that died with the in-parent <div> canvas.
8
+ */
9
+
10
+ import { dismissContextMenu, showContextMenu } from "./context-menu";
11
+ import { bubbleInlinePath } from "../canvas/canvas-helpers";
12
+ import { activeTab } from "../workspace/workspace";
13
+ import type { CanvasContextMenuHandler } from "../canvas/iframe-host";
14
+ import type { JxPath } from "../state";
15
+
16
+ /**
17
+ * Build the handler with its studio-owned dependency (component navigation), mirroring the other
18
+ * host DI factories.
19
+ */
20
+ export function makeCanvasContextMenuHandler(deps: {
21
+ navigateToComponent: (path: string) => void | Promise<void>;
22
+ }): CanvasContextMenuHandler {
23
+ return {
24
+ dismiss: () => dismissContextMenu(),
25
+ show: ({ path, clientX, clientY }) => {
26
+ if (!path) {
27
+ return;
28
+ }
29
+ // Lift inline-element paths (a <strong> inside a <p>) to their block parent — right-clicking
30
+ // Formatted text should act on the block, matching the legacy handler.
31
+ const bubbled = bubbleInlinePath(activeTab.value?.doc.document, path as JxPath);
32
+ // ShowContextMenu reads only preventDefault/clientX/clientY from the event, so a synthetic
33
+ // MouseEvent carries the converted coords safely.
34
+ const evt = new MouseEvent("contextmenu", { clientX, clientY });
35
+ showContextMenu(evt, bubbled, {
36
+ onEditComponent: (p) => void deps.navigateToComponent(p),
37
+ });
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,21 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Parent-realm adapter between the canvas iframe's slash-menu bridge and the real lit/Spectrum
4
+ * menu. The iframe engine detects "/", its {@link file://../canvas/iframe-slash.ts} controller posts
5
+ * `slashShow`/`slashNav`/`slashDismiss`, the host converts coordinates and calls this handler;
6
+ * select/dismiss flow back over the same channel as `slashSelect`/`slashDismissed`. Registered via
7
+ * {@link setCanvasSlashHandler} in studio.ts (DI keeps the host module free of the menu's lit
8
+ * dependencies).
9
+ */
10
+
11
+ import { dismissSlashMenu, handleSlashMenuKey, showSlashMenuAtRect } from "./slash-menu";
12
+ import type { CanvasSlashHandler } from "../canvas/iframe-host";
13
+
14
+ export const canvasSlashHandler: CanvasSlashHandler = {
15
+ dismiss: () => dismissSlashMenu(),
16
+ nav: (key) => handleSlashMenuKey(key),
17
+ // No filter input: the author keeps typing in the iframe contenteditable and the engine re-posts
18
+ // SlashShow with the updated filter.
19
+ show: ({ rect, filter, onSelect, onDismiss }) =>
20
+ showSlashMenuAtRect(rect, filter, { onDismiss, onSelect }),
21
+ };
@@ -19,6 +19,7 @@ import { convertToComponent } from "./convert-to-component";
19
19
  import { convertToRepeater } from "./convert-to-repeater";
20
20
  import { componentRegistry } from "../files/components";
21
21
  import { renderPopover } from "../ui/layers";
22
+ import { rectOf } from "../utils/geometry";
22
23
 
23
24
  import type { JxPath } from "../state";
24
25
  import type { JxMutableNode } from "@jxsuite/schema/types";
@@ -445,7 +446,7 @@ export function showContextMenu(
445
446
  }
446
447
  requestAnimationFrame(() => {
447
448
  const popover = el as HTMLElement;
448
- const menuRect = popover.getBoundingClientRect();
449
+ const menuRect = rectOf(popover);
449
450
  if (x + menuRect.width > window.innerWidth) {
450
451
  x = window.innerWidth - menuRect.width - 4;
451
452
  }
@@ -0,0 +1,183 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Inline-edit apply logic — turns the serializable result of a contenteditable session (committed
4
+ * children/text, a split, or a slash-insert) into `transactDoc` mutations. The inline editing
5
+ * session lives inside the canvas iframe, which posts these plain-JSON results across the bridge
6
+ * for this apply. Every input here is plain JSON — no DOM, no Range — so it crosses the frame
7
+ * boundary.
8
+ */
9
+
10
+ import { childIndex, getNodeAtPath, parentElementPath } from "../store";
11
+ import { isTabActive } from "../workspace/workspace";
12
+ import { mutateInsertNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
13
+ import { defaultDef } from "../panels/shared";
14
+
15
+ import type { JxContentResult, SlashCommand } from "./inline-edit";
16
+ import type { JxPath } from "../state";
17
+ import type { Tab } from "../tabs/tab";
18
+ import type { JxMutableNode } from "@jxsuite/schema/types";
19
+
20
+ /**
21
+ * Whether a committed inline-edit result is effectively empty (blank text / lone whitespace /
22
+ * `<br>`).
23
+ */
24
+ export function isEmptyContent(commitData?: JxContentResult): boolean {
25
+ if (!commitData) {
26
+ return true;
27
+ }
28
+ if (commitData.textContent != null && commitData.textContent.trim() === "") {
29
+ return true;
30
+ }
31
+ const kids = commitData.children;
32
+ if (!kids) {
33
+ return false;
34
+ }
35
+ if (kids.length === 0) {
36
+ return true;
37
+ }
38
+ if (kids.length === 1 && typeof kids[0] === "string" && kids[0].trim() === "") {
39
+ return true;
40
+ }
41
+ return kids.length === 1 && typeof kids[0] === "object" && kids[0]?.tagName === "br";
42
+ }
43
+
44
+ /**
45
+ * Commit edited content to the node at `path` of `tab`'s document (children when rich, else
46
+ * textContent). No-op if unchanged or the originating tab is gone. `tab` is the tab the edit
47
+ * session belonged to (resolved host-side from the posting iframe) — NEVER the active tab at
48
+ * message time, which may have changed while the commit was in flight.
49
+ */
50
+ export function applyInlineCommit(
51
+ tab: Tab | null,
52
+ path: JxPath,
53
+ children: (JxMutableNode | string)[] | null,
54
+ textContent: string | null,
55
+ ): void {
56
+ if (!tab) {
57
+ return;
58
+ }
59
+ const node = getNodeAtPath(tab.doc.document, path);
60
+ // Only clear the counterpart key when it actually exists: deleting an absent key is a semantic
61
+ // No-op, but the recorded op isn't — a spurious `set-prop children` turns a cheap in-place text
62
+ // Patch into a subtree re-render (and, pre-relaxation, forced a full render inside components).
63
+ if (children) {
64
+ if (node && JSON.stringify(node.children) === JSON.stringify(children)) {
65
+ return;
66
+ }
67
+ transactDoc(tab, (t) => {
68
+ if (node?.textContent != null) {
69
+ mutateUpdateProperty(t, path, "textContent");
70
+ }
71
+ mutateUpdateProperty(t, path, "children", children);
72
+ });
73
+ } else if (textContent != null) {
74
+ if (node && node.textContent === textContent && !node.children) {
75
+ return;
76
+ }
77
+ transactDoc(tab, (t) => {
78
+ if (node?.children) {
79
+ mutateUpdateProperty(t, path, "children");
80
+ }
81
+ mutateUpdateProperty(t, path, "textContent", textContent);
82
+ });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Apply a paragraph split to `tab`'s document: keep `before` in the node, insert a new `<p>` with
88
+ * `after`. Returns its path (unchanged when `tab` is gone — the caller may still use it for
89
+ * bookkeeping, but nothing is mutated).
90
+ */
91
+ export function applyInlineSplit(
92
+ tab: Tab | null,
93
+ path: JxPath,
94
+ before: JxContentResult,
95
+ after: JxContentResult,
96
+ ): JxPath {
97
+ const parentPath = parentElementPath(path) as JxPath;
98
+ const idx = childIndex(path) as number;
99
+ const newPath = [...parentPath, "children", idx + 1];
100
+ if (!tab) {
101
+ return newPath;
102
+ }
103
+ const newNode: JxMutableNode = { tagName: "p" };
104
+ if (after.textContent != null) {
105
+ newNode.textContent = after.textContent;
106
+ } else if (after.children) {
107
+ newNode.children = after.children;
108
+ } else {
109
+ newNode.textContent = "";
110
+ }
111
+
112
+ transactDoc(tab, (t) => {
113
+ if (before.textContent != null) {
114
+ mutateUpdateProperty(t, path, "children");
115
+ mutateUpdateProperty(t, path, "textContent", before.textContent);
116
+ } else if (before.children) {
117
+ mutateUpdateProperty(t, path, "textContent");
118
+ mutateUpdateProperty(t, path, "children", before.children);
119
+ }
120
+ mutateInsertNode(t, parentPath, idx + 1, newNode);
121
+ // A background tab's selection stays exactly as the user left it — only the visible tab's
122
+ // Selection follows the split.
123
+ if (isTabActive(tab)) {
124
+ t.session.selection = newPath;
125
+ }
126
+ });
127
+ return newPath;
128
+ }
129
+
130
+ /**
131
+ * Apply a slash-insert at `path` of `tab`'s document: swap the (empty) node's tag in place, or
132
+ * commit pending content and insert a new element after it. Returns the path to edit next (the
133
+ * swapped node or the new one); nothing is mutated when `tab` is gone.
134
+ */
135
+ export function applyInlineInsert(
136
+ tab: Tab | null,
137
+ path: JxPath,
138
+ cmd: SlashCommand,
139
+ commitData: JxContentResult | undefined,
140
+ ): JxPath {
141
+ if (isEmptyContent(commitData)) {
142
+ if (!tab) {
143
+ return path;
144
+ }
145
+ transactDoc(tab, (t) => {
146
+ mutateUpdateProperty(t, path, "tagName", cmd.tag);
147
+ mutateUpdateProperty(t, path, "children");
148
+ const def = defaultDef(cmd.tag);
149
+ if (def.textContent && def.textContent !== "Paragraph text") {
150
+ mutateUpdateProperty(t, path, "textContent", def.textContent);
151
+ } else {
152
+ mutateUpdateProperty(t, path, "textContent");
153
+ }
154
+ if (isTabActive(tab)) {
155
+ t.session.selection = path;
156
+ }
157
+ });
158
+ return path;
159
+ }
160
+
161
+ const parentPath = parentElementPath(path) as JxPath;
162
+ const idx = childIndex(path) as number;
163
+ const newPath = [...parentPath, "children", idx + 1];
164
+ if (!tab) {
165
+ return newPath;
166
+ }
167
+ const elementDef = defaultDef(cmd.tag);
168
+
169
+ transactDoc(tab, (t) => {
170
+ if (commitData?.children) {
171
+ mutateUpdateProperty(t, path, "textContent");
172
+ mutateUpdateProperty(t, path, "children", commitData.children);
173
+ } else if (commitData?.textContent != null) {
174
+ mutateUpdateProperty(t, path, "children");
175
+ mutateUpdateProperty(t, path, "textContent", commitData.textContent);
176
+ }
177
+ mutateInsertNode(t, parentPath, idx + 1, structuredClone(elementDef));
178
+ if (isTabActive(tab)) {
179
+ t.session.selection = newPath;
180
+ }
181
+ });
182
+ return newPath;
183
+ }
@@ -12,11 +12,37 @@ import { normalizeInlineContent, toggleInlineFormat } from "./inline-format";
12
12
  import type { JxPath } from "../state";
13
13
  import type { JxMutableNode } from "@jxsuite/schema/types";
14
14
 
15
- import {
16
- showSlashMenu as sharedShowSlashMenu,
17
- dismissSlashMenu as sharedDismissSlashMenu,
18
- isSlashMenuOpen,
19
- } from "./slash-menu";
15
+ /**
16
+ * The slash-menu surface inline editing drives. Injected (rather than imported) so this module
17
+ * stays free of the menu's heavy `lit-html` / `ui/layers` dependencies — the editor realm wires the
18
+ * real menu, the canvas iframe supplies its own (or a no-op) without bloating the slim iframe
19
+ * bundle.
20
+ */
21
+ export interface SlashController {
22
+ show: (
23
+ anchorEl: HTMLElement,
24
+ filter: string,
25
+ cbs: { onSelect: (cmd: SlashCommand) => void; showFilter?: boolean; commands?: SlashCommand[] },
26
+ ) => void;
27
+ dismiss: () => void;
28
+ isOpen: () => boolean;
29
+ }
30
+
31
+ const noopSlash: SlashController = {
32
+ dismiss: () => {
33
+ // No slash controller wired in this realm.
34
+ },
35
+ isOpen: () => false,
36
+ show: () => {
37
+ // No slash controller wired in this realm.
38
+ },
39
+ };
40
+ let slash: SlashController = noopSlash;
41
+
42
+ /** Inject the slash-menu controller inline editing should drive (call once at realm init). */
43
+ export function setSlashController(controller: SlashController): void {
44
+ slash = controller;
45
+ }
20
46
 
21
47
  export interface InlineAction {
22
48
  tag: string;
@@ -159,6 +185,24 @@ let insertFn:
159
185
  | null = null; // Function(path, elementDef, commitData?) to insert after current block
160
186
  let endFn: (() => void) | null = null; // Function() called when editing stops
161
187
 
188
+ /**
189
+ * When a parent toolbar (or its link popover) may take focus during a live session, the iframe sets
190
+ * this so {@link handleBlur} does NOT schedule a `stopEditing()` (the BLOCKER focus-loss race fix —
191
+ * a parent-toolbar click blurs the iframe editable, which must NOT tear the session down). Only an
192
+ * explicit commit (Escape/Enter/click-away-in-iframe) ends the session while suspended.
193
+ */
194
+ let _blurCloseSuspended = false;
195
+
196
+ /** Suspend blur-driven `stopEditing` (call while the parent toolbar/popover may steal focus). */
197
+ export function suspendBlurClose() {
198
+ _blurCloseSuspended = true;
199
+ }
200
+
201
+ /** Resume blur-driven `stopEditing` (call on real editEnd/teardown). */
202
+ export function resumeBlurClose() {
203
+ _blurCloseSuspended = false;
204
+ }
205
+
162
206
  /**
163
207
  * Check if an element is a text-bearing editable block.
164
208
  *
@@ -228,7 +272,9 @@ export function startEditing(
228
272
  },
229
273
  ) {
230
274
  if (activeEl) {
231
- stopEditing();
275
+ // Re-enter (e.g. after a split/insert re-render): tear the old session down WITHOUT firing the
276
+ // User-visible `onEnd` — a re-enter must not reset the parent toolbar via a stray `editEnd`.
277
+ stopEditing(true);
232
278
  }
233
279
 
234
280
  activeEl = el;
@@ -262,14 +308,19 @@ export function startEditing(
262
308
  el.addEventListener("paste", handlePaste);
263
309
  }
264
310
 
265
- /** Stop editing and commit changes. */
266
- export function stopEditing() {
311
+ /**
312
+ * Stop editing and commit changes. Pass `silent` to skip the `onEnd` callback (used by the re-enter
313
+ * path so a stop→start sequence doesn't post a user-visible `editEnd`).
314
+ *
315
+ * @param {boolean} [silent]
316
+ */
317
+ export function stopEditing(silent = false) {
267
318
  if (!activeEl) {
268
319
  return;
269
320
  }
270
321
 
271
322
  commitChanges();
272
- sharedDismissSlashMenu();
323
+ slash.dismiss();
273
324
 
274
325
  activeEl.contentEditable = "false";
275
326
  activeEl.style.pointerEvents = "";
@@ -288,7 +339,9 @@ export function stopEditing() {
288
339
  splitFn = null;
289
340
  insertFn = null;
290
341
 
291
- if (endFn) {
342
+ if (silent) {
343
+ endFn = null;
344
+ } else if (endFn) {
292
345
  const fn = endFn;
293
346
  endFn = null;
294
347
  fn();
@@ -313,6 +366,25 @@ export function getActiveElement() {
313
366
  return activeEl;
314
367
  }
315
368
 
369
+ /**
370
+ * Get the document path of the currently editing element (null when no session is live).
371
+ *
372
+ * @returns {JxPath | null}
373
+ */
374
+ export function getActivePath() {
375
+ return activePath;
376
+ }
377
+
378
+ /**
379
+ * Whether the DI'd slash menu reports itself open — session-lifecycle guards must not commit-and-
380
+ * end on a pointerdown that is really a slash-menu interaction.
381
+ *
382
+ * @returns {boolean}
383
+ */
384
+ export function isSlashActive() {
385
+ return slash.isOpen();
386
+ }
387
+
316
388
  // ─── Event handlers ────────────────────────────────────────────────────────
317
389
 
318
390
  /** @param {KeyboardEvent} e */
@@ -325,7 +397,7 @@ function handleKeydown(e: KeyboardEvent) {
325
397
  }
326
398
 
327
399
  if (e.key === "Enter" && !e.shiftKey) {
328
- if (isSlashMenuOpen()) {
400
+ if (slash.isOpen()) {
329
401
  return;
330
402
  } // Shared slash menu captures Enter
331
403
  e.preventDefault();
@@ -374,17 +446,14 @@ function handleKeydown(e: KeyboardEvent) {
374
446
  }
375
447
 
376
448
  // Dismiss slash menu on non-matching keys
377
- if (
378
- isSlashMenuOpen() &&
379
- !["ArrowUp", "ArrowDown", "Enter", "Backspace", "Delete"].includes(e.key)
380
- ) {
449
+ if (slash.isOpen() && !["ArrowUp", "ArrowDown", "Enter", "Backspace", "Delete"].includes(e.key)) {
381
450
  // Let the input handler deal with filtering
382
451
  }
383
452
  }
384
453
 
385
454
  function handleInput() {
386
455
  // Check if slash menu should update or dismiss
387
- if (isSlashMenuOpen()) {
456
+ if (slash.isOpen()) {
388
457
  updateSlashMenu();
389
458
  }
390
459
  }
@@ -392,12 +461,21 @@ function handleInput() {
392
461
  /** @param {FocusEvent} _e */
393
462
  function handleBlur(_e: FocusEvent) {
394
463
  // Don't close if focus moved to slash menu
395
- if (isSlashMenuOpen()) {
464
+ if (slash.isOpen()) {
465
+ return;
466
+ }
467
+
468
+ // The parent format toolbar/link popover may have taken focus across the bridge — blur must NOT
469
+ // Tear the session down (Phase 4b-2 focus-loss BLOCKER). Only an explicit commit ends it.
470
+ if (_blurCloseSuspended) {
396
471
  return;
397
472
  }
398
473
 
399
474
  // Delay to allow click events to fire
400
475
  setTimeout(() => {
476
+ if (_blurCloseSuspended) {
477
+ return;
478
+ }
401
479
  if (activeEl && document.activeElement !== activeEl) {
402
480
  stopEditing();
403
481
  }
@@ -669,7 +747,7 @@ function openSlashMenu() {
669
747
  const range = sel.getRangeAt(0);
670
748
  _slashFilterStart = getTextBeforeCursor(range).length;
671
749
 
672
- sharedShowSlashMenu(activeEl, "", { onSelect: handleSlashSelect });
750
+ slash.show(activeEl, "", { onSelect: handleSlashSelect });
673
751
  }
674
752
 
675
753
  function updateSlashMenu() {
@@ -679,7 +757,7 @@ function updateSlashMenu() {
679
757
 
680
758
  const sel = window.getSelection();
681
759
  if (!sel || !sel.rangeCount) {
682
- sharedDismissSlashMenu();
760
+ slash.dismiss();
683
761
  return;
684
762
  }
685
763
 
@@ -688,12 +766,12 @@ function updateSlashMenu() {
688
766
  const slashIdx = fullText.lastIndexOf("/");
689
767
 
690
768
  if (slashIdx === -1 || fullText.length < _slashFilterStart - 1) {
691
- sharedDismissSlashMenu();
769
+ slash.dismiss();
692
770
  return;
693
771
  }
694
772
 
695
773
  const filter = fullText.slice(slashIdx + 1).toLowerCase();
696
- sharedShowSlashMenu(activeEl, filter, { onSelect: handleSlashSelect });
774
+ slash.show(activeEl, filter, { onSelect: handleSlashSelect });
697
775
  }
698
776
 
699
777
  /** @param {SlashCommand} cmd */