@jxsuite/studio 0.28.4 → 0.29.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 (81) hide show
  1. package/dist/studio.js +6769 -5399
  2. package/dist/studio.js.map +86 -79
  3. package/package.json +5 -4
  4. package/src/browse/browse-modal.ts +2 -2
  5. package/src/browse/browse.ts +20 -19
  6. package/src/canvas/canvas-diff.ts +3 -3
  7. package/src/canvas/canvas-helpers.ts +15 -2
  8. package/src/canvas/canvas-live-render.ts +168 -86
  9. package/src/canvas/canvas-patcher.ts +656 -0
  10. package/src/canvas/canvas-perf.ts +68 -0
  11. package/src/canvas/canvas-render.ts +135 -23
  12. package/src/canvas/canvas-subtree-render.ts +113 -0
  13. package/src/canvas/canvas-utils.ts +4 -0
  14. package/src/editor/component-inline-edit.ts +4 -35
  15. package/src/editor/context-menu.ts +92 -74
  16. package/src/editor/convert-to-component.ts +11 -2
  17. package/src/editor/convert-to-repeater.ts +4 -5
  18. package/src/editor/inline-edit.ts +9 -9
  19. package/src/editor/inline-format.ts +11 -11
  20. package/src/editor/merge-tags.ts +120 -0
  21. package/src/editor/shortcuts.ts +4 -4
  22. package/src/files/components.ts +37 -0
  23. package/src/files/file-ops.ts +28 -7
  24. package/src/files/files.ts +25 -21
  25. package/src/github/github-auth.ts +14 -2
  26. package/src/github/github-publish.ts +12 -2
  27. package/src/panels/activity-bar.ts +1 -1
  28. package/src/panels/ai-panel.ts +67 -39
  29. package/src/panels/block-action-bar.ts +72 -1
  30. package/src/panels/canvas-dnd.ts +55 -26
  31. package/src/panels/data-explorer.ts +5 -3
  32. package/src/panels/dnd.ts +12 -15
  33. package/src/panels/editors.ts +5 -23
  34. package/src/panels/elements-panel.ts +2 -12
  35. package/src/panels/events-panel.ts +1 -1
  36. package/src/panels/git-panel.ts +6 -6
  37. package/src/panels/head-panel.ts +1 -1
  38. package/src/panels/layers-panel.ts +177 -147
  39. package/src/panels/preview-render.ts +15 -0
  40. package/src/panels/properties-panel.ts +16 -27
  41. package/src/panels/quick-search.ts +2 -2
  42. package/src/panels/right-panel.ts +2 -6
  43. package/src/panels/shared.ts +3 -0
  44. package/src/panels/signals-panel.ts +24 -22
  45. package/src/panels/statusbar.ts +15 -6
  46. package/src/panels/style-panel.ts +3 -3
  47. package/src/panels/style-utils.ts +3 -3
  48. package/src/panels/stylebook-panel.ts +4 -4
  49. package/src/panels/tab-bar.ts +198 -0
  50. package/src/panels/tab-strip.ts +2 -2
  51. package/src/panels/toolbar.ts +6 -75
  52. package/src/platforms/devserver.ts +60 -34
  53. package/src/reactivity.ts +2 -0
  54. package/src/services/cem-export.ts +23 -2
  55. package/src/services/code-services.ts +16 -2
  56. package/src/services/monaco-setup.ts +2 -1
  57. package/src/settings/content-types-editor.ts +16 -16
  58. package/src/settings/css-vars-editor.ts +2 -2
  59. package/src/settings/defs-editor.ts +14 -14
  60. package/src/settings/general-settings.ts +6 -6
  61. package/src/settings/head-editor.ts +2 -2
  62. package/src/site-context.ts +1 -1
  63. package/src/state.ts +66 -12
  64. package/src/store.ts +15 -3
  65. package/src/studio.ts +73 -33
  66. package/src/tabs/patch-ops.ts +143 -0
  67. package/src/tabs/tab.ts +15 -1
  68. package/src/tabs/transact.ts +454 -27
  69. package/src/types.ts +41 -0
  70. package/src/ui/color-selector.ts +7 -7
  71. package/src/ui/icons.ts +0 -30
  72. package/src/ui/media-picker.ts +2 -2
  73. package/src/ui/panel-resize.ts +6 -1
  74. package/src/ui/unit-selector.ts +2 -2
  75. package/src/ui/value-selector.ts +3 -3
  76. package/src/utils/canvas-media.ts +6 -6
  77. package/src/utils/edit-display.ts +125 -83
  78. package/src/utils/google-fonts.ts +1 -1
  79. package/src/utils/inherited-style.ts +3 -2
  80. package/src/utils/studio-utils.ts +1 -1
  81. package/src/view.ts +4 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Canvas render instrumentation. Counts full renders vs surgical patches so tests and users can
