@jxsuite/studio 0.9.0 → 0.10.2

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.
package/src/studio.js CHANGED
@@ -14,12 +14,9 @@ import {
14
14
  popDocument,
15
15
  getNodeAtPath,
16
16
  pathsEqual,
17
- parentElementPath,
18
17
  canvasWrap,
19
18
  toolbarEl,
20
- elToPath,
21
19
  canvasPanels,
22
- stripEventHandlers,
23
20
  registerRenderer,
24
21
  render,
25
22
  update,
@@ -42,30 +39,21 @@ import {
42
39
 
43
40
  import { view } from "./view.js";
44
41
 
45
- import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxsuite/runtime";
46
-
47
- import {
48
- isEditing,
49
- getActiveElement,
50
- isEditableBlock,
51
- isInlineInContext,
52
- } from "./editor/inline-edit.js";
42
+ import { isEditing, isEditableBlock } from "./editor/inline-edit.js";
53
43
  import {
54
44
  enterComponentInlineEdit,
55
45
  initComponentInlineEdit,
56
46
  } from "./editor/component-inline-edit.js";
57
- import { enterInlineEdit, initContentInlineEdit } from "./editor/content-inline-edit.js";
47
+ import { enterInlineEdit } from "./editor/content-inline-edit.js";
58
48
  import {
59
49
  initCanvasUtils,
60
- canvasPanelTemplate,
61
- observeCenterUntilStable,
62
50
  applyTransform,
63
- renderZoomIndicator,
64
- resetZoomIndicator,
65
51
  positionZoomIndicator,
66
52
  updateActivePanelHeaders,
67
53
  } from "./canvas/canvas-utils.js";
68
- import { dismissSlashMenu as sharedDismissSlashMenu } from "./editor/slash-menu.js";
54
+ import { initCanvasHelpers, getActivePanel, findCanvasElement } from "./canvas/canvas-helpers.js";
55
+ import { initCanvasRender, renderCanvas } from "./canvas/canvas-render.js";
56
+ import { initCanvasLiveRender } from "./canvas/canvas-live-render.js";
69
57
  import {
70
58
  renderStatusbar,
71
59
  statusMessage,
@@ -91,29 +79,13 @@ import { renderHeadTemplate } from "./panels/head-panel.js";
91
79
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export.js";
92
80
 
93
81
  import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
94
- import {
95
- parseMediaEntries,
96
- activeBreakpointsForWidth,
97
- collectMediaOverrides,
98
- applyOverridesToCanvas,
99
- } from "./utils/canvas-media.js";
82
+ import { parseMediaEntries } from "./utils/canvas-media.js";
100
83
  import { createDevServerPlatform } from "./platforms/devserver.js";
101
84
  import { codeService } from "./services/code-services.js";
102
- import {
103
- getEffectiveMedia,
104
- getEffectiveImports,
105
- getEffectiveElements,
106
- getEffectiveHead,
107
- } from "./site-context.js";
108
85
  import { defCategory, defBadgeLabel, renderSignalsTemplate } from "./panels/signals-panel.js";
109
- import {
110
- componentRegistry,
111
- loadComponentRegistry,
112
- computeRelativePath,
113
- } from "./files/components.js";
86
+ import { loadComponentRegistry } from "./files/components.js";
114
87
 
115
- import { html, render as litRender, nothing } from "lit-html";
116
- import { ref } from "lit-html/directives/ref.js";
88
+ import { html, render as litRender } from "lit-html";
117
89
 
118
90
  import webdata from "../data/webdata.json";
119
91
  import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
@@ -124,29 +96,20 @@ import { renderGitPanel } from "./panels/git-panel.js";
124
96
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
125
97
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
126
98
  import "./ui/panel-resize.js";
127
- import { dismissContextMenu } from "./editor/context-menu.js";
128
99
  import { initShortcuts } from "./editor/shortcuts.js";
129
100
  import { renderActivityBar } from "./panels/activity-bar.js";
130
- import { renderBrowse } from "./browse/browse.js";
131
101
  import * as toolbarPanel from "./panels/toolbar.js";
132
102
  import * as overlaysPanel from "./panels/overlays.js";
133
103
  import * as rightPanelMod from "./panels/right-panel.js";
134
104
  import * as leftPanelMod from "./panels/left-panel.js";
135
- import { renderStylebookMode, renderStylebookOverlays } from "./panels/stylebook-panel.js";
105
+ import { renderStylebookOverlays } from "./panels/stylebook-panel.js";
136
106
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd.js";
137
- import { mediaDisplayName, defaultDef } from "./panels/shared.js";
138
- import { renderFunctionEditor, registerFunctionCompletions } from "./panels/editors.js";
139
- import {
140
- renderBlockActionBar,
141
- dismissLinkPopover,
142
- initBlockActionBar,
143
- } from "./panels/block-action-bar.js";
107
+ import { defaultDef } from "./panels/shared.js";
108
+ import { registerFunctionCompletions } from "./panels/editors.js";
109
+ import { renderBlockActionBar, initBlockActionBar } from "./panels/block-action-bar.js";
144
110
  import { initCssData } from "./panels/style-utils.js";
145
- import { renderCanvasNode } from "./panels/preview-render.js";
146
- import { initPseudoPreview, updateForcedPseudoPreview } from "./panels/pseudo-preview.js";
147
- import { initCanvasDnD, registerPanelDnD } from "./panels/canvas-dnd.js";
148
- import { initPanelEvents, registerPanelEvents } from "./panels/panel-events.js";
149
- import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
111
+ import { updateForcedPseudoPreview } from "./panels/pseudo-preview.js";
112
+ import { initPanelEvents } from "./panels/panel-events.js";
150
113
 
151
114
  // ─── Globals ──────────────────────────────────────────────────────────────────
152
115
  // These mutable variables are local to studio.js for now. As sections are extracted
@@ -177,11 +140,12 @@ async function navigateToComponent(componentPath) {
177
140
  const platform = getPlatform();
178
141
  const content = await platform.readFile(componentPath);
179
142
  if (!content) return;
180
- const doc = JSON.parse(content);
181
- S = pushDocument(S, doc, componentPath);
143
+ const parsed = JSON.parse(content);
144
+ S = pushDocument(S, parsed, componentPath);
182
145
  S.dirty = false;
146
+ ({ doc, session } = fromFlat(S));
183
147
  render();
184
- statusMessage(`Editing component: ${doc.tagName || componentPath}`);
148
+ statusMessage(`Editing component: ${parsed.tagName || componentPath}`);
185
149
  } catch (/** @type {any} */ e) {
186
150
  const err = /** @type {any} */ (e);
187
151
  statusMessage(`Error: ${err.message}`);
@@ -200,6 +164,7 @@ async function navigateBack() {
200
164
  }
201
165
  }
202
166
  S = popDocument(S);
167
+ ({ doc, session } = fromFlat(S));
203
168
  render();
204
169
  statusMessage("Returned to parent document");
205
170
  }
@@ -230,259 +195,6 @@ async function closeFunctionEditor() {
230
195
  updateUi("editingFunction", null);
231
196
  }
232
197
 
233
- import { prepareForEditMode } from "./utils/edit-display.js";
234
-
235
- /**
236
- * Render a Jx document into a canvas element using the real runtime. Populates elToPath for each
237
- * created element via onNodeCreated callback. Returns the live state scope on success, null on
238
- * failure.
239
- *
240
- * @param {number} gen - Render generation for staleness detection
241
- * @param {any} doc
242
- * @param {any} canvasEl
243
- */
244
- async function renderCanvasLive(gen, doc, canvasEl) {
245
- canvasEl.innerHTML = "";
246
-
247
- // Apply content mode typography styling
248
- if (S.mode === "content") {
249
- canvasEl.setAttribute("data-content-mode", "");
250
- } else {
251
- canvasEl.removeAttribute("data-content-mode");
252
- }
253
-
254
- const renderDoc =
255
- canvasMode === "preview" ? structuredClone(doc) : prepareForEditMode(stripEventHandlers(doc));
256
-
257
- // In edit mode, collect paths where $map templates were inlined as children[0]
258
- // so we can remap runtime paths (children,0,...) → (children,map,...)
259
- const mapParentPaths = new Set();
260
- if (canvasMode === "design" || canvasMode === "edit") {
261
- (function findMapParents(/** @type {any} */ node, /** @type {any[]} */ path) {
262
- if (!node || typeof node !== "object") return;
263
- if (
264
- node.children &&
265
- typeof node.children === "object" &&
266
- node.children.$prototype === "Array"
267
- ) {
268
- mapParentPaths.add(path.join("/"));
269
- }
270
- if (Array.isArray(node.children)) {
271
- for (let i = 0; i < node.children.length; i++) {
272
- findMapParents(node.children[i], [...path, "children", i]);
273
- }
274
- }
275
- if (node.$switch && node.cases) {
276
- for (const [k, v] of Object.entries(node.cases)) {
277
- findMapParents(v, [...path, "cases", k]);
278
- }
279
- }
280
- })(doc, []);
281
- }
282
-
283
- try {
284
- const root = projectState?.projectRoot || "";
285
- const docPrefix = root ? `${root}/` : "";
286
- const docBase = S.documentPath ? `${location.origin}/${docPrefix}${S.documentPath}` : undefined;
287
-
288
- // Register custom elements so the runtime can render them
289
- let effectiveElements = getEffectiveElements(renderDoc.$elements);
290
-
291
- // In content mode (markdown), auto-discover components for directive-based
292
- // custom elements that have no explicit $elements registration.
293
- if (S.mode === "content" && componentRegistry.length > 0) {
294
- const existingRefs = new Set(
295
- effectiveElements.map((/** @type {any} */ e) => (typeof e === "string" ? e : e?.$ref)),
296
- );
297
- /** @param {any} node */
298
- const collectTags = (node) => {
299
- /** @type {Set<string>} */
300
- const tags = new Set();
301
- if (!node || typeof node !== "object") return tags;
302
- if (node.tagName) tags.add(node.tagName);
303
- if (Array.isArray(node.children)) {
304
- for (const child of node.children) {
305
- for (const t of collectTags(child)) tags.add(t);
306
- }
307
- }
308
- return tags;
309
- };
310
- for (const tag of collectTags(renderDoc)) {
311
- const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
312
- if (comp && comp.source !== "npm") {
313
- const relPath = computeRelativePath(S.documentPath, comp.path);
314
- if (!existingRefs.has(relPath)) {
315
- effectiveElements.push({ $ref: relPath });
316
- existingRefs.add(relPath);
317
- }
318
- }
319
- }
320
- }
321
-
322
- if (effectiveElements.length) {
323
- renderDoc.$elements = effectiveElements;
324
- for (const entry of effectiveElements) {
325
- if (typeof entry === "string") {
326
- try {
327
- const specifier =
328
- entry.startsWith("/") || entry.startsWith(".")
329
- ? entry
330
- : `/${projectState?.projectRoot || ""}/node_modules/${entry}`.replace(/\/+/g, "/");
331
- await import(specifier);
332
- } catch (/** @type {any} */ e) {
333
- console.warn("Studio: failed to import package", entry, e);
334
- }
335
- } else if (entry?.$ref) {
336
- const href = new URL(entry.$ref, docBase).href;
337
- try {
338
- await defineElement(href);
339
- } catch (/** @type {any} */ e) {
340
- console.warn("Studio: failed to register element", entry.$ref, e);
341
- }
342
- }
343
- }
344
- }
345
-
346
- // Bail out if a newer render started while we were importing elements
347
- if (gen !== view.renderGeneration) return null;
348
-
349
- // Inject site-level imports so buildScope can resolve $prototype names
350
- renderDoc.imports = getEffectiveImports(renderDoc.imports);
351
-
352
- // Apply project-level styles mirroring the compiler convention:
353
- // viewport ≈ :root → CSS custom properties (they inherit down)
354
- // canvasEl ≈ body → regular CSS properties (inline beats CSS defaults)
355
- // This ensures project font-family, color, etc. override the
356
- // content-mode fallback typography rules in the stylesheet.
357
- // In edit mode, propagate to the .content-edit-canvas wrapper for seamless appearance.
358
- const viewport = canvasEl.closest(".canvas-panel-viewport");
359
- const editSurface = canvasMode === "edit" ? canvasEl.closest(".content-edit-canvas") : null;
360
- const siteStyle = projectState?.projectConfig?.style;
361
- if (viewport) {
362
- viewport.style.cssText = "";
363
- if (siteStyle && typeof siteStyle === "object") {
364
- for (const [k, v] of Object.entries(siteStyle)) {
365
- if (k.startsWith("--")) {
366
- viewport.style.setProperty(k, String(v));
367
- } else {
368
- /** @type {any} */ (viewport.style)[k] = v;
369
- }
370
- }
371
- }
372
- }
373
- if (editSurface) {
374
- if (siteStyle && typeof siteStyle === "object") {
375
- for (const [k, v] of Object.entries(siteStyle)) {
376
- if (k.startsWith("--")) {
377
- /** @type {any} */ (editSurface).style.setProperty(k, String(v));
378
- } else {
379
- /** @type {any} */ (editSurface.style)[k] = v;
380
- }
381
- }
382
- }
383
- }
384
- if (siteStyle && typeof siteStyle === "object") {
385
- for (const [k, v] of Object.entries(siteStyle)) {
386
- if (!k.startsWith("--")) {
387
- /** @type {any} */ (canvasEl.style)[k] = v;
388
- }
389
- }
390
- }
391
-
392
- // Inject site-level $media so runtime can resolve media queries in styles
393
- renderDoc.$media = getEffectiveMedia(renderDoc.$media);
394
-
395
- // Inject $head elements (link/meta/script) into document.head
396
- const effectiveHead = getEffectiveHead(renderDoc.$head);
397
- if (effectiveHead.length) {
398
- for (const entry of effectiveHead) {
399
- if (!entry?.tagName) continue;
400
- const tag = entry.tagName.toLowerCase();
401
- const attrs = { ...entry.attributes };
402
- const root = projectState?.projectRoot || "";
403
- for (const key of ["href", "src"]) {
404
- if (
405
- attrs[key] &&
406
- !attrs[key].startsWith("/") &&
407
- !attrs[key].startsWith(".") &&
408
- !attrs[key].startsWith("http")
409
- ) {
410
- attrs[key] = `/${root}/node_modules/${attrs[key]}`.replace(/\/+/g, "/");
411
- }
412
- }
413
- const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
414
- if (selector !== tag && document.head.querySelector(selector)) continue;
415
- const el = document.createElement(tag);
416
- for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, /** @type {string} */ (v));
417
- if (entry.textContent) el.textContent = entry.textContent;
418
- document.head.appendChild(el);
419
- }
420
- }
421
-
422
- const $defs = await buildScope(renderDoc, {}, docBase);
423
- // Bail out if a newer render started while buildScope was running
424
- if (gen !== view.renderGeneration) return null;
425
- const el = /** @type {HTMLElement} */ (
426
- runtimeRenderNode(renderDoc, $defs, {
427
- onNodeCreated(/** @type {any} */ el, /** @type {any} */ path) {
428
- // Remap $map paths: wrapper and template children → real document paths
429
- // prepareForEditMode wraps $map template in: children[0] (wrapper) > children[0] (template)
430
- // Real paths: wrapper → ['children'] ($map container), template → ['children', 'map']
431
- let mappedPath = path;
432
- if ((canvasMode === "design" || canvasMode === "edit") && mapParentPaths.size > 0) {
433
- for (let i = 0; i < path.length - 1; i++) {
434
- if (path[i] === "children" && path[i + 1] === 0) {
435
- const parentKey = path.slice(0, i).join("/");
436
- if (mapParentPaths.has(parentKey)) {
437
- if (path.length === i + 2) {
438
- // Wrapper div itself → $map container path
439
- mappedPath = path.slice(0, i + 1);
440
- } else if (
441
- path.length >= i + 4 &&
442
- path[i + 2] === "children" &&
443
- path[i + 3] === 0
444
- ) {
445
- // Template or its descendants → children/map/...rest
446
- mappedPath = [...path.slice(0, i), "children", "map", ...path.slice(i + 4)];
447
- }
448
- break;
449
- }
450
- }
451
- }
452
- }
453
- elToPath.set(el, mappedPath);
454
- },
455
- _path: [],
456
- })
457
- );
458
- if (canvasMode === "design" || canvasMode === "edit") {
459
- // Disable pointer events on all rendered elements for edit mode
460
- el.style.pointerEvents = "none";
461
- for (const child of el.querySelectorAll("*")) {
462
- /** @type {any} */ (child).style.pointerEvents = "none";
463
- }
464
- }
465
- canvasEl.appendChild(el);
466
- if (canvasMode === "design" || canvasMode === "edit") {
467
- // Custom element connectedCallbacks render children asynchronously —
468
- // sweep again after they've had a chance to run
469
- requestAnimationFrame(() => {
470
- const editingEl = getActiveElement();
471
- for (const child of canvasEl.querySelectorAll("*")) {
472
- // Preserve pointer-events on the actively-edited element
473
- if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
474
- if (editingEl && child === editingEl) continue;
475
- /** @type {any} */ (child).style.pointerEvents = "none";
476
- }
477
- });
478
- }
479
- return $defs;
480
- } catch (/** @type {any} */ err) {
481
- console.warn("renderCanvasLive failed:", err.message, err);
482
- return null;
483
- }
484
- }
485
-
486
198
  // ─── Webdata: datalists for autocomplete ──────────────────────────────────────
487
199
 
488
200
  const datalistHost = document.createElement("div");
@@ -542,24 +254,19 @@ toolbarPanel.mount(toolbarEl, {
542
254
  });
543
255
 
544
256
  overlaysPanel.mount({
545
- effectiveZoom,
546
257
  getCanvasMode: () => canvasMode,
547
258
  isEditing,
548
259
  renderBlockActionBar,
549
- findCanvasElement,
550
- getActivePanel,
551
260
  });
552
261
 
553
262
  initBlockActionBar({
554
263
  getCanvasMode: () => canvasMode,
555
- findCanvasElement,
556
- getActivePanel,
557
264
  navigateToComponent,
558
265
  createFloatingContainer,
559
266
  });
560
267
 
561
268
  initComponentInlineEdit({ findCanvasElement });
562
- initContentInlineEdit({ findCanvasElement, getActivePanel });
269
+ initCanvasHelpers({ getCanvasMode: () => canvasMode });
563
270
  initCanvasUtils({
564
271
  getCanvasMode: () => canvasMode,
565
272
  getZoom: () => S.ui.zoom,
@@ -569,19 +276,29 @@ initCanvasUtils({
569
276
  },
570
277
  renderStylebookOverlays,
571
278
  });
572
- initPseudoPreview({ getActivePanel, findCanvasElement });
573
- initCanvasDnD({ effectiveZoom });
574
279
  initPanelEvents({
575
280
  getState: () => S,
576
281
  setState: (s) => {
577
282
  S = s;
283
+ ({ doc, session } = fromFlat(S));
578
284
  },
579
285
  getCanvasMode: () => canvasMode,
580
- bubbleInlinePath,
581
- findCanvasElement,
582
286
  enterInlineEdit,
583
287
  navigateToComponent,
584
- effectiveZoom,
288
+ });
289
+ initCanvasLiveRender({
290
+ getState: () => S,
291
+ getCanvasMode: () => canvasMode,
292
+ });
293
+ initCanvasRender({
294
+ getCanvasMode: () => canvasMode,
295
+ setCanvasMode: (mode) => {
296
+ canvasMode = mode;
297
+ },
298
+ getState: () => S,
299
+ update,
300
+ openFileFromTree,
301
+ exportFile,
585
302
  });
586
303
 
587
304
  rightPanelMod.mount({
@@ -700,10 +417,14 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
700
417
  if (patch.ui) {
701
418
  session.ui = { ...prev.ui, ...patch.ui };
702
419
  }
420
+ if (patch.canvas) {
421
+ session.canvas = { ...prev.canvas, ...patch.canvas };
422
+ }
703
423
  S = toFlat(doc, session);
704
424
 
705
425
  const selChanged = !pathsEqual(prev.selection, session.selection);
706
426
  const uiChanged = prev.ui !== session.ui;
427
+ const canvasChanged = prev.canvas !== session.canvas;
707
428
 
708
429
  const canvasUiChanged =
709
430
  uiChanged &&
@@ -724,7 +445,7 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
724
445
  console.error("renderCanvas error:", e);
725
446
  }
726
447
  safeRenderLeftPanel();
727
- } else if (selChanged || leftUiChanged) {
448
+ } else if (selChanged || leftUiChanged || canvasChanged) {
728
449
  safeRenderLeftPanel();
729
450
  }
730
451
 
@@ -732,20 +453,10 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
732
453
  updateActivePanelHeaders();
733
454
  }
734
455
 
735
- runPostRenderHooks(doc.document, prev.selection);
736
-
737
- const hoverChanged = prev.hover !== session.hover;
738
- notify({ doc: false, selection: selChanged, hover: hoverChanged, ui: uiChanged, mode: false });
739
- });
740
-
741
- // Register post-render hook for pseudo-state preview
742
- addPostRenderHook(() => updateForcedPseudoPreview());
743
-
744
- // Register post-render hook for pending inline edit
745
- addPostRenderHook((/** @type {any} */ prevDoc) => {
746
- if (view.pendingInlineEdit && prevDoc === S.document) {
747
- const { path, mediaName: mn } = view.pendingInlineEdit;
748
- view.pendingInlineEdit = null;
456
+ // Process pending inline edit when canvas becomes ready
457
+ if (canvasChanged && session.canvas.status === "ready" && session.ui?.pendingInlineEdit) {
458
+ const { path, mediaName: mn } = session.ui.pendingInlineEdit;
459
+ updateUi("pendingInlineEdit", null);
749
460
  const targetPanel =
750
461
  canvasPanels.find((/** @type {any} */ p) => p.mediaName === mn) || canvasPanels[0];
751
462
  if (targetPanel) {
@@ -753,8 +464,16 @@ addPostRenderHook((/** @type {any} */ prevDoc) => {
753
464
  if (el) enterComponentInlineEdit(el, path);
754
465
  }
755
466
  }
467
+
468
+ runPostRenderHooks(doc.document, prev.selection);
469
+
470
+ const hoverChanged = prev.hover !== session.hover;
471
+ notify({ doc: false, selection: selChanged, hover: hoverChanged, ui: uiChanged, mode: false });
756
472
  });
757
473
 
474
+ // Register post-render hook for pseudo-state preview
475
+ addPostRenderHook(() => updateForcedPseudoPreview());
476
+
758
477
  // Now that renderers and update are registered, bootstrap
759
478
  registerFunctionCompletions();
760
479
 
@@ -824,13 +543,19 @@ if (_openParam) {
824
543
  }
825
544
 
826
545
  // Read and open the file
827
- const fileRelPath = siteCtx.fileRelPath || _openParam;
546
+ const _fileParam = new URLSearchParams(location.search).get("file");
547
+ const fileRelPath = _fileParam || siteCtx.fileRelPath || _openParam;
828
548
  const content = await platform.readFile(fileRelPath);
829
549
  if (content) {
830
- const parsed = JSON.parse(content);
831
- S = createState(parsed);
550
+ if (fileRelPath.endsWith(".md")) {
551
+ await loadMarkdown(content, null);
552
+ S.documentPath = fileRelPath;
553
+ } else {
554
+ const parsed = JSON.parse(content);
555
+ S = createState(parsed);
556
+ S.documentPath = fileRelPath;
557
+ }
832
558
  S.dirty = false;
833
- S.documentPath = fileRelPath;
834
559
  S.ui = { ...S.ui, leftTab: "files" };
835
560
  ({ doc, session } = fromFlat(S));
836
561
  render();
@@ -847,480 +572,6 @@ if (_openParam) {
847
572
  render();
848
573
  }
849
574
 
850
- // ─── Media helpers ────────────────────────────────────────────────────────────
851
-
852
- /**
853
- * After a runtime render, apply active media overrides as inline styles so they beat the base
854
- * inline styles the runtime already set. The runtime uses @media CSS rules for overrides, but those
855
- * can never beat inline base styles.
856
- *
857
- * @param {Element} canvasEl
858
- * @param {Set<string>} activeBreakpoints
859
- */
860
- function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
861
- if (!activeBreakpoints.size) return;
862
- const docMedia = getEffectiveMedia(S.document.$media || {});
863
- const validBreakpoints = new Set();
864
- for (const name of activeBreakpoints) {
865
- if (docMedia[name]) validBreakpoints.add(name);
866
- }
867
- const overrides = collectMediaOverrides(document.styleSheets, validBreakpoints);
868
- applyOverridesToCanvas(canvasEl, overrides);
869
- }
870
-
871
- // ─── Canvas ───────────────────────────────────────────────────────────────────
872
-
873
- function renderCanvas() {
874
- // Advance render generation so stale async renders from the previous cycle bail out
875
- ++view.renderGeneration;
876
-
877
- // Always clear Lit's internal state so it builds fresh DOM. Stale async
878
- // renderCanvasLive calls from a previous cycle can corrupt nested ChildPart
879
- // markers (Comment nodes inside panzoom-wrap) in ways the root-only
880
- // ensureLitState check cannot detect.
881
- // @ts-ignore
882
- if (canvasWrap["_$litPart$"]) {
883
- canvasWrap.textContent = "";
884
- // @ts-ignore
885
- delete canvasWrap["_$litPart$"];
886
- }
887
-
888
- // Function editor mode: editing a function body in Monaco (JS)
889
- if (S.ui.editingFunction) {
890
- renderFunctionEditor();
891
- return;
892
- }
893
-
894
- // Dispose function editor if switching away
895
- if (view.functionEditor) {
896
- view.functionEditor.dispose();
897
- view.functionEditor = null;
898
- }
899
-
900
- // Source mode: update existing Monaco editor without recreating
901
- if (canvasMode === "source" && view.monacoEditor) {
902
- const jsonStr = JSON.stringify(S.document, null, 2);
903
- const currentVal = view.monacoEditor.getValue();
904
- if (currentVal !== jsonStr) {
905
- // Prevent triggering the onChange handler for this programmatic update
906
- view.monacoEditor._ignoreNextChange = true;
907
- view.monacoEditor.setValue(jsonStr);
908
- }
909
- return;
910
- }
911
-
912
- // Detect whether this is a mode transition or a content-only re-render
913
- const modeChanged = canvasMode !== view.prevCanvasMode;
914
- view.prevCanvasMode = canvasMode;
915
-
916
- // DnD handlers are registered on inner canvas elements that get replaced on every
917
- // content render, so always clean them up.
918
- for (const fn of view.canvasDndCleanups) fn();
919
- view.canvasDndCleanups = [];
920
-
921
- // Panel event handlers (click, dblclick, etc.) capture closures over panel references.
922
- // Always re-register to keep closures fresh across document switches.
923
- for (const fn of view.canvasEventCleanups) fn();
924
- view.canvasEventCleanups = [];
925
-
926
- // Panel JS objects are cheap — always clear and repopulate from templates.
927
- // The actual DOM elements are preserved by Lit's diffing on content-only re-renders.
928
- canvasPanels.length = 0;
929
-
930
- if (modeChanged) {
931
- // Full teardown on mode transitions — new panel structure needed
932
- if (view.centerObserver) {
933
- view.centerObserver.disconnect();
934
- view.centerObserver = null;
935
- }
936
-
937
- // Dispose Monaco editor if switching away from source mode
938
- if (view.monacoEditor) {
939
- view.monacoEditor.dispose();
940
- view.monacoEditor = null;
941
- }
942
-
943
- litRender(nothing, canvasWrap);
944
- view.panzoomWrap = null;
945
- // Reset inline style overrides from other modes
946
- canvasWrap.style.padding = "";
947
- canvasWrap.style.alignItems = "";
948
- canvasWrap.style.display = "";
949
- canvasWrap.style.overflow = "";
950
- canvasWrap.style.overflow = "";
951
-
952
- // Clear zoom indicator (only re-rendered by design/preview/stylebook)
953
- resetZoomIndicator();
954
-
955
- // Dismiss open popovers/toolbars that are no longer relevant
956
- if (view.blockActionBarEl) litRender(nothing, view.blockActionBarEl);
957
- dismissLinkPopover();
958
- dismissContextMenu();
959
- sharedDismissSlashMenu();
960
- }
961
-
962
- // Manage mode: project-level file browser table
963
- if (canvasMode === "manage") {
964
- canvasWrap.style.padding = "0";
965
- canvasWrap.style.overflow = "auto";
966
- renderBrowse(canvasWrap, {
967
- openFile: (/** @type {string} */ path) => {
968
- canvasMode = "edit";
969
- openFileFromTree(path);
970
- },
971
- });
972
- return;
973
- }
974
-
975
- // Settings mode: render element catalog with panzoom surface
976
- if (canvasMode === "settings") {
977
- renderStylebookMode({
978
- canvasPanelTemplate,
979
- applyTransform,
980
- observeCenterUntilStable,
981
- renderZoomIndicator,
982
- updateActivePanelHeaders,
983
- overlayBoxDescriptor,
984
- effectiveZoom,
985
- });
986
- return;
987
- }
988
-
989
- // Source mode: create Monaco editor instead of canvas
990
- if (canvasMode === "source") {
991
- canvasWrap.style.padding = "0";
992
- canvasWrap.style.display = "block";
993
- /** @type {HTMLDivElement | null} */
994
- let editorContainer = null;
995
- litRender(
996
- html`<div class="source-wrap">
997
- <div class="source-toolbar">
998
- <sp-action-button size="s" @click=${exportFile}>
999
- <sp-icon-export slot="icon"></sp-icon-export>
1000
- Export
1001
- </sp-action-button>
1002
- </div>
1003
- <div
1004
- class="source-editor"
1005
- ${ref((el) => {
1006
- if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
1007
- })}
1008
- ></div>
1009
- </div>`,
1010
- canvasWrap,
1011
- );
1012
-
1013
- const jsonStr = JSON.stringify(S.document, null, 2);
1014
- view.monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
1015
- value: jsonStr,
1016
- language: "json",
1017
- theme: "vs-dark",
1018
- automaticLayout: true,
1019
- minimap: { enabled: false },
1020
- fontSize: 12,
1021
- fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
1022
- lineNumbers: "on",
1023
- scrollBeyondLastLine: false,
1024
- wordWrap: "on",
1025
- tabSize: 2,
1026
- });
1027
-
1028
- // Debounced sync back to state
1029
- /** @type {any} */
1030
- let debounce;
1031
- view.monacoEditor.onDidChangeModelContent(() => {
1032
- if (view.monacoEditor._ignoreNextChange) {
1033
- view.monacoEditor._ignoreNextChange = false;
1034
- return;
1035
- }
1036
- clearTimeout(debounce);
1037
- debounce = setTimeout(() => {
1038
- try {
1039
- const parsed = JSON.parse(view.monacoEditor.getValue());
1040
- update({ ...S, document: parsed, dirty: true });
1041
- } catch {
1042
- // Invalid JSON — don't update state
1043
- }
1044
- }, 600);
1045
- });
1046
- return;
1047
- }
1048
-
1049
- // Edit (content) mode — centered column, no panzoom, always 100%
1050
- if (canvasMode === "edit") {
1051
- if (modeChanged) {
1052
- canvasWrap.style.padding = "0";
1053
- canvasWrap.style.overflow = "hidden";
1054
-
1055
- // Remove zoom indicator left over from design/preview mode
1056
- resetZoomIndicator();
1057
- }
1058
-
1059
- const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
1060
- const editTpl = html`
1061
- <div class="content-edit-canvas">
1062
- <div class="content-edit-column">${panelTpl}</div>
1063
- </div>
1064
- `;
1065
- litRender(editTpl, canvasWrap);
1066
- canvasPanels.push(panel);
1067
- renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
1068
- return;
1069
- }
1070
-
1071
- // Normal canvas mode (design / preview) — set up panzoom surface
1072
- if (modeChanged) {
1073
- canvasWrap.style.padding = "0";
1074
- canvasWrap.style.overflow = "hidden";
1075
- }
1076
-
1077
- const {
1078
- sizeBreakpoints,
1079
- featureQueries: _featureQueries,
1080
- baseWidth,
1081
- } = parseMediaEntries(getEffectiveMedia(S.document.$media));
1082
- const hasMedia = sizeBreakpoints.length > 0;
1083
- const featureToggles = S.ui.featureToggles;
1084
-
1085
- // Create panzoom wrapper (the element that gets transformed)
1086
- if (!hasMedia) {
1087
- // Single panel — use baseWidth if a custom one is defined, otherwise full-width
1088
- const effectiveMedia = getEffectiveMedia(S.document.$media);
1089
- const hasBaseWidth = effectiveMedia && effectiveMedia["--"];
1090
- const label = hasBaseWidth ? `${mediaDisplayName("--")} (${baseWidth}px)` : null;
1091
- const { tpl: panelTpl, panel } = canvasPanelTemplate(
1092
- hasBaseWidth ? "base" : null,
1093
- label,
1094
- !hasBaseWidth,
1095
- hasBaseWidth ? baseWidth : undefined,
1096
- );
1097
- litRender(
1098
- html`
1099
- <div
1100
- class="panzoom-wrap"
1101
- style="transform-origin:0 0"
1102
- ${ref((el) => {
1103
- if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1104
- })}
1105
- >
1106
- ${panelTpl}
1107
- </div>
1108
- `,
1109
- canvasWrap,
1110
- );
1111
- canvasPanels.push(panel);
1112
- renderCanvasIntoPanel(panel, new Set(), featureToggles);
1113
- applyTransform();
1114
- if (modeChanged) {
1115
- observeCenterUntilStable();
1116
- }
1117
- renderZoomIndicator();
1118
- return;
1119
- }
1120
-
1121
- // Build all panels: base first, then breakpoints in declared order (ascending for min-width,
1122
- // descending for max-width — matching the direction of the design's media queries).
1123
- const allPanelDefs = [
1124
- {
1125
- name: "base",
1126
- displayName: mediaDisplayName("--"),
1127
- width: baseWidth,
1128
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, baseWidth),
1129
- },
1130
- ];
1131
- for (const bp of sizeBreakpoints) {
1132
- allPanelDefs.push({
1133
- name: bp.name,
1134
- displayName: mediaDisplayName(bp.name),
1135
- width: bp.width,
1136
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
1137
- });
1138
- }
1139
-
1140
- /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
1141
- const panelEntries = allPanelDefs.map((def) => {
1142
- const label = `${def.displayName} (${def.width}px)`;
1143
- const { tpl, panel } = canvasPanelTemplate(def.name, label, false, def.width);
1144
- return { tpl, panel, activeSet: def.activeSet };
1145
- });
1146
-
1147
- litRender(
1148
- html`
1149
- <div
1150
- class="panzoom-wrap"
1151
- style="transform-origin:0 0"
1152
- ${ref((el) => {
1153
- if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1154
- })}
1155
- >
1156
- ${panelEntries.map((e) => e.tpl)}
1157
- </div>
1158
- `,
1159
- canvasWrap,
1160
- );
1161
-
1162
- for (const { panel, activeSet } of panelEntries) {
1163
- canvasPanels.push(panel);
1164
- renderCanvasIntoPanel(panel, activeSet, featureToggles);
1165
- }
1166
-
1167
- // Highlight active panel header
1168
- updateActivePanelHeaders();
1169
-
1170
- // Apply current zoom + pan transform
1171
- applyTransform();
1172
- if (modeChanged) {
1173
- observeCenterUntilStable();
1174
- }
1175
-
1176
- // Floating zoom indicator
1177
- renderZoomIndicator();
1178
- }
1179
-
1180
- /**
1181
- * Render document into a single canvas panel. Tries runtime rendering first, falls back to
1182
- * structural preview.
1183
- *
1184
- * @param {any} panel
1185
- * @param {any} activeBreakpoints
1186
- * @param {any} featureToggles
1187
- */
1188
- function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1189
- const gen = view.renderGeneration;
1190
- renderCanvasLive(gen, S.document, panel.canvas).then((scope) => {
1191
- // Skip post-render setup if a newer render has started
1192
- if (gen !== view.renderGeneration) return;
1193
- if (scope) {
1194
- view.liveScope = scope;
1195
- applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
1196
- statusMessage("Runtime render OK", 1500);
1197
- } else {
1198
- // Fallback to structural preview
1199
- renderCanvasNode(S.document, [], panel.canvas, activeBreakpoints, featureToggles);
1200
- }
1201
- registerPanelDnD(panel);
1202
- registerPanelEvents(panel);
1203
- renderOverlays();
1204
- updateForcedPseudoPreview();
1205
-
1206
- // Process pending inline edit now that the canvas is populated
1207
- if (view.pendingInlineEdit) {
1208
- const { path, mediaName: mn } = view.pendingInlineEdit;
1209
- view.pendingInlineEdit = null;
1210
- const targetPanel = canvasPanels.find((p) => p.mediaName === mn) || canvasPanels[0];
1211
- if (targetPanel) {
1212
- const el = findCanvasElement(path, targetPanel.canvas);
1213
- if (el) enterComponentInlineEdit(el, path);
1214
- }
1215
- }
1216
- });
1217
- }
1218
-
1219
- // ─── Overlay system ───────────────────────────────────────────────────────────
1220
-
1221
- function renderOverlays() {
1222
- overlaysPanel.render();
1223
- }
1224
-
1225
- /**
1226
- * Build an overlay box descriptor (no DOM creation).
1227
- *
1228
- * @param {any} el
1229
- * @param {any} type
1230
- * @param {any} panel
1231
- */
1232
- function overlayBoxDescriptor(el, type, panel) {
1233
- const vpRect = panel.viewport.getBoundingClientRect();
1234
- const elRect = el.getBoundingClientRect();
1235
- const scale = effectiveZoom();
1236
- return {
1237
- cls: `overlay-box overlay-${type}`,
1238
- top: `${(elRect.top - vpRect.top + panel.viewport.scrollTop) / scale}px`,
1239
- left: `${(elRect.left - vpRect.left + panel.viewport.scrollLeft) / scale}px`,
1240
- width: `${elRect.width / scale}px`,
1241
- height: `${elRect.height / scale}px`,
1242
- };
1243
- }
1244
-
1245
- function getActivePanel() {
1246
- if (canvasPanels.length === 0) return null;
1247
- if (canvasPanels.length === 1) return canvasPanels[0];
1248
- for (const p of canvasPanels) {
1249
- if (S.ui.activeMedia === null && (p.mediaName === "base" || p.mediaName === null)) return p;
1250
- if (p.mediaName === S.ui.activeMedia) return p;
1251
- }
1252
- return canvasPanels[0];
1253
- }
1254
-
1255
- /**
1256
- * Walk up the tree from a path, bubbling past inline elements until we find the nearest non-inline
1257
- * ancestor. Returns the original path if already non-inline.
1258
- *
1259
- * @param {any} doc
1260
- * @param {any} path
1261
- */
1262
- function bubbleInlinePath(doc, path) {
1263
- let currentPath = path;
1264
- while (currentPath.length >= 2) {
1265
- const node = getNodeAtPath(doc, currentPath);
1266
- const pPath = parentElementPath(currentPath);
1267
- const parentNode = pPath ? getNodeAtPath(doc, pPath) : null;
1268
- if (!node || !parentNode) break;
1269
- const childTag = (node.tagName ?? "div").toLowerCase();
1270
- const parentTag = (parentNode.tagName ?? "div").toLowerCase();
1271
- if (!isInlineInContext(childTag, parentTag)) break;
1272
- currentPath = pPath;
1273
- }
1274
- return currentPath;
1275
- }
1276
-
1277
- /** Effective zoom scale — always 1 in edit (content) mode, S.ui.zoom otherwise. */
1278
- function effectiveZoom() {
1279
- return canvasMode === "edit" ? 1 : S.ui.zoom;
1280
- }
1281
-
1282
- /**
1283
- * @param {any} path
1284
- * @param {any} canvasEl
1285
- */
1286
- function findCanvasElement(path, canvasEl) {
1287
- let el = canvasEl.firstElementChild;
1288
- if (!el) return null;
1289
- if (path.length === 0) return el;
1290
-
1291
- for (let i = 0; i < path.length; i += 2) {
1292
- if (path[i] !== "children" && path[i] !== "cases") return null;
1293
- const idx = path[i + 1];
1294
- if (idx === undefined) {
1295
- // Odd-length path like ['children', 2, 'children'] — $map container
1296
- // The wrapper div is children[0] of the current element
1297
- el = el.children[0];
1298
- } else if (idx === "map") {
1299
- // $map template: wrapper is children[0], template is wrapper.children[0]
1300
- el = el.children[0]?.children[0];
1301
- } else {
1302
- el = el.children[idx];
1303
- }
1304
- if (!el) break;
1305
- }
1306
-
1307
- // Verify the result: if DOM traversal landed on the wrong element
1308
- // (e.g. a custom element template child instead of the intended node),
1309
- // fall back to scanning elToPath.
1310
- if (el) {
1311
- const elPath = elToPath.get(el);
1312
- if (elPath && pathsEqual(elPath, path)) return el;
1313
- // el has no path or wrong path — it's a template element, not the target
1314
- }
1315
-
1316
- // Fall back: scan all descendants for an element with matching elToPath
1317
- for (const candidate of canvasEl.querySelectorAll("*")) {
1318
- const p = elToPath.get(candidate);
1319
- if (p && pathsEqual(p, path)) return candidate;
1320
- }
1321
- return null;
1322
- }
1323
-
1324
575
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
1325
576
 
