@jxsuite/studio 0.11.0 → 0.13.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 (47) hide show
  1. package/dist/studio.js +4248 -3412
  2. package/dist/studio.js.map +49 -43
  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 +3 -2
  7. package/src/canvas/canvas-render.js +152 -20
  8. package/src/canvas/canvas-utils.js +21 -23
  9. package/src/editor/component-inline-edit.js +54 -41
  10. package/src/editor/content-inline-edit.js +46 -47
  11. package/src/editor/context-menu.js +63 -39
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -10
  14. package/src/editor/shortcuts.js +69 -39
  15. package/src/files/components.js +15 -4
  16. package/src/files/files.js +72 -24
  17. package/src/panels/activity-bar.js +29 -7
  18. package/src/panels/block-action-bar.js +104 -80
  19. package/src/panels/dnd.js +32 -28
  20. package/src/panels/editors.js +7 -14
  21. package/src/panels/elements-panel.js +9 -3
  22. package/src/panels/events-panel.js +44 -39
  23. package/src/panels/git-panel.js +45 -3
  24. package/src/panels/layers-panel.js +16 -11
  25. package/src/panels/left-panel.js +108 -43
  26. package/src/panels/overlays.js +91 -41
  27. package/src/panels/panel-events.js +9 -12
  28. package/src/panels/properties-panel.js +179 -104
  29. package/src/panels/right-panel.js +85 -37
  30. package/src/panels/shared.js +0 -22
  31. package/src/panels/signals-panel.js +125 -54
  32. package/src/panels/statusbar.js +23 -8
  33. package/src/panels/style-inputs.js +4 -2
  34. package/src/panels/style-panel.js +128 -105
  35. package/src/panels/stylebook-panel.js +11 -15
  36. package/src/panels/tab-strip.js +124 -0
  37. package/src/panels/toolbar.js +39 -9
  38. package/src/reactivity.js +28 -0
  39. package/src/settings/content-types-editor.js +56 -7
  40. package/src/settings/defs-editor.js +56 -7
  41. package/src/settings/schema-field-ui.js +60 -25
  42. package/src/state.js +0 -456
  43. package/src/store.js +97 -124
  44. package/src/studio.js +112 -121
  45. package/src/tabs/tab.js +153 -0
  46. package/src/tabs/transact.js +406 -0
  47. package/src/workspace/workspace.js +89 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.11.0",
3
+ "version": "0.13.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
@@ -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,6 +30,7 @@ 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";
33
35
  import { renderStylebookMode } from "../panels/stylebook-panel.js";
34
36
  import { dismissLinkPopover, dismissBlockActionBar } from "../panels/block-action-bar.js";
@@ -49,10 +51,10 @@ let _ctx = null;
49
51
  * @param {{
50
52
  * getCanvasMode: () => string;
51
53
  * setCanvasMode: (mode: string) => void;
52
- * getState: () => any;
53
- * update: (s: any) => void;
54
54
  * openFileFromTree: (path: string) => void;
55
55
  * exportFile: () => void;
56
+ * gitDiffState: any;
57
+ * setGitDiffState: (state: any) => void;
56
58
  * }} ctx
57
59
  */
