@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,93 @@
1
+ /**
2
+ * Link-target.ts — pure, dependency-free helpers for classifying and composing anchor `href`
3
+ * targets in the properties panel's Link section.
4
+ *
5
+ * `classifyHref` splits a raw href string into a discriminated `{ kind, value }` pair so the UI can
6
+ * offer a target-kind selector (Internal page / External URL / #anchor / mailto: / tel:).
7
+ * `composeHref` is its inverse — the two round-trip for every kind so editing through the UI never
8
+ * corrupts an href.
9
+ *
10
+ * Both functions are total (never throw) and have no DOM or platform dependencies, so they are
11
+ * fully unit-testable in isolation.
12
+ */
13
+
14
+ /** The distinct target kinds an anchor href can address. */
15
+ export type LinkKind = "internal" | "external" | "anchor" | "mailto" | "tel";
16
+
17
+ /** A classified href: its {@link LinkKind} plus the "bare" value for that kind's input. */
18
+ export interface LinkTarget {
19
+ kind: LinkKind;
20
+ /**
21
+ * The user-facing value for the kind's input: - internal → the path (e.g. `/about/`) - external →
22
+ * the full href (e.g. `https://x.com`) - anchor → the fragment WITHOUT the leading `#` (e.g.
23
+ * `section`) - mailto → the email address WITHOUT the `mailto:` scheme - tel → the phone number
24
+ * WITHOUT the `tel:` scheme
25
+ */
26
+ value: string;
27
+ }
28
+
29
+ /** Matches an absolute URL scheme like `http://`, `https://`, `ftp://`, or protocol-relative `//`. */
30
+ const ABSOLUTE_SCHEME = /^([a-z][a-z\d+.-]*:)?\/\//i;
31
+
32
+ /**
33
+ * Classify a raw `href` string into a {@link LinkTarget}.
34
+ *
35
+ * Rules (checked in order): 1. `mailto:…` → `{ kind: "mailto", value: address }` 2. `tel:…` → `{
36
+ * kind: "tel", value: number }` 3. `#…` → `{ kind: "anchor", value: rest }` (leading `#` stripped)
37
+ * 4. absolute (`http(s)://…`) or protocol-relative (`//…`) → `{ kind: "external", value: href }` 5.
38
+ * `/…` → `{ kind: "internal", value: path }` 6. anything else → `{ kind: "external", value: href
39
+ * }`
40
+ *
41
+ * @param href The raw attribute value (may be empty)
42
+ */
43
+ export function classifyHref(href = ""): LinkTarget {
44
+ const value = href;
45
+ const lower = value.toLowerCase();
46
+
47
+ if (lower.startsWith("mailto:")) {
48
+ return { kind: "mailto", value: value.slice("mailto:".length) };
49
+ }
50
+ if (lower.startsWith("tel:")) {
51
+ return { kind: "tel", value: value.slice("tel:".length) };
52
+ }
53
+ if (value.startsWith("#")) {
54
+ return { kind: "anchor", value: value.slice(1) };
55
+ }
56
+ if (ABSOLUTE_SCHEME.test(value)) {
57
+ return { kind: "external", value };
58
+ }
59
+ if (value.startsWith("/")) {
60
+ return { kind: "internal", value };
61
+ }
62
+ return { kind: "external", value };
63
+ }
64
+
65
+ /**
66
+ * Compose an `href` string from a {@link LinkKind} and its bare value — the inverse of
67
+ * {@link classifyHref}. An empty (or whitespace-only) value yields `""` for every kind so callers
68
+ * can treat "cleared" uniformly. Round-trips with `classifyHref` for every kind.
69
+ *
70
+ * @param kind The target kind
71
+ * @param value The bare value for that kind (address/number/fragment/path/href)
72
+ */
73
+ export function composeHref(kind: LinkKind, value: string): string {
74
+ const bare = (value ?? "").trim();
75
+ if (!bare) {
76
+ return "";
77
+ }
78
+ switch (kind) {
79
+ case "mailto": {
80
+ return `mailto:${bare}`;
81
+ }
82
+ case "tel": {
83
+ return `tel:${bare}`;
84
+ }
85
+ case "anchor": {
86
+ return bare.startsWith("#") ? bare : `#${bare}`;
87
+ }
88
+ // Internal / external — the bare value IS the href.
89
+ default: {
90
+ return bare;
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Strip `on*` event-handler bindings from a Jx document tree (returns a shallow-rebuilt copy). Used
3
+ * to neutralize handlers before edit/design rendering so the canvas never wires live behavior, and
4
+ * to drop `timing: "server"` state entries the canvas can't run.
5
+ *
6
+ * Lives in its own dependency-light module (it pulls only the pure `@jxsuite/schema/guards`
7
+ * predicate) so the slim canvas-iframe subtree renderer can import it without dragging in the
8
+ * editor's reactive `store.ts`; `store.ts` re-exports it for the legacy in-realm patcher.
9
+ */
10
+
11
+ import { isEventBinding } from "@jxsuite/schema/guards";
12
+ import type { JxMutableNode } from "@jxsuite/schema/types";
13
+
14
+ /** Strip all `on*` event-handler properties (and server-timed state) from a document tree. */
15
+ export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
16
+ if (!node || typeof node !== "object") {
17
+ return node;
18
+ }
19
+ if (Array.isArray(node)) {
20
+ // Arrays of nodes round-trip element-wise; the array itself is not a node.
21
+ return node.map((n: JxMutableNode) => stripEventHandlers(n)) as unknown as JxMutableNode;
22
+ }
23
+ const out: Record<string, unknown> = {};
24
+ for (const [k, v] of Object.entries(node)) {
25
+ if (k.startsWith("on") && isEventBinding(v)) {
26
+ continue;
27
+ }
28
+ if (k === "children") {
29
+ out.children = Array.isArray(v)
30
+ ? v.map((c: JxMutableNode) => stripEventHandlers(c))
31
+ : stripEventHandlers(v as JxMutableNode);
32
+ } else if (k === "cases" && typeof v === "object") {
33
+ const cases: Record<string, unknown> = {};
34
+ for (const [ck, cv] of Object.entries(v as Record<string, unknown>)) {
35
+ cases[ck] = stripEventHandlers(cv as JxMutableNode);
36
+ }
37
+ out.cases = cases;
38
+ } else if (k === "state" && typeof v === "object" && v !== null) {
39
+ const state: Record<string, unknown> = {};
40
+ for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
41
+ if (sv && typeof sv === "object" && (sv as Record<string, unknown>).timing === "server") {
42
+ continue;
43
+ }
44
+ state[sk] = sv;
45
+ }
46
+ out.state = state;
47
+ } else if (k === "style" || k === "attributes" || k === "$media") {
48
+ out[k] = v;
49
+ } else {
50
+ out[k] = v;
51
+ }
52
+ }
53
+ return out;
54
+ }
package/src/view.ts CHANGED
@@ -28,15 +28,6 @@ interface ViewState {
28
28
  _editingTarget?: string | null;
29
29
  })