1326
577
  function renderLeftPanel() {
@@ -1359,6 +610,7 @@ function fileOpsCtx() {
1359
610
  S,
1360
611
  commit: (/** @type {any} */ ns) => {
1361
612
  S = ns;
613
+ ({ doc, session } = fromFlat(S));
1362
614
  render();
1363
615
  },
1364
616
  renderToolbar,
@@ -1370,6 +622,7 @@ function openFile() {
1370
622
  async function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
1371
623
  const ns = await _loadMarkdown(source, fileHandle);
1372
624
  S = ns;
625
+ ({ doc, session } = fromFlat(S));
1373
626
  }
1374
627
  function saveFile() {
1375
628
  return _saveFile(fileOpsCtx());
@@ -1388,6 +641,7 @@ function openProject() {
1388
641
  S,
1389
642
  commit: (/** @type {any} */ ns) => {
1390
643
  S = ns;
644
+ ({ doc, session } = fromFlat(S));
1391
645
  },
1392
646
  renderActivityBar: () => renderActivityBar(S),
1393
647
  renderLeftPanel,
@@ -1407,6 +661,7 @@ function openFileFromTree(/** @type {any} */ path) {
1407
661
  },
1408
662
  commit: (/** @type {any} */ ns) => {
1409
663
  S = ns;
664
+ ({ doc, session } = fromFlat(S));
1410
665
  },
1411
666
  render,
1412
667
  loadMarkdown,
@@ -1420,6 +675,7 @@ initShortcuts(() => ({
1420
675
  S,
1421
676
  setS: (ns) => {
1422
677
  S = ns;
678
+ ({ doc, session } = fromFlat(S));
1423
679
  },
1424
680
  canvasMode,
1425
681
  panX: view.panX,