58
60
  export function initCanvasRender(ctx) {
@@ -60,7 +62,9 @@ export function initCanvasRender(ctx) {
60
62
  }
61
63
 
62
64
  export function renderCanvas() {
63
- const S = _ctx.getState();
65
+ const tab = activeTab.value;
66
+ if (!tab) return;
67
+ const S = { document: tab.doc.document, ui: tab.session.ui, mode: tab.doc.mode };
64
68
  const canvasMode = _ctx.getCanvasMode();
65
69
 
66
70
  // Advance render generation so stale async renders from the previous cycle bail out
@@ -230,7 +234,9 @@ export function renderCanvas() {
230
234
  debounce = setTimeout(() => {
231
235
  try {
232
236
  const parsed = JSON.parse(view.monacoEditor.getValue());
233
- _ctx.update({ ..._ctx.getState(), document: parsed, dirty: true });
237
+ const tab = activeTab.value;
238
+ tab.doc.document = parsed;
239
+ tab.doc.dirty = true;
234
240
  } catch {
235
241
  // Invalid JSON — don't update state
236
242
  }
@@ -239,6 +245,75 @@ export function renderCanvas() {
239
245
  return;
240
246
  }
241
247
 
248
+ // Git diff mode — render original (left) and current (right) side-by-side
249
+ if (canvasMode === "git-diff") {
250
+ if (!_ctx.gitDiffState) {
251
+ // Fallback to design mode if diff state is missing
252
+ _ctx.setCanvasMode("design");
253
+ renderCanvas();
254
+ return;
255
+ }
256
+
257
+ canvasWrap.style.padding = "0";
258
+ canvasWrap.style.overflow = "hidden";
259
+
260
+ const gitDiffState = _ctx.gitDiffState;
261
+ const { tpl: origPanelTpl, panel: origPanel } = canvasPanelTemplate(
262
+ "git-diff-original",
263
+ "Original",
264
+ false,
265
+ "50%",
266
+ );
267
+ const { tpl: currPanelTpl, panel: currPanel } = canvasPanelTemplate(
268
+ "git-diff-current",
269
+ "Current",
270
+ false,
271
+ "50%",
272
+ );
273
+
274
+ const diffTpl = html`
275
+ <div style="display: flex; height: 100%; gap: 0;">
276
+ <div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
277
+ ${origPanelTpl}
278
+ </div>
279
+ <div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
280
+ ${currPanelTpl}
281
+ </div>
282
+ </div>
283
+ `;
284
+
285
+ litRender(diffTpl, canvasWrap);
286
+ canvasPanels.push(/** @type {any} */ (origPanel));
287
+ canvasPanels.push(/** @type {any} */ (currPanel));
288
+
289
+ // Parse original document from git content
290
+ let originalDoc = null;
291
+ try {
292
+ if (gitDiffState.isMarkdown) {
293
+ // For markdown, we need to convert it to Jx format
294
+ // For now, use a simple placeholder until we have markdown parsing
295
+ originalDoc = {
296
+ tagName: "div",
297
+ children: [{ tagName: "p", textContent: "Original (markdown)" }],
298
+ };
299
+ } else {
300
+ // Parse as JSON
301
+ originalDoc = JSON.parse(gitDiffState.originalContent);
302
+ }
303
+ } catch {
304
+ originalDoc = {
305
+ tagName: "div",
306
+ children: [{ tagName: "p", textContent: "Failed to parse original" }],
307
+ };
308
+ }
309
+
310
+ const currDoc = S.document;
311
+
312
+ renderCanvasIntoPanel(origPanel, new Set(), S.ui.featureToggles, originalDoc, gitDiffState);
313
+ renderCanvasIntoPanel(currPanel, new Set(), S.ui.featureToggles, currDoc, gitDiffState);
314
+ return;
315
+ }
316
+
242
317
  // Edit (content) mode — centered column, no panzoom, always 100%
243
318
  if (canvasMode === "edit") {
244
319
  if (modeChanged) {
@@ -257,7 +332,7 @@ export function renderCanvas() {
257
332
  </div>
258
333
  `;
259
334
  litRender(editTpl, canvasWrap);
260
- canvasPanels.push(panel);
335
+ canvasPanels.push(/** @type {any} */ (panel));
261
336
  renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
262
337
  return;
263
338
  }
@@ -302,7 +377,7 @@ export function renderCanvas() {
302
377
  `,
303
378
  canvasWrap,
304
379
  );
305
- canvasPanels.push(panel);
380
+ canvasPanels.push(/** @type {any} */ (panel));
306
381
  renderCanvasIntoPanel(panel, new Set(), featureToggles);
307
382
  applyTransform();
308
383
  if (modeChanged) {
@@ -354,7 +429,7 @@ export function renderCanvas() {
354
429
  );
355
430
 
356
431
  for (const { panel, activeSet } of panelEntries) {
357
- canvasPanels.push(panel);
432
+ canvasPanels.push(/** @type {any} */ (panel));
358
433
  renderCanvasIntoPanel(panel, activeSet, featureToggles);
359
434
  }
360
435
 
@@ -376,29 +451,51 @@ export function renderCanvas() {
376
451
  * structural preview.
377
452
  *
378
453
  * @param {any} panel
379
- * @param {any} activeBreakpoints
454
+ * @param {Set<string>} activeBreakpoints
380
455
  * @param {any} featureToggles
456
+ * @param {any} [docOverride] - Optional document to render (for diff mode). Uses active tab doc if
457
+ * not provided.
458
+ * @param {any} [gitDiffState] - Optional diff state. If provided, computes and applies diff
459
+ * highlighting.
381
460
  */
382
- function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
461
+ function renderCanvasIntoPanel(
462
+ panel,
463
+ activeBreakpoints,
464
+ featureToggles,
465
+ docOverride = null,
466
+ gitDiffState = null,
467
+ ) {
383
468
  const gen = view.renderGeneration;
384
- const S = _ctx.getState();
385
- renderCanvasLive(gen, S.document, panel.canvas).then((/** @type {any} */ scope) => {
469
+ const tab = activeTab.value;
470
+ const docToRender = docOverride || tab?.doc.document;
471
+
472
+ renderCanvasLive(gen, docToRender, panel.canvas).then((/** @type {any} */ scope) => {
386
473
  // Skip post-render setup if a newer render has started
387
474
  if (gen !== view.renderGeneration) return;
388
475
  if (scope) {
389
476
  updateCanvas({ status: "ready", scope, error: null });
390
477
  applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
391
478
  statusMessage("Runtime render OK", 1500);
479
+
480
+ // Apply diff highlighting if in git-diff mode
481
+ if (gitDiffState && docOverride) {
482
+ // Determine which document is original and which is current
483
+ const isOriginal = docOverride === (gitDiffState.originalDoc || gitDiffState.original);
484
+ const origDoc = isOriginal ? docOverride : gitDiffState.currentDoc || tab?.doc.document;
485
+ const currDoc = isOriginal ? gitDiffState.currentDoc || tab?.doc.document : docOverride;
486
+
487
+ const { byPath: diffMap } = computeDocumentDiff(origDoc, currDoc);
488
+
489
+ // Can't iterate WeakMap, so apply styling by walking the canvas
490
+ const { elToPath } = scope;
491
+ if (elToPath instanceof WeakMap) {
492
+ applyDiffHighlightToCanvas(panel.canvas, diffMap);
493
+ }
494
+ }
392
495
  } else {
393
496
  // Fallback to structural preview
394
497
  updateCanvas({ status: "ready", scope: null, error: null });
395
- renderCanvasNode(
396
- _ctx.getState().document,
397
- [],
398
- panel.canvas,
399
- activeBreakpoints,
400
- featureToggles,
401
- );
498
+ renderCanvasNode(docToRender, [], panel.canvas, activeBreakpoints, featureToggles);
402
499
  }
403
500
  registerPanelDnD(panel);
404
501
  registerPanelEvents(panel);
@@ -407,6 +504,40 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
407
504
  });
408
505
  }
409
506
 
507
+ /**
508
+ * Apply diff highlighting to canvas elements based on elToPath mapping. Walks the canvas DOM and
509
+ * applies classes based on diff status.
510
+ *
511
+ * @param {HTMLElement} canvas
512
+ * @param {Map<string, "added" | "removed" | "modified">} diffMap
513
+ */
514
+ function applyDiffHighlightToCanvas(canvas, diffMap) {
515
+ if (!diffMap || diffMap.size === 0) return;
516
+
517
+ // Walk all elements in canvas and check their data attributes or other markers
518
+ const walkCanvas = (/** @type {HTMLElement} */ el, /** @type {string} */ path = "") => {
519
+ const pathKey = path || "/";
520
+
521
+ if (diffMap.has(pathKey)) {
522
+ const status = diffMap.get(pathKey);
523
+ if (status === "added") el.classList.add("element-diff-added");
524
+ else if (status === "removed") el.classList.add("element-diff-removed");
525
+ else if (status === "modified") el.classList.add("element-diff-modified");
526
+ }
527
+
528
+ // Check for child elements (heuristic: children array markers)
529
+ let childIdx = 0;
530
+ for (const child of el.children) {
531
+ const childPath =
532
+ pathKey === "/" ? `children/${childIdx}` : `${pathKey}/children/${childIdx}`;
533
+ walkCanvas(/** @type {HTMLElement} */ (child), childPath);
534
+ childIdx++;
535
+ }
536
+ };
537
+
538
+ walkCanvas(canvas, "");
539
+ }
540
+
410
541
  /**
411
542
  * Apply media query overrides as inline styles on matching canvas elements. Needed because the
412
543
  * runtime renders base styles as inline — @media CSS rules in the injected stylesheet can't win
@@ -417,8 +548,9 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
417
548
  */
418
549
  function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
419
550
  if (!activeBreakpoints.size) return;
420
- const S = _ctx.getState();
421
- const docMedia = getEffectiveMedia(S.document.$media || {});
551
+ const tab = activeTab.value;
552
+ if (!tab) return;
553
+ const docMedia = getEffectiveMedia(tab.doc.document.$media || {});
422
554
  const validBreakpoints = new Set();
423
555
  for (const name of activeBreakpoints) {
424
556
  if (docMedia[name]) validBreakpoints.add(name);
@@ -8,8 +8,8 @@ import { ref } from "lit-html/directives/ref.js";
8
8
  import { styleMap } from "lit-html/directives/style-map.js";
9
9
  import { ifDefined } from "lit-html/directives/if-defined.js";
10
10
 
11
- import { getState, renderOnly, updateUi, canvasWrap, canvasPanels } from "../store.js";
12
- import { ensureLitState } from "../panels/shared.js";
11
+ import { renderOnly, updateUi, canvasWrap, canvasPanels } from "../store.js";
12
+ import { activeTab } from "../workspace/workspace.js";
13
13
  import { view } from "../view.js";
14
14
 
15
15
  /** @type {any} */
@@ -44,14 +44,14 @@ export function initCanvasUtils(ctx) {
44
44
  export function canvasPanelTemplate(mediaName, label, fullWidth, width = null) {
45
45
  /**
46
46
  * @type {{
47
- * mediaName: any;
48
- * element: Element | null;
49
- * canvas: Element | null;
50
- * overlay: Element | null;
51
- * overlayClk: Element | null;
52
- * viewport: Element | null;
53
- * dropLine: Element | null;
54
- * _width: any;
47
+ * mediaName: string;
48
+ * element: HTMLElement | null;
49
+ * canvas: HTMLElement | null;
50
+ * overlay: HTMLElement | null;
51
+ * overlayClk: HTMLElement | null;
52
+ * viewport: HTMLElement | null;
53
+ * dropLine: HTMLElement | null;
54
+ * _width: number | null;
55
55
  * }}
56
56
  */
57
57
  const panel = {
@@ -69,7 +69,7 @@ export function canvasPanelTemplate(mediaName, label, fullWidth, width = null) {
69
69
  class=${`canvas-panel${fullWidth ? " full-width" : ""}`}
70
70
  data-media=${ifDefined(mediaName !== null ? mediaName : undefined)}
71
71
  ${ref((el) => {
72
- if (el) panel.element = el;
72
+ if (el) panel.element = /** @type {HTMLElement} */ (el);
73
73
  })}
74
74
  >
75
75
  ${label
@@ -88,34 +88,34 @@ export function canvasPanelTemplate(mediaName, label, fullWidth, width = null) {
88
88
  class="canvas-panel-viewport"
89
89
  style=${styleMap({ width: width && !fullWidth ? `${width}px` : "" })}
90
90
  ${ref((el) => {
91
- if (el) panel.viewport = el;
91
+ if (el) panel.viewport = /** @type {HTMLElement} */ (el);
92
92
  })}
93
93
  >
94
94
  <div
95
95
  class="canvas-panel-canvas"
96
96
  style=${styleMap({ width: width ? `${width}px` : "" })}
97
97
  ${ref((el) => {
98
- if (el) panel.canvas = el;
98
+ if (el) panel.canvas = /** @type {HTMLElement} */ (el);
99
99
  })}
100
100
  ></div>
101
101
  <div
102
102
  class="canvas-panel-overlay"
103
103
  ${ref((el) => {
104
- if (el) panel.overlay = el;
104
+ if (el) panel.overlay = /** @type {HTMLElement} */ (el);
105
105
  })}
106
106
  >
107
107
  <div
108
108
  class="canvas-drop-indicator"
109
109
  style="display:none"
110
110
  ${ref((el) => {
111
- if (el) panel.dropLine = el;
111
+ if (el) panel.dropLine = /** @type {HTMLElement} */ (el);
112
112
  })}
113
113
  ></div>
114
114
  </div>
115
115
  <div
116
116
  class="canvas-panel-click"
117
117
  ${ref((el) => {
118
- if (el) panel.overlayClk = el;
118
+ if (el) panel.overlayClk = /** @type {HTMLElement} */ (el);
119
119
  })}
120
120
  ></div>
121
121
  </div>
@@ -207,7 +207,6 @@ export function fitToScreen() {
207
207
 
208
208
  /** Reset the zoom indicator (clear its content). Called when switching to non-panzoom modes. */
209
209
  export function resetZoomIndicator() {
210
- ensureLitState(zoomIndicatorHost);
211
210
  litRender(nothing, zoomIndicatorHost);
212
211
  }
213
212
 
@@ -220,7 +219,6 @@ export function renderZoomIndicator() {
220
219
  if (!zoomIndicatorHost.isConnected) {
221
220
  document.body.appendChild(zoomIndicatorHost);
222
221
  }
223
- ensureLitState(zoomIndicatorHost);
224
222
  litRender(
225
223
  html`
226
224
  <div class="zoom-indicator">
@@ -263,14 +261,14 @@ export function positionZoomIndicator() {
263
261
 
264
262
  /** Toggle "active" class on canvas panel headers based on activeMedia. */
265
263
  export function updateActivePanelHeaders() {
266
- const S = getState();
264
+ const activeMedia = activeTab.value?.session.ui.activeMedia ?? null;
267
265
  for (const p of canvasPanels) {
268
- const header = p.element.querySelector(".canvas-panel-header");
266
+ const header = p.element?.querySelector(".canvas-panel-header");
269
267
  if (header) {
270
268
  const isActive =
271
- (S.ui.activeMedia === null && p.mediaName === "base") ||
272
- (S.ui.activeMedia === null && p.mediaName === null) ||
273
- S.ui.activeMedia === p.mediaName;
269
+ (activeMedia === null && p.mediaName === "base") ||
270
+ (activeMedia === null && p.mediaName === null) ||
271
+ activeMedia === p.mediaName;
274
272
  header.classList.toggle("active", isActive);
275
273
  }
276
274
  }