@jxsuite/studio 0.8.0 → 0.10.1

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
@@ -8,24 +8,15 @@
8
8
  import {
9
9
  createState,
10
10
  selectNode,
11
- hoverNode,
12
- insertNode,
13
- removeNode,
14
11
  updateProperty,
15
12
  updateDef,
16
13
  pushDocument,
17
14
  popDocument,
18
15
  getNodeAtPath,
19
16
  pathsEqual,
20
- parentElementPath,
21
- childIndex,
22
- isAncestor,
23
17
  canvasWrap,
24
18
  toolbarEl,
25
- elToPath,
26
19
  canvasPanels,
27
- VOID_ELEMENTS,
28
- stripEventHandlers,
29
20
  registerRenderer,
30
21
  render,
31
22
  update,
@@ -48,21 +39,21 @@ import {
48
39
 
49
40
  import { view } from "./view.js";
50
41
 
51
- import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxsuite/runtime";
52
-
42
+ import { isEditing, isEditableBlock } from "./editor/inline-edit.js";
53
43
  import {
54
- startEditing,
55
- stopEditing,
56
- isEditing,
57
- getActiveElement,
58
- isEditableBlock,
59
- isInlineInContext,
60
- } from "./editor/inline-edit.js";
44
+ enterComponentInlineEdit,
45
+ initComponentInlineEdit,
46
+ } from "./editor/component-inline-edit.js";
47
+ import { enterInlineEdit } from "./editor/content-inline-edit.js";
61
48
  import {
62
- showSlashMenu as sharedShowSlashMenu,
63
- dismissSlashMenu as sharedDismissSlashMenu,
64
- isSlashMenuOpen,
65
- } from "./editor/slash-menu.js";
49
+ initCanvasUtils,
50
+ applyTransform,
51
+ positionZoomIndicator,
52
+ updateActivePanelHeaders,
53
+ } from "./canvas/canvas-utils.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";
66
57
  import {
67
58
  renderStatusbar,
68
59
  statusMessage,
@@ -88,42 +79,13 @@ import { renderHeadTemplate } from "./panels/head-panel.js";
88
79
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export.js";
89
80
 
90
81
  import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
91
- import {
92
- parseMediaEntries,
93
- activeBreakpointsForWidth,
94
- applyCanvasStyle,
95
- collectMediaOverrides,
96
- applyOverridesToCanvas,
97
- } from "./utils/canvas-media.js";
82
+ import { parseMediaEntries } from "./utils/canvas-media.js";
98
83
  import { createDevServerPlatform } from "./platforms/devserver.js";
99
84
  import { codeService } from "./services/code-services.js";
100
- import {
101
- getEffectiveMedia,
102
- getEffectiveImports,
103
- getEffectiveElements,
104
- getEffectiveHead,
105
- } from "./site-context.js";
106
- import {
107
- defCategory,
108
- defBadgeLabel,
109
- resolveDefaultForCanvas,
110
- renderSignalsTemplate,
111
- } from "./panels/signals-panel.js";
112
- import {
113
- componentRegistry,
114
- loadComponentRegistry,
115
- computeRelativePath,
116
- } from "./files/components.js";
85
+ import { defCategory, defBadgeLabel, renderSignalsTemplate } from "./panels/signals-panel.js";
86
+ import { loadComponentRegistry } from "./files/components.js";
117
87
 
118
- import {
119
- dropTargetForElements,
120
- monitorForElements,
121
- } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
122
-
123
- import { html, render as litRender, nothing } from "lit-html";
124
- import { ref } from "lit-html/directives/ref.js";
125
- import { styleMap } from "lit-html/directives/style-map.js";
126
- import { ifDefined } from "lit-html/directives/if-defined.js";
88
+ import { html, render as litRender } from "lit-html";
127
89
 
128
90
  import webdata from "../data/webdata.json";
129
91
  import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
@@ -134,31 +96,20 @@ import { renderGitPanel } from "./panels/git-panel.js";
134
96
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
135
97
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
136
98
  import "./ui/panel-resize.js";
137
- import { showContextMenu, dismissContextMenu } from "./editor/context-menu.js";
138
99
  import { initShortcuts } from "./editor/shortcuts.js";
139
- import * as insertionHelper from "./editor/insertion-helper.js";
140
100
  import { renderActivityBar } from "./panels/activity-bar.js";
141
- import { renderBrowse } from "./browse/browse.js";
142
101
  import * as toolbarPanel from "./panels/toolbar.js";
143
102
  import * as overlaysPanel from "./panels/overlays.js";
144
103
  import * as rightPanelMod from "./panels/right-panel.js";
145
104
  import * as leftPanelMod from "./panels/left-panel.js";
146
- import { renderStylebookMode, renderStylebookOverlays } from "./panels/stylebook-panel.js";
147
- import {
148
- registerLayersDnD,
149
- registerComponentsDnD,
150
- registerElementsDnD,
151
- applyDropInstruction,
152
- } from "./panels/dnd.js";
153
- import { mediaDisplayName, defaultDef } from "./panels/shared.js";
154
- import { renderFunctionEditor, registerFunctionCompletions } from "./panels/editors.js";
155
- import {
156
- renderBlockActionBar,
157
- dismissLinkPopover,
158
- initBlockActionBar,
159
- } from "./panels/block-action-bar.js";
105
+ import { renderStylebookOverlays } from "./panels/stylebook-panel.js";
106
+ import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd.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";
160
110
  import { initCssData } from "./panels/style-utils.js";
161
- 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";
162
113
 
163
114
  // ─── Globals ──────────────────────────────────────────────────────────────────
164
115
  // These mutable variables are local to studio.js for now. As sections are extracted
@@ -189,11 +140,12 @@ async function navigateToComponent(componentPath) {
189
140
  const platform = getPlatform();
190
141
  const content = await platform.readFile(componentPath);
191
142
  if (!content) return;
192
- const doc = JSON.parse(content);
193
- S = pushDocument(S, doc, componentPath);
143
+ const parsed = JSON.parse(content);
144
+ S = pushDocument(S, parsed, componentPath);
194
145
  S.dirty = false;
146
+ ({ doc, session } = fromFlat(S));
195
147
  render();
196
- statusMessage(`Editing component: ${doc.tagName || componentPath}`);
148
+ statusMessage(`Editing component: ${parsed.tagName || componentPath}`);
197
149
  } catch (/** @type {any} */ e) {
198
150
  const err = /** @type {any} */ (e);
199
151
  statusMessage(`Error: ${err.message}`);
@@ -212,6 +164,7 @@ async function navigateBack() {
212
164
  }
213
165
  }
214
166
  S = popDocument(S);
167
+ ({ doc, session } = fromFlat(S));
215
168
  render();
216
169
  statusMessage("Returned to parent document");
217
170
  }
@@ -242,449 +195,6 @@ async function closeFunctionEditor() {
242
195
  updateUi("editingFunction", null);
243
196
  }
244
197
 