30
30
  | null;
31
- componentInlineEdit: {
32
- el: HTMLElement;
33
- path: (string | number)[];
34
- originalText: string;
35
- mediaName: string | null;
36
- _outsideHandler?: ((e: MouseEvent) => void) | null;
37
- [k: string]: unknown;
38
- } | null;
39
- inlineEditCleanup: (() => void) | null;
40
31
  blockActionBarEl: HTMLElement | null;
41
32
  selDragCleanup: (() => void) | null;
42
33
  dndCleanups: (() => void)[];
@@ -46,16 +37,9 @@ interface ViewState {
46
37
  forcedAttrEl: HTMLElement | null;
47
38
  elementsCollapsed: Set<string>;
48
39
  elementsFilter: string;
49
- lastDragInput: {
50
- clientX: number;
51
- clientY: number;
52
- [k: string]: unknown;
53
- } | null;
54
40
  _currentDropTargetRow: HTMLElement | null;
55
41
  layerDragSourceHeight: number;
56
- savedRange: Range | null;
57
42
  _completionRegistered: boolean;
58
- stylebookElToTag: WeakMap<Element, string>;
59
43
  showAddBreakpointForm: boolean;
60
44
  addBreakpointPreview: string;
61
45
  layoutSelection: unknown;
@@ -81,10 +65,6 @@ export const view: ViewState = {
81
65
  monacoEditor: null,
82
66
  functionEditor: null,
83
67
 
84
- // Inline editing
85
- componentInlineEdit: null,
86
- inlineEditCleanup: null,
87
-
88
68
  // Floating UI containers
89
69
  blockActionBarEl: null,
90
70
 
@@ -105,16 +85,13 @@ export const view: ViewState = {
105
85
  elementsFilter: "",
106
86
 
107
87
  // Drag interaction
108
- lastDragInput: null,
109
88
  _currentDropTargetRow: null,
110
89
  layerDragSourceHeight: 0,
111
90
 
112
91
  // Editor state
113
- savedRange: null,
114
92
  _completionRegistered: false,
115
93
 
116
94
  // Canvas / stylebook
117
- stylebookElToTag: new WeakMap(),
118
95
 
119
96
  // Responsive breakpoints UI
120
97
  showAddBreakpointForm: false,
@@ -1,4 +1,4 @@
1
- import { computed, reactive } from "../reactivity";
1
+ import { computed, reactive, toRaw } from "../reactivity";
2
2
  import { createTab, disposeTab } from "../tabs/tab";
3
3
  import type { Tab } from "../tabs/tab";
4
4
 
@@ -56,6 +56,19 @@ export const activeTab = computed(() =>
56
56
  workspace.activeTabId ? (workspace.tabs.get(workspace.activeTabId) ?? null) : null,
57
57
  ) as unknown as ComputedRef<Tab | null>;
58
58
 
59
+ /**
60
+ * Whether `tab` is the active tab. Identity check survives reactive-proxy wrapping (activeTab.value
61
+ * is a proxy; callers may hold either the raw tab or a proxy of it).
62
+ *
63
+ * @param {Tab | null} tab
64
+ */
65
+ export function isTabActive(tab: Tab | null): boolean {
66
+ const active = activeTab.value;
67
+ return (
68
+ tab !== null && active !== null && toRaw(active as object) === toRaw(tab as unknown as object)
69
+ );
70
+ }
71
+
59
72
  /**
60
73
  * Open a new tab and make it active.
61
74
  *
@@ -1,113 +0,0 @@
1
- /// <reference lib="dom" />
2
- /// <reference lib="dom.iterable" />
3
- /**
4
- * Isolated subtree rendering for surgical canvas patches. Re-renders a single document node into
5
- * fresh DOM using the same edit-mode transform, runtime renderer, scope, and path mapper as the
6
- * panel's last full render — so the patched DOM is indistinguishable from a full re-render.
7
- */
8
-
9
- import { elToRenderScope, elToScope, getNodeAtPath, stripEventHandlers } from "../store";
10
- import { prepareForEditMode } from "../utils/edit-display";
11
- import { renderNode as runtimeRenderNode } from "@jxsuite/runtime";
12
- import { effectScope } from "../reactivity";
13
- import { canvasPerf } from "./canvas-perf";
14
-
15
- import type { JxPath } from "../state";
16
- import type { JxMutableNode } from "@jxsuite/schema/types";
17
- import type { CanvasPanel, PanelLiveCtx } from "../types";
18
-
19
- /**
20
- * Render the document node at docPath into a detached DOM subtree for the given panel.
21
- *
22
- * The scope comes from the parent element's recorded render scope (elToScope), falling back to the
23
- * panel's root scope — so $-bindings, $media, and state references resolve exactly as they did in
24
- * the full render. Throws when the panel has no live render context.
25
- *
26
- * @param {CanvasPanel} panel
27
- * @param {JxMutableNode} doc Raw (non-reactive) document root
28
- * @param {JxPath} docPath
29
- * @param {Element} parentEl The rendered parent element (scope source)
30
- */
31
- export function renderSubtree(
32
- panel: CanvasPanel,
33
- doc: JxMutableNode,
34
- docPath: JxPath,
35
- parentEl: Element,
36
- ): HTMLElement | Text {
37
- const { liveCtx } = panel;
38
- if (!liveCtx) {
39
- throw new Error("panel-missing-live-ctx");
40
- }
41
- const node = getNodeAtPath(doc, docPath) as JxMutableNode | string | undefined;
42
- if (node === undefined) {
43
- throw new Error(`node-not-found:${docPath.join("/")}`);
44
- }
45
- const def = typeof node === "string" ? node : prepareForEditMode(stripEventHandlers(node));
46
- const scope = elToScope.get(parentEl) ?? liveCtx.scope;
47
-
48
- // Render inside an effect scope parented to the panel's render scope: disposable on its own
49
- // When the subtree is later replaced/removed, and disposed with the panel on full renders.
50
- const render = () => {
51
- const subScope = effectScope();
52
- const rendered = subScope.run(() =>
53
- runtimeRenderNode(def, scope, {
54
- _path: docPathToRenderPath(docPath, liveCtx),
55
- onNodeCreated: liveCtx.pathMapper,
56
- }),
57
- )!;
58
- if (rendered instanceof HTMLElement) {
59
- elToRenderScope.set(rendered, subScope);
60
- }
61
- return rendered;
62
- };
63
- const el = panel.renderScope ? panel.renderScope.run(render)! : render();
64
- canvasPerf.subtreeRenders += 1;
65
-
66
- // Match full-render edit display: canvas content never receives pointer events directly.
67
- if (el instanceof HTMLElement) {
68
- el.style.pointerEvents = "none";
69
- for (const child of el.querySelectorAll("*")) {
70
- (child as HTMLElement).style.pointerEvents = "none";
71
- }
72
- }
73
- return el;
74
- }
75
-
76
- /**
77
- * Inverse of makePathMapper's layout-prefix strip: document paths are relative to the page doc,
78
- * render paths to the layout-merged doc. ($map/$switch paths never reach here — the patcher
79
- * escalates them.)
80
- *
81
- * @param {JxPath} docPath
82
- * @param {PanelLiveCtx} liveCtx
83
- */
84
- function docPathToRenderPath(docPath: JxPath, liveCtx: PanelLiveCtx): (string | number)[] {
85
- if (liveCtx.layoutWrapped && liveCtx.pageContentPrefix && docPath[0] === "children") {
86
- // Re-apply the slot-container offset stripped by makePathMapper: page child index N renders at
87
- // Container index N + offset (offset = leading layout siblings before the <slot>).
88
- const [, idx] = docPath;
89
- return typeof idx === "number"
90
- ? [...liveCtx.pageContentPrefix, idx + (liveCtx.pageContentOffset ?? 0), ...docPath.slice(2)]
91
- : [...liveCtx.pageContentPrefix, ...docPath.slice(1)];
92
- }
93
- return docPath;
94
- }
95
-
96
- /**
97
- * The DOM node currently occupying children[index] of the rendered parent, or null when appending
98
- * at the end. The runtime renders an optional leading text node (from textContent) followed by one
99
- * DOM node per children entry, in order.
100
- *
101
- * @param {Element} parentEl
102
- * @param {JxMutableNode} parentNode
103
- * @param {number} index
104
- */
105
- export function domChildReference(
106
- parentEl: Element,
107
- parentNode: JxMutableNode,
108
- index: number,
109
- ): ChildNode | null {
110
- const text = parentNode.textContent;
111
- const textOffset = text != null && String(text) !== "" ? 1 : 0;
112
- return parentEl.childNodes[textOffset + index] ?? null;
113
- }
@@ -1,349 +0,0 @@
1
- /// <reference lib="dom" />
2
- /**
3
- * Component inline edit — extracted from studio.js (Phase 4j). Manages plaintext-only editing on
4
- * canvas elements in design mode, with slash menu delegation for block insertion.
5
- */
6
-
7
- import {
8
- canvasPanels,
9
- childIndex,
10
- elToPath,
11
- getNodeAtPath,
12
- parentElementPath,
13
- renderOnly,
14
- updateUi,
15
- } from "../store";
16
- import { activeTab } from "../workspace/workspace";
17
- import type { JxPath } from "../state";
18
- import {
19
- mutateInsertNode,
20
- mutateRemoveNode,
21
- mutateUpdateProperty,
22
- transactDoc,
23
- } from "../tabs/transact";
24
- import { view } from "../view";
25
- import { panelMediaToActiveMedia } from "../canvas/canvas-helpers";
26
- import { dismissSlashMenu, isSlashMenuOpen, showSlashMenu } from "./slash-menu";
27
- import { renderBlockActionBar } from "../panels/block-action-bar";
28
- import { defaultDef } from "../panels/shared";
29
-
30
- /**
31
- * Initialize the component inline edit module.
32
- *
33
- * @param {{
34
- * findCanvasElement: (
35
- * path: import("../state").JxPath,
36
- * canvasEl: HTMLElement,
37
- * ) => HTMLElement | null;
38
- * }} _ctx
39
- */
40
- export function initComponentInlineEdit(_ctx: {
41
- findCanvasElement: (path: JxPath, canvasEl: HTMLElement) => HTMLElement | null;
42
- }) {
43
- // Public init hook retained for API symmetry; the ctx it once stored is no longer read here.
44
- void _ctx;
45
- }
46
-
47
- /**
48
- * Enter plaintext inline editing on a canvas element.
49
- *
50
- * @param {HTMLElement} el
51
- * @param {JxPath} path
52
- */
53
- export function enterComponentInlineEdit(el: HTMLElement, path: JxPath) {
54
- if (view.componentInlineEdit && view.componentInlineEdit.el === el) {
55
- return;
56
- }
57
-
58
- const node = getNodeAtPath(activeTab.value!.doc.document, path);
59
- if (!node) {
60
- return;
61
- }
62
-
63
- const tc = node.textContent;
64
- if (node.$props && (node.tagName || "").includes("-")) {
65
- return;
66
- }
67
- if (Array.isArray(node.children) && node.children.length > 0) {
68
- return;
69
- }
70
- if (node.children && typeof node.children === "object") {
71
- return;
72
- }
73
- if (tc && typeof tc === "object") {
74
- return;
75
- }
76
- const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed", "slot"]);
77
- if (voids.has(node.tagName || "")) {
78
- return;
79
- }
80
-
81
- for (const p of canvasPanels) {
82
- const boxes = p.overlay.querySelectorAll(".overlay-box") as NodeListOf<HTMLElement>;
83
- for (const box of boxes) {
84
- box.style.border = "none";
85
- }
86
- p.overlayClk.style.pointerEvents = "none";
87
- }
88
-
89
- el.contentEditable = "plaintext-only";
90
- el.style.pointerEvents = "auto";
91
- el.style.cursor = "text";
92
- el.style.outline = "1px solid var(--accent, #4f8bc7)";
93
- el.style.outlineOffset = "-1px";
94
- el.style.minHeight = "1em";
95
-
96
- const rawText = typeof tc === "string" ? tc : "";
97
- el.textContent = rawText;
98
-
99
- view.componentInlineEdit = {
100
- el,
101
- mediaName: canvasPanels.find((p) => p.canvas.contains(el))?.mediaName || null,
102
- originalText: rawText,
103
- path,
104
- };
105
-
106
- el.focus();
107
- const sel = window.getSelection();
108
- const range = document.createRange();
109
- range.selectNodeContents(el);
110
- range.collapse(false);
111
- sel?.removeAllRanges();
112
- sel?.addRange(range);
113
-
114
- el.addEventListener("keydown", componentInlineKeydown);
115
- el.addEventListener("input", componentInlineInput);
116
-
117
- const outsideHandler = (evt: MouseEvent) => {
118
- if (!view.componentInlineEdit) {
119
- document.removeEventListener("mousedown", outsideHandler, true);
120
- return;
121
- }
122
- if (view.componentInlineEdit.el.contains(evt.target as Node)) {
123
- return;
124
- }
125
- if (isSlashMenuOpen()) {
126
- return;
127
- }
128
- if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target as Node)) {
129
- return;
130
- }
131
- document.removeEventListener("mousedown", outsideHandler, true);
132
-
133
- let hitMedia = null;
134
- let hitPath: (string | number)[] | null = null;
135
- for (const p of canvasPanels) {
136
- const els = p.canvas.querySelectorAll("*") as NodeListOf<HTMLElement>;
137
- for (const child of els) {
138
- child.style.pointerEvents = "auto";
139
- }
140
- p.overlayClk.style.display = "none";
141
- const found = document.elementsFromPoint(evt.clientX, evt.clientY);
142
- p.overlayClk.style.display = "";
143
- for (const child of els) {
144
- child.style.pointerEvents = "none";
145
- }
146
- for (const hit of found) {
147
- if (p.canvas.contains(hit) && hit !== p.canvas) {
148
- const hitElPath = elToPath.get(hit);
149
- if (hitElPath) {
150
- hitPath = hitElPath;
151
- hitMedia = p.mediaName;
152
- break;
153
- }
154
- }
155
- }
156
- if (hitPath) {
157
- break;
158
- }
159
- }
160
-
161
- const { el: editEl, path: editPath, originalText } = view.componentInlineEdit;
162
- const newText = (editEl.textContent ?? "").trim();
163
- cleanupComponentInlineEdit(editEl);
164
-
165
- const isEmpty = !newText;
166
- const pPath = parentElementPath(editPath);
167
-
168
- if (hitPath) {
169
- let hp = hitPath;
170
- const media = panelMediaToActiveMedia(hitMedia);
171
- updateUi("pendingInlineEdit", { mediaName: hitMedia, path: hp });
172
- activeTab.value!.session.ui.activeMedia = media;
173
- if (isEmpty && pPath) {
174
- transactDoc(activeTab.value, (t) => {
175
- mutateRemoveNode(t, editPath);
176
- const removedIdx = childIndex(editPath) as number;
177
- const hitIdx = childIndex(hp) as number;
178
- const hitParent = parentElementPath(hp);
179
- if (
180
- hitParent &&
181
- pPath &&
182
- hitParent.join("/") === pPath.join("/") &&
183
- hitIdx > removedIdx
184
- ) {
185
- hp = [...pPath, "children", hitIdx - 1];
186
- updateUi("pendingInlineEdit", { mediaName: hitMedia, path: hp });
187
- }
188
- t.session.selection = hp;
189
- });
190
- } else if (newText !== originalText) {
191
- transactDoc(activeTab.value, (t) => {
192
- mutateUpdateProperty(t, editPath, "textContent", newText || undefined);
193
- t.session.selection = hp;
194
- });
195
- } else {
196
- activeTab.value!.session.selection = hp;
197
- }
198
- } else if (isEmpty && pPath) {
199
- transactDoc(activeTab.value, (t) => mutateRemoveNode(t, editPath));
200
- } else if (newText !== originalText) {
201
- transactDoc(activeTab.value, (t) =>
202
- mutateUpdateProperty(t, editPath, "textContent", newText || undefined),
203
- );
204
- } else {
205
- renderOnly("canvas");
206
- renderOnly("overlays");
207
- }
208
- };
209
- document.addEventListener("mousedown", outsideHandler, true);
210
- view.componentInlineEdit._outsideHandler = outsideHandler;
211
-
212
- renderBlockActionBar();
213
- }
214
-
215
- /** @param {KeyboardEvent} e */
216
- function componentInlineKeydown(e: KeyboardEvent) {
217
- if (isSlashMenuOpen() && ["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
218
- return;
219
- }
220
-
221
- if (e.key === "Enter" && !e.shiftKey) {
222
- e.preventDefault();
223
- splitParagraph();
224
- } else if (e.key === "Escape") {
225
- e.preventDefault();
226
- cancelComponentInlineEdit();
227
- }
228
- e.stopPropagation();
229
- }
230
-
231
- function splitParagraph() {
232
- if (!view.componentInlineEdit) {
233
- return;
234
- }
235
- const { el, path, mediaName } = view.componentInlineEdit;
236
-
237
- const sel = el.ownerDocument.defaultView?.getSelection() as Selection | null;
238
- const fullText = el.textContent || "";
239
- let offset = fullText.length;
240
- if (sel && sel.rangeCount) {
241
- const range = sel.getRangeAt(0);
242
- const preRange = document.createRange();
243
- preRange.selectNodeContents(el);
244
- preRange.setEnd(range.startContainer, range.startOffset);
245
- offset = preRange.toString().length;
246
- }
247
-
248
- const textBefore = fullText.slice(0, offset);
249
- const textAfter = fullText.slice(offset);
250
-
251
- const tag = "p";
252
- const pPath = parentElementPath(path) as JxPath;
253
- const idx = childIndex(path) as number;
254
- if (!pPath) {
255
- return;
256
- }
257
-
258
- const newDef = { tagName: tag, textContent: textAfter };
259
- const newPath = [...pPath, "children", idx + 1];
260
-
261
- cleanupComponentInlineEdit(el);
262
-
263
- transactDoc(activeTab.value, (t) => {
264
- mutateUpdateProperty(t, path, "textContent", textBefore || undefined);
265
- mutateInsertNode(t, pPath, idx + 1, newDef);
266
- t.session.selection = newPath;
267
- });
268
-
269
- updateUi("pendingInlineEdit", { mediaName, path: newPath });
270
- }
271
-
272
- function cancelComponentInlineEdit() {
273
- if (!view.componentInlineEdit) {
274
- return;
275
- }
276
- const { el } = view.componentInlineEdit;
277
- cleanupComponentInlineEdit(el);
278
- renderOnly("canvas");
279
- renderOnly("overlays");
280
- }
281
-
282
- /** @param {HTMLElement} el */
283
- function cleanupComponentInlineEdit(el: HTMLElement) {
284
- el.removeEventListener("keydown", componentInlineKeydown);
285
- el.removeEventListener("input", componentInlineInput);
286
- dismissSlashMenu();
287
- el.removeAttribute("contenteditable");
288
- el.style.cursor = "";
289
- el.style.outline = "";
290
- el.style.outlineOffset = "";
291
- el.style.minHeight = "";
292
- el.style.pointerEvents = "";
293
-
294
- if (view.componentInlineEdit?._outsideHandler) {
295
- document.removeEventListener("mousedown", view.componentInlineEdit._outsideHandler, true);
296
- }
297
- view.componentInlineEdit = null;
298
-
299
- for (const p of canvasPanels) {
300
- p.overlay.style.display = "";
301
- p.overlayClk.style.pointerEvents = "";
302
- }
303
- }
304
-
305
- // ─── Component-mode slash commands ──────────────────────────────────────────
306
-
307
- function componentInlineInput() {
308
- if (!view.componentInlineEdit) {
309
- return;
310
- }
311
- const { el, originalText } = view.componentInlineEdit;
312
- const text = el.textContent || "";
313
-
314
- if (originalText === "" && text.startsWith("/")) {
315
- const filter = text.slice(1).toLowerCase();
316
- showSlashMenu(el, filter, { onSelect: handleComponentSlashSelect });
317
- } else {
318
- dismissSlashMenu();
319
- }
320
- }
321
-
322
- /** @param {{ tag: string; label: string; description: string }} cmd */
323
- function handleComponentSlashSelect(cmd: { tag: string; label: string; description: string }) {
324
- if (!view.componentInlineEdit) {
325
- return;
326
- }
327
- const { el, path, mediaName } = view.componentInlineEdit;
328
- const pPath = parentElementPath(path);
329
- const idx = childIndex(path) as number;
330
- if (!pPath) {
331
- return;
332
- }
333
-
334
- cleanupComponentInlineEdit(el);
335
-
336
- const newDef = defaultDef(cmd.tag);
337
- const newPath = [...pPath, "children", idx];
338
-
339
- transactDoc(activeTab.value, (t) => {
340
- mutateRemoveNode(t, path);
341
- mutateInsertNode(t, pPath, idx, newDef);
342
- t.session.selection = newPath;
343
- });
344
-
345
- const hasText = newDef.textContent != null;
346
- if (hasText) {
347
- updateUi("pendingInlineEdit", { mediaName, path: newPath });
348
- }
349
- }