@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/package.json
CHANGED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas panel utilities — extracted from studio.js (Phase 4l). Panzoom infrastructure: panel DOM
|
|
3
|
+
* template creation, centering, transform application, zoom indicator, and fit-to-screen.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
7
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
8
|
+
import { styleMap } from "lit-html/directives/style-map.js";
|
|
9
|
+
import { ifDefined } from "lit-html/directives/if-defined.js";
|
|
10
|
+
|
|
11
|
+
import { getState, renderOnly, updateUi, canvasWrap, canvasPanels } from "../store.js";
|
|
12
|
+
import { view } from "../view.js";
|
|
13
|
+
|
|
14
|
+
/** @type {any} */
|
|
15
|
+
let _ctx = null;
|
|
16
|
+
|
|
17
|
+
let zoomIndicatorHost = document.createElement("div");
|
|
18
|
+
zoomIndicatorHost.style.display = "contents";
|
|
19
|
+
document.body.appendChild(zoomIndicatorHost);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the canvas utils module.
|
|
23
|
+
*
|
|
24
|
+
* @param {{
|
|
25
|
+
* getCanvasMode: () => string;
|
|
26
|
+
* getZoom: () => number;
|
|
27
|
+
* setZoomDirect: (zoom: number) => void;
|
|
28
|
+
* renderStylebookOverlays: () => void;
|
|
29
|
+
* }} ctx
|
|
30
|
+
*/
|
|
31
|
+
export function initCanvasUtils(ctx) {
|
|
32
|
+
_ctx = ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the DOM structure for a single canvas panel.
|
|
37
|
+
*
|
|
38
|
+
* @param {any} mediaName
|
|
39
|
+
* @param {any} label
|
|
40
|
+
* @param {any} fullWidth
|
|
41
|
+
* @param {any} width
|
|
42
|
+
*/
|
|
43
|
+
export function canvasPanelTemplate(mediaName, label, fullWidth, width = null) {
|
|
44
|
+
/**
|
|
45
|
+
* @type {{
|
|
46
|
+
* mediaName: any;
|
|
47
|
+
* element: Element | null;
|
|
48
|
+
* canvas: Element | null;
|
|
49
|
+
* overlay: Element | null;
|
|
50
|
+
* overlayClk: Element | null;
|
|
51
|
+
* viewport: Element | null;
|
|
52
|
+
* dropLine: Element | null;
|
|
53
|
+
* _width: any;
|
|
54
|
+
* }}
|
|
55
|
+
*/
|
|
56
|
+
const panel = {
|
|
57
|
+
mediaName,
|
|
58
|
+
element: null,
|
|
59
|
+
canvas: null,
|
|
60
|
+
overlay: null,
|
|
61
|
+
overlayClk: null,
|
|
62
|
+
viewport: null,
|
|
63
|
+
dropLine: null,
|
|
64
|
+
_width: width || null,
|
|
65
|
+
};
|
|
66
|
+
const tpl = html`
|
|
67
|
+
<div
|
|
68
|
+
class=${`canvas-panel${fullWidth ? " full-width" : ""}`}
|
|
69
|
+
data-media=${ifDefined(mediaName !== null ? mediaName : undefined)}
|
|
70
|
+
${ref((el) => {
|
|
71
|
+
if (el) panel.element = el;
|
|
72
|
+
})}
|
|
73
|
+
>
|
|
74
|
+
${label
|
|
75
|
+
? html`
|
|
76
|
+
<div
|
|
77
|
+
class="canvas-panel-header"
|
|
78
|
+
@click=${() => {
|
|
79
|
+
updateUi("activeMedia", mediaName === "base" ? null : mediaName);
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
${label}
|
|
83
|
+
</div>
|
|
84
|
+
`
|
|
85
|
+
: nothing}
|
|
86
|
+
<div
|
|
87
|
+
class="canvas-panel-viewport"
|
|
88
|
+
style=${styleMap({ width: width && !fullWidth ? `${width}px` : "" })}
|
|
89
|
+
${ref((el) => {
|
|
90
|
+
if (el) panel.viewport = el;
|
|
91
|
+
})}
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
class="canvas-panel-canvas"
|
|
95
|
+
style=${styleMap({ width: width ? `${width}px` : "" })}
|
|
96
|
+
${ref((el) => {
|
|
97
|
+
if (el) panel.canvas = el;
|
|
98
|
+
})}
|
|
99
|
+
></div>
|
|
100
|
+
<div
|
|
101
|
+
class="canvas-panel-overlay"
|
|
102
|
+
${ref((el) => {
|
|
103
|
+
if (el) panel.overlay = el;
|
|
104
|
+
})}
|
|
105
|
+
>
|
|
106
|
+
<div
|
|
107
|
+
class="canvas-drop-indicator"
|
|
108
|
+
style="display:none"
|
|
109
|
+
${ref((el) => {
|
|
110
|
+
if (el) panel.dropLine = el;
|
|
111
|
+
})}
|
|
112
|
+
></div>
|
|
113
|
+
</div>
|
|
114
|
+
<div
|
|
115
|
+
class="canvas-panel-click"
|
|
116
|
+
${ref((el) => {
|
|
117
|
+
if (el) panel.overlayClk = el;
|
|
118
|
+
})}
|
|
119
|
+
></div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
return { tpl, panel };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Center canvas in viewport. */
|
|
127
|
+
export function centerCanvas() {
|
|
128
|
+
if (!view.panzoomWrap) return;
|
|
129
|
+
const wrapWidth = canvasWrap.clientWidth;
|
|
130
|
+
const wrapHeight = canvasWrap.clientHeight;
|
|
131
|
+
const contentWidth = view.panzoomWrap.scrollWidth;
|
|
132
|
+
const contentHeight = view.panzoomWrap.scrollHeight;
|
|
133
|
+
const zoom = _ctx.getZoom();
|
|
134
|
+
const scaledWidth = contentWidth * zoom;
|
|
135
|
+
const scaledHeight = contentHeight * zoom;
|
|
136
|
+
view.panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
|
|
137
|
+
const verticalCenter = (wrapHeight - scaledHeight) / 2;
|
|
138
|
+
view.panY = verticalCenter > 16 ? verticalCenter : 16;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Attach a ResizeObserver to view.panzoomWrap that re-centers until the user pans. Handles async
|
|
143
|
+
* content (runtime rendering, data fetching) that changes layout after initial paint.
|
|
144
|
+
*/
|
|
145
|
+
export function observeCenterUntilStable() {
|
|
146
|
+
if (view.centerObserver) {
|
|
147
|
+
view.centerObserver.disconnect();
|
|
148
|
+
view.centerObserver = null;
|
|
149
|
+
}
|
|
150
|
+
if (!view.panzoomWrap) return;
|
|
151
|
+
view.needsCenter = true;
|
|
152
|
+
view.centerObserver = new ResizeObserver(() => {
|
|
153
|
+
if (!view.needsCenter) {
|
|
154
|
+
view.centerObserver?.disconnect();
|
|
155
|
+
view.centerObserver = null;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
centerCanvas();
|
|
159
|
+
applyTransform();
|
|
160
|
+
});
|
|
161
|
+
view.centerObserver.observe(view.panzoomWrap);
|
|
162
|
+
centerCanvas();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Apply the current zoom + pan transform to the panzoom wrapper. */
|
|
166
|
+
export function applyTransform() {
|
|
167
|
+
if (!view.panzoomWrap) return;
|
|
168
|
+
const zoom = _ctx.getZoom();
|
|
169
|
+
view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`;
|
|
170
|
+
const label = document.querySelector(".zoom-indicator-label");
|
|
171
|
+
if (label) label.textContent = `${Math.round(zoom * 100)}%`;
|
|
172
|
+
renderOnly("overlays");
|
|
173
|
+
if (_ctx.getCanvasMode() === "settings") _ctx.renderStylebookOverlays();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Calculate zoom + pan to fit all panels within the viewport. */
|
|
177
|
+
export function fitToScreen() {
|
|
178
|
+
if (!view.panzoomWrap) return;
|
|
179
|
+
const wrapWidth = canvasWrap.clientWidth;
|
|
180
|
+
const wrapHeight = canvasWrap.clientHeight;
|
|
181
|
+
const gap = 24;
|
|
182
|
+
const padding = 32;
|
|
183
|
+
let totalPanelWidth = 0;
|
|
184
|
+
for (const p of canvasPanels) {
|
|
185
|
+
totalPanelWidth += p._width || 800;
|
|
186
|
+
}
|
|
187
|
+
totalPanelWidth += gap * Math.max(0, canvasPanels.length - 1) + padding;
|
|
188
|
+
|
|
189
|
+
const zoom = _ctx.getZoom();
|
|
190
|
+
const wrapRect = view.panzoomWrap.getBoundingClientRect();
|
|
191
|
+
const unscaledHeight = wrapRect.height / zoom;
|
|
192
|
+
const maxPanelHeight = unscaledHeight + padding;
|
|
193
|
+
|
|
194
|
+
const fitZoomW = wrapWidth / totalPanelWidth;
|
|
195
|
+
const fitZoomH = wrapHeight / maxPanelHeight;
|
|
196
|
+
const fitZoom = Math.min(5.0, Math.max(0.05, Math.min(fitZoomW, fitZoomH)));
|
|
197
|
+
|
|
198
|
+
_ctx.setZoomDirect(fitZoom);
|
|
199
|
+
|
|
200
|
+
const scaledWidth = totalPanelWidth * fitZoom;
|
|
201
|
+
const scaledHeight = maxPanelHeight * fitZoom;
|
|
202
|
+
view.panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
|
|
203
|
+
view.panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
|
|
204
|
+
applyTransform();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Reset the zoom indicator (clear its content). Called when switching to non-panzoom modes. */
|
|
208
|
+
export function resetZoomIndicator() {
|
|
209
|
+
try {
|
|
210
|
+
litRender(nothing, zoomIndicatorHost);
|
|
211
|
+
} catch {
|
|
212
|
+
const newHost = document.createElement("div");
|
|
213
|
+
newHost.style.display = "contents";
|
|
214
|
+
zoomIndicatorHost.replaceWith(newHost);
|
|
215
|
+
zoomIndicatorHost = newHost;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Render the floating zoom indicator at the bottom center of canvas-wrap. Uses position: fixed,
|
|
221
|
+
* computed from canvas-wrap bounds.
|
|
222
|
+
*/
|
|
223
|
+
export function renderZoomIndicator() {
|
|
224
|
+
if (!zoomIndicatorHost.isConnected) document.body.appendChild(zoomIndicatorHost);
|
|
225
|
+
const zoom = _ctx.getZoom();
|
|
226
|
+
try {
|
|
227
|
+
litRender(
|
|
228
|
+
html`
|
|
229
|
+
<div class="zoom-indicator">
|
|
230
|
+
<span class="zoom-indicator-label">${Math.round(zoom * 100)}%</span>
|
|
231
|
+
<sp-action-button
|
|
232
|
+
quiet
|
|
233
|
+
size="s"
|
|
234
|
+
class="zoom-fit-btn"
|
|
235
|
+
title="Fit to screen"
|
|
236
|
+
@click=${fitToScreen}
|
|
237
|
+
>
|
|
238
|
+
<svg
|
|
239
|
+
width="14"
|
|
240
|
+
height="14"
|
|
241
|
+
viewBox="0 0 16 16"
|
|
242
|
+
fill="none"
|
|
243
|
+
stroke="currentColor"
|
|
244
|
+
stroke-width="1.5"
|
|
245
|
+
>
|
|
246
|
+
<rect x="2" y="2" width="12" height="12" rx="1" />
|
|
247
|
+
<path d="M2 6h12M6 2v12" />
|
|
248
|
+
</svg>
|
|
249
|
+
</sp-action-button>
|
|
250
|
+
</div>
|
|
251
|
+
`,
|
|
252
|
+
zoomIndicatorHost,
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
const newHost = document.createElement("div");
|
|
256
|
+
newHost.style.display = "contents";
|
|
257
|
+
zoomIndicatorHost.replaceWith(newHost);
|
|
258
|
+
zoomIndicatorHost = newHost;
|
|
259
|
+
litRender(
|
|
260
|
+
html`
|
|
261
|
+
<div class="zoom-indicator">
|
|
262
|
+
<span class="zoom-indicator-label">${Math.round(zoom * 100)}%</span>
|
|
263
|
+
<sp-action-button
|
|
264
|
+
quiet
|
|
265
|
+
size="s"
|
|
266
|
+
class="zoom-fit-btn"
|
|
267
|
+
title="Fit to screen"
|
|
268
|
+
@click=${fitToScreen}
|
|
269
|
+
>
|
|
270
|
+
<svg
|
|
271
|
+
width="14"
|
|
272
|
+
height="14"
|
|
273
|
+
viewBox="0 0 16 16"
|
|
274
|
+
fill="none"
|
|
275
|
+
stroke="currentColor"
|
|
276
|
+
stroke-width="1.5"
|
|
277
|
+
>
|
|
278
|
+
<rect x="2" y="2" width="12" height="12" rx="1" />
|
|
279
|
+
<path d="M2 6h12M6 2v12" />
|
|
280
|
+
</svg>
|
|
281
|
+
</sp-action-button>
|
|
282
|
+
</div>
|
|
283
|
+
`,
|
|
284
|
+
zoomIndicatorHost,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
positionZoomIndicator();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Position the zoom indicator relative to canvas-wrap bounds. */
|
|
291
|
+
export function positionZoomIndicator() {
|
|
292
|
+
const indicator = /** @type {HTMLElement | null} */ (document.querySelector(".zoom-indicator"));
|
|
293
|
+
if (!indicator) return;
|
|
294
|
+
const rect = canvasWrap.getBoundingClientRect();
|
|
295
|
+
indicator.style.left = `${rect.left + rect.width / 2}px`;
|
|
296
|
+
indicator.style.top = `${rect.bottom - 32}px`;
|
|
297
|
+
indicator.style.transform = "translateX(-50%)";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Toggle "active" class on canvas panel headers based on activeMedia. */
|
|
301
|
+
export function updateActivePanelHeaders() {
|
|
302
|
+
const S = getState();
|
|
303
|
+
for (const p of canvasPanels) {
|
|
304
|
+
const header = p.element.querySelector(".canvas-panel-header");
|
|
305
|
+
if (header) {
|
|
306
|
+
const isActive =
|
|
307
|
+
(S.ui.activeMedia === null && p.mediaName === "base") ||
|
|
308
|
+
(S.ui.activeMedia === null && p.mediaName === null) ||
|
|
309
|
+
S.ui.activeMedia === p.mediaName;
|
|
310
|
+
header.classList.toggle("active", isActive);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component inline edit — extracted from studio.js (Phase 4j). Manages plaintext-only editing on
|
|
3
|
+
* canvas elements in design mode, with slash menu delegation for block insertion.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getState,
|
|
8
|
+
update,
|
|
9
|
+
renderOnly,
|
|
10
|
+
getNodeAtPath,
|
|
11
|
+
selectNode,
|
|
12
|
+
removeNode,
|
|
13
|
+
insertNode,
|
|
14
|
+
updateProperty,
|
|
15
|
+
parentElementPath,
|
|
16
|
+
childIndex,
|
|
17
|
+
canvasPanels,
|
|
18
|
+
elToPath,
|
|
19
|
+
} from "../store.js";
|
|
20
|
+
import { view } from "../view.js";
|
|
21
|
+
import { isSlashMenuOpen, showSlashMenu, dismissSlashMenu } from "./slash-menu.js";
|
|
22
|
+
import { renderBlockActionBar } from "../panels/block-action-bar.js";
|
|
23
|
+
import { defaultDef } from "../panels/shared.js";
|
|
24
|
+
|
|
25
|
+
/** @type {any} */
|
|
26
|
+
let _ctx = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize the component inline edit module.
|
|
30
|
+
*
|
|
31
|
+
* @param {{ findCanvasElement: Function }} ctx
|
|
32
|
+
*/
|
|
33
|
+
export function initComponentInlineEdit(ctx) {
|
|
34
|
+
_ctx = ctx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enter plaintext inline editing on a canvas element.
|
|
39
|
+
*
|
|
40
|
+
* @param {any} el
|
|
41
|
+
* @param {any} path
|
|
42
|
+
*/
|
|
43
|
+
export function enterComponentInlineEdit(el, path) {
|
|
44
|
+
if (view.componentInlineEdit && view.componentInlineEdit.el === el) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const S = getState();
|
|
49
|
+
const node = getNodeAtPath(S.document, path);
|
|
50
|
+
if (!node) return;
|
|
51
|
+
|
|
52
|
+
const tc = node.textContent;
|
|
53
|
+
if (node.$props && (node.tagName || "").includes("-")) return;
|
|
54
|
+
if (Array.isArray(node.children) && node.children.length > 0) return;
|
|
55
|
+
if (node.children && typeof node.children === "object") return;
|
|
56
|
+
if (tc && typeof tc === "object") return;
|
|
57
|
+
const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed", "slot"]);
|
|
58
|
+
if (voids.has(node.tagName)) return;
|
|
59
|
+
|
|
60
|
+
for (const p of canvasPanels) {
|
|
61
|
+
const boxes = p.overlay.querySelectorAll(".overlay-box");
|
|
62
|
+
for (const box of boxes) {
|
|
63
|
+
box.style.border = "none";
|
|
64
|
+
}
|
|
65
|
+
p.overlayClk.style.pointerEvents = "none";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
el.contentEditable = "plaintext-only";
|
|
69
|
+
el.style.pointerEvents = "auto";
|
|
70
|
+
el.style.cursor = "text";
|
|
71
|
+
el.style.outline = "1px solid var(--accent, #4f8bc7)";
|
|
72
|
+
el.style.outlineOffset = "-1px";
|
|
73
|
+
el.style.minHeight = "1em";
|
|
74
|
+
|
|
75
|
+
const rawText = typeof tc === "string" ? tc : "";
|
|
76
|
+
el.textContent = rawText;
|
|
77
|
+
|
|
78
|
+
view.componentInlineEdit = {
|
|
79
|
+
el,
|
|
80
|
+
path,
|
|
81
|
+
originalText: rawText,
|
|
82
|
+
mediaName: canvasPanels.find((p) => p.canvas.contains(el))?.mediaName || null,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
el.focus();
|
|
86
|
+
const sel = window.getSelection();
|
|
87
|
+
const range = document.createRange();
|
|
88
|
+
range.selectNodeContents(el);
|
|
89
|
+
range.collapse(false);
|
|
90
|
+
sel?.removeAllRanges();
|
|
91
|
+
sel?.addRange(range);
|
|
92
|
+
|
|
93
|
+
el.addEventListener("keydown", componentInlineKeydown);
|
|
94
|
+
el.addEventListener("input", componentInlineInput);
|
|
95
|
+
|
|
96
|
+
const outsideHandler = (/** @type {any} */ evt) => {
|
|
97
|
+
if (!view.componentInlineEdit) {
|
|
98
|
+
document.removeEventListener("mousedown", outsideHandler, true);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (view.componentInlineEdit.el.contains(evt.target)) return;
|
|
102
|
+
if (isSlashMenuOpen()) return;
|
|
103
|
+
if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target)) return;
|
|
104
|
+
document.removeEventListener("mousedown", outsideHandler, true);
|
|
105
|
+
|
|
106
|
+
let hitPath = null,
|
|
107
|
+
hitMedia = null;
|
|
108
|
+
for (const p of canvasPanels) {
|
|
109
|
+
const els = p.canvas.querySelectorAll("*");
|
|
110
|
+
for (const el of els) el.style.pointerEvents = "auto";
|
|
111
|
+
p.overlayClk.style.display = "none";
|
|
112
|
+
const found = document.elementsFromPoint(evt.clientX, evt.clientY);
|
|
113
|
+
p.overlayClk.style.display = "";
|
|
114
|
+
for (const el of els) el.style.pointerEvents = "none";
|
|
115
|
+
for (const hit of found) {
|
|
116
|
+
if (p.canvas.contains(hit) && hit !== p.canvas) {
|
|
117
|
+
const path = elToPath.get(hit);
|
|
118
|
+
if (path) {
|
|
119
|
+
hitPath = path;
|
|
120
|
+
hitMedia = p.mediaName;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (hitPath) break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { el: editEl, path: editPath, originalText } = view.componentInlineEdit;
|
|
129
|
+
const newText = (editEl.textContent ?? "").trim();
|
|
130
|
+
cleanupComponentInlineEdit(editEl);
|
|
131
|
+
|
|
132
|
+
const isEmpty = !newText;
|
|
133
|
+
const pPath = parentElementPath(editPath);
|
|
134
|
+
const S = getState();
|
|
135
|
+
|
|
136
|
+
if (hitPath) {
|
|
137
|
+
const media = hitMedia === "base" ? null : (hitMedia ?? null);
|
|
138
|
+
view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
|
|
139
|
+
const withMedia = { ...S, ui: { ...S.ui, activeMedia: media } };
|
|
140
|
+
if (isEmpty && pPath) {
|
|
141
|
+
let s = removeNode(withMedia, editPath);
|
|
142
|
+
const removedIdx = /** @type {number} */ (childIndex(editPath));
|
|
143
|
+
const hitIdx = /** @type {number} */ (childIndex(hitPath));
|
|
144
|
+
const hitParent = parentElementPath(hitPath);
|
|
145
|
+
if (hitParent && pPath && hitParent.join("/") === pPath.join("/") && hitIdx > removedIdx) {
|
|
146
|
+
hitPath = [...pPath, "children", hitIdx - 1];
|
|
147
|
+
view.pendingInlineEdit = { path: hitPath, mediaName: hitMedia };
|
|
148
|
+
}
|
|
149
|
+
update(selectNode(s, hitPath));
|
|
150
|
+
} else if (newText !== originalText) {
|
|
151
|
+
update(
|
|
152
|
+
selectNode(
|
|
153
|
+
updateProperty(withMedia, editPath, "textContent", newText || undefined),
|
|
154
|
+
hitPath,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
update(selectNode(withMedia, hitPath));
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
if (isEmpty && pPath) {
|
|
162
|
+
update(removeNode(S, editPath));
|
|
163
|
+
} else if (newText !== originalText) {
|
|
164
|
+
update(updateProperty(S, editPath, "textContent", newText || undefined));
|
|
165
|
+
} else {
|
|
166
|
+
renderOnly("canvas");
|
|
167
|
+
renderOnly("overlays");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
document.addEventListener("mousedown", outsideHandler, true);
|
|
172
|
+
view.componentInlineEdit._outsideHandler = outsideHandler;
|
|
173
|
+
|
|
174
|
+
renderBlockActionBar();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @param {any} e */
|
|
178
|
+
function componentInlineKeydown(e) {
|
|
179
|
+
if (isSlashMenuOpen()) {
|
|
180
|
+
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
splitParagraph();
|
|
186
|
+
} else if (e.key === "Escape") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
cancelComponentInlineEdit();
|
|
189
|
+
}
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function splitParagraph() {
|
|
194
|
+
if (!view.componentInlineEdit) return;
|
|
195
|
+
const { el, path, mediaName } = view.componentInlineEdit;
|
|
196
|
+
|
|
197
|
+
const sel = /** @type {any} */ (el.ownerDocument.defaultView?.getSelection());
|
|
198
|
+
const fullText = el.textContent || "";
|
|
199
|
+
let offset = fullText.length;
|
|
200
|
+
if (sel.rangeCount) {
|
|
201
|
+
const range = sel.getRangeAt(0);
|
|
202
|
+
const preRange = document.createRange();
|
|
203
|
+
preRange.selectNodeContents(el);
|
|
204
|
+
preRange.setEnd(range.startContainer, range.startOffset);
|
|
205
|
+
offset = preRange.toString().length;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const textBefore = fullText.slice(0, offset);
|
|
209
|
+
const textAfter = fullText.slice(offset);
|
|
210
|
+
|
|
211
|
+
const tag = "p";
|
|
212
|
+
const pPath = /** @type {any} */ (parentElementPath(path));
|
|
213
|
+
const idx = /** @type {number} */ (childIndex(path));
|
|
214
|
+
if (!pPath) return;
|
|
215
|
+
|
|
216
|
+
const newDef = { tagName: tag, textContent: textAfter };
|
|
217
|
+
const newPath = [...pPath, "children", idx + 1];
|
|
218
|
+
|
|
219
|
+
cleanupComponentInlineEdit(el);
|
|
220
|
+
|
|
221
|
+
const S = getState();
|
|
222
|
+
let s = updateProperty(S, path, "textContent", textBefore || undefined);
|
|
223
|
+
s = insertNode(s, pPath, idx + 1, newDef);
|
|
224
|
+
s = selectNode(s, newPath);
|
|
225
|
+
|
|
226
|
+
view.pendingInlineEdit = { path: newPath, mediaName };
|
|
227
|
+
update(s);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function _commitComponentInlineEdit() {
|
|
231
|
+
if (!view.componentInlineEdit) return;
|
|
232
|
+
const { el, path, originalText } = view.componentInlineEdit;
|
|
233
|
+
const newText = (el.textContent ?? "").trim();
|
|
234
|
+
|
|
235
|
+
cleanupComponentInlineEdit(el);
|
|
236
|
+
|
|
237
|
+
const S = getState();
|
|
238
|
+
const pPath = parentElementPath(path);
|
|
239
|
+
if (!newText && pPath) {
|
|
240
|
+
update(removeNode(S, path));
|
|
241
|
+
} else if (newText !== originalText) {
|
|
242
|
+
update(updateProperty(S, path, "textContent", newText || undefined));
|
|
243
|
+
} else {
|
|
244
|
+
renderOnly("canvas");
|
|
245
|
+
renderOnly("overlays");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function cancelComponentInlineEdit() {
|
|
250
|
+
if (!view.componentInlineEdit) return;
|
|
251
|
+
const { el } = view.componentInlineEdit;
|
|
252
|
+
cleanupComponentInlineEdit(el);
|
|
253
|
+
renderOnly("canvas");
|
|
254
|
+
renderOnly("overlays");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @param {any} el */
|
|
258
|
+
function cleanupComponentInlineEdit(el) {
|
|
259
|
+
el.removeEventListener("keydown", componentInlineKeydown);
|
|
260
|
+
el.removeEventListener("input", componentInlineInput);
|
|
261
|
+
dismissSlashMenu();
|
|
262
|
+
el.removeAttribute("contenteditable");
|
|
263
|
+
el.style.cursor = "";
|
|
264
|
+
el.style.outline = "";
|
|
265
|
+
el.style.outlineOffset = "";
|
|
266
|
+
el.style.minHeight = "";
|
|
267
|
+
el.style.pointerEvents = "";
|
|
268
|
+
|
|
269
|
+
if (view.componentInlineEdit?._outsideHandler) {
|
|
270
|
+
document.removeEventListener("mousedown", view.componentInlineEdit._outsideHandler, true);
|
|
271
|
+
}
|
|
272
|
+
view.componentInlineEdit = null;
|
|
273
|
+
|
|
274
|
+
for (const p of canvasPanels) {
|
|
275
|
+
p.overlay.style.display = "";
|
|
276
|
+
p.overlayClk.style.pointerEvents = "";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Component-mode slash commands ──────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
function componentInlineInput() {
|
|
283
|
+
if (!view.componentInlineEdit) return;
|
|
284
|
+
const { el, originalText } = view.componentInlineEdit;
|
|
285
|
+
const text = el.textContent || "";
|
|
286
|
+
|
|
287
|
+
if (originalText === "" && text.startsWith("/")) {
|
|
288
|
+
const filter = text.slice(1).toLowerCase();
|
|
289
|
+
showSlashMenu(el, filter, { onSelect: handleComponentSlashSelect });
|
|
290
|
+
} else {
|
|
291
|
+
dismissSlashMenu();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @param {any} cmd */
|
|
296
|
+
function handleComponentSlashSelect(cmd) {
|
|
297
|
+
if (!view.componentInlineEdit) return;
|
|
298
|
+
const { el, path, mediaName } = view.componentInlineEdit;
|
|
299
|
+
const pPath = parentElementPath(path);
|
|
300
|
+
const idx = /** @type {number} */ (childIndex(path));
|
|
301
|
+
if (!pPath) return;
|
|
302
|
+
|
|
303
|
+
cleanupComponentInlineEdit(el);
|
|
304
|
+
|
|
305
|
+
const S = getState();
|
|
306
|
+
const newDef = defaultDef(cmd.tag);
|
|
307
|
+
const newPath = [...pPath, "children", idx];
|
|
308
|
+
|
|
309
|
+
let s = removeNode(S, path);
|
|
310
|
+
s = insertNode(s, pPath, idx, newDef);
|
|
311
|
+
s = selectNode(s, newPath);
|
|
312
|
+
|
|
313
|
+
const hasText = newDef.textContent != null;
|
|
314
|
+
if (hasText) view.pendingInlineEdit = { path: newPath, mediaName };
|
|
315
|
+
update(s);
|
|
316
|
+
}
|