245
- /**
246
- * Convert a template string to a displayable expression for edit mode. Replaces ${expr} with ❮ expr
247
- * ❯ so the runtime renders it as literal text.
248
- *
249
- * @param {any} str
250
- */
251
- function templateToEditDisplay(str) {
252
- return str.replace(/\$\{([^}]+)\}/g, "\u276A $1 \u276B");
253
- }
254
-
255
- /**
256
- * Reverse templateToEditDisplay: walk all text nodes in `el` and replace ❪ expr ❫ back to ${expr}
257
- * so the user edits raw template syntax.
258
- *
259
- * @param {any} el
260
- */
261
- function restoreTemplateExpressions(el) {
262
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
263
- while (walker.nextNode()) {
264
- const node = /** @type {any} */ (walker.currentNode);
265
- if (node.textContent.includes("\u276A")) {
266
- node.textContent = node.textContent.replace(/\u276A\s*(.*?)\s*\u276B/g, "${$1}");
267
- }
268
- }
269
- }
270
-
271
- /**
272
- * Prepare a document for edit-mode rendering. Replaces template strings with readable literal text,
273
- * $prototype:Array with placeholders, and $ref bindings with display labels. Preserves state so the
274
- * runtime can still initialise scope.
275
- *
276
- * @param {any} node
277
- * @returns {any}
278
- */
279
- function prepareForEditMode(node) {
280
- if (!node || typeof node !== "object") return node;
281
- if (Array.isArray(node)) return node.map(prepareForEditMode);
282
-
283
- /** @type {Record<string, any>} */
284
- const out = {};
285
- for (const [k, v] of Object.entries(node)) {
286
- if (k === "state" || k === "$media" || k === "$props" || k === "$elements") {
287
- out[k] = v; // preserve as-is for runtime resolution
288
- } else if (k === "children") {
289
- if (Array.isArray(v)) {
290
- out.children = v.map(prepareForEditMode);
291
- } else if (v && typeof v === "object" && v.$prototype === "Array") {
292
- // Wrap the map template in a visual repeater perimeter
293
- const template = v.map;
294
- if (template && typeof template === "object") {
295
- out.children = [
296
- {
297
- tagName: "div",
298
- className: "repeater-perimeter",
299
- state: {
300
- $map: { item: {}, index: 0 },
301
- "$map/item": {},
302
- "$map/index": 0,
303
- },
304
- children: [prepareForEditMode(template)],
305
- },
306
- ];
307
- } else {
308
- out.children = [];
309
- }
310
- } else {
311
- out.children = prepareForEditMode(v);
312
- }
313
- } else if (k === "cases" && node.$switch && v && typeof v === "object") {
314
- // Replace $switch cases with a placeholder showing the first case or a label
315
- const caseKeys = Object.keys(v);
316
- if (caseKeys.length > 0) {
317
- const firstCase = v[caseKeys[0]];
318
- if (firstCase && typeof firstCase === "object" && !firstCase.$ref) {
319
- out.children = [prepareForEditMode(firstCase)];
320
- } else {
321
- out.children = [
322
- {
323
- tagName: "div",
324
- textContent: `[$switch: ${caseKeys.join(" | ")}]`,
325
- style: {
326
- fontFamily: "'SF Mono', 'Fira Code', monospace",
327
- fontSize: "11px",
328
- padding: "6px 10px",
329
- background: "color-mix(in srgb, var(--danger) 8%, transparent)",
330
- border: "1px dashed color-mix(in srgb, var(--danger) 40%, transparent)",
331
- borderRadius: "4px",
332
- color: "var(--danger)",
333
- fontStyle: "italic",
334
- },
335
- },
336
- ];
337
- }
338
- }
339
- } else if (k === "style") {
340
- // Replace template strings in style values with empty strings
341
- if (v && typeof v === "object") {
342
- /** @type {Record<string, any>} */
343
- const s = {};
344
- for (const [sk, sv] of Object.entries(v)) {
345
- s[sk] = typeof sv === "string" && sv.includes("${") ? "" : sv;
346
- }
347
- out.style = s;
348
- } else {
349
- out.style = v;
350
- }
351
- } else if (typeof v === "string" && v.includes("${")) {
352
- // Template string in a display property → show raw expression
353
- out[k] = templateToEditDisplay(v);
354
- } else if (v && typeof v === "object" && v.$ref) {
355
- // $ref binding → show ref path as literal text
356
- const ref = v.$ref;
357
- const label = ref.startsWith("#/state/") ? ref.slice(8) : ref;
358
- out[k] = `{${label}}`;
359
- } else {
360
- out[k] = prepareForEditMode(v);
361
- }
362
- }
363
-
364
- // Mark empty elements with placeholder classes for design-mode visibility
365
- if (out.tagName && !out.textContent && !out.innerHTML) {
366
- const hasChildren = Array.isArray(out.children) && out.children.length > 0;
367
- if (!hasChildren) {
368
- const tag = out.tagName;
369
- const textTags = new Set([
370
- "p",
371
- "h1",
372
- "h2",
373
- "h3",
374
- "h4",
375
- "h5",
376
- "h6",
377
- "blockquote",
378
- "li",
379
- "dt",
380
- "dd",
381
- "th",
382
- "td",
383
- "span",
384
- "strong",
385
- "em",
386
- "small",
387
- "mark",
388
- "code",
389
- "abbr",
390
- "q",
391
- "sub",
392
- "sup",
393
- "time",
394
- "a",
395
- "button",
396
- "label",
397
- "legend",
398
- "caption",
399
- "summary",
400
- "pre",
401
- "option",
402
- ]);
403
- const containerTags = new Set([
404
- "div",
405
- "section",
406
- "article",
407
- "aside",
408
- "header",
409
- "footer",
410
- "main",
411
- "nav",
412
- "figure",
413
- "figcaption",
414
- "details",
415
- "fieldset",
416
- "form",
417
- "ul",
418
- "ol",
419
- "dl",
420
- "table",
421
- ]);
422
- if (textTags.has(tag)) {
423
- out.className = out.className
424
- ? out.className + " empty-text-placeholder"
425
- : "empty-text-placeholder";
426
- } else if (containerTags.has(tag)) {
427
- out.className = out.className
428
- ? out.className + " empty-container-placeholder"
429
- : "empty-container-placeholder";
430
- }
431
- }
432
- }
433
-
434
- return out;
435
- }
436
-
437
- /**
438
- * Render a Jx document into a canvas element using the real runtime. Populates elToPath for each
439
- * created element via onNodeCreated callback. Returns the live state scope on success, null on
440
- * failure.
441
- *
442
- * @param {number} gen - Render generation for staleness detection
443
- * @param {any} doc
444
- * @param {any} canvasEl
445
- */
446
- async function renderCanvasLive(gen, doc, canvasEl) {
447
- canvasEl.innerHTML = "";
448
-
449
- // Apply content mode typography styling
450
- if (S.mode === "content") {
451
- canvasEl.setAttribute("data-content-mode", "");
452
- } else {
453
- canvasEl.removeAttribute("data-content-mode");
454
- }
455
-
456
- const renderDoc =
457
- canvasMode === "preview" ? structuredClone(doc) : prepareForEditMode(stripEventHandlers(doc));
458
-
459
- // In edit mode, collect paths where $map templates were inlined as children[0]
460
- // so we can remap runtime paths (children,0,...) → (children,map,...)
461
- const mapParentPaths = new Set();
462
- if (canvasMode === "design" || canvasMode === "edit") {
463
- (function findMapParents(/** @type {any} */ node, /** @type {any[]} */ path) {
464
- if (!node || typeof node !== "object") return;
465
- if (
466
- node.children &&
467
- typeof node.children === "object" &&
468
- node.children.$prototype === "Array"
469
- ) {
470
- mapParentPaths.add(path.join("/"));
471
- }
472
- if (Array.isArray(node.children)) {
473
- for (let i = 0; i < node.children.length; i++) {
474
- findMapParents(node.children[i], [...path, "children", i]);
475
- }
476
- }
477
- if (node.$switch && node.cases) {
478
- for (const [k, v] of Object.entries(node.cases)) {
479
- findMapParents(v, [...path, "cases", k]);
480
- }
481
- }
482
- })(doc, []);
483
- }
484
-
485
- try {
486
- const root = projectState?.projectRoot || "";
487
- const docPrefix = root ? `${root}/` : "";
488
- const docBase = S.documentPath ? `${location.origin}/${docPrefix}${S.documentPath}` : undefined;
489
-
490
- // Register custom elements so the runtime can render them
491
- let effectiveElements = getEffectiveElements(renderDoc.$elements);
492
-
493
- // In content mode (markdown), auto-discover components for directive-based
494
- // custom elements that have no explicit $elements registration.
495
- if (S.mode === "content" && componentRegistry.length > 0) {
496
- const existingRefs = new Set(
497
- effectiveElements.map((/** @type {any} */ e) => (typeof e === "string" ? e : e?.$ref)),
498
- );
499
- /** @param {any} node */
500
- const collectTags = (node) => {
501
- /** @type {Set<string>} */
502
- const tags = new Set();
503
- if (!node || typeof node !== "object") return tags;
504
- if (node.tagName) tags.add(node.tagName);
505
- if (Array.isArray(node.children)) {
506
- for (const child of node.children) {
507
- for (const t of collectTags(child)) tags.add(t);
508
- }
509
- }
510
- return tags;
511
- };
512
- for (const tag of collectTags(renderDoc)) {
513
- const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
514
- if (comp && comp.source !== "npm") {
515
- const relPath = computeRelativePath(S.documentPath, comp.path);
516
- if (!existingRefs.has(relPath)) {
517
- effectiveElements.push({ $ref: relPath });
518
- existingRefs.add(relPath);
519
- }
520
- }
521
- }
522
- }
523
-
524
- if (effectiveElements.length) {
525
- renderDoc.$elements = effectiveElements;
526
- for (const entry of effectiveElements) {
527
- if (typeof entry === "string") {
528
- try {
529
- const specifier =
530
- entry.startsWith("/") || entry.startsWith(".")
531
- ? entry
532
- : `/${projectState?.projectRoot || ""}/node_modules/${entry}`.replace(/\/+/g, "/");
533
- await import(specifier);
534
- } catch (/** @type {any} */ e) {
535
- console.warn("Studio: failed to import package", entry, e);
536
- }
537
- } else if (entry?.$ref) {
538
- const href = new URL(entry.$ref, docBase).href;
539
- try {
540
- await defineElement(href);
541
- } catch (/** @type {any} */ e) {
542
- console.warn("Studio: failed to register element", entry.$ref, e);
543
- }
544
- }
545
- }
546
- }
547
-
548
- // Bail out if a newer render started while we were importing elements
549
- if (gen !== view.renderGeneration) return null;
550
-
551
- // Inject site-level imports so buildScope can resolve $prototype names
552
- renderDoc.imports = getEffectiveImports(renderDoc.imports);
553
-
554
- // Apply project-level styles mirroring the compiler convention:
555
- // viewport ≈ :root → CSS custom properties (they inherit down)
556
- // canvasEl ≈ body → regular CSS properties (inline beats CSS defaults)
557
- // This ensures project font-family, color, etc. override the
558
- // content-mode fallback typography rules in the stylesheet.
559
- // In edit mode, propagate to the .content-edit-canvas wrapper for seamless appearance.
560
- const viewport = canvasEl.closest(".canvas-panel-viewport");
561
- const editSurface = canvasMode === "edit" ? canvasEl.closest(".content-edit-canvas") : null;
562
- const siteStyle = projectState?.projectConfig?.style;
563
- if (viewport) {
564
- viewport.style.cssText = "";
565
- if (siteStyle && typeof siteStyle === "object") {
566
- for (const [k, v] of Object.entries(siteStyle)) {
567
- if (k.startsWith("--")) {
568
- viewport.style.setProperty(k, String(v));
569
- } else {
570
- /** @type {any} */ (viewport.style)[k] = v;
571
- }
572
- }
573
- }
574
- }
575
- if (editSurface) {
576
- if (siteStyle && typeof siteStyle === "object") {
577
- for (const [k, v] of Object.entries(siteStyle)) {
578
- if (k.startsWith("--")) {
579
- /** @type {any} */ (editSurface).style.setProperty(k, String(v));
580
- } else {
581
- /** @type {any} */ (editSurface.style)[k] = v;
582
- }
583
- }
584
- }
585
- }
586
- if (siteStyle && typeof siteStyle === "object") {
587
- for (const [k, v] of Object.entries(siteStyle)) {
588
- if (!k.startsWith("--")) {
589
- /** @type {any} */ (canvasEl.style)[k] = v;
590
- }
591
- }
592
- }
593
-
594
- // Inject site-level $media so runtime can resolve media queries in styles
595
- renderDoc.$media = getEffectiveMedia(renderDoc.$media);
596
-
597
- // Inject $head elements (link/meta/script) into document.head
598
- const effectiveHead = getEffectiveHead(renderDoc.$head);
599
- if (effectiveHead.length) {
600
- for (const entry of effectiveHead) {
601
- if (!entry?.tagName) continue;
602
- const tag = entry.tagName.toLowerCase();
603
- const attrs = { ...entry.attributes };
604
- const root = projectState?.projectRoot || "";
605
- for (const key of ["href", "src"]) {
606
- if (
607
- attrs[key] &&
608
- !attrs[key].startsWith("/") &&
609
- !attrs[key].startsWith(".") &&
610
- !attrs[key].startsWith("http")
611
- ) {
612
- attrs[key] = `/${root}/node_modules/${attrs[key]}`.replace(/\/+/g, "/");
613
- }
614
- }
615
- const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
616
- if (selector !== tag && document.head.querySelector(selector)) continue;
617
- const el = document.createElement(tag);
618
- for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, /** @type {string} */ (v));
619
- if (entry.textContent) el.textContent = entry.textContent;
620
- document.head.appendChild(el);
621
- }
622
- }
623
-
624
- const $defs = await buildScope(renderDoc, {}, docBase);
625
- // Bail out if a newer render started while buildScope was running
626
- if (gen !== view.renderGeneration) return null;
627
- const el = /** @type {HTMLElement} */ (
628
- runtimeRenderNode(renderDoc, $defs, {
629
- onNodeCreated(/** @type {any} */ el, /** @type {any} */ path) {
630
- // Remap $map paths: wrapper and template children → real document paths
631
- // prepareForEditMode wraps $map template in: children[0] (wrapper) > children[0] (template)
632
- // Real paths: wrapper → ['children'] ($map container), template → ['children', 'map']
633
- let mappedPath = path;
634
- if ((canvasMode === "design" || canvasMode === "edit") && mapParentPaths.size > 0) {
635
- for (let i = 0; i < path.length - 1; i++) {
636
- if (path[i] === "children" && path[i + 1] === 0) {
637
- const parentKey = path.slice(0, i).join("/");
638
- if (mapParentPaths.has(parentKey)) {
639
- if (path.length === i + 2) {
640
- // Wrapper div itself → $map container path
641
- mappedPath = path.slice(0, i + 1);
642
- } else if (
643
- path.length >= i + 4 &&
644
- path[i + 2] === "children" &&
645
- path[i + 3] === 0
646
- ) {
647
- // Template or its descendants → children/map/...rest
648
- mappedPath = [...path.slice(0, i), "children", "map", ...path.slice(i + 4)];
649
- }
650
- break;
651
- }
652
- }
653
- }
654
- }
655
- elToPath.set(el, mappedPath);
656
- },
657
- _path: [],
658
- })
659
- );
660
- if (canvasMode === "design" || canvasMode === "edit") {
661
- // Disable pointer events on all rendered elements for edit mode
662
- el.style.pointerEvents = "none";
663
- for (const child of el.querySelectorAll("*")) {
664
- /** @type {any} */ (child).style.pointerEvents = "none";
665
- }
666
- }
667
- canvasEl.appendChild(el);
668
- if (canvasMode === "design" || canvasMode === "edit") {
669
- // Custom element connectedCallbacks render children asynchronously —
670
- // sweep again after they've had a chance to run
671
- requestAnimationFrame(() => {
672
- const editingEl = getActiveElement();
673
- for (const child of canvasEl.querySelectorAll("*")) {
674
- // Preserve pointer-events on the actively-edited element
675
- if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
676
- if (editingEl && child === editingEl) continue;
677
- /** @type {any} */ (child).style.pointerEvents = "none";
678
- }
679
- });
680
- }
681
- return $defs;
682
- } catch (/** @type {any} */ err) {
683
- console.warn("renderCanvasLive failed:", err.message, err);
684
- return null;
685
- }
686
- }
687
-
688
198
  // ─── Webdata: datalists for autocomplete ──────────────────────────────────────
689
199
 
690
200
  const datalistHost = document.createElement("div");
@@ -704,11 +214,6 @@ litRender(
704
214
 
705
215
  initCssData(webdata);
706
216
 
707
- // Persistent render hosts for lit-html (must be before bootstrap/render)
708
- let zoomIndicatorHost = document.createElement("div");
709
- zoomIndicatorHost.style.display = "contents";
710
- document.body.appendChild(zoomIndicatorHost);
711
-
712
217
  // ─── Module-level UI state (must be before render() call) ─────────────────────
713
218
 
714
219
  // ─── Bootstrap ────────────────────────────────────────────────────────────────
@@ -749,22 +254,53 @@ toolbarPanel.mount(toolbarEl, {
749
254
  });
750
255
 
751
256
  overlaysPanel.mount({
752
- effectiveZoom,
753
257
  getCanvasMode: () => canvasMode,
754
258
  isEditing,
755
259
  renderBlockActionBar,
756
- findCanvasElement,
757
- getActivePanel,
758
260
  });
759
261
 
760
262
  initBlockActionBar({
761
263
  getCanvasMode: () => canvasMode,
762
- findCanvasElement,
763
- getActivePanel,
764
264
  navigateToComponent,
765
265
  createFloatingContainer,
766
266
  });
767
267
 
268
+ initComponentInlineEdit({ findCanvasElement });
269
+ initCanvasHelpers({ getCanvasMode: () => canvasMode });
270
+ initCanvasUtils({
271
+ getCanvasMode: () => canvasMode,
272
+ getZoom: () => S.ui.zoom,
273
+ setZoomDirect: (zoom) => {
274
+ session = { ...session, ui: { ...session.ui, zoom } };
275
+ S = toFlat(doc, session);
276
+ },
277
+ renderStylebookOverlays,
278
+ });
279
+ initPanelEvents({
280
+ getState: () => S,
281
+ setState: (s) => {
282
+ S = s;
283
+ ({ doc, session } = fromFlat(S));
284
+ },
285
+ getCanvasMode: () => canvasMode,
286
+ enterInlineEdit,
287
+ navigateToComponent,
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,
302
+ });
303
+
768
304
  rightPanelMod.mount({
769
305
  navigateToComponent,
770
306
  getCanvasMode: () => canvasMode,
@@ -803,6 +339,13 @@ registerRenderer("statusbar", () => renderStatusbar(S));
803
339
  setStatusbarRenderer(() => renderStatusbar(S));
804
340
  mountStatusbar();
805
341
 
342
+ // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
343
+ canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
344
+ if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
345
+ if (!S.selection) return;
346
+ update(selectNode(S, null));
347
+ });
348
+
806
349
  function safeRenderLeftPanel() {
807
350
  leftPanelMod.render();
808
351
  }
@@ -874,10 +417,14 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
874
417
  if (patch.ui) {
875
418
  session.ui = { ...prev.ui, ...patch.ui };
876
419
  }
420
+ if (patch.canvas) {
421
+ session.canvas = { ...prev.canvas, ...patch.canvas };
422
+ }
877
423
  S = toFlat(doc, session);
878
424
 
879
425
  const selChanged = !pathsEqual(prev.selection, session.selection);
880
426
  const uiChanged = prev.ui !== session.ui;
427
+ const canvasChanged = prev.canvas !== session.canvas;
881
428
 
882
429
  const canvasUiChanged =
883
430
  uiChanged &&
@@ -898,7 +445,7 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
898
445
  console.error("renderCanvas error:", e);
899
446
  }
