@jxsuite/studio 0.28.5 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.28.5",
3
+ "version": "0.29.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "scripts": {
26
26
  "build": "bun build ./src/studio.ts --outdir dist --target browser --sourcemap=linked && bun build ./node_modules/monaco-editor/esm/vs/editor/editor.worker.js --outdir dist/workers --target browser --entry-naming editor.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/json/json.worker.js --outdir dist/workers --target browser --entry-naming json.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js --outdir dist/workers --target browser --entry-naming ts.worker.js",
27
+ "build:metafile": "bun build ./src/studio.ts --outdir dist --target browser --sourcemap=linked --metafile=metafile.json",
27
28
  "gen:webdata": "bun run scripts/gen-webdata.js",
28
29
  "test": "bun test --isolate",
29
30
  "test:coverage": "bun test --isolate --coverage",
@@ -32,9 +33,9 @@
32
33
  "dependencies": {
33
34
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
34
35
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0",
35
- "@jxsuite/parser": "^0.28.5",
36
- "@jxsuite/runtime": "^0.28.5",
37
- "@jxsuite/schema": "^0.28.5",
36
+ "@jxsuite/parser": "^0.29.0",
37
+ "@jxsuite/runtime": "^0.29.0",
38
+ "@jxsuite/schema": "^0.29.0",
38
39
  "@spectrum-web-components/accordion": "^1.12.1",
39
40
  "@spectrum-web-components/action-bar": "1.12.1",
40
41
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -40,10 +40,10 @@ export function openBrowseModal() {
40
40
  ${ref((el) => {
41
41
  if (el) {
42
42
  requestAnimationFrame(() => {
43
- renderBrowse(el as HTMLElement, {
43
+ void renderBrowse(el as HTMLElement, {
44
44
  openFile: (path: string) => {
45
45
  closeBrowseModal();
46
- openFileInTab(path);
46
+ void openFileInTab(path);
47
47
  },
48
48
  });
49
49
  });
@@ -14,6 +14,7 @@ import { repeat } from "lit-html/directives/repeat.js";
14
14
  import { getPlatform } from "../platform";
15
15
  import { projectState } from "../store";
16
16
  import { yamlDefault } from "../settings/schema-field-ui";
17
+ import type { SchemaProperty } from "../settings/schema-field-ui";
17
18
  import { invalidateMediaCache } from "../ui/media-picker";
18
19
  import { statusMessage } from "../panels/statusbar";
19
20
  import { componentRegistry } from "../files/components";
@@ -31,7 +32,7 @@ import {
31
32
  } from "../format/format-host";
32
33
 
33
34
  import type { ComponentEntry } from "../files/components";
34
- import type { ContentTypeDef } from "@jxsuite/schema/types";
35
+ import type { ContentTypeDef, JxDocument } from "@jxsuite/schema/types";
35
36
 
36
37
  // ─── Category definitions ────────────────────────────────────────────────────
37
38
 
@@ -267,9 +268,9 @@ function buildFrontmatterYaml(contentTypeName: string) {
267
268
  }
268
269
 
269
270
  let yaml = "";
270
- for (const [field, def] of Object.entries(col.schema.properties)) {
271
- const d = /** @type {{ type?: string; format?: string }} */ def;
272
- yaml += `${field}: ${yamlDefault(d.type || "", d.format || "")}\n`;
271
+ const props = col.schema.properties as Record<string, SchemaProperty>;
272
+ for (const [field, def] of Object.entries(props)) {
273
+ yaml += `${field}: ${yamlDefault(def.type || "", def.format || "")}\n`;
273
274
  }
274
275
  return yaml || "title: Untitled\n";
275
276
  }
@@ -311,7 +312,7 @@ function getContentTypeTypes() {
311
312
  */
312
313
  async function handleNewEntity(
313
314
  typeKey: string,
314
- container: HTMLElement,
315
+ _container: HTMLElement,
315
316
  ctx: { openFile: (path: string) => void },
316
317
  ) {
317
318
  const isContentType = typeKey.startsWith("contentType:");
@@ -384,7 +385,7 @@ async function handleUpload(
384
385
  }
385
386
  invalidateBrowseCache();
386
387
  invalidateMediaCache();
387
- renderBrowse(container, ctx);
388
+ void renderBrowse(container, ctx);
388
389
  }
389
390
 
390
391
  // ─── Context menu ───────────────────────────────────────────────────────────
@@ -467,7 +468,7 @@ function showBrowseContextMenu(
467
468
  style=${item.danger ? "color: var(--danger)" : ""}
468
469
  @click=${() => {
469
470
  dismissBrowseContextMenu();
470
- item.action?.();
471
+ void item.action?.();
471
472
  }}
472
473
  >${item.label}</sp-menu-item
473
474
  >`,
@@ -505,7 +506,7 @@ async function browseRenameFile(
505
506
  const platform = getPlatform();
506
507
  await platform.renameFile(file.path, newPath);
507
508
  invalidateBrowseCache();
508
- renderBrowse(container, ctx);
509
+ void renderBrowse(container, ctx);
509
510
  statusMessage(`Renamed to ${newName}`);
510
511
  } catch (error) {
511
512
  statusMessage(`Error: ${errorMessage(error)}`);
@@ -533,7 +534,7 @@ async function browseDuplicateFile(
533
534
  const content = await platform.readFile(file.path);
534
535
  await platform.writeFile(copyPath, content);
535
536
  invalidateBrowseCache();
536
- renderBrowse(container, ctx);
537
+ void renderBrowse(container, ctx);
537
538
  statusMessage(`Duplicated as ${copyName}`);
538
539
  } catch (error) {
539
540
  statusMessage(`Error: ${errorMessage(error)}`);
@@ -558,7 +559,7 @@ async function browseDeleteFile(
558
559
  const platform = getPlatform();
559
560
  await platform.deleteFile(file.path);
560
561
  invalidateBrowseCache();
561
- renderBrowse(container, ctx);
562
+ void renderBrowse(container, ctx);
562
563
  statusMessage(`Deleted ${file.name}`);
563
564
  } catch (error) {
564
565
  statusMessage(`Error: ${errorMessage(error)}`);
@@ -669,9 +670,9 @@ async function renderDocPreview(filePath: string) {
669
670
  let doc;
670
671
  if (formatForPath(filePath)) {
671
672
  const result = await parseSourceForPath(filePath, content);
672
- doc = result.document;
673
+ doc = result.document as JxDocument;
673
674
  } else {
674
- doc = JSON.parse(content);
675
+ doc = JSON.parse(content) as JxDocument;
675
676
  }
676
677
  const scope = await buildScope(doc, {}, location.href);
677
678
  const el = renderNode(doc, scope);
@@ -748,7 +749,7 @@ function renderCard(
748
749
  ${needsPreview
749
750
  ? ref((el: Element | undefined) => {
750
751
  if (el) {
751
- loadPreview(el, file);
752
+ void loadPreview(el, file);
752
753
  }
753
754
  })
754
755
  : nothing}
@@ -798,7 +799,7 @@ export async function renderBrowse(
798
799
  ?selected=${activeCategory === cat.key}
799
800
  @click=${() => {
800
801
  activeCategory = cat.key;
801
- renderBrowse(container, ctx);
802
+ void renderBrowse(container, ctx);
802
803
  }}
803
804
  >
804
805
  ${cat.label}
@@ -812,7 +813,7 @@ export async function renderBrowse(
812
813
  .value=${searchQuery}
813
814
  @input=${(e: Event) => {
814
815
  searchQuery = (e.target as HTMLInputElement).value;
815
- renderBrowse(container, ctx);
816
+ void renderBrowse(container, ctx);
816
817
  }}
817
818
  @submit=${(e: Event) => e.preventDefault()}
818
819
  ></sp-search>
@@ -856,7 +857,7 @@ export async function renderBrowse(
856
857
  @change=${(e: Event) => {
857
858
  const input = e.target as HTMLInputElement;
858
859
  if (input.files?.length) {
859
- handleUpload(input.files, container, ctx);
860
+ void handleUpload(input.files, container, ctx);
860
861
  }
861
862
  input.value = "";
862
863
  }}
@@ -867,7 +868,7 @@ export async function renderBrowse(
867
868
  ?selected=${viewMode === "grid"}
868
869
  @click=${() => {
869
870
  viewMode = "grid";
870
- renderBrowse(container, ctx);
871
+ void renderBrowse(container, ctx);
871
872
  }}
872
873
  title="Grid view"
873
874
  >
@@ -878,7 +879,7 @@ export async function renderBrowse(
878
879
  ?selected=${viewMode === "table"}
879
880
  @click=${() => {
880
881
  viewMode = "table";
881
- renderBrowse(container, ctx);
882
+ void renderBrowse(container, ctx);
882
883
  }}
883
884
  title="Table view"
884
885
  >
@@ -956,7 +957,7 @@ export async function renderBrowse(
956
957
  (e.currentTarget as HTMLElement).classList.remove("browse-drop-active");
957
958
  const droppedFiles = e.dataTransfer?.files;
958
959
  if (droppedFiles?.length) {
959
- handleUpload(droppedFiles, container, ctx);
960
+ void handleUpload(droppedFiles, container, ctx);
960
961
  }
961
962
  }}
962
963
  >
@@ -93,7 +93,7 @@ function markRecursive(
93
93
  const children = elementChildren(node);
94
94
  for (let i = 0; i < children.length; i++) {
95
95
  const childPath = `${path}/children/${i}`;
96
- markRecursive(children[i], status, childPath, diffMap, allPaths);
96
+ markRecursive(children[i]!, status, childPath, diffMap, allPaths);
97
97
  }
98
98
  }
99
99
 
@@ -151,7 +151,7 @@ export function computeDocumentDiff(
151
151
  const children = elementChildren(currNode);
152
152
  for (let i = 0; i < children.length; i++) {
153
153
  const childPath = pathKey === "/" ? `children/${i}` : `${pathKey}/children/${i}`;
154
- markRecursive(children[i], "added", childPath, diffMap, allPaths);
154
+ markRecursive(children[i]!, "added", childPath, diffMap, allPaths);
155
155
  }
156
156
  return;
157
157
  }
@@ -162,7 +162,7 @@ export function computeDocumentDiff(
162
162
  const children = elementChildren(origNode);
163
163
  for (let i = 0; i < children.length; i++) {
164
164
  const childPath = pathKey === "/" ? `children/${i}` : `${pathKey}/children/${i}`;
165
- markRecursive(children[i], "removed", childPath, diffMap, allPaths);
165
+ markRecursive(children[i]!, "removed", childPath, diffMap, allPaths);
166
166
  }
167
167
  }
168
168
  };
@@ -107,14 +107,26 @@ export function findCanvasElement(path: JxPath, canvasEl: HTMLElement) {
107
107
  return el;
108
108
  }
109
109
 
110
- for (let i = 0; i < path.length; i += 2) {
111
- if (path[i] !== "children" && path[i] !== "cases") {
110
+ let i = 0;
111
+ while (i < path.length) {
112
+ const seg = path[i];
113
+ // A lone "map" segment steps into a repeater perimeter's single template child.
114
+ if (seg === "map") {
115
+ el = el.children[0] as HTMLElement | undefined;
116
+ i += 1;
117
+ if (!el) {
118
+ break;
119
+ }
120
+ continue;
121
+ }
122
+ if (seg !== "children" && seg !== "cases") {
112
123
  return null;
113
124
  }
114
125
  const idx = path[i + 1];
115
126
  if (idx === undefined) {
116
127
  el = el.children[0] as HTMLElement | undefined;
117
128
  } else if (idx === "map") {
129
+ // Legacy whole-children template `[..., "children", "map"]`: perimeter at child[0].
118
130
  el = el.children[0]?.children[0] as HTMLElement | undefined;
119
131
  } else {
120
132
  el = el.children[idx as number] as HTMLElement | undefined;
@@ -122,6 +134,7 @@ export function findCanvasElement(path: JxPath, canvasEl: HTMLElement) {
122
134
  if (!el) {
123
135
  break;
124
136
  }
137
+ i += 2;
125
138
  }
126
139
 
127
140
  if (el) {
@@ -6,11 +6,11 @@
6
6
  * mapping ($map remapping), site-level style injection, and $head element injection.
7
7
  */
8
8
 
9
- import { elToPath, projectState, stripEventHandlers } from "../store";
9
+ import { elToPath, elToScope, projectState, stripEventHandlers } from "../store";
10
10
  import { errorMessage } from "@jxsuite/schema/parse";
11
11
  import { activeTab } from "../workspace/workspace";
12
12
  import { view } from "../view";
13
- import { toRaw } from "../reactivity";
13
+ import { effectScope, toRaw } from "../reactivity";
14
14
  import {
15
15
  buildScope,
16
16
  defineElement,
@@ -33,6 +33,8 @@ import { buildNestedSiteCSS } from "./nested-site-style";
33
33
 
34
34
  import type { JxDocument, JxElement, JxMutableNode } from "@jxsuite/schema/types";
35
35
  import type { ComponentEntry } from "../files/components.js";
36
+ import type { CanvasPanel } from "../types";
37
+ import type { EffectScope } from "../reactivity";
36
38
 
37
39
  export { buildNestedSiteCSS } from "./nested-site-style";
38
40
 
@@ -57,26 +59,31 @@ const _failedElements = new Set();
57
59
  let _failedElementsDocPath: string | null = null;
58
60
 
59
61
  /**
60
- * Walk the merged document tree to find the path prefix where page children were distributed into
61
- * the layout slot. Returns the path to the container whose children are the page content (first
62
- * non-$__layout children array).
62
+ * Walk the merged document tree to find where page children were distributed into the layout slot.
63
+ * Returns the `prefix` path to the container whose children hold the page content (first container
64
+ * with a non-$__layout child), plus the `offset` — the index of the first page-content child within
65
+ * that container's children array. The offset is non-zero when the layout places sibling nodes
66
+ * (e.g. a `<noscript>`) before the `<slot>`, which shifts every page child's render index relative
67
+ * to its page-document index. The path mapper subtracts this offset so canvas paths line up with
68
+ * the page document (and thus the layers panel).
63
69
  *
64
70
  * @param {JxMutableNode} node
65
71
  * @param {(string | number)[]} [path]
66
- * @returns {(string | number)[] | null}
72
+ * @returns {{ prefix: (string | number)[]; offset: number } | null}
67
73
  */
68
74
  function findPageContentPrefix(
69
75
  node: JxMutableNode,
70
76
  path: (string | number)[] = [],
71
- ): (string | number)[] | null {
77
+ ): { prefix: (string | number)[]; offset: number } | null {
72
78
  if (!node || typeof node !== "object") {
73
79
  return null;
74
80
  }
75
81
  if (Array.isArray(node.children)) {
76
- for (const child of node.children) {
77
- if (child && typeof child === "object" && !child.$__layout) {
78
- return [...path, "children"];
79
- }
82
+ const firstPageIdx = node.children.findIndex(
83
+ (child) => child && typeof child === "object" && !child.$__layout,
84
+ );
85
+ if (firstPageIdx !== -1) {
86
+ return { offset: firstPageIdx, prefix: [...path, "children"] };
80
87
  }
81
88
  for (let i = 0; i < node.children.length; i++) {
82
89
  const child = node.children[i];
@@ -131,6 +138,92 @@ export function initCanvasLiveRender(ctx: { getCanvasMode: () => string }) {
131
138
  _ctx = ctx;
132
139
  }
133
140
 
141
+ /** Context needed to map runtime render paths back to document paths. */
142
+ export interface PathMapperCtx {
143
+ canvasMode: string;
144
+ layoutWrapped: boolean;
145
+ pageContentPrefix: (string | number)[] | null;
146
+ /** Index of the first page-content child within the slot container (leading layout siblings). */
147
+ pageContentOffset?: number;
148
+ /** Document paths of every mapped-array node, used to remap edit-mode repeater perimeters. */
149
+ arrayPaths: Set<string>;
150
+ }
151
+
152
+ /**
153
+ * Build the onNodeCreated callback that records each rendered element's document path in elToPath.
154
+ * Handles layout-prefix stripping and repeater-perimeter/template remapping. Shared by full panel
155
+ * renders and isolated subtree re-renders so path bookkeeping stays consistent.
156
+ */
157
+ export function makePathMapper(ctx: PathMapperCtx) {
158
+ const { canvasMode, layoutWrapped, pageContentPrefix, pageContentOffset, arrayPaths } = ctx;
159
+ return function onNodeCreated(
160
+ created: Node,
161
+ path: (string | number)[],
162
+ def: unknown,
163
+ state?: Record<string, unknown>,
164
+ ) {
165
+ if (!(created instanceof HTMLElement)) {
166
+ return;
167
+ }
168
+ if (state) {
169
+ elToScope.set(created, state);
170
+ }
171
+ // Track layout-originated elements — don't store in elToPath to avoid
172
+ // Path collisions with remapped page content paths
173
+ if (layoutWrapped && typeof def === "object" && (def as JxMutableNode)?.$__layout) {
174
+ layoutElements.add(created);
175
+ created.dataset.jxLayout = "";
176
+ return;
177
+ }
178
+
179
+ // Remap layout-wrapped paths: strip the layout prefix so paths are
180
+ // Relative to the original page document (which is what S.document holds)
181
+ let mappedPath = path;
182
+ if (layoutWrapped && pageContentPrefix) {
183
+ const pfx = pageContentPrefix;
184
+ if (
185
+ path.length >= pfx.length &&
186
+ pfx.every((seg: string | number, i: number) => path[i] === seg)
187
+ ) {
188
+ // Page children render at container indices [offset, offset+1, …] when the layout places
189
+ // Sibling nodes before the <slot>. Subtract the offset so they map back to the page
190
+ // Document's 0-based child indices (what flattenTree / the layers panel use).
191
+ const rest = path.slice(pfx.length);
192
+ const [containerIdx] = rest;
193
+ mappedPath =
194
+ typeof containerIdx === "number"
195
+ ? ["children", containerIdx - (pageContentOffset ?? 0), ...rest.slice(1)]
196
+ : ["children", ...rest];
197
+ }
198
+ }
199
+
200
+ // Remap repeater perimeters: prepareForEditMode renders each mapped-array node as a
201
+ // `<div class="repeater-perimeter">` at the array's own child index, with the map template as
202
+ // Its single child[0]. The perimeter's render path already equals the array's document path,
203
+ // So only the template hop needs collapsing: for any array document path P, a render path of
204
+ // `[...P, "children", 0, ...rest]` maps to `[...P, "map", ...rest]`. Looping handles nested
205
+ // Repeaters (an array whose template contains another array).
206
+ if ((canvasMode === "design" || canvasMode === "edit") && arrayPaths.size > 0) {
207
+ let changed = true;
208
+ while (changed) {
209
+ changed = false;
210
+ for (let i = 1; i < mappedPath.length - 1; i++) {
211
+ if (
212
+ mappedPath[i] === "children" &&
213
+ mappedPath[i + 1] === 0 &&
214
+ arrayPaths.has(mappedPath.slice(0, i).join("/"))
215
+ ) {
216
+ mappedPath = [...mappedPath.slice(0, i), "map", ...mappedPath.slice(i + 2)];
217
+ changed = true;
218
+ break;
219
+ }
220
+ }
221
+ }
222
+ }
223
+ elToPath.set(created, mappedPath);
224
+ };
225
+ }
226
+
134
227
  /**
135
228
  * Render a Jx document into a canvas element using the real runtime. Populates elToPath for each
136
229
  * created element via onNodeCreated callback. Returns the live state scope on success, null on
@@ -139,8 +232,14 @@ export function initCanvasLiveRender(ctx: { getCanvasMode: () => string }) {
139
232
  * @param {number} gen - Render generation for staleness detection
140
233
  * @param {JxMutableNode} doc
141
234
  * @param {HTMLElement} canvasEl
235
+ * @param {CanvasPanel | null} [panel] - Panel to persist render context on (for surgical patches)
142
236
  */
143
- export async function renderCanvasLive(gen: number, doc: JxMutableNode, canvasEl: HTMLElement) {
237
+ export async function renderCanvasLive(
238
+ gen: number,
239
+ doc: JxMutableNode,
240
+ canvasEl: HTMLElement,
241
+ panel: CanvasPanel | null = null,
242
+ ) {
144
243
  const tab = activeTab.value;
145
244
  const S = {
146
245
  document: tab?.doc.document,
@@ -177,11 +276,13 @@ export async function renderCanvasLive(gen: number, doc: JxMutableNode, canvasEl
177
276
 
178
277
  /** @type {(string | number)[] | null} Path prefix in merged doc where page children live */
179
278
  let pageContentPrefix = null;
279
+ /** Index of the first page child within the slot container (offset by leading layout siblings). */
280
+ let pageContentOffset = 0;
180
281
 
181
282
  if (isPage) {
182
283
  const layoutPath = getEffectiveLayoutPath(doc.$layout);
183
284
  if (layoutPath) {
184
- const layoutDoc = await resolveLayoutDoc(layoutPath);
285
+ const layoutDoc = (await resolveLayoutDoc(layoutPath)) as JxMutableNode | null;
185
286
  if (layoutDoc) {
186
287
  if (gen !== view.renderGeneration) {
187
288
  return null;
@@ -193,25 +294,30 @@ export async function renderCanvasLive(gen: number, doc: JxMutableNode, canvasEl
193
294
  renderDoc =
194
295
  canvasMode === "preview" ? merged : prepareForEditMode(stripEventHandlers(merged));
195
296
  layoutWrapped = true;
196
- pageContentPrefix = findPageContentPrefix(merged);
297
+ const pageContent = findPageContentPrefix(merged);
298
+ pageContentPrefix = pageContent?.prefix ?? null;
299
+ pageContentOffset = pageContent?.offset ?? 0;
197
300
  }
198
301
  }
199
302
  }
200
303
 
201
- // In edit mode, collect paths where $map templates were inlined as children[0]
202
- // So we can remap runtime paths (children,0,...) (children,map,...)
203
- const mapParentPaths = new Set();
304
+ // In edit mode, collect the document path of every mapped-array node so the path mapper can
305
+ // Remap their edit-mode perimeters (and templates) back to document paths.
306
+ const arrayPaths = new Set<string>();
307
+ let renderScope: EffectScope | null = null;
204
308
  if (canvasMode === "design" || canvasMode === "edit") {
205
- (function findMapParents(node: JxMutableNode, path: (string | number)[]) {
309
+ (function findArrayPaths(node: JxMutableNode, path: (string | number)[]) {
206
310
  if (!node || typeof node !== "object") {
207
311
  return;
208
312
  }
209
- if (
210
- node.children &&
211
- typeof node.children === "object" &&
212
- (node.children as unknown as { $prototype?: string }).$prototype === "Array"
213
- ) {
214
- mapParentPaths.add(path.join("/"));
313
+ // The node itself is a mapped array (reached as a member or whole-children slot).
314
+ if ((node as unknown as { $prototype?: string }).$prototype === "Array") {
315
+ arrayPaths.add(path.join("/"));
316
+ const mapDef = (node as JxMutableNode).map;
317
+ if (mapDef && typeof mapDef === "object") {
318
+ findArrayPaths(mapDef, [...path, "map"]);
319
+ }
320
+ return;
215
321
  }
216
322
  if (Array.isArray(node.children)) {
217
323
  for (let i = 0; i < node.children.length; i++) {
@@ -219,12 +325,19 @@ export async function renderCanvasLive(gen: number, doc: JxMutableNode, canvasEl
219
325
  if (typeof child === "string") {
220
326
  continue;
221
327
  }
222
- findMapParents(child, [...path, "children", i]);
328
+ findArrayPaths(child!, [...path, "children", i]);
223
329
  }
330
+ } else if (
331
+ node.children &&
332
+ typeof node.children === "object" &&
333
+ (node.children as unknown as { $prototype?: string }).$prototype === "Array"
334
+ ) {
335
+ // Legacy whole-children repeater.
336
+ findArrayPaths(node.children as JxMutableNode, [...path, "children"]);
224
337
  }
225
338
  if (node.$switch && node.cases) {
226
339
  for (const [k, v] of Object.entries(node.cases)) {
227
- findMapParents(v, [...path, "cases", k]);
340
+ findArrayPaths(v, [...path, "cases", k]);
228
341
  }
229
342
  }
230
343
  })(doc, []);
@@ -433,67 +546,22 @@ export async function renderCanvasLive(gen: number, doc: JxMutableNode, canvasEl
433
546
  if (gen !== view.renderGeneration) {
434
547
  return null;
435
548
  }
436
- const el = /** @type {HTMLElement} */ runtimeRenderNode(renderDoc, $defs, {
437
- _path: [],
438
- onNodeCreated(
439
- created: HTMLElement | Text,
440
- path: (string | number)[],
441
- def: JxElement | string,
442
- ) {
443
- if (!(created instanceof HTMLElement)) {
444
- return;
445
- }
446
- // Track layout-originated elements — don't store in elToPath to avoid
447
- // Path collisions with remapped page content paths
448
- if (layoutWrapped && typeof def === "object" && def?.$__layout) {
449
- layoutElements.add(created);
450
- created.dataset.jxLayout = "";
451
- return;
452
- }
453
-
454
- // Remap layout-wrapped paths: strip the layout prefix so paths are
455
- // Relative to the original page document (which is what S.document holds)
456
- let mappedPath = path;
457
- if (layoutWrapped && pageContentPrefix) {
458
- const pfx = pageContentPrefix;
459
- if (
460
- path.length >= pfx.length &&
461
- pfx.every((seg: string | number, i: number) => path[i] === seg)
462
- ) {
463
- mappedPath = ["children", ...path.slice(pfx.length)];
464
- }
465
- }
466
-
467
- // Remap $map paths: wrapper and template children → real document paths
468
- // PrepareForEditMode wraps $map template in: children[0] (wrapper) > children[0] (template)
469
- // Real paths: wrapper → ['children'] ($map container), template → ['children', 'map']
470
- if ((canvasMode === "design" || canvasMode === "edit") && mapParentPaths.size > 0) {
471
- for (let i = 0; i < mappedPath.length - 1; i++) {
472
- if (mappedPath[i] === "children" && mappedPath[i + 1] === 0) {
473
- const parentKey = mappedPath.slice(0, i).join("/");
474
- if (mapParentPaths.has(parentKey)) {
475
- if (mappedPath.length === i + 2) {
476
- mappedPath = mappedPath.slice(0, i + 1);
477
- } else if (
478
- mappedPath.length >= i + 4 &&
479
- mappedPath[i + 2] === "children" &&
480
- mappedPath[i + 3] === 0
481
- ) {
482
- mappedPath = [
483
- ...mappedPath.slice(0, i),
484
- "children",
485
- "map",
486
- ...mappedPath.slice(i + 4),
487
- ];
488
- }
489
- break;
490
- }
491
- }
492
- }
493
- }
494
- elToPath.set(created, mappedPath);
495
- },
549
+ const pathMapper = makePathMapper({
550
+ arrayPaths,
551
+ canvasMode,
552
+ layoutWrapped,
553
+ pageContentOffset,
554
+ pageContentPrefix,
496
555
  });
556
+ // Render inside a detached effect scope so the tree's reactive effects (template bindings,
557
+ // $map/$switch containers) can be disposed when the panel is rebuilt, instead of leaking.
558
+ renderScope = effectScope(true);
559
+ const el = /** @type {HTMLElement} */ renderScope.run(() =>
560
+ runtimeRenderNode(renderDoc, $defs, {
561
+ _path: [],
562
+ onNodeCreated: pathMapper,
563
+ }),
564
+ )!;
497
565
  if ((canvasMode === "design" || canvasMode === "edit") && el instanceof HTMLElement) {
498
566
  // Disable pointer events on all rendered elements for edit mode
499
567
  el.style.pointerEvents = "none";
@@ -533,8 +601,22 @@ export async function renderCanvasLive(gen: number, doc: JxMutableNode, canvasEl
533
601
  }
534
602
  });
535
603
  }
604
+ if (panel) {
605
+ panel.renderScope?.stop();
606
+ panel.renderScope = renderScope;
607
+ panel.liveCtx = {
608
+ arrayPaths,
609
+ canvasMode,
610
+ layoutWrapped,
611
+ pageContentOffset,
612
+ pageContentPrefix,
613
+ pathMapper,
614
+ scope: $defs as Record<string, unknown>,
615
+ };
616
+ }
536
617
  return $defs;
537
618
  } catch (error) {
619
+ renderScope?.stop();
538
620
  console.warn("renderCanvasLive failed:", errorMessage(error), error);
539
621
  return null;
540
622
  }