@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/dist/studio.js +120246 -120127
- package/dist/studio.js.map +71 -63
- package/package.json +1 -1
- package/src/canvas/canvas-utils.js +313 -0
- package/src/editor/component-inline-edit.js +316 -0
- package/src/editor/content-inline-edit.js +220 -0
- package/src/panels/canvas-dnd.js +165 -0
- package/src/panels/panel-events.js +263 -0
- package/src/panels/preview-render.js +103 -0
- package/src/panels/pseudo-preview.js +64 -0
- package/src/studio.js +59 -1536
- package/src/utils/edit-display.js +197 -0
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
} from "./editor/
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|