3
+ * verify that hot-path edits do not rebuild the canvas.
4
+ *
5
+ * Inspect in the browser via `globalThis.__jxCanvasPerf`; enable per-event logging with
6
+ * `localStorage["jx-canvas-debug"] = "1"`.
7
+ */
8
+
9
+ export interface CanvasPerf {
10
+ /** Full renderCanvas() invocations (whole-canvas orchestration). */
11
+ fullRenders: number;
12
+ /** Individual panel renders (one per breakpoint panel per full render). */
13
+ panelRenders: number;
14
+ /** Doc-effect triggers skipped because the change was consumed by the patcher. */
15
+ skippedFullRenders: number;
16
+ /** Patch ops applied surgically to the live canvas DOM. */
17
+ patchedOps: number;
18
+ /** Isolated subtree re-renders (structural patches). */
19
+ subtreeRenders: number;
20
+ /** Patch batches that escalated to a full render. */
21
+ escalations: number;
22
+ lastEscalationReason: string;
23
+ }
24
+
25
+ export const canvasPerf: CanvasPerf = {
26
+ fullRenders: 0,
27
+ panelRenders: 0,
28
+ skippedFullRenders: 0,
29
+ patchedOps: 0,
30
+ subtreeRenders: 0,
31
+ escalations: 0,
32
+ lastEscalationReason: "",
33
+ };
34
+
35
+ (globalThis as Record<string, unknown>).__jxCanvasPerf = canvasPerf;
36
+
37
+ function debugEnabled() {
38
+ try {
39
+ return typeof localStorage !== "undefined" && Boolean(localStorage.getItem("jx-canvas-debug"));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** Log a canvas perf event when debug mode is on. */
46
+ export function perfLog(event: string, detail?: unknown) {
47
+ if (debugEnabled()) {
48
+ console.debug(`[jx-canvas] ${event}`, detail ?? "");
49
+ }
50
+ }
51
+
52
+ /** Record an escalation to full render with its reason. */
53
+ export function recordEscalation(reason: string) {
54
+ canvasPerf.escalations += 1;
55
+ canvasPerf.lastEscalationReason = reason;
56
+ perfLog("escalation", reason);
57
+ }
58
+
59
+ /** Reset all counters (for tests). */
60
+ export function resetCanvasPerf() {
61
+ canvasPerf.fullRenders = 0;
62
+ canvasPerf.panelRenders = 0;
63
+ canvasPerf.skippedFullRenders = 0;
64
+ canvasPerf.patchedOps = 0;
65
+ canvasPerf.subtreeRenders = 0;
66
+ canvasPerf.escalations = 0;
67
+ canvasPerf.lastEscalationReason = "";
68
+ }
@@ -33,6 +33,7 @@ import {
33
33
  } from "../utils/canvas-media";
34
34
  import { getEffectiveMedia } from "../site-context";
35
35
  import { renderCanvasLive } from "./canvas-live-render";
36
+ import { canvasPerf } from "./canvas-perf";
36
37
  import { renderCanvasNode } from "../panels/preview-render";
37
38
  import { registerPanelDnD } from "../panels/canvas-dnd";
38
39
  import { registerPanelEvents } from "../panels/panel-events";
@@ -57,8 +58,6 @@ interface CanvasRenderCtx {
57
58
  getCanvasMode: () => string;
58
59
  setCanvasMode: (mode: string) => void;
59
60
  openFileFromTree: (path: string) => void;
60
- exportFile: () => void;
61
- closeFunctionEditor: () => void;
62
61
  gitDiffState: GitDiffState | null;
63
62
  setGitDiffState: (state: GitDiffState | null) => void;
64
63
  }
@@ -100,13 +99,99 @@ async function sourceContent(tab: Tab, lang: string) {
100
99
  return JSON.stringify(tab.doc.document, null, 2);
101
100
  }
102
101
 
102
+ // Double-RAF scheduling so the canvas render yields to higher-priority panel paints first.
103
+ // Concurrent schedule requests within the same frame are deduped.
104
+ let _canvasRafId = 0;
105
+ export function scheduleCanvasRender() {
106
+ if (_canvasRafId) {
107
+ return;
108
+ }
109
+ _canvasRafId = requestAnimationFrame(() => {
110
+ _canvasRafId = requestAnimationFrame(() => {
111
+ _canvasRafId = 0;
112
+ try {
113
+ renderCanvas();
114
+ } catch (error) {
115
+ console.error("renderCanvas error:", error);
116
+ }
117
+ });
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Eject canvasWrap's DOM and Lit render part together. Setting textContent/innerHTML removes the
123
+ * comment nodes Lit uses as a ChildPart's markers; if the private `_$litPart$` reference is left
124
+ * behind, the next litRender() reuses a part whose markers are detached from the DOM and throws
125
+ * "This `ChildPart` has no `parentNode`…". Always eject the markers through this helper so the two
126
+ * operations can never drift apart.
127
+ */
128
+ function hardClearCanvasWrap() {
129
+ canvasWrap.textContent = "";
130
+ // @ts-expect-error -- _$litPart$ is Lit's private render-part marker, not in the DOM types
131
+ delete canvasWrap["_$litPart$"];
132
+ }
133
+
134
+ /**
135
+ * Tear the canvas all the way back down to a pristine state. Invoked whenever there is no active
136
+ * tab (e.g. every tab was closed) so the canvas can never get wedged in a half-initialized state:
137
+ * open editors and observers are disposed, outgoing panel scopes stopped, pending cleanups run, the
138
+ * Lit render part is ejected cleanly, inline style overrides reset, and `prevCanvasMode` cleared so
139
+ * the very next render is treated as a fresh mode transition and rebuilds the surface from
140
+ * scratch.
141
+ */
142
+ function resetCanvasView() {
143
+ if (view.functionEditor) {
144
+ view.functionEditor.dispose();
145
+ view.functionEditor = null;
146
+ }
147
+ if (view.monacoEditor) {
148
+ view.monacoEditor.getModel()?.dispose();
149
+ view.monacoEditor.dispose();
150
+ view.monacoEditor = null;
151
+ }
152
+ if (view.centerObserver) {
153
+ view.centerObserver.disconnect();
154
+ view.centerObserver = null;
155
+ }
156
+ for (const fn of view.canvasDndCleanups) {
157
+ fn();
158
+ }
159
+ view.canvasDndCleanups = [];
160
+ for (const fn of view.canvasEventCleanups) {
161
+ fn();
162
+ }
163
+ view.canvasEventCleanups = [];
164
+ for (const p of canvasPanels) {
165
+ p.renderScope?.stop();
166
+ p.renderScope = null;
167
+ }
168
+ canvasPanels.length = 0;
169
+
170
+ hardClearCanvasWrap();
171
+
172
+ view.panzoomWrap = null;
173
+ canvasWrap.style.padding = "";
174
+ canvasWrap.style.alignItems = "";
175
+ canvasWrap.style.flexDirection = "";
176
+ canvasWrap.style.display = "";
177
+ canvasWrap.style.overflow = "";
178
+ resetZoomIndicator();
179
+ dismissBlockActionBar();
180
+ dismissLinkPopover();
181
+ dismissContextMenu();
182
+ dismissSlashMenu();
183
+ view.prevCanvasMode = null;
184
+ }
185
+
103
186
  export function renderCanvas() {
104
187
  const tab = activeTab.value;
105
188
  if (!tab) {
189
+ // No active tab — reset every piece of canvas view state so reopening a file can never inherit
190
+ // A stale Lit part, a dead Monaco editor, or a mismatched prevCanvasMode (the toxic states that
191
+ // Previously left the canvas unrenderable until a full reload).
192
+ resetCanvasView();
106
193
  if (!projectState) {
107
194
  renderWelcome(canvasWrap);
108
- } else {
109
- canvasWrap.textContent = "";
110
195
  }
111
196
  return;
112
197
  }
@@ -120,6 +205,7 @@ export function renderCanvas() {
120
205
 
121
206
  // Advance render generation so stale async renders from the previous cycle bail out
122
207
  view.renderGeneration += 1;
208
+ canvasPerf.fullRenders += 1;
123
209
 
124
210
  // Detect whether this is a mode transition or a content-only re-render
125
211
  const modeChanged = canvasMode !== view.prevCanvasMode;
@@ -130,14 +216,12 @@ export function renderCanvas() {
130
216
  // RenderCanvasLive uses atomic clear (innerHTML = "" right before appendChild).
131
217
  // @ts-expect-error -- _$litPart$ is Lit's private render-part marker, not in the DOM types
132
218
  if (modeChanged && canvasWrap["_$litPart$"]) {
133
- canvasWrap.textContent = "";
134
- // @ts-expect-error -- _$litPart$ is Lit's private render-part marker, not in the DOM types
135
- delete canvasWrap["_$litPart$"];
219
+ hardClearCanvasWrap();
136
220
  }
137
221
 
138
222
  // Function editor mode: editing a function body in Monaco (JS)
139
223
  if (S.ui.editingFunction) {
140
- renderFunctionEditor(ctx.closeFunctionEditor);
224
+ renderFunctionEditor();
141
225
  return;
142
226
  }
143
227
 
@@ -199,6 +283,12 @@ export function renderCanvas() {
199
283
 
200
284
  // Panel JS objects are cheap — always clear and repopulate from templates.
201
285
  // The actual DOM elements are preserved by Lit's diffing on content-only re-renders.
286
+ // Stop each outgoing panel's render effect scope (and its surgical-subtree child scopes)
287
+ // So render-time reactive effects don't accumulate across renders.
288
+ for (const p of canvasPanels) {
289
+ p.renderScope?.stop();
290
+ p.renderScope = null;
291
+ }
202
292
  canvasPanels.length = 0;
203
293
 
204
294
  if (modeChanged) {
@@ -258,12 +348,6 @@ export function renderCanvas() {
258
348
  let editorContainer: HTMLDivElement | null = null;
259
349
  litRender(
260
350
  html`<div class="source-wrap">
261
- <div class="source-toolbar">
262
- <sp-action-button size="s" @click=${ctx.exportFile}>
263
- <sp-icon-export slot="icon"></sp-icon-export>
264
- Export
265
- </sp-action-button>
266
- </div>
267
351
  <div
268
352
  class="source-editor"
269
353
  ${ref((el) => {
@@ -336,7 +420,7 @@ export function renderCanvas() {
336
420
  }
337
421
  } else if (lang === "json") {
338
422
  try {
339
- tabNow.doc.document = JSON.parse(editor.getValue());
423
+ tabNow.doc.document = JSON.parse(editor.getValue()) as JxMutableNode;
340
424
  tabNow.doc.dirty = true;
341
425
  } catch {
342
426
  // Invalid JSON — don't update state
@@ -395,18 +479,17 @@ export function renderCanvas() {
395
479
  canvasWrap,
396
480
  );
397
481
 
398
- canvasPanels.push(origPanel as unknown as CanvasPanel);
399
- canvasPanels.push(currPanel as unknown as CanvasPanel);
482
+ canvasPanels.push(origPanel as unknown as CanvasPanel, currPanel as unknown as CanvasPanel);
400
483
 
401
484
  /** @param {string} content */
402
- const parseContent = (content: string) => {
485
+ const parseContent = (content: string): Promise<JxMutableNode> => {
403
486
  const fmtPath = gitDiffState.filePath ?? "";
404
487
  if (formatForPath(fmtPath)) {
405
488
  return parseSourceForPath(fmtPath, content).then((r) => r.document);
406
489
  }
407
490
  return Promise.resolve().then(() => {
408
491
  try {
409
- return JSON.parse(content);
492
+ return JSON.parse(content) as JxMutableNode;
410
493
  } catch {
411
494
  return {
412
495
  children: [{ tagName: "p", textContent: "Failed to parse" }],
@@ -417,7 +500,7 @@ export function renderCanvas() {
417
500
  };
418
501
 
419
502
  const { featureToggles } = S.ui;
420
- Promise.all([
503
+ void Promise.all([
421
504
  parseContent(gitDiffState.originalContent || ""),
422
505
  parseContent(gitDiffState.currentContent || ""),
423
506
  ]).then(([originalDoc, currentDoc]) => {
@@ -568,7 +651,7 @@ export function renderCanvas() {
568
651
  );
569
652
 
570
653
  for (let i = 0; i < panelEntries.length; i++) {
571
- const { panel, activeSet } = panelEntries[i];
654
+ const { panel, activeSet } = panelEntries[i]!;
572
655
  const p = panel as CanvasPanel;
573
656
  canvasPanels.push(p);
574
657
  if (i === 0) {
@@ -615,7 +698,12 @@ function renderCanvasIntoPanel(
615
698
  const docToRender = docOverride || (tab?.doc.document as JxMutableNode);
616
699
  const canvas = panel.canvas as HTMLElement;
617
700
 
618
- renderCanvasLive(gen, docToRender, canvas)
701
+ canvasPerf.panelRenders += 1;
702
+ panel.ready = false;
703
+ panel.liveCtx = null;
704
+ panel.activeBreakpoints = activeBreakpoints;
705
+
706
+ renderCanvasLive(gen, docToRender, canvas, panel)
619
707
  .then((scope: Record<string, unknown> | null) => {
620
708
  // Skip post-render setup if a newer render has started
621
709
  if (gen !== view.renderGeneration) {
@@ -625,6 +713,9 @@ function renderCanvasIntoPanel(
625
713
  updateCanvas({ error: null, scope, status: "ready" });
626
714
  applyCanvasMediaOverrides(canvas, activeBreakpoints);
627
715
  statusMessage("Runtime render OK", 1500);
716
+ // Panel is patchable only when the live runtime path rendered the real document
717
+ panel.ready = !docOverride;
718
+ scheduleStyleTagSweep();
628
719
 
629
720
  // Apply diff highlighting if in git-diff mode
630
721
  if (gitDiffState && docOverride) {
@@ -741,7 +832,7 @@ function applyDiffHighlightToCanvas(
741
832
  * @param {Element} canvasEl
742
833
  * @param {Set<string>} activeBreakpoints
743
834
  */
744
- function applyCanvasMediaOverrides(canvasEl: Element, activeBreakpoints: Set<string>) {
835
+ export function applyCanvasMediaOverrides(canvasEl: Element, activeBreakpoints: Set<string>) {
745
836
  if (activeBreakpoints.size === 0) {
746
837
  return;
747
838
  }
@@ -764,3 +855,24 @@ function applyCanvasMediaOverrides(canvasEl: Element, activeBreakpoints: Set<str
764
855
  export function renderOverlays() {
765
856
  overlaysPanel.render();
766
857
  }
858
+
859
+ // ─── Style-tag hygiene ────────────────────────────────────────────────────────
860
+
861
+ let _sweepTimer: ReturnType<typeof setTimeout> | undefined;
862
+
863
+ /**
864
+ * Remove scoped <style> tags whose owner elements are no longer in the document. The runtime emits
865
+ * one tag per styled element (nested selectors / media rules) and full re-renders orphan the
866
+ * previous tree's tags. Debounced so in-flight panel renders finish attaching first.
867
+ */
868
+ function scheduleStyleTagSweep() {
869
+ clearTimeout(_sweepTimer);
870
+ _sweepTimer = setTimeout(() => {
871
+ for (const tag of document.head.querySelectorAll("style[data-jx-owner]")) {
872
+ const uid = (tag as HTMLElement).dataset.jxOwner;
873
+ if (uid && !document.querySelector(`[data-jx="${uid}"]`)) {
874
+ tag.remove();
875
+ }
876
+ }
877
+ }, 250);
878
+ }
@@ -0,0 +1,113 @@
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
+ }
@@ -64,12 +64,16 @@ export function canvasPanelTemplate(
64
64
  // Which lit runs synchronously during render — before any consumer reads them.
65
65
  const panel = {
66
66
  _width: width || null,
67
+ activeBreakpoints: null,
67
68
  canvas: null,
68
69
  dropLine: null,
69
70
  element: null,
71
+ liveCtx: null,
70
72
  mediaName: mediaName || "",
71
73
  overlay: null,
72
74
  overlayClk: null,
75
+ ready: false,
76
+ renderScope: null,
73
77
  scrollContainer: null,
74
78
  viewport: null,
75
79
  } as unknown as CanvasPanel;
@@ -27,16 +27,6 @@ import { dismissSlashMenu, isSlashMenuOpen, showSlashMenu } from "./slash-menu";
27
27
  import { renderBlockActionBar } from "../panels/block-action-bar";
28
28
  import { defaultDef } from "../panels/shared";
29
29
 
30
- /**
31
- * @type {{
32
- * findCanvasElement: (
33
- * path: import("../state").JxPath,
34
- * canvasEl: HTMLElement,
35
- * ) => HTMLElement | null;
36
- * } | null}
37
- */
38
- let _ctx = null;
39
-
40
30
  /**
41
31
  * Initialize the component inline edit module.
42
32
  *
@@ -45,12 +35,13 @@ let _ctx = null;
45
35
  * path: import("../state").JxPath,
46
36
  * canvasEl: HTMLElement,
47
37
  * ) => HTMLElement | null;
48
- * }} ctx
38
+ * }} _ctx
49
39
  */
50
- export function initComponentInlineEdit(ctx: {
40
+ export function initComponentInlineEdit(_ctx: {
51
41
  findCanvasElement: (path: JxPath, canvasEl: HTMLElement) => HTMLElement | null;
52
42
  }) {
53
- _ctx = ctx;
43
+ // Public init hook retained for API symmetry; the ctx it once stored is no longer read here.
44
+ void _ctx;
54
45
  }
55
46
 
56
47
  /**
@@ -278,28 +269,6 @@ function splitParagraph() {
278
269
  updateUi("pendingInlineEdit", { mediaName, path: newPath });
279
270
  }
280
271
 
281
- function _commitComponentInlineEdit() {
282
- if (!view.componentInlineEdit) {
283
- return;
284
- }
285
- const { el, path, originalText } = view.componentInlineEdit;
286
- const newText = (el.textContent ?? "").trim();
287
-
288
- cleanupComponentInlineEdit(el);
289
-
290
- const pPath = parentElementPath(path);
291
- if (!newText && pPath) {
292
- transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path));
293
- } else if (newText !== originalText) {
294
- transactDoc(activeTab.value, (t) =>
295
- mutateUpdateProperty(t, path, "textContent", newText || undefined),
296
- );
297
- } else {
298
- renderOnly("canvas");
299
- renderOnly("overlays");
300
- }
301
- }
302
-
303
272
  function cancelComponentInlineEdit() {
304
273
  if (!view.componentInlineEdit) {
305
274
  return;