@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }