@jxsuite/studio 0.11.0 → 0.14.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 (55) hide show
  1. package/dist/studio.js +6517 -5870
  2. package/dist/studio.js.map +57 -51
  3. package/package.json +5 -3
  4. package/src/canvas/canvas-diff.js +184 -0
  5. package/src/canvas/canvas-helpers.js +10 -14
  6. package/src/canvas/canvas-live-render.js +28 -2
  7. package/src/canvas/canvas-render.js +170 -20
  8. package/src/canvas/canvas-utils.js +22 -25
  9. package/src/editor/component-inline-edit.js +55 -44
  10. package/src/editor/content-inline-edit.js +47 -50
  11. package/src/editor/context-menu.js +78 -53
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -11
  14. package/src/editor/shortcuts.js +88 -64
  15. package/src/files/components.js +15 -4
  16. package/src/files/file-ops.js +57 -108
  17. package/src/files/files.js +81 -28
  18. package/src/panels/activity-bar.js +31 -7
  19. package/src/panels/block-action-bar.js +104 -80
  20. package/src/panels/canvas-dnd.js +4 -10
  21. package/src/panels/dnd.js +77 -35
  22. package/src/panels/editors.js +17 -26
  23. package/src/panels/elements-panel.js +17 -11
  24. package/src/panels/events-panel.js +44 -39
  25. package/src/panels/git-panel.js +47 -4
  26. package/src/panels/layers-panel.js +25 -21
  27. package/src/panels/left-panel.js +109 -44
  28. package/src/panels/overlays.js +91 -41
  29. package/src/panels/panel-events.js +28 -37
  30. package/src/panels/preview-render.js +7 -3
  31. package/src/panels/properties-panel.js +179 -104
  32. package/src/panels/pseudo-preview.js +9 -8
  33. package/src/panels/right-panel.js +85 -37
  34. package/src/panels/shared.js +0 -22
  35. package/src/panels/signals-panel.js +125 -54
  36. package/src/panels/statusbar.js +26 -19
  37. package/src/panels/style-inputs.js +5 -4
  38. package/src/panels/style-panel.js +128 -105
  39. package/src/panels/style-utils.js +8 -6
  40. package/src/panels/stylebook-layers-panel.js +5 -5
  41. package/src/panels/stylebook-panel.js +27 -31
  42. package/src/panels/tab-strip.js +124 -0
  43. package/src/panels/toolbar.js +40 -10
  44. package/src/reactivity.js +28 -0
  45. package/src/settings/content-types-editor.js +56 -7
  46. package/src/settings/defs-editor.js +56 -7
  47. package/src/settings/schema-field-ui.js +60 -25
  48. package/src/state.js +2 -459
  49. package/src/store.js +61 -219
  50. package/src/studio.js +163 -300
  51. package/src/tabs/tab.js +168 -0
  52. package/src/tabs/transact.js +406 -0
  53. package/src/ui/color-selector.js +4 -5
  54. package/src/view.js +3 -0
  55. package/src/workspace/workspace.js +90 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.11.0",
3
+ "version": "0.14.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
32
32
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
33
- "@jxsuite/runtime": "^0.10.2",
33
+ "@jxsuite/runtime": "^0.12.0",
34
34
  "@spectrum-web-components/accordion": "^1.12.1",
35
35
  "@spectrum-web-components/action-bar": "1.12.1",
36
36
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -60,6 +60,7 @@
60
60
  "@spectrum-web-components/theme": "^1.12.1",
61
61
  "@spectrum-web-components/toast": "^1.12.1",
62
62
  "@spectrum-web-components/tooltip": "^1.12.1",
63
+ "@vue/reactivity": "3.5.34",
63
64
  "lit-html": "^3.3.3",
64
65
  "monaco-editor": "^0.55.1",
65
66
  "remark-directive": "^4.0.0",
@@ -75,5 +76,6 @@
75
76
  "@webref/css": "^8.5.6",
76
77
  "@webref/elements": "^2.7.1",
77
78
  "@webref/idl": "^3.78.0"
78
- }
79
+ },
80
+ "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
79
81
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Canvas Diff — computes visual diff between original and current Jx documents.
3
+ *
4
+ * Compares two document trees recursively, marking nodes as added/removed/modified, and propagates
5
+ * "modified" status up the tree for structural changes (children added/removed/reordered).
6
+ */
7
+
8
+ /**
9
+ * @typedef {Record<string, any>} JxNode
10
+ *
11
+ * @typedef {"added" | "removed" | "modified"} DiffStatus
12
+ *
13
+ * @typedef {{ byPath: Map<string, DiffStatus>; allPaths: Set<string> }} DiffResult
14
+ */
15
+
16
+ /**
17
+ * Deep equality check for two values. Ignores function and ref properties.
18
+ *
19
+ * @param {any} a
20
+ * @param {any} b
21
+ * @returns {boolean}
22
+ */
23
+ function valuesEqual(a, b) {
24
+ if (a === b) return true;
25
+ if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
26
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
27
+
28
+ if (Array.isArray(a)) {
29
+ if (a.length !== b.length) return false;
30
+ return a.every((/** @type {any} */ v, /** @type {number} */ i) => valuesEqual(v, b[i]));
31
+ }
32
+
33
+ const keysA = Object.keys(a).filter(
34
+ (/** @type {string} */ k) => !k.startsWith("on") && k !== "$ref",
35
+ );
36
+ const keysB = Object.keys(b).filter(
37
+ (/** @type {string} */ k) => !k.startsWith("on") && k !== "$ref",
38
+ );
39
+
40
+ if (keysA.length !== keysB.length) return false;
41
+ if (!keysA.every((/** @type {string} */ k) => keysB.includes(k))) return false;
42
+
43
+ return keysA.every((/** @type {string} */ k) => valuesEqual(a[k], b[k]));
44
+ }
45
+
46
+ /**
47
+ * Filter element children from a node's children array.
48
+ *
49
+ * @param {any} node
50
+ * @returns {any[]}
51
+ */
52
+ function elementChildren(node) {
53
+ if (!node?.children || !Array.isArray(node.children)) return [];
54
+ return node.children.filter((/** @type {any} */ c) => c != null && typeof c === "object");
55
+ }
56
+
57
+ /**
58
+ * Mark an entire subtree with a diff status.
59
+ *
60
+ * @param {any} node
61
+ * @param {DiffStatus} status
62
+ * @param {string} path
63
+ * @param {Map<string, DiffStatus>} diffMap
64
+ * @param {Set<string>} allPaths
65
+ */
66
+ function markRecursive(node, status, path, diffMap, allPaths) {
67
+ allPaths.add(path);
68
+ diffMap.set(path, status);
69
+ const children = elementChildren(node);
70
+ for (let i = 0; i < children.length; i++) {
71
+ const childPath = `${path}/children/${i}`;
72
+ markRecursive(children[i], status, childPath, diffMap, allPaths);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Compute structural diff between original and current documents.
78
+ *
79
+ * @param {any} originalDoc - Original document (from git)
80
+ * @param {any} currentDoc - Current document (in memory/on disk)
81
+ * @returns {DiffResult}
82
+ */
83
+ export function computeDocumentDiff(originalDoc, currentDoc) {
84
+ /** @type {Map<string, DiffStatus>} */
85
+ const diffMap = new Map();
86
+ /** @type {Set<string>} */
87
+ const allPaths = new Set();
88
+
89
+ /**
90
+ * Walk both trees in parallel and mark differences.
91
+ *
92
+ * @param {any} origNode
93
+ * @param {any} currNode
94
+ * @param {string} path
95
+ */
96
+ const walk = (origNode, currNode, path = "") => {
97
+ const pathKey = path || "/";
98
+ allPaths.add(pathKey);
99
+
100
+ // Both exist but differ
101
+ if (origNode != null && currNode != null) {
102
+ if (!valuesEqual(origNode, currNode)) {
103
+ diffMap.set(pathKey, "modified");
104
+ }
105
+
106
+ // Walk children
107
+ if (origNode.children && currNode.children) {
108
+ const origChildren = elementChildren(origNode);
109
+ const currChildren = elementChildren(currNode);
110
+
111
+ for (let i = 0; i < Math.max(origChildren.length, currChildren.length); i++) {
112
+ const childPath = pathKey === "/" ? `children/${i}` : `${pathKey}/children/${i}`;
113
+ walk(origChildren[i], currChildren[i], childPath);
114
+ }
115
+ }
116
+ return;
117
+ }
118
+
119
+ // In current only (added)
120
+ if (currNode != null && origNode == null) {
121
+ diffMap.set(pathKey, "added");
122
+ const children = elementChildren(currNode);
123
+ for (let i = 0; i < children.length; i++) {
124
+ const childPath = pathKey === "/" ? `children/${i}` : `${pathKey}/children/${i}`;
125
+ markRecursive(children[i], "added", childPath, diffMap, allPaths);
126
+ }
127
+ return;
128
+ }
129
+
130
+ // In original only (removed)
131
+ if (origNode != null && currNode == null) {
132
+ diffMap.set(pathKey, "removed");
133
+ const children = elementChildren(origNode);
134
+ for (let i = 0; i < children.length; i++) {
135
+ const childPath = pathKey === "/" ? `children/${i}` : `${pathKey}/children/${i}`;
136
+ markRecursive(children[i], "removed", childPath, diffMap, allPaths);
137
+ }
138
+ return;
139
+ }
140
+ };
141
+
142
+ // Start comparison
143
+ walk(originalDoc, currentDoc, "");
144
+
145
+ // Propagate "modified" status to parents of added/removed children
146
+ const propagateModified = () => {
147
+ let changed = false;
148
+ for (const [path, status] of diffMap.entries()) {
149
+ if ((status === "added" || status === "removed") && path !== "/") {
150
+ const parentPath = path.split("/").slice(0, -2).join("/") || "/";
151
+ if (parentPath !== "/") {
152
+ const parentStatus = diffMap.get(parentPath);
153
+ if (
154
+ parentStatus !== "modified" &&
155
+ parentStatus !== "added" &&
156
+ parentStatus !== "removed"
157
+ ) {
158
+ diffMap.set(parentPath, "modified");
159
+ changed = true;
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return changed;
165
+ };
166
+
167
+ // Propagate upwards until no more changes
168
+ while (propagateModified()) {}
169
+
170
+ return { byPath: diffMap, allPaths };
171
+ }
172
+
173
+ /**
174
+ * Clear diff highlighting from all elements in a canvas.
175
+ *
176
+ * @param {HTMLElement} canvas
177
+ */
178
+ export function clearDiffHighlight(canvas) {
179
+ canvas
180
+ .querySelectorAll(".element-diff-added, .element-diff-removed, .element-diff-modified")
181
+ .forEach((/** @type {Element} */ el) => {
182
+ el.classList.remove("element-diff-added", "element-diff-removed", "element-diff-modified");
183
+ });
184
+ }
@@ -3,14 +3,8 @@
3
3
  * multiple canvas-related modules: element lookup, zoom, panel resolution, inline bubbling.
4
4
  */
5
5
 
6
- import {
7
- getState,
8
- canvasPanels,
9
- elToPath,
10
- pathsEqual,
11
- getNodeAtPath,
12
- parentElementPath,
13
- } from "../store.js";
6
+ import { canvasPanels, elToPath, pathsEqual, getNodeAtPath, parentElementPath } from "../store.js";
7
+ import { activeTab } from "../workspace/workspace.js";
14
8
  import { isInlineInContext } from "../editor/inline-edit.js";
15
9
 
16
10
  /** @type {any} */
@@ -19,25 +13,27 @@ let _ctx = null;
19
13
  /**
20
14
  * Initialize the canvas helpers module.
21
15
  *
22
- * @param {{ getCanvasMode: () => string }} ctx
16
+ * @param {{ getCanvasMode: () => string; getZoom: () => number }} ctx
23
17
  */
24
18
  export function initCanvasHelpers(ctx) {
25
19
  _ctx = ctx;
26
20
  }
27
21
 
28
- /** Effective zoom scale — always 1 in edit (content) mode, S.ui.zoom otherwise. */
22
+ /** Effective zoom scale — always 1 in edit (content) mode, actual zoom otherwise. */
29
23
  export function effectiveZoom() {
30
- return _ctx.getCanvasMode() === "edit" ? 1 : getState().ui.zoom;
24
+ return _ctx.getCanvasMode() === "edit"
25
+ ? 1
26
+ : (_ctx.getZoom?.() ?? activeTab.value?.session.ui.zoom ?? 1);
31
27
  }
32
28
 
33
29
  /** Return the active canvas panel based on the current activeMedia setting. */
34
30
  export function getActivePanel() {
35
31
  if (canvasPanels.length === 0) return null;
36
32
  if (canvasPanels.length === 1) return canvasPanels[0];
37
- const S = getState();
33
+ const activeMedia = activeTab.value?.session.ui.activeMedia ?? null;
38
34
  for (const p of canvasPanels) {
39
- if (S.ui.activeMedia === null && (p.mediaName === "base" || p.mediaName === null)) return p;
40
- if (p.mediaName === S.ui.activeMedia) return p;
35
+ if (activeMedia === null && (p.mediaName === "base" || p.mediaName === null)) return p;
36
+ if (p.mediaName === activeMedia) return p;
41
37
  }
42
38
  return canvasPanels[0];
43
39
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { elToPath, stripEventHandlers, projectState } from "../store.js";
8
+ import { activeTab } from "../workspace/workspace.js";
8
9
  import { view } from "../view.js";
9
10
  import {
10
11
  renderNode as runtimeRenderNode,
@@ -82,7 +83,6 @@ function markLayoutNodes(/** @type {any} */ node) {
82
83
  * Initialize the canvas live render module.
83
84
  *
84
85
  * @param {{
85
- * getState: () => any;
86
86
  * getCanvasMode: () => string;
87
87
  * }} ctx
88
88
  */
@@ -100,7 +100,8 @@ export function initCanvasLiveRender(ctx) {
100
100
  * @param {any} canvasEl
101
101
  */
102
102
  export async function renderCanvasLive(gen, doc, canvasEl) {
103
- const S = _ctx.getState();
103
+ const tab = activeTab.value;
104
+ const S = { documentPath: tab?.documentPath, mode: tab?.doc.mode, document: tab?.doc.document };
104
105
  const canvasMode = _ctx.getCanvasMode();
105
106
 
106
107
  // Suppress server function resolution in non-preview modes to avoid
@@ -388,6 +389,31 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
388
389
  } else {
389
390
  canvasEl.removeAttribute("data-content-mode");
390
391
  }
392
+
393
+ // Resolve relative asset URLs through the PAL for environments that can't
394
+ // serve project files natively (e.g. ElectroBun's views:// scheme).
395
+ const platform = /** @type {any} */ (globalThis).__jxPlatform;
396
+ if (platform?.resolveAssetUrl && docBase) {
397
+ const mediaEls = el.querySelectorAll("img[src], video[src], source[src], video[poster]");
398
+ for (const node of mediaEls) {
399
+ for (const attr of ["src", "poster"]) {
400
+ const val = node.getAttribute(attr);
401
+ if (
402
+ val &&
403
+ !val.startsWith("data:") &&
404
+ !val.startsWith("blob:") &&
405
+ !val.startsWith("http")
406
+ ) {
407
+ try {
408
+ const resolved = new URL(val, docBase).pathname.replace(/^\//, "");
409
+ const dataUrl = await platform.resolveAssetUrl(resolved);
410
+ if (dataUrl) node.setAttribute(attr, dataUrl);
411
+ } catch {}
412
+ }
413
+ }
414
+ }
415
+ }
416
+
391
417
  canvasEl.appendChild(el);
392
418
  if (canvasMode === "design" || canvasMode === "edit") {
393
419
  // Custom element connectedCallbacks render children asynchronously —
@@ -8,6 +8,7 @@ import { ref } from "lit-html/directives/ref.js";
8
8
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
9
9
 
10
10
  import { canvasWrap, canvasPanels, updateCanvas } from "../store.js";
11
+ import { activeTab } from "../workspace/workspace.js";
11
12
  import { view } from "../view.js";
12
13
  import {
13
14
  canvasPanelTemplate,
@@ -29,7 +30,10 @@ import { renderCanvasLive } from "./canvas-live-render.js";
29
30
  import { renderCanvasNode } from "../panels/preview-render.js";
30
31
  import { registerPanelDnD } from "../panels/canvas-dnd.js";
31
32
  import { registerPanelEvents } from "../panels/panel-events.js";
33
+ import { computeDocumentDiff } from "./canvas-diff.js";
32
34
  import { updateForcedPseudoPreview } from "../panels/pseudo-preview.js";
35
+ import { findCanvasElement } from "./canvas-helpers.js";
36
+ import { enterComponentInlineEdit } from "../editor/component-inline-edit.js";
33
37
  import { renderStylebookMode } from "../panels/stylebook-panel.js";
34
38
  import { dismissLinkPopover, dismissBlockActionBar } from "../panels/block-action-bar.js";
35
39
  import { dismissContextMenu } from "../editor/context-menu.js";
@@ -49,10 +53,10 @@ let _ctx = null;
49
53
  * @param {{
50
54
  * getCanvasMode: () => string;
51
55
  * setCanvasMode: (mode: string) => void;
52
- * getState: () => any;
53
- * update: (s: any) => void;
54
56
  * openFileFromTree: (path: string) => void;
55
57
  * exportFile: () => void;
58
+ * gitDiffState: any;
59
+ * setGitDiffState: (state: any) => void;
56
60
  * }} ctx
57
61
  */
58
62
  export function initCanvasRender(ctx) {
@@ -60,7 +64,9 @@ export function initCanvasRender(ctx) {
60
64
  }
61
65
 
62
66
  export function renderCanvas() {
63
- const S = _ctx.getState();
67
+ const tab = activeTab.value;
68
+ if (!tab) return;
69
+ const S = { document: tab.doc.document, ui: tab.session.ui, mode: tab.doc.mode };
64
70
  const canvasMode = _ctx.getCanvasMode();
65
71
 
66
72
  // Advance render generation so stale async renders from the previous cycle bail out
@@ -230,7 +236,9 @@ export function renderCanvas() {
230
236
  debounce = setTimeout(() => {
231
237
  try {
232
238
  const parsed = JSON.parse(view.monacoEditor.getValue());
233
- _ctx.update({ ..._ctx.getState(), document: parsed, dirty: true });
239
+ const tab = activeTab.value;
240
+ tab.doc.document = parsed;
241
+ tab.doc.dirty = true;
234
242
  } catch {
235
243
  // Invalid JSON — don't update state
236
244
  }
@@ -239,6 +247,75 @@ export function renderCanvas() {
239
247
  return;
240
248
  }
241
249
 
250
+ // Git diff mode — render original (left) and current (right) side-by-side
251
+ if (canvasMode === "git-diff") {
252
+ if (!_ctx.gitDiffState) {
253
+ // Fallback to design mode if diff state is missing
254
+ _ctx.setCanvasMode("design");
255
+ renderCanvas();
256
+ return;
257
+ }
258
+
259
+ canvasWrap.style.padding = "0";
260
+ canvasWrap.style.overflow = "hidden";
261
+
262
+ const gitDiffState = _ctx.gitDiffState;
263
+ const { tpl: origPanelTpl, panel: origPanel } = canvasPanelTemplate(
264
+ "git-diff-original",
265
+ "Original",
266
+ false,
267
+ "50%",
268
+ );
269
+ const { tpl: currPanelTpl, panel: currPanel } = canvasPanelTemplate(
270
+ "git-diff-current",
271
+ "Current",
272
+ false,
273
+ "50%",
274
+ );
275
+
276
+ const diffTpl = html`
277
+ <div style="display: flex; height: 100%; gap: 0;">
278
+ <div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
279
+ ${origPanelTpl}
280
+ </div>
281
+ <div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
282
+ ${currPanelTpl}
283
+ </div>
284
+ </div>
285
+ `;
286
+
287
+ litRender(diffTpl, canvasWrap);
288
+ canvasPanels.push(/** @type {any} */ (origPanel));
289
+ canvasPanels.push(/** @type {any} */ (currPanel));
290
+
291
+ // Parse original document from git content
292
+ let originalDoc = null;
293
+ try {
294
+ if (gitDiffState.isMarkdown) {
295
+ // For markdown, we need to convert it to Jx format
296
+ // For now, use a simple placeholder until we have markdown parsing
297
+ originalDoc = {
298
+ tagName: "div",
299
+ children: [{ tagName: "p", textContent: "Original (markdown)" }],
300
+ };
301
+ } else {
302
+ // Parse as JSON
303
+ originalDoc = JSON.parse(gitDiffState.originalContent);
304
+ }
305
+ } catch {
306
+ originalDoc = {
307
+ tagName: "div",
308
+ children: [{ tagName: "p", textContent: "Failed to parse original" }],
309
+ };
310
+ }
311
+
312
+ const currDoc = S.document;
313
+
314
+ renderCanvasIntoPanel(origPanel, new Set(), S.ui.featureToggles, originalDoc, gitDiffState);
315
+ renderCanvasIntoPanel(currPanel, new Set(), S.ui.featureToggles, currDoc, gitDiffState);
316
+ return;
317
+ }
318
+
242
319
  // Edit (content) mode — centered column, no panzoom, always 100%
243
320
  if (canvasMode === "edit") {
244
321
  if (modeChanged) {
@@ -257,7 +334,7 @@ export function renderCanvas() {
257
334
  </div>
258
335
  `;
259
336
  litRender(editTpl, canvasWrap);
260
- canvasPanels.push(panel);
337
+ canvasPanels.push(/** @type {any} */ (panel));
261
338
  renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
262
339
  return;
263
340
  }
@@ -302,7 +379,7 @@ export function renderCanvas() {
302
379
  `,
303
380
  canvasWrap,
304
381
  );
305
- canvasPanels.push(panel);
382
+ canvasPanels.push(/** @type {any} */ (panel));
306
383
  renderCanvasIntoPanel(panel, new Set(), featureToggles);
307
384
  applyTransform();
308
385
  if (modeChanged) {
@@ -354,7 +431,7 @@ export function renderCanvas() {
354
431
  );
355
432
 
356
433
  for (const { panel, activeSet } of panelEntries) {
357
- canvasPanels.push(panel);
434
+ canvasPanels.push(/** @type {any} */ (panel));
358
435
  renderCanvasIntoPanel(panel, activeSet, featureToggles);
359
436
  }
360
437
 
@@ -376,37 +453,109 @@ export function renderCanvas() {
376
453
  * structural preview.
377
454
  *
378
455
  * @param {any} panel
379
- * @param {any} activeBreakpoints
456
+ * @param {Set<string>} activeBreakpoints
380
457
  * @param {any} featureToggles
458
+ * @param {any} [docOverride] - Optional document to render (for diff mode). Uses active tab doc if
459
+ * not provided.
460
+ * @param {any} [gitDiffState] - Optional diff state. If provided, computes and applies diff
461
+ * highlighting.
381
462
  */
382
- function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
463
+ function renderCanvasIntoPanel(
464
+ panel,
465
+ activeBreakpoints,
466
+ featureToggles,
467
+ docOverride = null,
468
+ gitDiffState = null,
469
+ ) {
383
470
  const gen = view.renderGeneration;
384
- const S = _ctx.getState();
385
- renderCanvasLive(gen, S.document, panel.canvas).then((/** @type {any} */ scope) => {
471
+ const tab = activeTab.value;
472
+ const docToRender = docOverride || tab?.doc.document;
473
+
474
+ renderCanvasLive(gen, docToRender, panel.canvas).then((/** @type {any} */ scope) => {
386
475
  // Skip post-render setup if a newer render has started
387
476
  if (gen !== view.renderGeneration) return;
388
477
  if (scope) {
389
478
  updateCanvas({ status: "ready", scope, error: null });
390
479
  applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
391
480
  statusMessage("Runtime render OK", 1500);
481
+
482
+ // Apply diff highlighting if in git-diff mode
483
+ if (gitDiffState && docOverride) {
484
+ // Determine which document is original and which is current
485
+ const isOriginal = docOverride === (gitDiffState.originalDoc || gitDiffState.original);
486
+ const _tab = activeTab.value;
487
+ const origDoc = isOriginal ? docOverride : gitDiffState.currentDoc || _tab?.doc.document;
488
+ const currDoc = isOriginal ? gitDiffState.currentDoc || _tab?.doc.document : docOverride;
489
+
490
+ const { byPath: diffMap } = computeDocumentDiff(origDoc, currDoc);
491
+
492
+ // Can't iterate WeakMap, so apply styling by walking the canvas
493
+ const { elToPath } = scope;
494
+ if (elToPath instanceof WeakMap) {
495
+ applyDiffHighlightToCanvas(panel.canvas, diffMap);
496
+ }
497
+ }
392
498
  } else {
393
499
  // Fallback to structural preview
394
500
  updateCanvas({ status: "ready", scope: null, error: null });
395
- renderCanvasNode(
396
- _ctx.getState().document,
397
- [],
398
- panel.canvas,
399
- activeBreakpoints,
400
- featureToggles,
401
- );
501
+ renderCanvasNode(docToRender, [], panel.canvas, activeBreakpoints, featureToggles);
402
502
  }
403
503
  registerPanelDnD(panel);
404
504
  registerPanelEvents(panel);
405
505
  renderOverlays();
406
506
  updateForcedPseudoPreview();
507
+
508
+ // Process pending inline edit when canvas becomes ready
509
+ const currentTab = activeTab.value;
510
+ if (currentTab?.session.ui?.pendingInlineEdit) {
511
+ const { path, mediaName: mn } = /** @type {{ path: any; mediaName: string }} */ (
512
+ currentTab.session.ui.pendingInlineEdit
513
+ );
514
+ currentTab.session.ui.pendingInlineEdit = null;
515
+ const targetPanel =
516
+ canvasPanels.find((/** @type {any} */ p) => p.mediaName === mn) || canvasPanels[0];
517
+ if (targetPanel) {
518
+ const el = findCanvasElement(path, targetPanel.canvas);
519
+ if (el) enterComponentInlineEdit(el, path);
520
+ }
521
+ }
407
522
  });
408
523
  }
409
524
 
525
+ /**
526
+ * Apply diff highlighting to canvas elements based on elToPath mapping. Walks the canvas DOM and
527
+ * applies classes based on diff status.
528
+ *
529
+ * @param {HTMLElement} canvas
530
+ * @param {Map<string, "added" | "removed" | "modified">} diffMap
531
+ */
532
+ function applyDiffHighlightToCanvas(canvas, diffMap) {
533
+ if (!diffMap || diffMap.size === 0) return;
534
+
535
+ // Walk all elements in canvas and check their data attributes or other markers
536
+ const walkCanvas = (/** @type {HTMLElement} */ el, /** @type {string} */ path = "") => {
537
+ const pathKey = path || "/";
538
+
539
+ if (diffMap.has(pathKey)) {
540
+ const status = diffMap.get(pathKey);
541
+ if (status === "added") el.classList.add("element-diff-added");
542
+ else if (status === "removed") el.classList.add("element-diff-removed");
543
+ else if (status === "modified") el.classList.add("element-diff-modified");
544
+ }
545
+
546
+ // Check for child elements (heuristic: children array markers)
547
+ let childIdx = 0;
548
+ for (const child of el.children) {
549
+ const childPath =
550
+ pathKey === "/" ? `children/${childIdx}` : `${pathKey}/children/${childIdx}`;
551
+ walkCanvas(/** @type {HTMLElement} */ (child), childPath);
552
+ childIdx++;
553
+ }
554
+ };
555
+
556
+ walkCanvas(canvas, "");
557
+ }
558
+
410
559
  /**
411
560
  * Apply media query overrides as inline styles on matching canvas elements. Needed because the
412
561
  * runtime renders base styles as inline — @media CSS rules in the injected stylesheet can't win
@@ -417,8 +566,9 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
417
566
  */
418
567
  function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
419
568
  if (!activeBreakpoints.size) return;
420
- const S = _ctx.getState();
421
- const docMedia = getEffectiveMedia(S.document.$media || {});
569
+ const tab = activeTab.value;
570
+ if (!tab) return;
571
+ const docMedia = getEffectiveMedia(tab.doc.document.$media || {});
422
572
  const validBreakpoints = new Set();
423
573
  for (const name of activeBreakpoints) {
424
574
  if (docMedia[name]) validBreakpoints.add(name);