@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,89 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Inline link + template-token application for contenteditable editing.
4
+ *
5
+ * Realm-agnostic: every DOM access uses ambient `window`/`document`, so bundled into the canvas
6
+ * iframe these operate on the iframe realm where the edited DOM (and its Selection) live. The
7
+ * parent format toolbar never calls these — it posts an `applyFormat` intent and the iframe applies
8
+ * it here (Phase 4b-2).
9
+ *
10
+ * `document.execCommand` is uncoverable under happy-dom (no implementation), so it is wrapped in a
11
+ * one-line shim {@link exec} that the branch logic calls; tests stub `document.execCommand` and
12
+ * assert at the stub level (documented as stub-level, not behavioral).
13
+ */
14
+
15
+ import { normalizeInlineContent } from "./inline-format";
16
+
17
+ /** The one uncoverable call, isolated so the surrounding branch logic stays unit-testable. */
18
+ const exec = (cmd: string, val?: string): boolean => document.execCommand(cmd, false, val);
19
+
20
+ /**
21
+ * Read the link state of the current selection relative to `root`: walk the selection's anchor node
22
+ * up to `root` looking for an `<a>`. Returns the author-entered `href` attribute (NOT the resolved
23
+ * `.href` property), so relative/templated URLs round-trip unchanged.
24
+ */
25
+ export function linkStateForSelection(root: HTMLElement): { active: boolean; href: string | null } {
26
+ const sel = window.getSelection();
27
+ if (!sel || !sel.rangeCount) {
28
+ return { active: false, href: null };
29
+ }
30
+ let node: Node | null = sel.anchorNode;
31
+ while (node && node !== root) {
32
+ if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "a") {
33
+ return { active: true, href: (node as Element).getAttribute("href") };
34
+ }
35
+ node = node.parentNode;
36
+ }
37
+ return { active: false, href: null };
38
+ }
39
+
40
+ /**
41
+ * Find the `<a>` that contains the selection anchor (up to `root`), if any.
42
+ *
43
+ * @param {HTMLElement} root
44
+ * @returns {HTMLAnchorElement | null}
45
+ */
46
+ function anchorInSelection(root: HTMLElement): HTMLAnchorElement | null {
47
+ const sel = window.getSelection();
48
+ if (!sel || !sel.rangeCount) {
49
+ return null;
50
+ }
51
+ let node: Node | null = sel.anchorNode;
52
+ while (node && node !== root) {
53
+ if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "a") {
54
+ return node as HTMLAnchorElement;
55
+ }
56
+ node = node.parentNode;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Apply (or remove) a link on the current selection within `root`. With an existing `<a>` in the
63
+ * selection: rewrite its `href`, or unwrap it when `href` is null/empty. Otherwise create a link
64
+ * via `execCommand("createLink")`. Always normalizes the inline content afterwards.
65
+ */
66
+ export function applyLink(root: HTMLElement, href: string | null): void {
67
+ const existing = anchorInSelection(root);
68
+ if (existing) {
69
+ if (href) {
70
+ existing.setAttribute("href", href);
71
+ } else {
72
+ // Unwrap: replace the anchor with its children, preserving the text.
73
+ const frag = document.createDocumentFragment();
74
+ while (existing.firstChild) {
75
+ frag.append(existing.firstChild);
76
+ }
77
+ existing.replaceWith(frag);
78
+ }
79
+ } else if (href) {
80
+ exec("createLink", href);
81
+ }
82
+ normalizeInlineContent(root);
83
+ }
84
+
85
+ /** Insert a `${token}` template expression at the caret inside `root` (joins the native undo stack). */
86
+ export function insertTemplateToken(root: HTMLElement, token: string): void {
87
+ root.focus();
88
+ exec("insertText", `\${${token}}`);
89
+ }
@@ -0,0 +1,35 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Insert-zone-action.ts — the click handler for the cross-origin insertion "+" affordance.
4
+ *
5
+ * Extracted verbatim from the studio.ts bootstrap so it can carry its own unit test (the inline
6
+ * arrow form added two uncovered anonymous functions to the hard-to-test bootstrap). Registered by
7
+ * name via `setInsertZoneClickHandler(runInsertZoneAction)` in studio.ts.
8
+ *
9
+ * The cross-origin insertion "+" (drawn by the host from posted zones) runs the parent-realm
10
+ * slash-menu → mutateInsertNode flow on click. The host captured the zone (parentPath + index); the
11
+ * new node is selected at its inserted path. Salvaged from the orphaned insertion-helper.ts.
12
+ */
13
+
14
+ import { showSlashMenu } from "./slash-menu";
15
+ import { defaultDef } from "../panels/shared";
16
+ import { mutateInsertNode, transactDoc } from "../tabs/transact";
17
+ import { activeTab } from "../workspace/workspace";
18
+ import type { InsertZone } from "../canvas/iframe-protocol";
19
+ import type { JxPath } from "../state";
20
+
21
+ export function runInsertZoneAction(btn: HTMLElement, zone: InsertZone): void {
22
+ showSlashMenu(btn, "", {
23
+ onSelect: (cmd) => {
24
+ const def = defaultDef(cmd.tag);
25
+ const insertPath = zone.insertParentPath as JxPath;
26
+ const idx = zone.index;
27
+ const newPath = [...insertPath, "children", idx];
28
+ transactDoc(activeTab.value, (t) => {
29
+ mutateInsertNode(t, insertPath, idx, def);
30
+ t.session.selection = newPath;
31
+ });
32
+ },
33
+ showFilter: true,
34
+ });
35
+ }
@@ -74,8 +74,8 @@ function walk(value: unknown, prefix: string, category: string, depth: number, o
74
74
  *
75
75
  * @param state - The document's `state` definitions (`tab.doc.document.state`), used for names and
76
76
  * classification (functions are skipped — they are not text-insertable values).
77
- * @param scope - The live resolved scope (`panel.liveCtx.scope`) for type/preview hints and the
78
- * nested-property walk. May be null before the canvas has rendered.
77
+ * @param scope - The live resolved scope for type/preview hints and the nested-property walk. May
78
+ * be null (the iframe canvas holds the render scope, so the parent passes null).
79
79
  * @param localScope - The editing element's recorded render scope (`elToScope.get(el)`). When it
80
80
  * carries a `$map` (repeater) context, `item` / `item.*` / `index` tags are appended.
81
81
  */
@@ -118,3 +118,27 @@ export function buildMergeTags(
118
118
 
119
119
  return out;
120
120
  }
121
+
122
+ /**
123
+ * Build repeater-scope merge tags from a flat list of pre-resolved tokens (e.g. `item`, `index`,
124
+ * `item.data.title`). Unlike {@link buildMergeTags}'s live-scope branch, these carry no live value —
125
+ * in edit mode the iframe holds no `$map`, so fields are resolved parent-side from schema and the
126
+ * hint is empty. `item` and `index` are guaranteed first; the list is deduped preserving order.
127
+ *
128
+ * @param tokens - Insertion tokens (see `resolveRepeaterItemFields`); typically leads
129
+ * `item`/`index`.
130
+ * @returns One {@link MergeTag} per unique token, `category: "repeater"`, `label === token`.
131
+ */
132
+ export function buildRepeaterTagsFromFields(tokens: string[]): MergeTag[] {
133
+ const ordered = ["item", "index", ...tokens];
134
+ const seen = new Set<string>();
135
+ const out: MergeTag[] = [];
136
+ for (const token of ordered) {
137
+ if (!token || seen.has(token)) {
138
+ continue;
139
+ }
140
+ seen.add(token);
141
+ out.push({ category: "repeater", hint: "", label: token, token });
142
+ }
143
+ return out;
144
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Repeater scope resolution — enumerate the local `item` / `index` / field tokens available when
3
+ * the author inline-edits text INSIDE a repeater (`$prototype: "Array"`) on the design canvas.
4
+ *
5
+ * There is no live `$map` in edit mode: `prepareForEditMode` renders a mapped array as a
6
+ * `repeater-perimeter` wrapping one glyph-ified template instance, so `renderMappedArrayInto`
7
+ * (which sets `child.$map`) never runs. Instead we resolve field names PARENT-SIDE from the
8
+ * repeater's binding + the project's content-type / state schema, keyed off the selected element's
9
+ * document path (which carries a real `"map"` segment — see canvas/path-mapping.ts
10
+ * `classifyRenderNode`).
11
+ *
12
+ * Pure and DOM-free: deps limited to `@jxsuite/schema` guards/types + {@link getNodeAtPath}.
13
+ */
14
+
15
+ import { isRef } from "@jxsuite/schema/guards";
16
+ import { getNodeAtPath } from "../state";
17
+
18
+ import type { JxPath } from "../state";
19
+ import type {
20
+ JxMappedArray,
21
+ JxMutableNode,
22
+ ProjectConfig,
23
+ ContentTypeSchema,
24
+ } from "@jxsuite/schema/types";
25
+
26
+ /** Cap on how many inferred/declared fields we enumerate (mirrors merge-tags BREADTH_CAP breadth). */
27
+ const FIELD_CAP = 50;
28
+
29
+ /**
30
+ * Find the repeater (`$prototype: "Array"`) that encloses the node at `path`. The stamper rewrites
31
+ * an edit-mode perimeter render path back to `[...P, "map", …]` where `P` is the repeater's own doc
32
+ * path, so an element inside a repeater carries a real-doc path containing a `"map"` segment. We
33
+ * take the LAST `"map"` (innermost repeater wins for nesting) and resolve the node at the prefix
34
+ * before it.
35
+ *
36
+ * @param doc - The tab's document root (`tab.doc.document`).
37
+ * @param path - The selected element's document path.
38
+ * @returns The enclosing `JxMappedArray` node, or `null` when the path is not inside a repeater.
39
+ */
40
+ export function findEnclosingRepeater(
41
+ doc: JxMutableNode,
42
+ path: JxPath | null | undefined,
43
+ ): JxMappedArray | null {
44
+ if (!Array.isArray(path) || path.length === 0) {
45
+ return null;
46
+ }
47
+ const mapIdx = path.lastIndexOf("map");
48
+ if (mapIdx <= 0) {
49
+ return null;
50
+ }
51
+ const node = getNodeAtPath(doc, path.slice(0, mapIdx)) as
52
+ | (Record<string, unknown> & JxMappedArray)
53
+ | undefined;
54
+ return node && node.$prototype === "Array" ? (node as JxMappedArray) : null;
55
+ }
56
+
57
+ /** Strip a `#/state/<name>` JSON-Pointer ref to its bare state name (null when it is not one). */
58
+ function refToStateName(ref: unknown): string | null {
59
+ if (!isRef(ref)) {
60
+ return null;
61
+ }
62
+ const { $ref: target } = ref;
63
+ return target.startsWith("#/state/") ? target.slice("#/state/".length) : null;
64
+ }
65
+
66
+ /** Cap + $-filter a field-name list to a stable, author-facing subset. */
67
+ function safeFields(names: string[]): string[] {
68
+ return names.filter((k) => !k.startsWith("$")).slice(0, FIELD_CAP);
69
+ }
70
+
71
+ /**
72
+ * Resolve the local scope tokens for text edited inside `arrayNode`. Always leads with `item` and
73
+ * `index`; appends field tokens resolved from the repeater's binding:
74
+ *
75
+ * - **Content collection** — `items` refs a `{ contentType }` state def. Item shape is `{ id, data: {
76
+ * …fields }, body }`, so fields come from
77
+ * `projectConfig.contentTypes[contentType].schema.properties` and emit as `item.data.<f>` (the
78
+ * `.data.` nesting is LOAD-BEARING — runtime templates use `${item.data.title}`), plus `item.id`
79
+ * / `item.body`.
80
+ * - **State array** — `items` refs a state def that is an array of objects. Fields come from a
81
+ * declared `items.properties`, else are inferred from a sample `default[0]`. Emitted FLAT
82
+ * (`item.<f>`, no `.data.`).
83
+ * - **Inline array** — `items` is a literal array; fields inferred from `items[0]` object keys.
84
+ * - **Fallback** — anything unresolvable yields exactly `["item", "index"]`.
85
+ *
86
+ * @param arrayNode - The repeater node from {@link findEnclosingRepeater}.
87
+ * @param state - The document's `state` definitions (`tab.doc.document.state`).
88
+ * @param projectConfig - Parsed `project.json` (`projectState?.projectConfig`).
89
+ * @returns Token strings, e.g. `["item", "index", "item.id", "item.body", "item.data.title"]`.
90
+ */
91
+ export function resolveRepeaterItemFields(
92
+ arrayNode: JxMappedArray,
93
+ state: Record<string, unknown> | null | undefined,
94
+ projectConfig: ProjectConfig | null | undefined,
95
+ ): string[] {
96
+ const base = ["item", "index"];
97
+ const { items } = arrayNode as { items?: unknown };
98
+ const defs = state ?? {};
99
+
100
+ const stateName = refToStateName(items);
101
+ if (stateName) {
102
+ const def = defs[stateName] as Record<string, unknown> | undefined;
103
+ if (def && typeof def === "object") {
104
+ // (a) Content collection — `{ contentType: "<name>" }`. Import-aliased `$prototype` still
105
+ // Matches because we detect on the `contentType` string, not the prototype name.
106
+ if (typeof def.contentType === "string") {
107
+ const ct = projectConfig?.contentTypes?.[def.contentType];
108
+ const schema = ct?.schema as ContentTypeSchema | undefined;
109
+ const props = schema?.properties;
110
+ const out = [...base, "item.id", "item.body"];
111
+ if (props && typeof props === "object") {
112
+ for (const f of safeFields(Object.keys(props))) {
113
+ out.push(`item.data.${f}`);
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // (b) State array — declared object item-schema, or inferred from a sample default[0].
120
+ const itemsSchema = def.items as Record<string, unknown> | undefined;
121
+ const declared = itemsSchema?.properties as Record<string, unknown> | undefined;
122
+ if (declared && typeof declared === "object") {
123
+ return [...base, ...safeFields(Object.keys(declared)).map((f) => `item.${f}`)];
124
+ }
125
+ const defaults = def.default as unknown[] | undefined;
126
+ const sample: unknown = Array.isArray(defaults) ? defaults[0] : undefined;
127
+ if (sample && typeof sample === "object" && !Array.isArray(sample)) {
128
+ return [...base, ...safeFields(Object.keys(sample)).map((f) => `item.${f}`)];
129
+ }
130
+ }
131
+ return base;
132
+ }
133
+
134
+ // (c) Inline literal array — infer fields from the first object element.
135
+ if (Array.isArray(items)) {
136
+ const [first] = items as unknown[];
137
+ if (first && typeof first === "object" && !Array.isArray(first)) {
138
+ return [...base, ...safeFields(Object.keys(first)).map((f) => `item.${f}`)];
139
+ }
140
+ }
141
+
142
+ // (d) Fallback — unresolvable binding, primitive/empty items, or missing schema.
143
+ return base;
144
+ }
@@ -20,6 +20,7 @@ import { isEditing, stopEditing } from "./inline-edit";
20
20
  import { copyNode, cutNode, pasteNode } from "./context-menu";
21
21
  import { openQuickSearch } from "../panels/quick-search";
22
22
  import { showConfirmDialog } from "../ui/layers";
23
+ import { rectOf } from "../utils/geometry";
23
24
 
24
25
  import type { JxPath } from "../state";
25
26
 
@@ -33,10 +34,8 @@ import type { JxPath } from "../state";
33
34
  * setPan: (x: number, y: number) => void;
34
35
  * applyTransform: () => void;
35
36
  * positionZoomIndicator: () => void;
36
- * componentInlineEdit: object | null;
37
37
  * saveFile: () => void;
38
38
  * openProject: () => void;
39
- * enterEditOnPath: (path: JxPath) => void;
40
39
  * }} getContext
41
40
  */
42
41
  export function initShortcuts(
@@ -47,10 +46,8 @@ export function initShortcuts(
47
46
  setPan: (x: number, y: number) => void;
48
47
  applyTransform: () => void;
49
48
  positionZoomIndicator: () => void;
50
- componentInlineEdit: Record<string, unknown> | null;
51
49
  saveFile: () => void;
52
50
  openProject: () => void;
53
- enterEditOnPath: (path: JxPath) => void;
54
51
  },
55
52
  ) {
56
53
  // Wheel handler: Ctrl+Scroll = zoom (cursor-centered), plain scroll = pan
@@ -58,8 +55,16 @@ export function initShortcuts(
58
55
  "wheel",
59
56
  (e: WheelEvent) => {
60
57
  const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
61
- // Edit (content) mode: let the scroll container handle scrolling natively
58
+ // Edit (content) mode: scroll the edit-mode container ourselves. The canvas iframe is sized to
59
+ // Its content (no internal scroll) and a cross-origin OOPIF doesn't bubble wheel to the parent,
60
+ // So the wheel reaches us forwarded (or over the canvas chrome) but never triggers native scroll.
62
61
  if (canvasMode === "edit") {
62
+ const sc = canvasWrap.querySelector<HTMLElement>(".content-edit-canvas");
63
+ if (sc) {
64
+ e.preventDefault();
65
+ sc.scrollTop += e.deltaY;
66
+ sc.scrollLeft += e.deltaX;
67
+ }
63
68
  return;
64
69
  }
65
70
  // Manage mode: browse table handles its own scrolling
@@ -69,7 +74,7 @@ export function initShortcuts(
69
74
  e.preventDefault();
70
75
  if (e.ctrlKey || e.metaKey) {
71
76
  // Zoom towards cursor
72
- const rect = canvasWrap.getBoundingClientRect();
77
+ const rect = rectOf(canvasWrap);
73
78
  const cursorX = e.clientX - rect.left;
74
79
  const cursorY = e.clientY - rect.top;
75
80
  const oldZoom = activeTab.value?.session.ui.zoom ?? 1;
@@ -124,15 +129,7 @@ export function initShortcuts(
124
129
  window.addEventListener("resize", () => getContext().positionZoomIndicator());
125
130
 
126
131
  document.addEventListener("keydown", (e) => {
127
- const {
128
- canvasMode,
129
- setPan,
130
- applyTransform,
131
- componentInlineEdit,
132
- saveFile,
133
- openProject,
134
- enterEditOnPath,
135
- } = getContext();
132
+ const { canvasMode, setPan, applyTransform, saveFile, openProject } = getContext();
136
133
  const tab = activeTab.value;
137
134
  const mod = e.ctrlKey || e.metaKey;
138
135
 
@@ -163,18 +160,6 @@ export function initShortcuts(
163
160
  }
164
161
  return;
165
162
  }
166
- if (componentInlineEdit) {
167
- if (mod && e.key === "s") {
168
- e.preventDefault();
169
- stopEditing();
170
- saveFile();
171
- }
172
- if (mod && e.key === "w") {
173
- e.preventDefault();
174
- }
175
- return;
176
- }
177
-
178
163
  if (mod) {
179
164
  switch (e.key) {
180
165
  case "w": {
@@ -305,7 +290,8 @@ export function initShortcuts(
305
290
  mutateInsertNode(t, pp, idx + 1, { tagName: "p", textContent: "" });
306
291
  t.session.selection = newPath;
307
292
  });
308
- enterEditOnPath(newPath);
293
+ // The iframe canvas re-enters inline edit for the freshly-selected node via its own
294
+ // Posted enterEdit flow, so no parent-side enterEditOnPath is needed here.
309
295
  }
310
296
  break;
311
297
  }
@@ -11,6 +11,7 @@
11
11
  import { html, render as litRender, nothing } from "lit-html";
12
12
  import { ref } from "lit-html/directives/ref.js";
13
13
  import { getLayerSlot } from "../ui/layers";
14
+ import { rectOf } from "../utils/geometry";
14
15
 
15
16
  interface SlashCommand {
16
17
  label: string;
@@ -40,16 +41,26 @@ const SLASH_COMMANDS = [
40
41
 
41
42
  // ─── State ────────────────────────────────────────────────────────────────────
42
43
 
43
- let callbacks: {
44
+ /** Callbacks a caller passes to {@link showSlashMenu}/{@link showSlashMenuAtRect}. */
45
+ export interface SlashMenuCallbacks {
44
46
  onSelect: (cmd: SlashCommand) => void;
47
+ /** Fired whenever the menu closes (outside click, Escape, no matches, and just before select). */
48
+ onDismiss?: () => void;
45
49
  showFilter?: boolean;
46
50
  commands?: SlashCommand[];
47
- } | null = null;
51
+ }
52
+
53
+ /** The anchor geometry the menu positions from (a DOMRect satisfies this). */
54
+ export interface SlashMenuAnchorRect {
55
+ left: number;
56
+ bottom: number;
57
+ }
58
+
59
+ let callbacks: SlashMenuCallbacks | null = null;
48
60
  let activeIdx = 0;
49
61
  let filteredItems: SlashCommand[] = [];
50
62
  let open = false;
51
- let _anchorEl: HTMLElement | null = null;
52
- let _anchorRect: DOMRect | null = null;
63
+ let _anchorRect: SlashMenuAnchorRect | null = null;
53
64
  let _filterEl: HTMLInputElement | null = null;
54
65
  let _popoverEl: HTMLElement | null = null;
55
66
 
@@ -70,24 +81,33 @@ export function isSlashMenuOpen() {
70
81
  *
71
82
  * @param {HTMLElement} anchorEl — the element being edited (for positioning)
72
83
  * @param {string} filter — current typed filter text (after the "/")
73
- * @param {{
74
- * onSelect: (cmd: SlashCommand) => void;
75
- * showFilter?: boolean;
76
- * commands?: SlashCommand[];
77
- * }} cbs
84
+ * @param {SlashMenuCallbacks} cbs
85
+ */
86
+ export function showSlashMenu(anchorEl: HTMLElement, filter: string, cbs: SlashMenuCallbacks) {
87
+ showAt(rectOf(anchorEl), filter, cbs);
88
+ }
89
+
90
+ /**
91
+ * Show (or update) the slash menu anchored below a PARENT-VIEWPORT rect — for callers with no
92
+ * anchor element in this realm (the canvas iframe posts the edited element's rect across the bridge
93
+ * and the host converts it).
94
+ *
95
+ * @param {SlashMenuAnchorRect} rect
96
+ * @param {string} filter
97
+ * @param {SlashMenuCallbacks} cbs
78
98
  */
79
- export function showSlashMenu(
80
- anchorEl: HTMLElement,
99
+ export function showSlashMenuAtRect(
100
+ rect: SlashMenuAnchorRect,
81
101
  filter: string,
82
- cbs: {
83
- onSelect: (cmd: SlashCommand) => void;
84
- showFilter?: boolean;
85
- commands?: SlashCommand[];
86
- },
102
+ cbs: SlashMenuCallbacks,
87
103
  ) {
104
+ showAt(rect, filter, cbs);
105
+ }
106
+
107
+ /** Shared body of the two show entry points. */
108
+ function showAt(rect: SlashMenuAnchorRect, filter: string, cbs: SlashMenuCallbacks) {
88
109
  callbacks = cbs;
89
- _anchorEl = anchorEl;
90
- _anchorRect = anchorEl.getBoundingClientRect();
110
+ _anchorRect = rect;
91
111
 
92
112
  const source = cbs.commands || SLASH_COMMANDS;
93
113
  filteredItems = filter
@@ -103,7 +123,7 @@ export function showSlashMenu(
103
123
 
104
124
  activeIdx = 0;
105
125
 
106
- render(anchorEl, cbs.showFilter || false);
126
+ render(cbs.showFilter || false);
107
127
 
108
128
  if (!open) {
109
129
  open = true;
@@ -126,9 +146,9 @@ export function dismissSlashMenu() {
126
146
  if (!open) {
127
147
  return;
128
148
  }
149
+ const cbs = callbacks;
129
150
  open = false;
130
151
  callbacks = null;
131
- _anchorEl = null;
132
152
  _anchorRect = null;
133
153
  _filterEl = null;
134
154
  _popoverEl = null;
@@ -136,16 +156,19 @@ export function dismissSlashMenu() {
136
156
  document.removeEventListener("keydown", onKeydown, true);
137
157
  document.removeEventListener("mousedown", onOutsideClick, true);
138
158
  litRender(nothing, getHost());
159
+ // After teardown so a re-entrant show from the callback sees a closed menu. select() relies on
160
+ // This ordering too: dismiss (→ onDismiss) fires BEFORE onSelect.
161
+ cbs?.onDismiss?.();
139
162
  }
140
163
 
141
164
  // ─── Internal ─────────────────────────────────────────────────────────────────
142
165
 
143
- /**
144
- * @param {HTMLElement} anchorEl
145
- * @param {boolean} showFilter
146
- */
147
- function render(anchorEl: HTMLElement, showFilter: boolean) {
148
- const rect = _anchorRect || anchorEl.getBoundingClientRect();
166
+ /** @param {boolean} showFilter */
167
+ function render(showFilter: boolean) {
168
+ const rect = _anchorRect;
169
+ if (!rect) {
170
+ return;
171
+ }
149
172
 
150
173
  litRender(
151
174
  html`
@@ -224,9 +247,7 @@ function onFilterInput(e: Event) {
224
247
  : source;
225
248
 
226
249
  activeIdx = 0;
227
- if (_anchorEl) {
228
- render(_anchorEl, true);
229
- }
250
+ render(true);
230
251
 
231
252
  // Re-focus input after re-render
232
253
  requestAnimationFrame(() => {
@@ -238,17 +259,21 @@ function onFilterInput(e: Event) {
238
259
  });
239
260
  }
240
261
 
241
- /** @param {KeyboardEvent} e */
242
- function onKeydown(e: KeyboardEvent) {
262
+ /**
263
+ * Drive the open menu with a navigation key. The canvas-iframe bridge calls this DIRECTLY (the key
264
+ * was pressed in the iframe realm — a synthetic keydown redispatch on this document would lose the
265
+ * capture-first + stopPropagation semantics the menu relies on to shield other handlers).
266
+ *
267
+ * @param {string} key — "ArrowDown" | "ArrowUp" | "Enter" | "Escape"
268
+ */
269
+ export function handleSlashMenuKey(key: string): void {
243
270
  if (!open) {
244
271
  return;
245
272
  }
246
273
 
247
274
  const items = getHost().querySelectorAll("sp-menu-item:not([disabled])") as NodeListOf<Element>;
248
275
 
249
- if (e.key === "ArrowDown") {
250
- e.preventDefault();
251
- e.stopPropagation();
276
+ if (key === "ArrowDown") {
252
277
  if (items.length === 0) {
253
278
  return;
254
279
  }
@@ -256,9 +281,7 @@ function onKeydown(e: KeyboardEvent) {
256
281
  activeIdx = (activeIdx + 1) % items.length;
257
282
  items[activeIdx]?.setAttribute("focused", "");
258
283
  items[activeIdx]?.scrollIntoView({ block: "nearest" });
259
- } else if (e.key === "ArrowUp") {
260
- e.preventDefault();
261
- e.stopPropagation();
284
+ } else if (key === "ArrowUp") {
262
285
  if (items.length === 0) {
263
286
  return;
264
287
  }
@@ -266,16 +289,24 @@ function onKeydown(e: KeyboardEvent) {
266
289
  activeIdx = (activeIdx - 1 + items.length) % items.length;
267
290
  items[activeIdx]?.setAttribute("focused", "");
268
291
  items[activeIdx]?.scrollIntoView({ block: "nearest" });
269
- } else if (e.key === "Enter") {
270
- e.preventDefault();
271
- e.stopPropagation();
292
+ } else if (key === "Enter") {
272
293
  const cmd = filteredItems[activeIdx];
273
294
  if (cmd) {
274
295
  select(cmd);
275
296
  }
276
- } else if (e.key === "Escape") {
297
+ } else if (key === "Escape") {
298
+ dismissSlashMenu();
299
+ }
300
+ }
301
+
302
+ /** @param {KeyboardEvent} e */
303
+ function onKeydown(e: KeyboardEvent) {
304
+ if (!open) {
305
+ return;
306
+ }
307
+ if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
277
308
  e.preventDefault();
278
309
  e.stopPropagation();
279
- dismissSlashMenu();
310
+ handleSlashMenuKey(e.key);
280
311
  }
281
312
  }
@@ -41,6 +41,7 @@ import type { JxMutableNode } from "@jxsuite/schema/types";
41
41
  import type { StudioState } from "../state.js";
42
42
  import type { Tab } from "../tabs/tab.js";
43
43
  import type { RenameResult } from "../types";
44
+ import { rectOf } from "../utils/geometry";
44
45
 
45
46
  // ─── File icon map ────────────────────────────────────────────────────────────
46
47
 
@@ -738,7 +739,7 @@ function showFileContextMenu(
738
739
  }
739
740
  requestAnimationFrame(() => {
740
741
  const popover = el as HTMLElement;
741
- const menuRect = popover.getBoundingClientRect();
742
+ const menuRect = rectOf(popover);
742
743
  if (x + menuRect.width > window.innerWidth) {
743
744
  x = window.innerWidth - menuRect.width - 4;
744
745
  }