900
447
  safeRenderLeftPanel();
901
- } else if (selChanged || leftUiChanged) {
448
+ } else if (selChanged || leftUiChanged || canvasChanged) {
902
449
  safeRenderLeftPanel();
903
450
  }
904
451
 
@@ -906,20 +453,10 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
906
453
  updateActivePanelHeaders();
907
454
  }
908
455
 
909
- runPostRenderHooks(doc.document, prev.selection);
910
-
911
- const hoverChanged = prev.hover !== session.hover;
912
- notify({ doc: false, selection: selChanged, hover: hoverChanged, ui: uiChanged, mode: false });
913
- });
914
-
915
- // Register post-render hook for pseudo-state preview
916
- addPostRenderHook(() => updateForcedPseudoPreview());
917
-
918
- // Register post-render hook for pending inline edit
919
- addPostRenderHook((/** @type {any} */ prevDoc) => {
920
- if (view.pendingInlineEdit && prevDoc === S.document) {
921
- const { path, mediaName: mn } = view.pendingInlineEdit;
922
- 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);
923
460
  const targetPanel =
924
461
  canvasPanels.find((/** @type {any} */ p) => p.mediaName === mn) || canvasPanels[0];
925
462
  if (targetPanel) {
@@ -927,8 +464,16 @@ addPostRenderHook((/** @type {any} */ prevDoc) => {
927
464
  if (el) enterComponentInlineEdit(el, path);
928
465
  }
929
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 });
930
472
  });
931
473
 
474
+ // Register post-render hook for pseudo-state preview
475
+ addPostRenderHook(() => updateForcedPseudoPreview());
476
+
932
477
  // Now that renderers and update are registered, bootstrap
933
478
  registerFunctionCompletions();
934
479
 
@@ -1021,1832 +566,57 @@ if (_openParam) {
1021
566
  render();
1022
567
  }
1023
568
 
1024
- // ─── Media helpers ────────────────────────────────────────────────────────────
569
+ // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
1025
570
 
1026
- /**
1027
- * After a runtime render, apply active media overrides as inline styles so they beat the base
1028
- * inline styles the runtime already set. The runtime uses @media CSS rules for overrides, but those
1029
- * can never beat inline base styles.
1030
- *
1031
- * @param {Element} canvasEl
1032
- * @param {Set<string>} activeBreakpoints
1033
- */
1034
- function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
1035
- if (!activeBreakpoints.size) return;
1036
- const docMedia = getEffectiveMedia(S.document.$media || {});
1037
- const validBreakpoints = new Set();
1038
- for (const name of activeBreakpoints) {
1039
- if (docMedia[name]) validBreakpoints.add(name);
1040
- }
1041
- const overrides = collectMediaOverrides(document.styleSheets, validBreakpoints);
1042
- applyOverridesToCanvas(canvasEl, overrides);
571
+ function renderLeftPanel() {
572
+ leftPanelMod.render();
1043
573
  }
1044
574
 
1045
- // ─── Canvas ───────────────────────────────────────────────────────────────────
1046
-
1047
- function renderCanvas() {
1048
- // Advance render generation so stale async renders from the previous cycle bail out
1049
- ++view.renderGeneration;
1050
-
1051
- // Always clear Lit's internal state so it builds fresh DOM. Stale async
1052
- // renderCanvasLive calls from a previous cycle can corrupt nested ChildPart
1053
- // markers (Comment nodes inside panzoom-wrap) in ways the root-only
1054
- // ensureLitState check cannot detect.
1055
- // @ts-ignore
1056
- if (canvasWrap["_$litPart$"]) {
1057
- canvasWrap.textContent = "";
1058
- // @ts-ignore
1059
- delete canvasWrap["_$litPart$"];
1060
- }
1061
-
1062
- // Function editor mode: editing a function body in Monaco (JS)
1063
- if (S.ui.editingFunction) {
1064
- renderFunctionEditor();
1065
- return;
1066
- }
1067
-
1068
- // Dispose function editor if switching away
1069
- if (view.functionEditor) {
1070
- view.functionEditor.dispose();
1071
- view.functionEditor = null;
1072
- }
1073
-
1074
- // Source mode: update existing Monaco editor without recreating
1075
- if (canvasMode === "source" && view.monacoEditor) {
1076
- const jsonStr = JSON.stringify(S.document, null, 2);
1077
- const currentVal = view.monacoEditor.getValue();
1078
- if (currentVal !== jsonStr) {
1079
- // Prevent triggering the onChange handler for this programmatic update
1080
- view.monacoEditor._ignoreNextChange = true;
1081
- view.monacoEditor.setValue(jsonStr);
1082
- }
1083
- return;
1084
- }
1085
-
1086
- // Detect whether this is a mode transition or a content-only re-render
1087
- const modeChanged = canvasMode !== view.prevCanvasMode;
1088
- view.prevCanvasMode = canvasMode;
1089
-
1090
- // DnD handlers are registered on inner canvas elements that get replaced on every
1091
- // content render, so always clean them up.
1092
- for (const fn of view.canvasDndCleanups) fn();
1093
- view.canvasDndCleanups = [];
1094
-
1095
- // Panel event handlers (click, dblclick, etc.) capture closures over panel references.
1096
- // Always re-register to keep closures fresh across document switches.
1097
- for (const fn of view.canvasEventCleanups) fn();
1098
- view.canvasEventCleanups = [];
1099
-
1100
- // Panel JS objects are cheap — always clear and repopulate from templates.
1101
- // The actual DOM elements are preserved by Lit's diffing on content-only re-renders.
1102
- canvasPanels.length = 0;
1103
-
1104
- if (modeChanged) {
1105
- // Full teardown on mode transitions — new panel structure needed
1106
- if (view.centerObserver) {
1107
- view.centerObserver.disconnect();
1108
- view.centerObserver = null;
1109
- }
1110
-
1111
- // Dispose Monaco editor if switching away from source mode
1112
- if (view.monacoEditor) {
1113
- view.monacoEditor.dispose();
1114
- view.monacoEditor = null;
1115
- }
1116
-
1117
- litRender(nothing, canvasWrap);
1118
- view.panzoomWrap = null;
1119
- // Reset inline style overrides from other modes
1120
- canvasWrap.style.padding = "";
1121
- canvasWrap.style.alignItems = "";
1122
- canvasWrap.style.display = "";
1123
- canvasWrap.style.overflow = "";
1124
- canvasWrap.style.overflow = "";
1125
-
1126
- // Clear zoom indicator (only re-rendered by design/preview/stylebook)
1127
- try {
1128
- litRender(nothing, zoomIndicatorHost);
1129
- } catch {
1130
- const newHost = document.createElement("div");
1131
- newHost.style.display = "contents";
1132
- zoomIndicatorHost.replaceWith(newHost);
1133
- zoomIndicatorHost = newHost;
1134
- }
1135
-
1136
- // Dismiss open popovers/toolbars that are no longer relevant
1137
- if (view.blockActionBarEl) litRender(nothing, view.blockActionBarEl);
1138
- dismissLinkPopover();
1139
- dismissContextMenu();
1140
- sharedDismissSlashMenu();
1141
- }
1142
-
1143
- // Manage mode: project-level file browser table
1144
- if (canvasMode === "manage") {
1145
- canvasWrap.style.padding = "0";
1146
- canvasWrap.style.overflow = "auto";
1147
- renderBrowse(canvasWrap, {
1148
- openFile: (/** @type {string} */ path) => {
1149
- canvasMode = "edit";
1150
- openFileFromTree(path);
1151
- },
1152
- });
1153
- return;
1154
- }
1155
-
1156
- // Settings mode: render element catalog with panzoom surface
1157
- if (canvasMode === "settings") {
1158
- renderStylebookMode({
1159
- canvasPanelTemplate,
1160
- applyTransform,
1161
- observeCenterUntilStable,
1162
- renderZoomIndicator,
1163
- updateActivePanelHeaders,
1164
- overlayBoxDescriptor,
1165
- effectiveZoom,
1166
- });
1167
- return;
1168
- }
1169
-
1170
- // Source mode: create Monaco editor instead of canvas
1171
- if (canvasMode === "source") {
1172
- canvasWrap.style.padding = "0";
1173
- canvasWrap.style.display = "block";
1174
- /** @type {HTMLDivElement | null} */
1175
- let editorContainer = null;
1176
- litRender(
1177
- html`<div class="source-wrap">
1178
- <div class="source-toolbar">
1179
- <sp-action-button size="s" @click=${exportFile}>
1180
- <sp-icon-export slot="icon"></sp-icon-export>
1181
- Export
1182
- </sp-action-button>
1183
- </div>
1184
- <div
1185
- class="source-editor"
1186
- ${ref((el) => {
1187
- if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
1188
- })}
1189
- ></div>
1190
- </div>`,
1191
- canvasWrap,
1192
- );
1193
-
1194
- const jsonStr = JSON.stringify(S.document, null, 2);
1195
- view.monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
1196
- value: jsonStr,
1197
- language: "json",
1198
- theme: "vs-dark",
1199
- automaticLayout: true,
1200
- minimap: { enabled: false },
1201
- fontSize: 12,
1202
- fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
1203
- lineNumbers: "on",
1204
- scrollBeyondLastLine: false,
1205
- wordWrap: "on",
1206
- tabSize: 2,
1207
- });
1208
-
1209
- // Debounced sync back to state
1210
- /** @type {any} */
1211
- let debounce;
1212
- view.monacoEditor.onDidChangeModelContent(() => {
1213
- if (view.monacoEditor._ignoreNextChange) {
1214
- view.monacoEditor._ignoreNextChange = false;
1215
- return;
1216
- }
1217
- clearTimeout(debounce);
1218
- debounce = setTimeout(() => {
1219
- try {
1220
- const parsed = JSON.parse(view.monacoEditor.getValue());
1221
- update({ ...S, document: parsed, dirty: true });
1222
- } catch {
1223
- // Invalid JSON — don't update state
1224
- }
1225
- }, 600);
1226
- });
1227
- return;
1228
- }
1229
-
1230
- // Edit (content) mode — centered column, no panzoom, always 100%
1231
- if (canvasMode === "edit") {
1232
- if (modeChanged) {
1233
- canvasWrap.style.padding = "0";
1234
- canvasWrap.style.overflow = "hidden";
1235
-
1236
- // Remove zoom indicator left over from design/preview mode
1237
- try {
1238
- litRender(nothing, zoomIndicatorHost);
1239
- } catch {
1240
- const newHost = document.createElement("div");
1241
- newHost.style.display = "contents";
1242
- zoomIndicatorHost.replaceWith(newHost);
1243
- zoomIndicatorHost = newHost;
1244
- }
1245
- }
1246
-
1247
- const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
1248
- const editTpl = html`
1249
- <div class="content-edit-canvas">
1250
- <div class="content-edit-column">${panelTpl}</div>
1251
- </div>
1252
- `;
1253
- litRender(editTpl, canvasWrap);
1254
- canvasPanels.push(panel);
1255
- renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
1256
- return;
1257
- }
575
+ // ─── DnD registration: delegated to panels/dnd.js ───────────────────────────
1258
576
 
1259
- // Normal canvas mode (design / preview) — set up panzoom surface
1260
- if (modeChanged) {
1261
- canvasWrap.style.padding = "0";
1262
- canvasWrap.style.overflow = "hidden";
1263
- }
577
+ // ─── Stylebook ───────────────────────────────────────────────────────────────
578
+ // Extracted to panels/stylebook-panel.js
1264
579
 
