@jxsuite/studio 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/studio.js CHANGED
@@ -8,9 +8,6 @@
8
8
  import {
9
9
  createState,
10
10
  selectNode,
11
- hoverNode,
12
- insertNode,
13
- removeNode,
14
11
  updateProperty,
15
12
  updateDef,
16
13
  pushDocument,
@@ -18,13 +15,10 @@ import {
18
15
  getNodeAtPath,
19
16
  pathsEqual,
20
17
  parentElementPath,
21
- childIndex,
22
- isAncestor,
23
18
  canvasWrap,
24
19
  toolbarEl,
25
20
  elToPath,
26
21
  canvasPanels,
27
- VOID_ELEMENTS,
28
22
  stripEventHandlers,
29
23
  registerRenderer,
30
24
  render,
@@ -51,18 +45,27 @@ import { view } from "./view.js";
51
45
  import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxsuite/runtime";
52
46
 
53
47
  import {
54
- startEditing,
55
- stopEditing,
56
48
  isEditing,
57
49
  getActiveElement,
58
50
  isEditableBlock,
59
51
  isInlineInContext,
60
52
  } from "./editor/inline-edit.js";
61
53
  import {
62
- showSlashMenu as sharedShowSlashMenu,
63
- dismissSlashMenu as sharedDismissSlashMenu,
64
- isSlashMenuOpen,
65
- } from "./editor/slash-menu.js";
54
+ enterComponentInlineEdit,
55
+ initComponentInlineEdit,
56
+ } from "./editor/component-inline-edit.js";
57
+ import { enterInlineEdit, initContentInlineEdit } from "./editor/content-inline-edit.js";
58
+ import {
59
+ initCanvasUtils,
60
+ canvasPanelTemplate,
61
+ observeCenterUntilStable,
62
+ applyTransform,
63
+ renderZoomIndicator,
64
+ resetZoomIndicator,
65
+ positionZoomIndicator,
66
+ updateActivePanelHeaders,
67
+ } from "./canvas/canvas-utils.js";
68
+ import { dismissSlashMenu as sharedDismissSlashMenu } from "./editor/slash-menu.js";
66
69
  import {
67
70
  renderStatusbar,
68
71
  statusMessage,
@@ -91,7 +94,6 @@ import { registerPlatform, getPlatform, hasPlatform } from "./platform.js";
91
94
  import {
92
95
  parseMediaEntries,
93
96
  activeBreakpointsForWidth,
94
- applyCanvasStyle,
95
97
  collectMediaOverrides,
96
98
  applyOverridesToCanvas,
97
99
  } from "./utils/canvas-media.js";
@@ -103,27 +105,15 @@ import {
103
105
  getEffectiveElements,
104
106
  getEffectiveHead,
105
107
  } from "./site-context.js";
106
- import {
107
- defCategory,
108
- defBadgeLabel,
109
- resolveDefaultForCanvas,
110
- renderSignalsTemplate,
111
- } from "./panels/signals-panel.js";
108
+ import { defCategory, defBadgeLabel, renderSignalsTemplate } from "./panels/signals-panel.js";
112
109
  import {
113
110
  componentRegistry,
114
111
  loadComponentRegistry,
115
112
  computeRelativePath,
116
113
  } from "./files/components.js";
117
114
 
118
- import {
119
- dropTargetForElements,
120
- monitorForElements,
121
- } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
122
-
123
115
  import { html, render as litRender, nothing } from "lit-html";
124
116
  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";
127
117
 
128
118
  import webdata from "../data/webdata.json";
129
119
  import { renderDataExplorerTemplate } from "./panels/data-explorer.js";
@@ -134,9 +124,8 @@ import { renderGitPanel } from "./panels/git-panel.js";
134
124
  // by Bun's bundler despite sideEffects declarations in Spectrum's package.json.
135
125
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
136
126
  import "./ui/panel-resize.js";
137
- import { showContextMenu, dismissContextMenu } from "./editor/context-menu.js";
127
+ import { dismissContextMenu } from "./editor/context-menu.js";
138
128
  import { initShortcuts } from "./editor/shortcuts.js";
139
- import * as insertionHelper from "./editor/insertion-helper.js";
140
129
  import { renderActivityBar } from "./panels/activity-bar.js";
141
130
  import { renderBrowse } from "./browse/browse.js";
142
131
  import * as toolbarPanel from "./panels/toolbar.js";
@@ -144,12 +133,7 @@ import * as overlaysPanel from "./panels/overlays.js";
144
133
  import * as rightPanelMod from "./panels/right-panel.js";
145
134
  import * as leftPanelMod from "./panels/left-panel.js";
146
135
  import { renderStylebookMode, renderStylebookOverlays } from "./panels/stylebook-panel.js";
147
- import {
148
- registerLayersDnD,
149
- registerComponentsDnD,
150
- registerElementsDnD,
151
- applyDropInstruction,
152
- } from "./panels/dnd.js";
136
+ import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd.js";
153
137
  import { mediaDisplayName, defaultDef } from "./panels/shared.js";
154
138
  import { renderFunctionEditor, registerFunctionCompletions } from "./panels/editors.js";
155
139
  import {
@@ -158,6 +142,10 @@ import {
158
142
  initBlockActionBar,
159
143
  } from "./panels/block-action-bar.js";
160
144
  import { initCssData } from "./panels/style-utils.js";
145
+ import { renderCanvasNode } from "./panels/preview-render.js";
146
+ import { initPseudoPreview, updateForcedPseudoPreview } from "./panels/pseudo-preview.js";
147
+ import { initCanvasDnD, registerPanelDnD } from "./panels/canvas-dnd.js";
148
+ import { initPanelEvents, registerPanelEvents } from "./panels/panel-events.js";
161
149
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
162
150
 
163
151
  // ─── Globals ──────────────────────────────────────────────────────────────────
@@ -242,197 +230,7 @@ async function closeFunctionEditor() {
242
230
  updateUi("editingFunction", null);
243
231
  }
244
232
 
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
- }
233
+ import { prepareForEditMode } from "./utils/edit-display.js";
436
234
 
437
235
  /**
438
236
  * Render a Jx document into a canvas element using the real runtime. Populates elToPath for each
@@ -704,11 +502,6 @@ litRender(
704
502
 
705
503
  initCssData(webdata);
706
504
 
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
505
  // ─── Module-level UI state (must be before render() call) ─────────────────────
713
506
 
714
507
  // ─── Bootstrap ────────────────────────────────────────────────────────────────
@@ -765,6 +558,32 @@ initBlockActionBar({
765
558
  createFloatingContainer,
766
559
  });
767
560
 
561
+ initComponentInlineEdit({ findCanvasElement });
562
+ initContentInlineEdit({ findCanvasElement, getActivePanel });
563
+ initCanvasUtils({
564
+ getCanvasMode: () => canvasMode,
565
+ getZoom: () => S.ui.zoom,
566
+ setZoomDirect: (zoom) => {
567
+ session = { ...session, ui: { ...session.ui, zoom } };
568
+ S = toFlat(doc, session);
569
+ },
570
+ renderStylebookOverlays,
571
+ });
572
+ initPseudoPreview({ getActivePanel, findCanvasElement });
573
+ initCanvasDnD({ effectiveZoom });
574
+ initPanelEvents({
575
+ getState: () => S,
576
+ setState: (s) => {
577
+ S = s;
578
+ },
579
+ getCanvasMode: () => canvasMode,
580
+ bubbleInlinePath,
581
+ findCanvasElement,
582
+ enterInlineEdit,
583
+ navigateToComponent,
584
+ effectiveZoom,
585
+ });
586
+
768
587
  rightPanelMod.mount({
769
588
  navigateToComponent,
770
589
  getCanvasMode: () => canvasMode,
@@ -803,6 +622,13 @@ registerRenderer("statusbar", () => renderStatusbar(S));
803
622
  setStatusbarRenderer(() => renderStatusbar(S));
804
623
  mountStatusbar();
805
624
 
625
+ // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
626
+ canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
627
+ if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
628
+ if (!S.selection) return;
629
+ update(selectNode(S, null));
630
+ });
631
+
806
632
  function safeRenderLeftPanel() {
807
633
  leftPanelMod.render();
808
634
  }
@@ -1124,14 +950,7 @@ function renderCanvas() {
1124
950
  canvasWrap.style.overflow = "";
1125
951
 
1126
952
  // 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
- }
953
+ resetZoomIndicator();
1135
954
 
1136
955
  // Dismiss open popovers/toolbars that are no longer relevant
1137
956
  if (view.blockActionBarEl) litRender(nothing, view.blockActionBarEl);
@@ -1234,14 +1053,7 @@ function renderCanvas() {
1234
1053
  canvasWrap.style.overflow = "hidden";
1235
1054
 
1236
1055
  // 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
- }
1056
+ resetZoomIndicator();
1245
1057
  }
1246
1058
 
1247
1059
  const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
@@ -1389,6 +1201,7 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1389
1201
  registerPanelDnD(panel);
1390
1202
  registerPanelEvents(panel);
1391
1203
  renderOverlays();
1204
+ updateForcedPseudoPreview();
1392
1205
 
1393
1206
  // Process pending inline edit now that the canvas is populated
1394
1207
  if (view.pendingInlineEdit) {
@@ -1403,518 +1216,6 @@ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
1403
1216
  });
1404
1217
  }
1405
1218
 
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,
1437
- };
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
- }
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
1219
  // ─── Overlay system ───────────────────────────────────────────────────────────
1919
1220
 
1920
1221
  function renderOverlays() {
@@ -1951,57 +1252,6 @@ function getActivePanel() {
1951
1252
  return canvasPanels[0];
1952
1253
  }
1953
1254
 
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
1255
  /**
2006
1256
  * Walk up the tree from a path, bubbling past inline elements until we find the nearest non-inline
2007
1257
  * ancestor. Returns the original path if already non-inline.
@@ -2071,733 +1321,6 @@ function findCanvasElement(path, canvasEl) {
2071
1321
  return null;
2072
1322
  }
2073
1323
 
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
1324
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
2802
1325
 
2803
1326
  function renderLeftPanel() {