1265
- const {
1266
- sizeBreakpoints,
1267
- featureQueries: _featureQueries,
1268
- baseWidth,
1269
- } = parseMediaEntries(getEffectiveMedia(S.document.$media));
1270
- const hasMedia = sizeBreakpoints.length > 0;
1271
- const featureToggles = S.ui.featureToggles;
1272
-
1273
- // Create panzoom wrapper (the element that gets transformed)
1274
- if (!hasMedia) {
1275
- // Single panel — use baseWidth if a custom one is defined, otherwise full-width
1276
- const effectiveMedia = getEffectiveMedia(S.document.$media);
1277
- const hasBaseWidth = effectiveMedia && effectiveMedia["--"];
1278
- const label = hasBaseWidth ? `${mediaDisplayName("--")} (${baseWidth}px)` : null;
1279
- const { tpl: panelTpl, panel } = canvasPanelTemplate(
1280
- hasBaseWidth ? "base" : null,
1281
- label,
1282
- !hasBaseWidth,
1283
- hasBaseWidth ? baseWidth : undefined,
1284
- );
1285
- litRender(
1286
- html`
1287
- <div
1288
- class="panzoom-wrap"
1289
- style="transform-origin:0 0"
1290
- ${ref((el) => {
1291
- if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1292
- })}
1293
- >
1294
- ${panelTpl}
1295
- </div>
1296
- `,
1297
- canvasWrap,
1298
- );
1299
- canvasPanels.push(panel);
1300
- renderCanvasIntoPanel(panel, new Set(), featureToggles);
1301
- applyTransform();
1302
- if (modeChanged) {
1303
- observeCenterUntilStable();
1304
- }
1305
- renderZoomIndicator();
1306
- return;
1307
- }
580
+ // ─── Inspector ────────────────────────────────────────────────────────────────
581
+ // Extracted to panels/properties-panel.js
1308
582
 
1309
- // Build all panels: base first, then breakpoints in declared order (ascending for min-width,
1310
- // descending for max-width — matching the direction of the design's media queries).
1311
- const allPanelDefs = [
1312
- {
1313
- name: "base",
1314
- displayName: mediaDisplayName("--"),
1315
- width: baseWidth,
1316
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, baseWidth),
1317
- },
1318
- ];
1319
- for (const bp of sizeBreakpoints) {
1320
- allPanelDefs.push({
1321
- name: bp.name,
1322
- displayName: mediaDisplayName(bp.name),
1323
- width: bp.width,
1324
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
1325
- });
1326
- }
583
+ // ─── Style Sidebar (metadata-driven) ───────────────────────────────────────────
1327
584
 
1328
- /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
1329
- const panelEntries = allPanelDefs.map((def) => {
1330
- const label = `${def.displayName} (${def.width}px)`;
1331
- const { tpl, panel } = canvasPanelTemplate(def.name, label, false, def.width);
1332
- return { tpl, panel, activeSet: def.activeSet };
1333
- });
585
+ // UNIT_RE imported from ui/unit-selector.js
1334
586
 
1335
- litRender(
1336
- html`
1337
- <div
1338
- class="panzoom-wrap"
1339
- style="transform-origin:0 0"
1340
- ${ref((el) => {
1341
- if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
1342
- })}
1343
- >
1344
- ${panelEntries.map((e) => e.tpl)}
1345
- </div>
1346
- `,
1347
- canvasWrap,
1348
- );
587
+ // inferInputType — imported from studio-utils.js
1349
588
 
1350
- for (const { panel, activeSet } of panelEntries) {
1351
- canvasPanels.push(panel);
1352
- renderCanvasIntoPanel(panel, activeSet, featureToggles);
1353
- }
589
+ // ─── Style panel ────────────────────────────────────────────────────────────
590
+ // Extracted to panels/style-utils.js, panels/style-inputs.js, panels/style-panel.js
1354
591
 
1355
- // Highlight active panel header
1356
- updateActivePanelHeaders();
592
+ // ─── Source/Function editors: delegated to panels/editors.js ─────────────────
1357
593
 
1358
- // Apply current zoom + pan transform
1359
- applyTransform();
1360
- if (modeChanged) {
1361
- observeCenterUntilStable();
1362
- }
594
+ // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
1363
595
 
1364
- // Floating zoom indicator
1365
- renderZoomIndicator();
596
+ function renderToolbar() {
597
+ toolbarPanel.render();
1366
598
  }
1367
599
 
1368
- /**
1369
- * Render document into a single canvas panel. Tries runtime rendering first, falls back to
1370
- * structural preview.
1371
- *
1372
- * @param {any} panel
1373
- * @param {any} activeBreakpoints
1374
- * @param {any} featureToggles
1375
- */
1376
- function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1377
- const gen = view.renderGeneration;
1378
- renderCanvasLive(gen, S.document, panel.canvas).then((scope) => {
1379
- // Skip post-render setup if a newer render has started
1380
- if (gen !== view.renderGeneration) return;
1381
- if (scope) {
1382
- view.liveScope = scope;
1383
- applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
1384
- statusMessage("Runtime render OK", 1500);
1385
- } else {
1386
- // Fallback to structural preview
1387
- renderCanvasNode(S.document, [], panel.canvas, activeBreakpoints, featureToggles);
1388
- }
1389
- registerPanelDnD(panel);
1390
- registerPanelEvents(panel);
1391
- renderOverlays();
1392
-
1393
- // Process pending inline edit now that the canvas is populated
1394
- if (view.pendingInlineEdit) {
1395
- const { path, mediaName: mn } = view.pendingInlineEdit;
1396
- view.pendingInlineEdit = null;
1397
- const targetPanel = canvasPanels.find((p) => p.mediaName === mn) || canvasPanels[0];
1398
- if (targetPanel) {
1399
- const el = findCanvasElement(path, targetPanel.canvas);
1400
- if (el) enterComponentInlineEdit(el, path);
1401
- }
1402
- }
1403
- });
1404
- }
600
+ // ─── File Operations (delegated to file-ops.js) ─────────────────────────────
1405
601
 
1406
- /**
1407
- * Create a canvas panel DOM structure. Returns { mediaName, element, canvas, overlay, overlayClk,
1408
- * viewport, dropLine }
1409
- *
1410
- * @param {any} mediaName
1411
- * @param {any} label
1412
- * @param {any} fullWidth
1413
- * @param {any} [width]
1414
- */
1415
- function canvasPanelTemplate(mediaName, label, fullWidth, width) {
1416
- /**
1417
- * @type {{
1418
- * mediaName: any;
1419
- * element: Element | null;
1420
- * canvas: Element | null;
1421
- * overlay: Element | null;
1422
- * overlayClk: Element | null;
1423
- * viewport: Element | null;
1424
- * dropLine: Element | null;
1425
- * _width: any;
1426
- * }}
1427
- */
1428
- const panel = {
1429
- mediaName,
1430
- element: null,
1431
- canvas: null,
1432
- overlay: null,
1433
- overlayClk: null,
1434
- viewport: null,
1435
- dropLine: null,
1436
- _width: width || null,
602
+ function fileOpsCtx() {
603
+ return {
604
+ S,
605
+ commit: (/** @type {any} */ ns) => {
606
+ S = ns;
607
+ ({ doc, session } = fromFlat(S));
608
+ render();
609
+ },
610
+ renderToolbar,
1437
611
  };
1438
- const tpl = html`
1439
- <div
1440
- class=${`canvas-panel${fullWidth ? " full-width" : ""}`}
1441
- data-media=${ifDefined(mediaName !== null ? mediaName : undefined)}
1442
- ${ref((el) => {
1443
- if (el) panel.element = el;
1444
- })}
1445
- >
1446
- ${label
1447
- ? html`
1448
- <div
1449
- class="canvas-panel-header"
1450
- @click=${() => {
1451
- updateUi("activeMedia", mediaName === "base" ? null : mediaName);
1452
- }}
1453
- >
1454
- ${label}
1455
- </div>
1456
- `
1457
- : nothing}
1458
- <div
1459
- class="canvas-panel-viewport"
1460
- style=${styleMap({ width: width && !fullWidth ? `${width}px` : "" })}
1461
- ${ref((el) => {
1462
- if (el) panel.viewport = el;
1463
- })}
1464
- >
1465
- <div
1466
- class="canvas-panel-canvas"
1467
- style=${styleMap({ width: width ? `${width}px` : "" })}
1468
- ${ref((el) => {
1469
- if (el) panel.canvas = el;
1470
- })}
1471
- ></div>
1472
- <div
1473
- class="canvas-panel-overlay"
1474
- ${ref((el) => {
1475
- if (el) panel.overlay = el;
1476
- })}
1477
- >
1478
- <div
1479
- class="canvas-drop-indicator"
1480
- style="display:none"
1481
- ${ref((el) => {
1482
- if (el) panel.dropLine = el;
1483
- })}
1484
- ></div>
1485
- </div>
1486
- <div
1487
- class="canvas-panel-click"
1488
- ${ref((el) => {
1489
- if (el) panel.overlayClk = el;
1490
- })}
1491
- ></div>
1492
- </div>
1493
- </div>
1494
- `;
1495
- return { tpl, panel };
1496
- }
1497
-
1498
- /** Center canvas in viewport. */
1499
- function centerCanvas() {
1500
- if (!view.panzoomWrap) return;
1501
- const wrapWidth = canvasWrap.clientWidth;
1502
- const wrapHeight = canvasWrap.clientHeight;
1503
- const contentWidth = view.panzoomWrap.scrollWidth;
1504
- const contentHeight = view.panzoomWrap.scrollHeight;
1505
- const scaledWidth = contentWidth * S.ui.zoom;
1506
- const scaledHeight = contentHeight * S.ui.zoom;
1507
- view.panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
1508
- // Center vertically only when content fits; top-align with margin when taller
1509
- const verticalCenter = (wrapHeight - scaledHeight) / 2;
1510
- view.panY = verticalCenter > 16 ? verticalCenter : 16;
1511
612
  }
1512
-
1513
- /**
1514
- * Attach a ResizeObserver to view.panzoomWrap that re-centers until the user pans. Handles async
1515
- * content (runtime rendering, data fetching) that changes layout after initial paint.
1516
- */
1517
- function observeCenterUntilStable() {
1518
- if (view.centerObserver) {
1519
- view.centerObserver.disconnect();
1520
- view.centerObserver = null;
1521
- }
1522
- if (!view.panzoomWrap) return;
1523
- view.needsCenter = true;
1524
- view.centerObserver = new ResizeObserver(() => {
1525
- if (!view.needsCenter) {
1526
- view.centerObserver?.disconnect();
1527
- view.centerObserver = null;
1528
- return;
1529
- }
1530
- centerCanvas();
1531
- applyTransform();
1532
- });
1533
- view.centerObserver.observe(view.panzoomWrap);
1534
- // Also center immediately for synchronous content
1535
- centerCanvas();
1536
- }
1537
-
1538
- /** Apply the current zoom + pan transform to the panzoom wrapper. */
1539
- function applyTransform() {
1540
- if (!view.panzoomWrap) return;
1541
- view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${S.ui.zoom})`;
1542
- const label = document.querySelector(".zoom-indicator-label");
1543
- if (label) label.textContent = `${Math.round(S.ui.zoom * 100)}%`;
1544
- renderOverlays();
1545
- if (canvasMode === "settings") renderStylebookOverlays();
1546
- }
1547
-
1548
- /** Lightweight in-place zoom update — no full re-render. */
1549
- function _applyZoom() {
1550
- applyTransform();
1551
- }
1552
-
1553
- /** Calculate zoom + pan to fit all panels within the viewport. */
1554
- function fitToScreen() {
1555
- if (!view.panzoomWrap) return;
1556
- const wrapWidth = canvasWrap.clientWidth;
1557
- const wrapHeight = canvasWrap.clientHeight;
1558
- const gap = 24;
1559
- const padding = 32;
1560
- let totalPanelWidth = 0;
1561
- let maxPanelHeight = 0;
1562
- for (const p of canvasPanels) {
1563
- totalPanelWidth += p._width || 800;
1564
- }
1565
- totalPanelWidth += gap * Math.max(0, canvasPanels.length - 1) + padding;
1566
-
1567
- // Get actual content height from rendered panels
1568
- const wrapRect = view.panzoomWrap.getBoundingClientRect();
1569
- const unscaledHeight = wrapRect.height / S.ui.zoom;
1570
- maxPanelHeight = unscaledHeight + padding;
1571
-
1572
- const fitZoomW = wrapWidth / totalPanelWidth;
1573
- const fitZoomH = wrapHeight / maxPanelHeight;
1574
- const fitZoom = Math.min(5.0, Math.max(0.05, Math.min(fitZoomW, fitZoomH)));
1575
-
1576
- session = { ...session, ui: { ...session.ui, zoom: fitZoom } };
1577
- S = toFlat(doc, session);
1578
- // Center the content
1579
- const scaledWidth = totalPanelWidth * fitZoom;
1580
- const scaledHeight = maxPanelHeight * fitZoom;
1581
- view.panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
1582
- view.panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
1583
- applyTransform();
1584
- }
1585
-
1586
- /**
1587
- * Render the floating zoom indicator at the bottom center of canvas-wrap. Uses position: fixed,
1588
- * computed from canvas-wrap bounds.
1589
- */
1590
- function renderZoomIndicator() {
1591
- // Reset lit-html state if the host was disconnected or markers were ejected
1592
- if (!zoomIndicatorHost.isConnected) document.body.appendChild(zoomIndicatorHost);
1593
- try {
1594
- litRender(
1595
- html`
1596
- <div class="zoom-indicator">
1597
- <span class="zoom-indicator-label">${Math.round(S.ui.zoom * 100)}%</span>
1598
- <sp-action-button
1599
- quiet
1600
- size="s"
1601
- class="zoom-fit-btn"
1602
- title="Fit to screen"
1603
- @click=${fitToScreen}
1604
- >
1605
- <svg
1606
- width="14"
1607
- height="14"
1608
- viewBox="0 0 16 16"
1609
- fill="none"
1610
- stroke="currentColor"
1611
- stroke-width="1.5"
1612
- >
1613
- <rect x="2" y="2" width="12" height="12" rx="1" />
1614
- <path d="M2 6h12M6 2v12" />
1615
- </svg>
1616
- </sp-action-button>
1617
- </div>
1618
- `,
1619
- zoomIndicatorHost,
1620
- );
1621
- } catch {
1622
- // Lit markers were corrupted — replace the host element to fully reset Lit state
1623
- const newHost = document.createElement("div");
1624
- newHost.style.display = "contents";
1625
- zoomIndicatorHost.replaceWith(newHost);
1626
- zoomIndicatorHost = newHost;
1627
- litRender(
1628
- html`
1629
- <div class="zoom-indicator">
1630
- <span class="zoom-indicator-label">${Math.round(S.ui.zoom * 100)}%</span>
1631
- <sp-action-button
1632
- quiet
1633
- size="s"
1634
- class="zoom-fit-btn"
1635
- title="Fit to screen"
1636
- @click=${fitToScreen}
1637
- >
1638
- <svg
1639
- width="14"
1640
- height="14"
1641
- viewBox="0 0 16 16"
1642
- fill="none"
1643
- stroke="currentColor"
1644
- stroke-width="1.5"
1645
- >
1646
- <rect x="2" y="2" width="12" height="12" rx="1" />
1647
- <path d="M2 6h12M6 2v12" />
1648
- </svg>
1649
- </sp-action-button>
1650
- </div>
1651
- `,
1652
- zoomIndicatorHost,
1653
- );
1654
- }
1655
- positionZoomIndicator();
1656
- }
1657
-
1658
- function positionZoomIndicator() {
1659
- const indicator = /** @type {HTMLElement | null} */ (document.querySelector(".zoom-indicator"));
1660
- if (!indicator) return;
1661
- const rect = canvasWrap.getBoundingClientRect();
1662
- indicator.style.left = `${rect.left + rect.width / 2}px`;
1663
- indicator.style.top = `${rect.bottom - 32}px`;
1664
- indicator.style.transform = "translateX(-50%)";
1665
- }
1666
-
1667
- function updateActivePanelHeaders() {
1668
- for (const p of canvasPanels) {
1669
- const header = p.element.querySelector(".canvas-panel-header");
1670
- if (header) {
1671
- const isActive =
1672
- (S.ui.activeMedia === null && p.mediaName === "base") ||
1673
- (S.ui.activeMedia === null && p.mediaName === null) ||
1674
- S.ui.activeMedia === p.mediaName;
1675
- header.classList.toggle("active", isActive);
1676
- }
1677
- }
1678
- }
1679
-
1680
- /**
1681
- * Recursively render a Jx node to the canvas DOM. Media-aware: applies base styles + active
1682
- * breakpoint/feature overrides.
1683
- *
1684
- * @param {any} node
1685
- * @param {any} path
1686
- * @param {any} parent
1687
- * @param {any} activeBreakpoints
1688
- * @param {any} featureToggles
1689
- */
1690
- function renderCanvasNode(node, path, parent, activeBreakpoints, featureToggles) {
1691
- // Text node children: bare strings/numbers/booleans → DOM Text nodes
1692
- if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") {
1693
- parent.appendChild(document.createTextNode(String(node)));
1694
- return;
1695
- }
1696
- if (!node || typeof node !== "object") return;
1697
-
1698
- const tag = node.tagName || "div";
1699
- const el = document.createElement(tag);
1700
-
1701
- elToPath.set(el, path);
1702
-
1703
- if (typeof node.textContent === "string") {
1704
- el.textContent = node.textContent;
1705
- } else if (typeof node.textContent === "object" && node.textContent?.$ref) {
1706
- const resolved = resolveDefaultForCanvas(node.textContent, S.document.state);
1707
- el.textContent = resolved;
1708
- el.style.opacity = "0.7";
1709
- el.style.fontStyle = "italic";
1710
- el.title = `Bound: ${node.textContent.$ref}`;
1711
- }
1712
-
1713
- if (node.id) el.id = node.id;
1714
- if (node.className) el.className = node.className;
1715
-
1716
- applyCanvasStyle(el, node.style, activeBreakpoints, featureToggles);
1717
-
1718
- if (node.attributes && typeof node.attributes === "object") {
1719
- for (const [attr, val] of Object.entries(node.attributes)) {
1720
- try {
1721
- if (typeof val === "object" && val?.$ref) {
1722
- const resolved = resolveDefaultForCanvas(val, S.document.state);
1723
- el.setAttribute(attr, resolved);
1724
- } else {
1725
- el.setAttribute(attr, val);
1726
- }
1727
- } catch {}
1728
- }
1729
- }
1730
-
1731
- if (Array.isArray(node.children)) {
1732
- for (let i = 0; i < node.children.length; i++) {
1733
- renderCanvasNode(
1734
- node.children[i],
1735
- [...path, "children", i],
1736
- el,
1737
- activeBreakpoints,
1738
- featureToggles,
1739
- );
1740
- }
1741
- } else if (
1742
- node.children &&
1743
- typeof node.children === "object" &&
1744
- node.children.$prototype === "Array"
1745
- ) {
1746
- // Wrap the map template in a visual repeater perimeter
1747
- const template = node.children.map;
1748
- if (template && typeof template === "object") {
1749
- const wrapper = document.createElement("div");
1750
- wrapper.className = "repeater-perimeter";
1751
- elToPath.set(wrapper, [...path, "children"]);
1752
- renderCanvasNode(
1753
- template,
1754
- [...path, "children", "map"],
1755
- wrapper,
1756
- activeBreakpoints,
1757
- featureToggles,
1758
- );
1759
- el.appendChild(wrapper);
1760
- }
1761
- }
1762
-
1763
- if (node.$switch && node.cases && typeof node.cases === "object") {
1764
- // $switch placeholder in structural preview
1765
- const keys = Object.keys(node.cases);
1766
- const placeholder = document.createElement("div");
1767
- placeholder.textContent = `[$switch: ${keys.join(" | ")}]`;
1768
- placeholder.style.cssText =
1769
- "font-family:monospace;font-size:11px;padding:6px 10px;background:color-mix(in srgb, var(--danger) 8%, transparent);border:1px dashed color-mix(in srgb, var(--danger) 40%, transparent);border-radius:4px;color:var(--danger);font-style:italic";
1770
- el.appendChild(placeholder);
1771
- }
1772
-
1773
- el.style.pointerEvents = "none";
1774
- parent.appendChild(el);
1775
- return el;
1776
- }
1777
-
1778
- /**
1779
- * Track the last drag pointer position for canvas drop calculations
1780
- *
1781
- * @type {any}
1782
- */
1783
-
1784
- /**
1785
- * Register all canvas elements in a panel as DnD drop targets.
1786
- *
1787
- * @param {any} panel
1788
- */
1789
- function registerPanelDnD(panel) {
1790
- const { canvas, overlayClk: _overlayClk, dropLine } = panel;
1791
- const allEls = canvas.querySelectorAll("*");
1792
-
1793
- const monitorCleanup = monitorForElements({
1794
- onDragStart() {
1795
- for (const el of canvas.querySelectorAll("*")) {
1796
- /** @type {any} */ (el).style.pointerEvents = "auto";
1797
- }
1798
- // Disable click layers on ALL panels during drag
1799
- for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "none";
1800
- },
1801
- onDrag({ location }) {
1802
- view.lastDragInput = location.current.input;
1803
- },
1804
- onDrop() {
1805
- // Hide all drop lines
1806
- for (const p of canvasPanels) p.dropLine.style.display = "none";
1807
- view.lastDragInput = null;
1808
- for (const el of canvas.querySelectorAll("*")) {
1809
- /** @type {any} */ (el).style.pointerEvents = "none";
1810
- }
1811
- for (const p of canvasPanels) p.overlayClk.style.pointerEvents = "";
1812
- },
1813
- });
1814
- view.canvasDndCleanups.push(monitorCleanup);
1815
-
1816
- for (const el of allEls) {
1817
- const elPath = elToPath.get(el);
1818
- if (!elPath) continue;
1819
-
1820
- const node = getNodeAtPath(S.document, elPath);
1821
- const isVoid = VOID_ELEMENTS.has((node?.tagName || "div").toLowerCase());
1822
-
1823
- const cleanup = dropTargetForElements({
1824
- element: el,
1825
- canDrop({ source }) {
1826
- const srcPath = source.data.path;
1827
- if (srcPath && isAncestor(/** @type {any} */ (srcPath), elPath)) return false;
1828
- return true;
1829
- },
1830
- getData() {
1831
- return { path: elPath, _isVoid: isVoid };
1832
- },
1833
- onDragEnter() {
1834
- showCanvasDropIndicator(el, elPath, isVoid, panel);
1835
- },
1836
- onDrag() {
1837
- showCanvasDropIndicator(el, elPath, isVoid, panel);
1838
- },
1839
- onDragLeave() {
1840
- dropLine.style.display = "none";
1841
- el.classList.remove("canvas-drop-target");
1842
- },
1843
- onDrop({ source }) {
1844
- dropLine.style.display = "none";
1845
- el.classList.remove("canvas-drop-target");
1846
- const instruction = getCanvasDropInstruction(el, elPath, isVoid);
1847
- if (!instruction) return;
1848
- applyDropInstruction(instruction, source.data, elPath);
1849
- },
1850
- });
1851
- view.canvasDndCleanups.push(cleanup);
1852
- }
1853
- }
1854
-
1855
- /**
1856
- * @param {any} el
1857
- * @param {any} elPath
1858
- * @param {any} isVoid
1859
- */
1860
- function getCanvasDropInstruction(el, elPath, isVoid) {
1861
- const rect = el.getBoundingClientRect();
1862
- if (!view.lastDragInput) return null;
1863
- const y = view.lastDragInput.clientY;
1864
- const relY = (y - rect.top) / rect.height;
1865
-
1866
- if (elPath.length === 0) return { type: "make-child" };
1867
- if (isVoid) return relY < 0.5 ? { type: "reorder-above" } : { type: "reorder-below" };
1868
- if (relY < 0.25) return { type: "reorder-above" };
1869
- if (relY > 0.75) return { type: "reorder-below" };
1870
- return { type: "make-child" };
1871
- }
1872
-
1873
- /**
1874
- * @param {any} el
1875
- * @param {any} elPath
1876
- * @param {any} isVoid
1877
- * @param {any} panel
1878
- */
1879
- function showCanvasDropIndicator(el, elPath, isVoid, panel) {
1880
- const instruction = getCanvasDropInstruction(el, elPath, isVoid);
1881
- const { dropLine, viewport } = panel;
1882
- if (!instruction) {
1883
- dropLine.style.display = "none";
1884
- return;
1885
- }
1886
-
1887
- const scale = effectiveZoom();
1888
- const wrapRect = viewport.getBoundingClientRect();
1889
- const elRect = el.getBoundingClientRect();
1890
- const left = (elRect.left - wrapRect.left + viewport.scrollLeft) / scale;
1891
- const width = elRect.width / scale;
1892
-
1893
- if (instruction.type === "make-child") {
1894
- dropLine.style.display = "block";
1895
- dropLine.style.top = `${(elRect.top - wrapRect.top + viewport.scrollTop) / scale}px`;
1896
- dropLine.style.left = `${left}px`;
1897
- dropLine.style.width = `${width}px`;
1898
- dropLine.style.height = `${elRect.height / scale}px`;
1899
- dropLine.className = "canvas-drop-indicator inside";
1900
- el.classList.add("canvas-drop-target");
1901
- return;
1902
- }
1903
-
1904
- el.classList.remove("canvas-drop-target");
1905
- const top =
1906
- instruction.type === "reorder-above"
1907
- ? (elRect.top - wrapRect.top + viewport.scrollTop) / scale
1908
- : (elRect.bottom - wrapRect.top + viewport.scrollTop) / scale;
1909
-
1910
- dropLine.style.display = "block";
1911
- dropLine.style.top = `${top}px`;
1912
- dropLine.style.left = `${left}px`;
1913
- dropLine.style.width = `${width}px`;
1914
- dropLine.style.height = "2px";
1915
- dropLine.className = "canvas-drop-indicator line";
1916
- }
1917
-
1918
- // ─── Overlay system ───────────────────────────────────────────────────────────
1919
-
1920
- function renderOverlays() {
1921
- overlaysPanel.render();
1922
- }
1923
-
1924
- /**
1925
- * Build an overlay box descriptor (no DOM creation).
1926
- *
1927
- * @param {any} el
1928
- * @param {any} type
1929
- * @param {any} panel
1930
- */
1931
- function overlayBoxDescriptor(el, type, panel) {
1932
- const vpRect = panel.viewport.getBoundingClientRect();
1933
- const elRect = el.getBoundingClientRect();
1934
- const scale = effectiveZoom();
1935
- return {
1936
- cls: `overlay-box overlay-${type}`,
1937
- top: `${(elRect.top - vpRect.top + panel.viewport.scrollTop) / scale}px`,
1938
- left: `${(elRect.left - vpRect.left + panel.viewport.scrollLeft) / scale}px`,
1939
- width: `${elRect.width / scale}px`,
1940
- height: `${elRect.height / scale}px`,
1941
- };
1942
- }
1943
-
1944
- function getActivePanel() {
1945
- if (canvasPanels.length === 0) return null;
1946
- if (canvasPanels.length === 1) return canvasPanels[0];
1947
- for (const p of canvasPanels) {
1948
- if (S.ui.activeMedia === null && (p.mediaName === "base" || p.mediaName === null)) return p;
1949
- if (p.mediaName === S.ui.activeMedia) return p;
1950
- }
1951
- return canvasPanels[0];
1952
- }
1953
-
1954
- // ── Pseudo-state preview ──────────────────────────────────────────────────────
1955
- // When a pseudo-selector (:hover, :focus, etc.) is active in the style sidebar,
1956
- // force those styles onto the selected element so the user can see the result.
1957
-
1958
- function updateForcedPseudoPreview() {
1959
- // Clean up previous
1960
- if (view.forcedStyleTag) {
1961
- view.forcedStyleTag.remove();
1962
- view.forcedStyleTag = null;
1963
- }
1964
- if (view.forcedAttrEl) {
1965
- view.forcedAttrEl.removeAttribute("data-studio-forced");
1966
- view.forcedAttrEl = null;
1967
- }
1968
-
1969
- const sel = S.ui?.activeSelector;
1970
- if (!sel || !sel.startsWith(":") || !S.selection) return;
1971
-
1972
- const panel = getActivePanel();
1973
- if (!panel) return;
1974
- const el = findCanvasElement(S.selection, panel.canvas);
1975
- if (!el) return;
1976
-
1977
- // Read the nested style object for this selector
1978
- const node = getNodeAtPath(S.document, S.selection);
1979
- if (!node?.style) return;
1980
- const activeTab = S.ui.activeMedia;
1981
- /** @type {any} */
1982
- const ctx = activeTab ? node.style[`@${activeTab}`] || {} : node.style;
1983
- const rules = ctx[sel];
1984
- if (!rules || typeof rules !== "object") return;
1985
-
1986
- // Build CSS text from the rules
1987
- const cssProps = Object.entries(rules)
1988
- .filter(([k]) => typeof rules[k] === "string" || typeof rules[k] === "number")
1989
- .map(
1990
- ([k, v]) =>
1991
- `${k.replace(/[A-Z]/g, (/** @type {any} */ c) => `-${c.toLowerCase()}`)}: ${v} !important`,
1992
- )
1993
- .join("; ");
1994
- if (!cssProps) return;
1995
-
1996
- el.setAttribute("data-studio-forced", "1");
1997
- view.forcedAttrEl = el;
1998
-
1999
- const tag = document.createElement("style");
2000
- tag.textContent = `[data-studio-forced] { ${cssProps} }`;
2001
- document.head.appendChild(tag);
2002
- view.forcedStyleTag = tag;
2003
- }
2004
-
2005
- /**
2006
- * Walk up the tree from a path, bubbling past inline elements until we find the nearest non-inline
2007
- * ancestor. Returns the original path if already non-inline.
2008
- *
2009
- * @param {any} doc
2010
- * @param {any} path
2011
- */
2012
- function bubbleInlinePath(doc, path) {
2013
- let currentPath = path;
2014
- while (currentPath.length >= 2) {
2015
- const node = getNodeAtPath(doc, currentPath);
2016
- const pPath = parentElementPath(currentPath);
2017
- const parentNode = pPath ? getNodeAtPath(doc, pPath) : null;
2018
- if (!node || !parentNode) break;
2019
- const childTag = (node.tagName ?? "div").toLowerCase();
2020
- const parentTag = (parentNode.tagName ?? "div").toLowerCase();
2021
- if (!isInlineInContext(childTag, parentTag)) break;
2022
- currentPath = pPath;
2023
- }
2024
- return currentPath;
2025
- }
2026
-
2027
- /** Effective zoom scale — always 1 in edit (content) mode, S.ui.zoom otherwise. */
2028
- function effectiveZoom() {
2029
- return canvasMode === "edit" ? 1 : S.ui.zoom;
2030
- }
2031
-
2032
- /**
2033
- * @param {any} path
2034
- * @param {any} canvasEl
2035
- */
2036
- function findCanvasElement(path, canvasEl) {
2037
- let el = canvasEl.firstElementChild;
2038
- if (!el) return null;
2039
- if (path.length === 0) return el;
2040
-
2041
- for (let i = 0; i < path.length; i += 2) {
2042
- if (path[i] !== "children" && path[i] !== "cases") return null;
2043
- const idx = path[i + 1];
2044
- if (idx === undefined) {
2045
- // Odd-length path like ['children', 2, 'children'] — $map container
2046
- // The wrapper div is children[0] of the current element
2047
- el = el.children[0];
2048
- } else if (idx === "map") {
2049
- // $map template: wrapper is children[0], template is wrapper.children[0]
2050
- el = el.children[0]?.children[0];
2051
- } else {
2052
- el = el.children[idx];
2053
- }
2054
- if (!el) break;
2055
- }
2056
-
2057
- // Verify the result: if DOM traversal landed on the wrong element
2058
- // (e.g. a custom element template child instead of the intended node),
2059
- // fall back to scanning elToPath.
2060
- if (el) {
2061
- const elPath = elToPath.get(el);
2062
- if (elPath && pathsEqual(elPath, path)) return el;
2063
- // el has no path or wrong path — it's a template element, not the target
2064
- }
2065
-
2066
- // Fall back: scan all descendants for an element with matching elToPath
2067
- for (const candidate of canvasEl.querySelectorAll("*")) {
2068
- const p = elToPath.get(candidate);
2069
- if (p && pathsEqual(p, path)) return candidate;
2070
- }
2071
- return null;
2072
- }
2073
-
2074
- // ─── Per-panel click-to-select ────────────────────────────────────────────────
2075
-
2076
- /** @param {any} panel */
2077
- function registerPanelEvents(panel) {
2078
- const { canvas, overlayClk, mediaName } = panel;
2079
- const ac = new AbortController();
2080
- const opts = { signal: ac.signal };
2081
- view.canvasEventCleanups.push(() => ac.abort());
2082
-
2083
- /** @param {any} fn */
2084
- function withPanelPointerEvents(fn) {
2085
- const els = canvas.querySelectorAll("*");
2086
- for (const el of els) el.style.pointerEvents = "auto";
2087
- overlayClk.style.display = "none";
2088
- const result = fn();
2089
- overlayClk.style.display = "";
2090
- for (const el of els) el.style.pointerEvents = "none";
2091
- return result;
2092
- }
2093
-
2094
- // During component inline edit, the overlayClk is disabled (see enterComponentInlineEdit).
2095
- // No mousedown passthrough needed — native events reach the contenteditable directly.
2096
-
2097
- overlayClk.addEventListener(
2098
- "click",
2099
- (/** @type {any} */ e) => {
2100
- // Don't intercept clicks meant for the block action bar
2101
- const barInner = view.blockActionBarEl?.firstElementChild;
2102
- if (barInner) {
2103
- const r = barInner.getBoundingClientRect();
2104
- if (
2105
- e.clientX >= r.left &&
2106
- e.clientX <= r.right &&
2107
- e.clientY >= r.top &&
2108
- e.clientY <= r.bottom
2109
- )
2110
- return;
2111
- }
2112
- // If content-mode inline editing is active, treat click outside as blur
2113
- if (isEditing()) {
2114
- stopEditing();
2115
- }
2116
-
2117
- // Component-mode inline editing is handled by its own document-level listener
2118
- // (see enterComponentInlineEdit), so nothing to do here — just fall through.
2119
-
2120
- const elements = withPanelPointerEvents(() =>
2121
- document.elementsFromPoint(e.clientX, e.clientY),
2122
- );
2123
-
2124
- for (const el of elements) {
2125
- if (canvas.contains(el) && el !== canvas) {
2126
- const originalPath = elToPath.get(el);
2127
- if (originalPath) {
2128
- let path = bubbleInlinePath(S.document, originalPath);
2129
- const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2130
- const withMedia = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2131
-
2132
- // Find the DOM element for the bubbled path (may differ from hit element)
2133
- // When path didn't change (no inline bubbling), prefer the hit element directly
2134
- // since findCanvasElement can't navigate into custom element template DOM.
2135
- const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
2136
-
2137
- // Re-click on selected editable block: enter inline editing
2138
- // Edit mode / content mode → rich text editing (enterInlineEdit)
2139
- // Design mode → plaintext component editing (enterComponentInlineEdit via view.pendingInlineEdit)
2140
- if (
2141
- pathsEqual(path, S.selection) &&
2142
- isEditableBlock(resolvedEl) &&
2143
- (canvasMode === "edit" || S.mode === "content")
2144
- ) {
2145
- S = withMedia;
2146
- enterInlineEdit(resolvedEl, path);
2147
- return;
2148
- }
2149
-
2150
- // Design mode or first click: select and schedule component inline editing
2151
- if (canvasMode === "design" && S.mode !== "content") {
2152
- view.pendingInlineEdit = { path, mediaName };
2153
- update(selectNode(withMedia, path));
2154
- return;
2155
- }
2156
-
2157
- update(selectNode(withMedia, path));
2158
- return;
2159
- }
2160
- }
2161
- }
2162
- update(selectNode(S, null));
2163
- },
2164
- opts,
2165
- );
2166
-
2167
- // Double-click shortcut for immediate inline editing
2168
- overlayClk.addEventListener(
2169
- "dblclick",
2170
- (/** @type {any} */ e) => {
2171
- const barInner = view.blockActionBarEl?.firstElementChild;
2172
- if (barInner) {
2173
- const r = barInner.getBoundingClientRect();
2174
- if (
2175
- e.clientX >= r.left &&
2176
- e.clientX <= r.right &&
2177
- e.clientY >= r.top &&
2178
- e.clientY <= r.bottom
2179
- )
2180
- return;
2181
- }
2182
- if (canvasMode !== "edit" && canvasMode !== "design") return;
2183
-
2184
- const elements = withPanelPointerEvents(() =>
2185
- document.elementsFromPoint(e.clientX, e.clientY),
2186
- );
2187
-
2188
- for (const el of elements) {
2189
- if (canvas.contains(el) && el !== canvas) {
2190
- const originalPath = elToPath.get(el);
2191
- if (originalPath) {
2192
- const path = bubbleInlinePath(S.document, originalPath);
2193
- const resolvedEl = path === originalPath ? el : findCanvasElement(path, canvas) || el;
2194
- if (isEditableBlock(resolvedEl)) {
2195
- const newMedia = mediaName === "base" ? null : (mediaName ?? null);
2196
- const withMedia = { ...S, ui: { ...S.ui, activeMedia: newMedia } };
2197
- update(selectNode(withMedia, path));
2198
- enterInlineEdit(resolvedEl, path);
2199
- return;
2200
- }
2201
- }
2202
- }
2203
- }
2204
- },
2205
- opts,
2206
- );
2207
-
2208
- overlayClk.addEventListener(
2209
- "contextmenu",
2210
- (/** @type {any} */ e) => {
2211
- const barInner = view.blockActionBarEl?.firstElementChild;
2212
- if (barInner) {
2213
- const r = barInner.getBoundingClientRect();
2214
- if (
2215
- e.clientX >= r.left &&
2216
- e.clientX <= r.right &&
2217
- e.clientY >= r.top &&
2218
- e.clientY <= r.bottom
2219
- )
2220
- return;
2221
- }
2222
- const elements = withPanelPointerEvents(() =>
2223
- document.elementsFromPoint(e.clientX, e.clientY),
2224
- );
2225
- for (const el of elements) {
2226
- if (canvas.contains(el) && el !== canvas) {
2227
- let path = elToPath.get(el);
2228
- if (path) {
2229
- path = bubbleInlinePath(S.document, path);
2230
- showContextMenu(e, path, S, { onEditComponent: navigateToComponent });
2231
- return;
2232
- }
2233
- }
2234
- }
2235
- e.preventDefault();
2236
- },
2237
- opts,
2238
- );
2239
-
2240
- overlayClk.addEventListener(
2241
- "mousemove",
2242
- (/** @type {any} */ e) => {
2243
- const barInner = view.blockActionBarEl?.firstElementChild;
2244
- if (barInner) {
2245
- const r = barInner.getBoundingClientRect();
2246
- if (
2247
- e.clientX >= r.left &&
2248
- e.clientX <= r.right &&
2249
- e.clientY >= r.top &&
2250
- e.clientY <= r.bottom
2251
- )
2252
- return;
2253
- }
2254
- const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
2255
- if (el && canvas.contains(el) && el !== canvas) {
2256
- let path = elToPath.get(el);
2257
- if (path) {
2258
- path = bubbleInlinePath(S.document, path);
2259
- if (!pathsEqual(path, S.hover)) {
2260
- S = hoverNode(S, path);
2261
- renderOverlays();
2262
- }
2263
- }
2264
- } else if (S.hover) {
2265
- S = hoverNode(S, null);
2266
- renderOverlays();
2267
- }
2268
- },
2269
- opts,
2270
- );
2271
-
2272
- overlayClk.addEventListener(
2273
- "mouseleave",
2274
- () => {
2275
- if (S.hover) {
2276
- S = hoverNode(S, null);
2277
- renderOverlays();
2278
- }
2279
- },
2280
- opts,
2281
- );
2282
-
2283
- // Mount insertion helper — positioned via CSS Anchor Positioning
2284
- insertionHelper.mount({
2285
- getState: () => S,
2286
- update,
2287
- getCanvasMode: () => canvasMode,
2288
- withPanelPointerEvents,
2289
- effectiveZoom,
2290
- defaultDef,
2291
- insertNode,
2292
- selectNode,
2293
- parentElementPath,
2294
- childIndex,
2295
- getNodeAtPath,
2296
- elToPath,
2297
- panel,
2298
- });
2299
- view.canvasEventCleanups.push(() => insertionHelper.unmount());
2300
- }
2301
-
2302
- // ─── Inline editing bridge ────────────────────────────────────────────────────
2303
-
2304
- /**
2305
- * Enter inline editing mode on a canvas element. Hides the overlay for the element and makes it
2306
- * contenteditable.
2307
- *
2308
- * @param {any} el
2309
- * @param {any} path
2310
- */
2311
- function enterInlineEdit(el, path) {
2312
- // Restore raw template expressions before editing.
2313
- // prepareForEditMode renders ${expr} as ❪ expr ❫ for display;
2314
- // revert so the user edits the real syntax and commits it back intact.
2315
- restoreTemplateExpressions(el);
2316
-
2317
- // Hide overlays while editing
2318
- for (const p of canvasPanels) {
2319
- p.overlay.style.display = "none";
2320
- p.overlayClk.style.pointerEvents = "none";
2321
- }
2322
-
2323
- startEditing(el, path, {
2324
- onCommit(
2325
- /** @type {any} */ commitPath,
2326
- /** @type {any} */ children,
2327
- /** @type {any} */ textContent,
2328
- ) {
2329
- // Update the Jx node with the edited content
2330
- if (children) {
2331
- let s = updateProperty(S, commitPath, "textContent", undefined);
2332
- s = updateProperty(s, commitPath, "children", children);
2333
- update(s);
2334
- } else if (textContent != null) {
2335
- let s = updateProperty(S, commitPath, "children", undefined);
2336
- s = updateProperty(s, commitPath, "textContent", textContent);
2337
- update(s);
2338
- }
2339
- },
2340
-
2341
- onSplit(/** @type {any} */ splitPath, /** @type {any} */ before, /** @type {any} */ after) {
2342
- // Update current element with "before" content
2343
- const tag = "p";
2344
- let s = S;
2345
-
2346
- if (before.textContent != null) {
2347
- s = updateProperty(s, splitPath, "children", undefined);
2348
- s = updateProperty(s, splitPath, "textContent", before.textContent);
2349
- } else if (before.children) {
2350
- s = updateProperty(s, splitPath, "textContent", undefined);
2351
- s = updateProperty(s, splitPath, "children", before.children);
2352
- }
2353
-
2354
- // Insert new element after with "after" content
2355
- const parentPath = /** @type {any} */ (parentElementPath(splitPath));
2356
- const idx = /** @type {number} */ (childIndex(splitPath));
2357
- /** @type {any} */
2358
- const newNode = { tagName: tag };
2359
- if (after.textContent != null) {
2360
- newNode.textContent = after.textContent;
2361
- } else if (after.children) {
2362
- newNode.children = after.children;
2363
- } else {
2364
- newNode.textContent = "";
2365
- }
2366
-
2367
- s = insertNode(s, parentPath, idx + 1, newNode);
2368
- // Select the new element
2369
- const newPath = [...parentPath, "children", idx + 1];
2370
- s = selectNode(s, newPath);
2371
- update(s);
2372
-
2373
- // Re-enter editing on the new element after render
2374
- requestAnimationFrame(() => {
2375
- const activePanel = getActivePanel();
2376
- if (activePanel) {
2377
- const newEl = findCanvasElement(newPath, activePanel.canvas);
2378
- if (newEl && isEditableBlock(newEl)) {
2379
- enterInlineEdit(newEl, newPath);
2380
- // Place cursor at start of new element
2381
- const sel = window.getSelection();
2382
- const range = document.createRange();
2383
- range.selectNodeContents(newEl);
2384
- range.collapse(true); // collapse to start
2385
- sel?.removeAllRanges();
2386
- sel?.addRange(range);
2387
- }
2388
- }
2389
- });
2390
- },
2391
-
2392
- onInsert(/** @type {any} */ afterPath, /** @type {any} */ cmd, /** @type {any} */ commitData) {
2393
- // cmd comes from the shared slash menu: { label, tag, description }
2394
- const isEmpty =
2395
- !commitData ||
2396
- (commitData.textContent != null && commitData.textContent.trim() === "") ||
2397
- (commitData.children &&
2398
- (commitData.children.length === 0 ||
2399
- (commitData.children.length === 1 &&
2400
- typeof commitData.children[0] === "string" &&
2401
- commitData.children[0].trim() === "") ||
2402
- (commitData.children.length === 1 &&
2403
- typeof commitData.children[0] === "object" &&
2404
- commitData.children[0]?.tagName === "br")));
2405
-
2406
- // If the element is empty, swap its tagName instead of inserting after
2407
- if (isEmpty) {
2408
- let s = S;
2409
- s = updateProperty(s, afterPath, "tagName", cmd.tag);
2410
- s = updateProperty(s, afterPath, "children", undefined);
2411
- const def = defaultDef(cmd.tag);
2412
- if (def.textContent && def.textContent !== "Paragraph text") {
2413
- s = updateProperty(s, afterPath, "textContent", def.textContent);
2414
- } else {
2415
- s = updateProperty(s, afterPath, "textContent", undefined);
2416
- }
2417
- s = selectNode(s, afterPath);
2418
- update(s);
2419
-
2420
- requestAnimationFrame(() => {
2421
- const activePanel = getActivePanel();
2422
- if (activePanel) {
2423
- const el = findCanvasElement(afterPath, activePanel.canvas);
2424
- if (el && isEditableBlock(el)) {
2425
- enterInlineEdit(el, afterPath);
2426
- }
2427
- }
2428
- });
2429
- return;
2430
- }
2431
-
2432
- const elementDef = defaultDef(cmd.tag);
2433
- const parentPath = /** @type {any} */ (parentElementPath(afterPath));
2434
- const idx = /** @type {number} */ (childIndex(afterPath));
2435
-
2436
- // Apply pending commit from inline edit first (batched to avoid double render)
2437
- let s = S;
2438
- if (commitData) {
2439
- if (commitData.children) {
2440
- s = updateProperty(s, afterPath, "textContent", undefined);
2441
- s = updateProperty(s, afterPath, "children", commitData.children);
2442
- } else if (commitData.textContent != null) {
2443
- s = updateProperty(s, afterPath, "children", undefined);
2444
- s = updateProperty(s, afterPath, "textContent", commitData.textContent);
2445
- }
2446
- }
2447
-
2448
- s = insertNode(s, parentPath, idx + 1, structuredClone(elementDef));
2449
- const newPath = [...parentPath, "children", idx + 1];
2450
- s = selectNode(s, newPath);
2451
- update(s);
2452
-
2453
- // If the inserted element is editable, enter editing
2454
- requestAnimationFrame(() => {
2455
- const activePanel = getActivePanel();
2456
- if (activePanel) {
2457
- const newEl = findCanvasElement(newPath, activePanel.canvas);
2458
- if (newEl && isEditableBlock(newEl)) {
2459
- enterInlineEdit(newEl, newPath);
2460
- }
2461
- }
2462
- });
2463
- },
2464
-
2465
- onEnd() {
2466
- // Cleanup inline edit listeners
2467
- if (view.inlineEditCleanup) {
2468
- view.inlineEditCleanup();
2469
- view.inlineEditCleanup = null;
2470
- }
2471
- // Restore overlays after inline editing ends
2472
- for (const p of canvasPanels) {
2473
- p.overlay.style.display = "";
2474
- p.overlayClk.style.pointerEvents = "";
2475
- }
2476
- renderOverlays();
2477
- },
2478
- });
2479
-
2480
- // Show the block action bar (with inline formatting buttons) on the viewport
2481
- // Defer to ensure this runs after any synchronous renderOverlays() from update()
2482
- requestAnimationFrame(() => renderBlockActionBar());
2483
-
2484
- // Re-render action bar when selection changes inside contenteditable
2485
- const selectionHandler = () => renderBlockActionBar();
2486
- document.addEventListener("selectionchange", selectionHandler);
2487
- el.addEventListener("mouseup", selectionHandler);
2488
- el.addEventListener("keyup", selectionHandler);
2489
-
2490
- // Store listeners for cleanup
2491
- const inlineEditCleanup = () => {
2492
- document.removeEventListener("selectionchange", selectionHandler);
2493
- el.removeEventListener("mouseup", selectionHandler);
2494
- el.removeEventListener("keyup", selectionHandler);
2495
- };
2496
- view.inlineEditCleanup = inlineEditCleanup;
2497
- }
2498
-
2499
- // ─── Component-mode inline text editing ──────────────────────────────────────
2500
-
2501
- /**
2502
- * @param {any} el
2503
- * @param {any} path
2504
- */
2505
- function enterComponentInlineEdit(el, path) {
2506
- // Already editing this element
2507
- if (view.componentInlineEdit && view.componentInlineEdit.el === el) {
2508
- return;
2509
- }
2510
-
2511
- const node = getNodeAtPath(S.document, path);
2512
- if (!node) return;
2513
-
2514
- // Skip nodes that shouldn't be inline-edited
2515
- const tc = node.textContent;
2516
- if (node.$props && (node.tagName || "").includes("-")) return;
2517
- if (Array.isArray(node.children) && node.children.length > 0) return;
2518
- if (node.children && typeof node.children === "object") return;
2519
- if (tc && typeof tc === "object") return;
2520
- const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed", "slot"]);
2521
- if (voids.has(node.tagName)) return;
2522
-
2523
- // Keep overlay visible for the label, but hide selection border to not obscure editing outline.
2524
- // Disable click interceptor so native contenteditable handles all mouse interaction.
2525
- for (const p of canvasPanels) {
2526
- const boxes = p.overlay.querySelectorAll(".overlay-box");
2527
- for (const box of boxes) {
2528
- box.style.border = "none";
2529
- }
2530
- p.overlayClk.style.pointerEvents = "none";
2531
- }
2532
-
2533
- el.contentEditable = "plaintext-only";
2534
- el.style.pointerEvents = "auto"; // required for caretRangeFromPoint hit-testing
2535
- el.style.cursor = "text";
2536
- el.style.outline = "1px solid var(--accent, #4f8bc7)";
2537
- el.style.outlineOffset = "-1px";
2538
- el.style.minHeight = "1em";
2539
-
2540
- // Show raw textContent (not the ❮...❯ display transform)
2541
- const rawText = typeof tc === "string" ? tc : "";
2542
- el.textContent = rawText;
2543
-
2544
- view.componentInlineEdit = {
2545
- el,
2546
- path,
2547
- originalText: rawText,
2548
- mediaName: canvasPanels.find((p) => p.canvas.contains(el))?.mediaName || null,
2549
- };
2550
-
2551
- // Focus and place cursor at end
2552
- el.focus();
2553
- const sel = window.getSelection();
2554
- const range = document.createRange();
2555
- range.selectNodeContents(el);
2556
- range.collapse(false);
2557
- sel?.removeAllRanges();
2558
- sel?.addRange(range);
2559
-
2560
- el.addEventListener("keydown", componentInlineKeydown);
2561
- el.addEventListener("input", componentInlineInput);
2562
-
2563
- // Document-level mousedown: clicking outside the editing element commits
2564
- // the edit and selects the new target element for inline editing.
2565
- const outsideHandler = (/** @type {any} */ evt) => {
2566
- if (!view.componentInlineEdit) {
2567
- document.removeEventListener("mousedown", outsideHandler, true);
2568
- return;
2569
- }
2570
- if (view.componentInlineEdit.el.contains(evt.target)) return; // click within editing el — let it through
2571
- // Let clicks through when the slash command menu is open
2572
- if (isSlashMenuOpen()) return;
2573
- // Let clicks inside the block action bar through
2574
- if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target)) return;
2575
- document.removeEventListener("mousedown", outsideHandler, true);
2576
-
2577
- // Hit-test BEFORE commit (while the current canvas DOM + elToPath are still valid)
2578
- let hitPath = null,
2579
- hitMedia = null;
2580
- for (const p of canvasPanels) {
2581
- const els = p.canvas.querySelectorAll("*");
2582
- for (const el of els) el.style.pointerEvents = "auto";
2583
- p.overlayClk.style.display = "none";
2584
- const found = document.elementsFromPoint(evt.clientX, evt.clientY);
2585
- p.overlayClk.style.display = "";
2586
- for (const el of els) el.style.pointerEvents = "none";
2587
- for (const hit of found) {
2588
- if (p.canvas.contains(hit) && hit !== p.canvas) {
2589
- const path = elToPath.get(hit);
2590
- if (path) {
2591
- hitPath = path;
2592
- hitMedia = p.mediaName;
2593
- break;
2594
- }
2595
- }
2596
- }
2597
- if (hitPath) break;
2598
- }
2599
-
2600
- // Commit + select new element in a single state update if possible
2601
- const { el: editEl, path: editPath, originalText } = view.componentInlineEdit;
2602
- const newText = (editEl.textContent ?? "").trim();
2603
- cleanupComponentInlineEdit(editEl);
2604
-
2605
- // If empty, remove the node entirely
2606
- const isEmpty = !newText;
2607
- const pPath = parentElementPath(editPath);
2608
-
2609
- if (hitPath) {
2610
- const media = hitMedia === "base" ? null : (hitMedia ?? null);
2611
- view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
2612
- const withMedia = { ...S, ui: { ...S.ui, activeMedia: media } };
2613
- if (isEmpty && pPath) {
2614
- // Remove empty node; adjust hitPath if it shifts after removal
2615
- let s = removeNode(withMedia, editPath);
2616
- // If hit path is a later sibling in the same parent, adjust index
2617
- const removedIdx = /** @type {number} */ (childIndex(editPath));
2618
- const hitIdx = /** @type {number} */ (childIndex(hitPath));
2619
- const hitParent = parentElementPath(hitPath);
2620
- if (hitParent && pPath && hitParent.join("/") === pPath.join("/") && hitIdx > removedIdx) {
2621
- hitPath = [...pPath, "children", hitIdx - 1];
2622
- view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
2623
- }
2624
- update(selectNode(s, hitPath));
2625
- } else if (newText !== originalText) {
2626
- update(
2627
- selectNode(
2628
- updateProperty(withMedia, editPath, "textContent", newText || undefined),
2629
- hitPath,
2630
- ),
2631
- );
2632
- } else {
2633
- update(selectNode(withMedia, hitPath));
2634
- }
2635
- } else {
2636
- // Clicked on empty space — just commit
2637
- if (isEmpty && pPath) {
2638
- update(removeNode(S, editPath));
2639
- } else if (newText !== originalText) {
2640
- update(updateProperty(S, editPath, "textContent", newText || undefined));
2641
- } else {
2642
- renderCanvas();
2643
- renderOverlays();
2644
- }
2645
- }
2646
- };
2647
- document.addEventListener("mousedown", outsideHandler, true);
2648
- view.componentInlineEdit._outsideHandler = outsideHandler;
2649
-
2650
- // Re-render block action bar to show inline formatting buttons
2651
- renderBlockActionBar();
2652
- }
2653
-
2654
- /** @param {any} e */
2655
- function componentInlineKeydown(e) {
2656
- // When slash menu is open, let the shared module's capturing handler deal with it
2657
- if (isSlashMenuOpen()) {
2658
- if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) return;
2659
- }
2660
-
2661
- if (e.key === "Enter" && !e.shiftKey) {
2662
- e.preventDefault();
2663
- splitParagraph();
2664
- } else if (e.key === "Escape") {
2665
- e.preventDefault();
2666
- cancelComponentInlineEdit();
2667
- }
2668
- e.stopPropagation(); // prevent studio keyboard shortcuts
2669
- }
2670
-
2671
- function splitParagraph() {
2672
- if (!view.componentInlineEdit) return;
2673
- const { el, path, mediaName } = view.componentInlineEdit;
2674
-
2675
- // Determine cursor offset within text
2676
- const sel = /** @type {any} */ (el.ownerDocument.defaultView?.getSelection());
2677
- const fullText = el.textContent || "";
2678
- let offset = fullText.length;
2679
- if (sel.rangeCount) {
2680
- const range = sel.getRangeAt(0);
2681
- const preRange = document.createRange();
2682
- preRange.selectNodeContents(el);
2683
- preRange.setEnd(range.startContainer, range.startOffset);
2684
- offset = preRange.toString().length;
2685
- }
2686
-
2687
- const textBefore = fullText.slice(0, offset);
2688
- const textAfter = fullText.slice(offset);
2689
-
2690
- const tag = "p";
2691
- const pPath = /** @type {any} */ (parentElementPath(path));
2692
- const idx = /** @type {number} */ (childIndex(path));
2693
- if (!pPath) return; // can't split root
2694
-
2695
- const newDef = { tagName: tag, textContent: textAfter };
2696
- const newPath = [...pPath, "children", idx + 1];
2697
-
2698
- cleanupComponentInlineEdit(el);
2699
-
2700
- // Compound mutation: update current text + insert sibling + select new
2701
- let s = updateProperty(S, path, "textContent", textBefore || undefined);
2702
- s = insertNode(s, pPath, idx + 1, newDef);
2703
- s = selectNode(s, newPath);
2704
-
2705
- view.pendingInlineEdit = { path: newPath, mediaName };
2706
- update(s);
2707
- }
2708
-
2709
- function _commitComponentInlineEdit() {
2710
- if (!view.componentInlineEdit) return;
2711
- const { el, path, originalText } = view.componentInlineEdit;
2712
- const newText = (el.textContent ?? "").trim();
2713
-
2714
- cleanupComponentInlineEdit(el);
2715
-
2716
- // If empty, remove the node entirely
2717
- const pPath = parentElementPath(path);
2718
- if (!newText && pPath) {
2719
- update(removeNode(S, path));
2720
- } else if (newText !== originalText) {
2721
- update(updateProperty(S, path, "textContent", newText || undefined));
2722
- } else {
2723
- renderCanvas();
2724
- renderOverlays();
2725
- }
2726
- }
2727
-
2728
- function cancelComponentInlineEdit() {
2729
- if (!view.componentInlineEdit) return;
2730
- const { el } = view.componentInlineEdit;
2731
- cleanupComponentInlineEdit(el);
2732
- renderCanvas();
2733
- renderOverlays();
2734
- }
2735
-
2736
- /** @param {any} el */
2737
- function cleanupComponentInlineEdit(el) {
2738
- el.removeEventListener("keydown", componentInlineKeydown);
2739
- el.removeEventListener("input", componentInlineInput);
2740
- sharedDismissSlashMenu();
2741
- el.removeAttribute("contenteditable");
2742
- el.style.cursor = "";
2743
- el.style.outline = "";
2744
- el.style.outlineOffset = "";
2745
- el.style.minHeight = "";
2746
- el.style.pointerEvents = "";
2747
-
2748
- // Remove the document-level outside-click handler
2749
- if (view.componentInlineEdit?._outsideHandler) {
2750
- document.removeEventListener("mousedown", view.componentInlineEdit._outsideHandler, true);
2751
- }
2752
- view.componentInlineEdit = null;
2753
-
2754
- // Restore overlay and click interceptor
2755
- for (const p of canvasPanels) {
2756
- p.overlay.style.display = "";
2757
- p.overlayClk.style.pointerEvents = "";
2758
- }
2759
- }
2760
-
2761
- // ─── Component-mode slash commands (delegates to shared slash-menu.js) ────────
2762
-
2763
- function componentInlineInput() {
2764
- if (!view.componentInlineEdit) return;
2765
- const { el, originalText } = view.componentInlineEdit;
2766
- const text = el.textContent || "";
2767
-
2768
- // Only trigger slash menu when the paragraph was originally empty and starts with /
2769
- if (originalText === "" && text.startsWith("/")) {
2770
- const filter = text.slice(1).toLowerCase();
2771
- sharedShowSlashMenu(el, filter, { onSelect: handleComponentSlashSelect });
2772
- } else {
2773
- sharedDismissSlashMenu();
2774
- }
2775
- }
2776
-
2777
- /** @param {any} cmd */
2778
- function handleComponentSlashSelect(cmd) {
2779
- if (!view.componentInlineEdit) return;
2780
- const { el, path, mediaName } = view.componentInlineEdit;
2781
- const pPath = parentElementPath(path);
2782
- const idx = /** @type {number} */ (childIndex(path));
2783
- if (!pPath) return;
2784
-
2785
- cleanupComponentInlineEdit(el);
2786
-
2787
- const newDef = defaultDef(cmd.tag);
2788
- const newPath = [...pPath, "children", idx];
2789
-
2790
- // Replace current empty paragraph with the chosen element
2791
- let s = removeNode(S, path);
2792
- s = insertNode(s, pPath, idx, newDef);
2793
- s = selectNode(s, newPath);
2794
-
2795
- // If the new element has textContent, enter inline edit on it
2796
- const hasText = newDef.textContent != null;
2797
- if (hasText) view.pendingInlineEdit = { path: newPath, mediaName };
2798
- update(s);
2799
- }
2800
-
2801
- // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
2802
-
2803
- function renderLeftPanel() {
2804
- leftPanelMod.render();
2805
- }
2806
-
2807
- // ─── DnD registration: delegated to panels/dnd.js ───────────────────────────
2808
-
2809
- // ─── Stylebook ───────────────────────────────────────────────────────────────
2810
- // Extracted to panels/stylebook-panel.js
2811
-
2812
- // ─── Inspector ────────────────────────────────────────────────────────────────
2813
- // Extracted to panels/properties-panel.js
2814
-
2815
- // ─── Style Sidebar (metadata-driven) ───────────────────────────────────────────
2816
-
2817
- // UNIT_RE — imported from ui/unit-selector.js
2818
-
2819
- // inferInputType — imported from studio-utils.js
2820
-
2821
- // ─── Style panel ────────────────────────────────────────────────────────────
2822
- // Extracted to panels/style-utils.js, panels/style-inputs.js, panels/style-panel.js
2823
-
2824
- // ─── Source/Function editors: delegated to panels/editors.js ─────────────────
2825
-
2826
- // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
2827
-
2828
- function renderToolbar() {
2829
- toolbarPanel.render();
2830
- }
2831
-
2832
- // ─── File Operations (delegated to file-ops.js) ─────────────────────────────
2833
-
2834
- function fileOpsCtx() {
2835
- return {
2836
- S,
2837
- commit: (/** @type {any} */ ns) => {
2838
- S = ns;
2839
- render();
2840
- },
2841
- renderToolbar,
2842
- };
2843
- }
2844
- function openFile() {
2845
- return _openFile(fileOpsCtx());
613
+ function openFile() {
614
+ return _openFile(fileOpsCtx());
2846
615
  }
2847
616
  async function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
2848
617
  const ns = await _loadMarkdown(source, fileHandle);
2849
618
  S = ns;
619
+ ({ doc, session } = fromFlat(S));
2850
620
  }
2851
621
  function saveFile() {
2852
622
  return _saveFile(fileOpsCtx());
@@ -2865,6 +635,7 @@ function openProject() {
2865
635
  S,
2866
636
  commit: (/** @type {any} */ ns) => {
2867
637
  S = ns;
638
+ ({ doc, session } = fromFlat(S));
2868
639
  },
2869
640
  renderActivityBar: () => renderActivityBar(S),
2870
641
  renderLeftPanel,
@@ -2884,6 +655,7 @@ function openFileFromTree(/** @type {any} */ path) {
2884
655
  },
2885
656
  commit: (/** @type {any} */ ns) => {
2886
657
  S = ns;
658
+ ({ doc, session } = fromFlat(S));
2887
659
  },
2888
660
  render,
2889
661
  loadMarkdown,
@@ -2897,6 +669,7 @@ initShortcuts(() => ({
2897
669
  S,
2898
670
  setS: (ns) => {
2899
671
  S = ns;
672
+ ({ doc, session } = fromFlat(S));
2900
673
  },
2901
674
  canvasMode,
2902
675
  panX: view.panX,