@avocadostudio-ai/preview-adapter 0.1.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.
@@ -0,0 +1,1527 @@
1
+ /**
2
+ * Shared DOM manipulation functions for the editor overlay.
3
+ *
4
+ * These functions are used by both the iframe-based PreviewBridgeCore (postMessage mode)
5
+ * and the immersive editing widget (direct mode). They operate on the site's DOM and
6
+ * communicate back to the editor through injectable callbacks, decoupled from any
7
+ * specific transport (postMessage vs direct).
8
+ */
9
+ import { isImagePath } from "@avocadostudio-ai/shared";
10
+ // ---------------------------------------------------------------------------
11
+ // Markdown → HTML helper (mirrors _shared.tsx renderRichTextContent)
12
+ // ---------------------------------------------------------------------------
13
+ function inlineToHtml(text) {
14
+ return text
15
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
16
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
17
+ .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
18
+ }
19
+ export function markdownToHtml(md) {
20
+ const escaped = md
21
+ .replace(/&/g, "&amp;")
22
+ .replace(/</g, "&lt;")
23
+ .replace(/>/g, "&gt;");
24
+ const normalized = escaped
25
+ .replace(/\r\n?/g, "\n")
26
+ .replace(/([.!?])([A-Z])/g, "$1 $2")
27
+ .replace(/\n{3,}/g, "\n\n")
28
+ .trim();
29
+ if (!normalized.includes("\n")) {
30
+ return inlineToHtml(normalized);
31
+ }
32
+ const blocks = normalized.split(/\n\s*\n+/).filter(Boolean);
33
+ return blocks
34
+ .map((block) => {
35
+ const lines = block.split(/\n+/).map((l) => l.trim()).filter(Boolean);
36
+ if (lines.length === 0)
37
+ return "";
38
+ const hMatch = /^(#{1,6})\s+(.+)$/.exec(lines[0]);
39
+ if (hMatch) {
40
+ let html = `<h3>${inlineToHtml(hMatch[2].trim())}</h3>`;
41
+ const rest = lines.slice(1).join(" ").trim();
42
+ if (rest)
43
+ html += `<p>${inlineToHtml(rest)}</p>`;
44
+ return html;
45
+ }
46
+ const ulItems = lines.map((l) => /^\s*[-*+•]\s+(.+)$/.exec(l)?.[1]?.trim() ?? null);
47
+ if (ulItems.every((i) => i !== null)) {
48
+ return `<ul>${ulItems.map((i) => `<li>${inlineToHtml(i)}</li>`).join("")}</ul>`;
49
+ }
50
+ const olItems = lines.map((l) => /^\s*\d+[.)]\s+(.+)$/.exec(l)?.[1]?.trim() ?? null);
51
+ if (olItems.every((i) => i !== null)) {
52
+ return `<ol>${olItems.map((i) => `<li>${inlineToHtml(i)}</li>`).join("")}</ol>`;
53
+ }
54
+ return `<p>${inlineToHtml(block)}</p>`;
55
+ })
56
+ .join("");
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Pure DOM queries
60
+ // ---------------------------------------------------------------------------
61
+ export function findBlockNode(blockId) {
62
+ if (!blockId)
63
+ return null;
64
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
65
+ return document.querySelector(`[data-block-id='${CSS.escape(blockId)}']`);
66
+ }
67
+ return document.querySelector(`[data-block-id='${blockId}']`);
68
+ }
69
+ export function findEditableNode(parent, editablePath) {
70
+ const nodes = parent.querySelectorAll("[data-editable-target]");
71
+ for (const node of nodes) {
72
+ if ((node.getAttribute("data-editable-target") ?? "") === editablePath)
73
+ return node;
74
+ }
75
+ return null;
76
+ }
77
+ // Block-level tags that signal a field React rendered as parsed-markdown element
78
+ // children (e.g. RichText `body`, Tabs `content`) rather than a single text node.
79
+ const RICH_EDITABLE_SELECTOR = "p,ul,ol,li,blockquote,pre,table,h1,h2,h3,h4,h5,h6,hr";
80
+ /**
81
+ * True when React rendered this editable field as block-level element children
82
+ * (parsed markdown), not a plain text node.
83
+ *
84
+ * The live-draft overlay previews text by writing `node.innerHTML` directly,
85
+ * which desyncs React's virtual DOM from the real DOM. For a single text node
86
+ * that's harmless — the post-stream `router.refresh()` reconcile still updates
87
+ * the text correctly. But for a structurally-rich subtree, React diffs the new
88
+ * server content against its STALE vdom (the pre-edit markdown elements) and
89
+ * applies positional patches that mis-map onto the overlay's flat innerHTML,
90
+ * leaving truncated/garbled content that only a full page reload recovers.
91
+ * So these nodes must be left under React's control and never innerHTML-mutated.
92
+ */
93
+ export function isRichEditableNode(node) {
94
+ return node.querySelector(RICH_EDITABLE_SELECTOR) !== null;
95
+ }
96
+ export function parseListItemPath(editablePath) {
97
+ const path = String(editablePath ?? "");
98
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+)\](?:\.|$)/.exec(path);
99
+ if (!match)
100
+ return null;
101
+ return { listKey: match[1], index: Number(match[2]) };
102
+ }
103
+ export function supportsInlineEditablePath(editablePath) {
104
+ if (!editablePath)
105
+ return false;
106
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\[[0-9]+\]\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(editablePath))
107
+ return false;
108
+ if (/(^|\.)(?:ctaHref|secondaryCtaHref|imageUrl|imageAlt|href|url)$/i.test(editablePath))
109
+ return false;
110
+ if (/\]\.(src|alt|poster)$/i.test(editablePath))
111
+ return false;
112
+ return true;
113
+ }
114
+ export function readNodeText(node) {
115
+ /*
116
+ * `innerText` is the *rendered* text, and this value is what gets committed
117
+ * as the block's new prop — so CSS leaks into stored content.
118
+ *
119
+ * The one that bites is `text-transform`. On a site that uppercases its
120
+ * headings in CSS (which is most of them), double-clicking a heading and
121
+ * pressing enter without typing anything reads back the uppercased string
122
+ * and writes it to the CMS. The page looks identical, so nothing suggests
123
+ * the content was just rewritten.
124
+ *
125
+ * Neutralising the transform for the duration of the read keeps everything
126
+ * else `innerText` is chosen for — the newlines it derives from block
127
+ * children and `<br>`, and its skipping of hidden nodes — which
128
+ * `textContent` would lose. Descendants are covered too, since one that sets
129
+ * its own transform would not inherit the reset.
130
+ *
131
+ * The styles are restored in the same synchronous turn, so React never
132
+ * observes an intermediate state and the node ends byte-identical to how it
133
+ * started.
134
+ */
135
+ const styled = [node, ...Array.from(node.querySelectorAll("*"))];
136
+ const saved = styled.map((el) => el.style.textTransform);
137
+ for (const el of styled)
138
+ el.style.textTransform = "none";
139
+ try {
140
+ return node.innerText.replace(/\r\n/g, "\n").replace(/\u00a0/g, " ");
141
+ }
142
+ finally {
143
+ styled.forEach((el, i) => {
144
+ // Restore exactly: an empty saved value means there was no inline style,
145
+ // and removing the property is not the same as setting it to "".
146
+ if (saved[i])
147
+ el.style.textTransform = saved[i];
148
+ else
149
+ el.style.removeProperty("text-transform");
150
+ });
151
+ }
152
+ }
153
+ export function placeCaretAtEnd(node) {
154
+ const selection = window.getSelection?.();
155
+ if (!selection)
156
+ return;
157
+ const range = document.createRange();
158
+ range.selectNodeContents(node);
159
+ range.collapse(false);
160
+ selection.removeAllRanges();
161
+ selection.addRange(range);
162
+ }
163
+ export function orderedBlockNodes() {
164
+ const main = document.querySelector("main.editor-mode, main");
165
+ const directChildren = main
166
+ ? Array.from(main.children).filter((node) => node instanceof HTMLElement && node.hasAttribute("data-block-id"))
167
+ : [];
168
+ if (directChildren.length > 0)
169
+ return directChildren;
170
+ return Array.from(document.querySelectorAll("[data-block-id]"));
171
+ }
172
+ export function blockOrderIndex(blockId, preferredNode) {
173
+ const nodes = orderedBlockNodes();
174
+ const order = nodes
175
+ .map((node) => node.getAttribute("data-block-id"))
176
+ .filter((id) => Boolean(id && id.length > 0));
177
+ if (preferredNode && preferredNode.isConnected) {
178
+ const preferredIndex = nodes.indexOf(preferredNode);
179
+ if (preferredIndex !== -1)
180
+ return { idx: preferredIndex, order };
181
+ }
182
+ return { idx: order.findIndex((id) => id === blockId), order };
183
+ }
184
+ export function computeMoveAfter(blockId, direction, preferredNode) {
185
+ const { idx, order } = blockOrderIndex(blockId, preferredNode);
186
+ if (idx === -1)
187
+ return { canMove: false, afterBlockId: null };
188
+ if (direction === "up") {
189
+ if (idx === 0)
190
+ return { canMove: false, afterBlockId: null };
191
+ return { canMove: true, afterBlockId: idx - 2 >= 0 ? order[idx - 2] : null };
192
+ }
193
+ if (idx >= order.length - 1)
194
+ return { canMove: false, afterBlockId: null };
195
+ return { canMove: true, afterBlockId: order[idx + 1] ?? null };
196
+ }
197
+ export function computeInsertBefore(blockId, preferredNode) {
198
+ const { idx, order } = blockOrderIndex(blockId, preferredNode);
199
+ if (idx <= 0)
200
+ return { afterBlockId: null, beforeBlockId: blockId };
201
+ return { afterBlockId: order[idx - 1] ?? null, beforeBlockId: blockId };
202
+ }
203
+ export function groupListItemNodes(block) {
204
+ const groups = new Map();
205
+ block.querySelectorAll("[data-editable-target]").forEach((node) => {
206
+ const path = String(node.getAttribute("data-editable-target") ?? "");
207
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+)\](?:\.|$)/.exec(path);
208
+ if (!match)
209
+ return;
210
+ const listKey = match[1];
211
+ const index = Number(match[2]);
212
+ const key = `${listKey}:${index}`;
213
+ const existing = groups.get(key);
214
+ if (existing) {
215
+ existing.nodes.push(node);
216
+ }
217
+ else {
218
+ groups.set(key, { listKey, index, nodes: [node] });
219
+ }
220
+ });
221
+ return [...groups.values()];
222
+ }
223
+ export function commonItemRoot(block, nodes) {
224
+ if (nodes.length === 0)
225
+ return null;
226
+ let candidate = nodes[0];
227
+ while (candidate && !nodes.every((node) => candidate?.contains(node))) {
228
+ candidate = candidate.parentElement;
229
+ }
230
+ if (!candidate || candidate === block)
231
+ return null;
232
+ if (!block.contains(candidate))
233
+ return null;
234
+ return candidate;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Stateless DOM mutations
238
+ // ---------------------------------------------------------------------------
239
+ export function setNestedLabelsVisibility(visible) {
240
+ document.documentElement.classList.toggle("editor-hide-nested-labels", !visible);
241
+ }
242
+ export function clearChildFocus() {
243
+ document.querySelectorAll(".editor-child-highlight").forEach((node) => node.classList.remove("editor-child-highlight"));
244
+ }
245
+ export function showSkeleton(afterBlockId, blockType) {
246
+ const skeleton = document.createElement("div");
247
+ skeleton.className = "editor-skeleton-block";
248
+ skeleton.setAttribute("data-skeleton-for", blockType);
249
+ if (afterBlockId) {
250
+ const afterNode = findBlockNode(afterBlockId);
251
+ if (afterNode) {
252
+ afterNode.after(skeleton);
253
+ return;
254
+ }
255
+ }
256
+ const main = document.querySelector("main") ?? document.body;
257
+ main.append(skeleton);
258
+ }
259
+ export function removeSkeletons() {
260
+ document.querySelectorAll(".editor-skeleton-block").forEach((node) => node.remove());
261
+ }
262
+ export function ensureBlockBadges() {
263
+ document.querySelectorAll("[data-block-id]").forEach((node) => {
264
+ const blockType = node.getAttribute("data-block-type") ?? "Block";
265
+ if (node.querySelector(".editor-block-badge")) {
266
+ node.classList.add("editor-has-badge");
267
+ return;
268
+ }
269
+ const badge = document.createElement("div");
270
+ badge.className = "editor-block-badge";
271
+ const label = document.createElement("span");
272
+ label.className = "editor-block-badge-label";
273
+ label.textContent = blockType;
274
+ badge.append(label);
275
+ node.prepend(badge);
276
+ node.classList.add("editor-has-badge");
277
+ });
278
+ }
279
+ export function clearListItemSelection(scope) {
280
+ ;
281
+ (scope ?? document).querySelectorAll(".editor-item-selected").forEach((node) => node.classList.remove("editor-item-selected"));
282
+ (scope ?? document).querySelectorAll(".editor-child-selected").forEach((node) => node.classList.remove("editor-child-selected"));
283
+ (scope ?? document).querySelectorAll(".editor-child-selection-locked").forEach((node) => node.classList.remove("editor-child-selection-locked"));
284
+ }
285
+ export function removeOverlayControls(deleteConfirmTimer) {
286
+ document.querySelectorAll(".editor-selected-delete").forEach((node) => node.remove());
287
+ document.querySelectorAll(".editor-selected-move").forEach((node) => node.remove());
288
+ document.querySelectorAll(".editor-selected-add").forEach((node) => node.remove());
289
+ document.querySelectorAll(".editor-list-item-controls").forEach((node) => node.remove());
290
+ document.querySelectorAll(".editor-list-item-delete").forEach((node) => node.remove());
291
+ document.querySelectorAll(".editor-list-item-add").forEach((node) => node.remove());
292
+ document.querySelectorAll(".editor-list-item-move").forEach((node) => node.remove());
293
+ document.querySelectorAll(".editor-item-has-delete").forEach((node) => node.classList.remove("editor-item-has-delete"));
294
+ clearListItemSelection();
295
+ document.querySelectorAll(".editor-delete-confirm").forEach((node) => node.remove());
296
+ if (deleteConfirmTimer.current) {
297
+ window.clearTimeout(deleteConfirmTimer.current);
298
+ deleteConfirmTimer.current = null;
299
+ }
300
+ }
301
+ export function clearAllHighlights() {
302
+ document.querySelectorAll(".editor-highlight").forEach((node) => node.classList.remove("editor-highlight"));
303
+ }
304
+ export function applyAiFieldLoading(blockId, editablePath, active) {
305
+ // Early-out: if shimmer is already active on the same target, skip DOM rebuild
306
+ if (active) {
307
+ const existing = document.querySelector(".aifx-shimmer-overlay");
308
+ const existingBlockId = existing?.getAttribute("data-aifx-block-id") ?? "";
309
+ const existingPath = existing?.getAttribute("data-aifx-editable-path") ?? "";
310
+ if (existingBlockId === (blockId ?? "") && existingPath === (editablePath ?? "")) {
311
+ return;
312
+ }
313
+ }
314
+ // Remove all existing shimmer overlays and restore inline styles
315
+ document.querySelectorAll(".aifx-shimmer-sparkle").forEach((el) => el.remove());
316
+ document.querySelectorAll(".aifx-shimmer-overlay").forEach((el) => {
317
+ const parent = el.parentElement;
318
+ el.remove();
319
+ if (parent) {
320
+ const prevOverflow = parent.getAttribute("data-aifx-prev-overflow");
321
+ if (prevOverflow !== null) {
322
+ if (prevOverflow.length > 0)
323
+ parent.style.overflow = prevOverflow;
324
+ else
325
+ parent.style.removeProperty("overflow");
326
+ parent.removeAttribute("data-aifx-prev-overflow");
327
+ }
328
+ const prevPosition = parent.getAttribute("data-aifx-prev-position");
329
+ if (prevPosition !== null) {
330
+ if (prevPosition.length > 0)
331
+ parent.style.position = prevPosition;
332
+ else
333
+ parent.style.removeProperty("position");
334
+ parent.removeAttribute("data-aifx-prev-position");
335
+ }
336
+ if (!parent.style.cssText.trim())
337
+ parent.removeAttribute("style");
338
+ }
339
+ });
340
+ if (active) {
341
+ const scopedBlock = (blockId ? findBlockNode(blockId) : null) ??
342
+ document.querySelector(".editor-highlight[data-block-id]") ??
343
+ document.querySelector("[data-block-id]") ??
344
+ document.querySelector("main.editor-mode, main");
345
+ if (!scopedBlock)
346
+ return;
347
+ // Always paint shimmer on block/root so it's clearly visible in preview.
348
+ // Field-level nodes are often inline/small and can make shimmer imperceptible.
349
+ const target = scopedBlock;
350
+ const overlay = document.createElement("div");
351
+ overlay.className = "aifx-shimmer-overlay";
352
+ overlay.setAttribute("data-aifx-block-id", blockId ?? "");
353
+ overlay.setAttribute("data-aifx-editable-path", editablePath ?? "");
354
+ const sparkle = document.createElement("div");
355
+ sparkle.className = "aifx-shimmer-sparkle";
356
+ sparkle.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
357
+ if (!target.hasAttribute("data-aifx-prev-position")) {
358
+ target.setAttribute("data-aifx-prev-position", target.style.position);
359
+ }
360
+ target.style.position = target.style.position || "relative";
361
+ target.appendChild(overlay);
362
+ if (blockId) {
363
+ target.appendChild(sparkle);
364
+ }
365
+ }
366
+ }
367
+ export function cleanupOverlayElements() {
368
+ document.querySelectorAll(".aifx-shimmer-overlay, .aifx-shimmer-sparkle, .editor-image-change-btn").forEach((el) => el.remove());
369
+ document.documentElement.removeAttribute("data-editor-active");
370
+ document.documentElement.removeAttribute("data-editor-selection-mode");
371
+ }
372
+ export function createBridgeState() {
373
+ return {
374
+ selectedBlockId: null,
375
+ selectedEditablePath: null,
376
+ pendingFocusId: null,
377
+ pendingScrollIntoView: false,
378
+ pendingScrollAnchorY: null,
379
+ pendingScrollRestore: null,
380
+ suppressClickUntil: 0,
381
+ selectionMode: false,
382
+ skipParentAnimationOnce: false,
383
+ deleteConfirmTimer: { current: null },
384
+ pendingListItemMovePath: null,
385
+ childSelectionLock: null,
386
+ inlineEditing: null,
387
+ serverVersion: 0,
388
+ liveDraftActiveBlockId: null,
389
+ liveDraftBadgeTimer: null,
390
+ liveDraftOriginals: new Map(),
391
+ knownBlockIds: new Set(Array.from(document.querySelectorAll("[data-block-id]"))
392
+ .map((node) => node.getAttribute("data-block-id"))
393
+ .filter((id) => Boolean(id))),
394
+ expectingNewBlocks: false,
395
+ hoveredItemRoot: null,
396
+ hoveredBlockRoot: null,
397
+ observer: null,
398
+ activeShimmer: null,
399
+ };
400
+ }
401
+ /**
402
+ * Creates all stateful DOM manipulation functions bound to the given state and callbacks.
403
+ * Used by both PreviewBridgeCore (iframe/postMessage) and the immersive widget (direct).
404
+ */
405
+ export function createBridgeFunctions(state, callbacks, config) {
406
+ const { slug, pathname, refresh, navigate } = config;
407
+ // -- Live draft ----------------------------------------------------------
408
+ const clearLiveDraftBadgeTimer = () => {
409
+ if (state.liveDraftBadgeTimer === null)
410
+ return;
411
+ window.clearTimeout(state.liveDraftBadgeTimer);
412
+ state.liveDraftBadgeTimer = null;
413
+ };
414
+ const clearLiveDraft = () => {
415
+ clearLiveDraftBadgeTimer();
416
+ document.querySelectorAll(".editor-live-draft").forEach((node) => node.remove());
417
+ document.querySelectorAll(".editor-live-draft-active").forEach((node) => node.classList.remove("editor-live-draft-active"));
418
+ document.querySelectorAll(".editor-block-badge-status").forEach((node) => node.remove());
419
+ document.querySelectorAll(".editor-live-typing").forEach((node) => node.classList.remove("editor-live-typing"));
420
+ document.querySelectorAll(".editor-skeleton-block").forEach((node) => node.remove());
421
+ state.liveDraftActiveBlockId = null;
422
+ };
423
+ const setLiveDraftBadge = (block, active) => {
424
+ clearLiveDraftBadgeTimer();
425
+ const badge = block.querySelector(".editor-block-badge");
426
+ if (!badge)
427
+ return;
428
+ badge.querySelectorAll(".editor-block-badge-status").forEach((node) => node.remove());
429
+ if (!active)
430
+ return;
431
+ state.liveDraftBadgeTimer = window.setTimeout(() => {
432
+ if (!block.classList.contains("editor-live-draft-active"))
433
+ return;
434
+ const status = document.createElement("span");
435
+ status.className = "editor-block-badge-status";
436
+ status.textContent = "Updating";
437
+ badge.append(status);
438
+ }, 600);
439
+ };
440
+ const restoreLiveDraftOriginals = () => {
441
+ for (const [node, html] of state.liveDraftOriginals) {
442
+ node.innerHTML = html;
443
+ node.classList.remove("editor-live-typing");
444
+ }
445
+ state.liveDraftOriginals.clear();
446
+ };
447
+ /** Discard stored originals without restoring them (use after content is committed). */
448
+ const discardLiveDraftOriginals = () => {
449
+ for (const [node] of state.liveDraftOriginals) {
450
+ node.classList.remove("editor-live-typing");
451
+ }
452
+ state.liveDraftOriginals.clear();
453
+ state.liveDraftActiveBlockId = null;
454
+ };
455
+ // Write each streamed field value into its editable node (plain-text fields
456
+ // only; rich fields stay under React's control — see isRichEditableNode).
457
+ // Used by both the active streaming path and the final commit post.
458
+ const applyLiveDraftFields = (block, fields) => {
459
+ const nextPaths = new Set(Object.keys(fields));
460
+ for (const [node, html] of [...state.liveDraftOriginals.entries()]) {
461
+ const path = node.getAttribute("data-editable-target") ?? "";
462
+ if (!nextPaths.has(path)) {
463
+ node.innerHTML = html;
464
+ node.classList.remove("editor-live-typing");
465
+ state.liveDraftOriginals.delete(node);
466
+ }
467
+ }
468
+ for (const [path, value] of Object.entries(fields)) {
469
+ if (isImagePath(path))
470
+ continue;
471
+ const node = findEditableNode(block, path);
472
+ if (!node)
473
+ continue;
474
+ // Rich fields (RichText body, Tabs content, etc.) render as block-level
475
+ // React children. Writing innerHTML here desyncs React's vdom and the
476
+ // final router.refresh() then mis-reconciles the subtree (truncated text
477
+ // that needs a manual reload). Leave them to React — shimmer only; the
478
+ // committed content lands cleanly when the op applies and refreshes.
479
+ if (isRichEditableNode(node)) {
480
+ node.classList.add("editor-live-typing");
481
+ continue;
482
+ }
483
+ if (!state.liveDraftOriginals.has(node)) {
484
+ state.liveDraftOriginals.set(node, node.innerHTML);
485
+ }
486
+ node.innerHTML = markdownToHtml(value);
487
+ node.classList.add("editor-live-typing");
488
+ }
489
+ };
490
+ // commit=true means the server has already applied the op, so the
491
+ // optimistic DOM (written via innerHTML during field_draft streaming) now
492
+ // matches the committed state. Discarding originals keeps the new content
493
+ // on screen while the router.refresh() reconciles — without this branch
494
+ // we flash back to the old text for ~200ms and cause a visible flicker.
495
+ const renderLiveDraft = (blockId, text, active, fields, commit) => {
496
+ if (!active) {
497
+ if (commit) {
498
+ // Fast-streaming nested fields (e.g. every CardGrid card) usually reach
499
+ // the site ONLY in this final commit post — the active flush that would
500
+ // have carried them is cancelled by endLiveDraft before it fires. Apply
501
+ // them here so the optimistic DOM matches the committed state; otherwise
502
+ // those fields stay in their original language until the (batched) final
503
+ // reconcile, ~the whole stream later. Rich nodes stay under React.
504
+ if (fields && typeof fields === "object") {
505
+ const block = findBlockNode(blockId);
506
+ if (block)
507
+ applyLiveDraftFields(block, fields);
508
+ }
509
+ discardLiveDraftOriginals();
510
+ }
511
+ else {
512
+ restoreLiveDraftOriginals();
513
+ }
514
+ clearLiveDraft();
515
+ return;
516
+ }
517
+ const trimmed = text.trim();
518
+ if (!blockId || (!trimmed && !fields)) {
519
+ restoreLiveDraftOriginals();
520
+ clearLiveDraft();
521
+ return;
522
+ }
523
+ const block = findBlockNode(blockId);
524
+ if (!block)
525
+ return;
526
+ const switchingBlock = state.liveDraftActiveBlockId !== null && state.liveDraftActiveBlockId !== blockId;
527
+ if (switchingBlock) {
528
+ // Keep the previous block's streamed text on screen rather than reverting it.
529
+ // A multi-block edit (e.g. translate a whole page) streams one block at a
530
+ // time; restoring here made each just-edited block snap back to its old text
531
+ // the instant the next block started. The op applies block-by-block and the
532
+ // draftUpdated refresh reconciles; non-apply paths (cancel/rollback/plan-only)
533
+ // post their own draftUpdated to revert.
534
+ discardLiveDraftOriginals();
535
+ clearLiveDraft();
536
+ }
537
+ if (fields && typeof fields === "object") {
538
+ applyLiveDraftFields(block, fields);
539
+ }
540
+ if (state.liveDraftActiveBlockId === blockId)
541
+ return;
542
+ block.classList.add("editor-live-draft-active");
543
+ state.liveDraftActiveBlockId = blockId;
544
+ setLiveDraftBadge(block, true);
545
+ };
546
+ // Chrome-only variant for the live-preview store path: the streamed CONTENT is
547
+ // applied by the React store (no innerHTML mutation), so here we only paint the
548
+ // "Updating" badge + active-block outline and track which block is live.
549
+ const renderLiveDraftChromeOnly = (blockId, active) => {
550
+ if (!active) {
551
+ clearLiveDraft();
552
+ return;
553
+ }
554
+ const block = findBlockNode(blockId);
555
+ if (!block)
556
+ return;
557
+ if (state.liveDraftActiveBlockId !== null && state.liveDraftActiveBlockId !== blockId) {
558
+ clearLiveDraft();
559
+ }
560
+ if (state.liveDraftActiveBlockId === blockId)
561
+ return;
562
+ block.classList.add("editor-live-draft-active");
563
+ state.liveDraftActiveBlockId = blockId;
564
+ setLiveDraftBadge(block, true);
565
+ };
566
+ // -- Hovered list item ---------------------------------------------------
567
+ const setHoveredListItem = (next) => {
568
+ if (state.hoveredItemRoot === next)
569
+ return;
570
+ if (state.hoveredItemRoot)
571
+ state.hoveredItemRoot.classList.remove("editor-item-hover");
572
+ if (state.hoveredBlockRoot)
573
+ state.hoveredBlockRoot.classList.remove("editor-child-hovering");
574
+ state.hoveredItemRoot = next;
575
+ state.hoveredBlockRoot = null;
576
+ if (!next)
577
+ return;
578
+ next.classList.add("editor-item-hover");
579
+ const block = next.closest(".editor-highlight");
580
+ if (!block)
581
+ return;
582
+ block.classList.add("editor-child-hovering");
583
+ state.hoveredBlockRoot = block;
584
+ };
585
+ // -- Child selection lock ------------------------------------------------
586
+ const setChildSelectionLock = (next) => {
587
+ state.childSelectionLock = next;
588
+ document.querySelectorAll(".editor-child-selection-locked").forEach((node) => node.classList.remove("editor-child-selection-locked"));
589
+ if (!next)
590
+ return;
591
+ const block = findBlockNode(next.blockId);
592
+ if (!block)
593
+ return;
594
+ block.classList.add("editor-child-selection-locked");
595
+ };
596
+ // -- List item selection -------------------------------------------------
597
+ const applyListItemSelection = (block, editablePath) => {
598
+ clearListItemSelection(block);
599
+ const parsed = parseListItemPath(editablePath);
600
+ if (!parsed)
601
+ return false;
602
+ let itemRoot = block.querySelector(`.editor-item-has-delete[data-editor-list-key="${parsed.listKey}"][data-editor-list-index="${parsed.index}"]`);
603
+ if (!itemRoot) {
604
+ const lock = state.childSelectionLock;
605
+ if (lock &&
606
+ lock.blockId === (block.getAttribute("data-block-id") ?? "") &&
607
+ lock.listKey === parsed.listKey &&
608
+ typeof lock.position === "number") {
609
+ const candidates = Array.from(block.querySelectorAll(`.editor-item-has-delete[data-editor-list-key="${parsed.listKey}"]`));
610
+ itemRoot = candidates[lock.position] ?? null;
611
+ if (itemRoot) {
612
+ const resolvedIndex = Number(itemRoot.getAttribute("data-editor-list-index") ?? lock.index);
613
+ state.selectedEditablePath = `${parsed.listKey}[${resolvedIndex}]`;
614
+ state.pendingListItemMovePath = state.selectedEditablePath;
615
+ }
616
+ }
617
+ }
618
+ if (!itemRoot)
619
+ return false;
620
+ itemRoot.classList.add("editor-item-selected");
621
+ block.classList.add("editor-child-selected");
622
+ const lock = state.childSelectionLock;
623
+ if (lock && lock.blockId === (block.getAttribute("data-block-id") ?? "") && lock.listKey === parsed.listKey && lock.index === parsed.index) {
624
+ block.classList.add("editor-child-selection-locked");
625
+ }
626
+ return true;
627
+ };
628
+ // -- Child focus ---------------------------------------------------------
629
+ const applyChildFocus = (parentBlockId, editablePath) => {
630
+ clearChildFocus();
631
+ state.selectedBlockId = parentBlockId;
632
+ state.selectedEditablePath = editablePath ?? null;
633
+ if (!editablePath)
634
+ return;
635
+ const parent = findBlockNode(parentBlockId);
636
+ if (!parent)
637
+ return;
638
+ const child = findEditableNode(parent, editablePath);
639
+ if (!child)
640
+ return;
641
+ child.classList.add("editor-child-highlight");
642
+ };
643
+ // -- Inline editing ------------------------------------------------------
644
+ const cancelInlineEdit = () => {
645
+ const s = state.inlineEditing;
646
+ if (!s)
647
+ return;
648
+ s.node.textContent = s.initialValue;
649
+ s.node.setAttribute("contenteditable", "false");
650
+ s.node.classList.remove("editor-inline-editing");
651
+ state.inlineEditing = null;
652
+ };
653
+ const commitInlineEdit = () => {
654
+ const s = state.inlineEditing;
655
+ if (!s)
656
+ return;
657
+ s.node.setAttribute("contenteditable", "false");
658
+ s.node.classList.remove("editor-inline-editing");
659
+ state.inlineEditing = null;
660
+ const nextValue = readNodeText(s.node);
661
+ if (nextValue === s.initialValue)
662
+ return;
663
+ callbacks.onInlineTextCommitted({ slug, blockId: s.blockId, blockType: s.blockType, editablePath: s.editablePath, value: nextValue });
664
+ };
665
+ const startInlineEdit = (args) => {
666
+ if (!supportsInlineEditablePath(args.editablePath))
667
+ return;
668
+ if (args.node.children.length > 0)
669
+ return;
670
+ const existing = state.inlineEditing;
671
+ if (existing?.node === args.node)
672
+ return;
673
+ if (existing)
674
+ commitInlineEdit();
675
+ const initialValue = readNodeText(args.node);
676
+ state.inlineEditing = {
677
+ node: args.node,
678
+ blockId: args.blockId,
679
+ blockType: args.blockType,
680
+ editablePath: args.editablePath,
681
+ initialValue,
682
+ isMultiline: args.editablePath === "body"
683
+ };
684
+ args.node.setAttribute("contenteditable", "true");
685
+ args.node.classList.add("editor-inline-editing");
686
+ args.node.focus();
687
+ placeCaretAtEnd(args.node);
688
+ };
689
+ // -- Overlay controls (toolbar, delete, move, add) -----------------------
690
+ const removeSelectedDeleteHandle = () => {
691
+ setHoveredListItem(null);
692
+ removeOverlayControls(state.deleteConfirmTimer);
693
+ };
694
+ const mountSelectedDeleteHandle = () => {
695
+ const selected = document.querySelector(".editor-highlight[data-block-id]");
696
+ if (!selected)
697
+ return;
698
+ const blockId = selected.getAttribute("data-block-id");
699
+ const blockType = selected.getAttribute("data-block-type") ?? "Block";
700
+ if (!blockId)
701
+ return;
702
+ const del = document.createElement("button");
703
+ del.type = "button";
704
+ del.className = "editor-selected-delete";
705
+ del.draggable = false;
706
+ del.setAttribute("aria-label", `Delete ${blockType}`);
707
+ del.setAttribute("data-tooltip", `Delete ${blockType}`);
708
+ del.innerHTML =
709
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
710
+ del.addEventListener("click", (event) => {
711
+ event.preventDefault();
712
+ event.stopPropagation();
713
+ const existing = selected.querySelector(".editor-delete-confirm");
714
+ if (existing) {
715
+ existing.remove();
716
+ if (state.deleteConfirmTimer.current) {
717
+ window.clearTimeout(state.deleteConfirmTimer.current);
718
+ state.deleteConfirmTimer.current = null;
719
+ }
720
+ return;
721
+ }
722
+ const popover = document.createElement("div");
723
+ popover.className = "editor-delete-confirm";
724
+ const label = document.createElement("span");
725
+ label.textContent = "Delete block?";
726
+ const confirmBtn = document.createElement("button");
727
+ confirmBtn.type = "button";
728
+ confirmBtn.className = "editor-delete-confirm-btn";
729
+ confirmBtn.textContent = "Confirm";
730
+ confirmBtn.addEventListener("click", (e) => {
731
+ e.preventDefault();
732
+ e.stopPropagation();
733
+ popover.remove();
734
+ if (state.deleteConfirmTimer.current) {
735
+ window.clearTimeout(state.deleteConfirmTimer.current);
736
+ state.deleteConfirmTimer.current = null;
737
+ }
738
+ callbacks.onBlockDeleteRequested({ slug, blockId, blockType });
739
+ });
740
+ popover.append(label, confirmBtn);
741
+ selected.prepend(popover);
742
+ state.deleteConfirmTimer.current = window.setTimeout(() => {
743
+ popover.remove();
744
+ state.deleteConfirmTimer.current = null;
745
+ }, 4000);
746
+ });
747
+ const badge = selected.querySelector(":scope > .editor-block-badge");
748
+ if (badge) {
749
+ badge.append(del);
750
+ }
751
+ else {
752
+ selected.prepend(del);
753
+ }
754
+ };
755
+ const mountListItemDeleteHandles = (block, blockId) => {
756
+ const blockType = block.getAttribute("data-block-type") ?? "Block";
757
+ const groups = groupListItemNodes(block);
758
+ if (groups.length === 0)
759
+ return;
760
+ const resolved = groups
761
+ .map((group) => ({ ...group, root: commonItemRoot(block, group.nodes) }))
762
+ .filter((group) => Boolean(group.root));
763
+ if (resolved.length === 0)
764
+ return;
765
+ const perListCounts = new Map();
766
+ const lastByList = new Map();
767
+ const sortedIndicesByList = new Map();
768
+ const firstNodeByListAndIndex = new Map();
769
+ for (const group of resolved) {
770
+ perListCounts.set(group.listKey, (perListCounts.get(group.listKey) ?? 0) + 1);
771
+ const currentLast = lastByList.get(group.listKey);
772
+ if (!currentLast || group.index > currentLast.index) {
773
+ lastByList.set(group.listKey, { listKey: group.listKey, index: group.index, root: group.root });
774
+ }
775
+ if (!sortedIndicesByList.has(group.listKey))
776
+ sortedIndicesByList.set(group.listKey, []);
777
+ sortedIndicesByList.get(group.listKey)?.push(group.index);
778
+ if (!firstNodeByListAndIndex.has(group.listKey))
779
+ firstNodeByListAndIndex.set(group.listKey, new Map());
780
+ firstNodeByListAndIndex.get(group.listKey)?.set(group.index, group.nodes[0]);
781
+ }
782
+ for (const [key, indices] of sortedIndicesByList.entries()) {
783
+ sortedIndicesByList.set(key, [...new Set(indices)].sort((a, b) => a - b));
784
+ }
785
+ const horizontalByList = new Map();
786
+ for (const [listKey, order] of sortedIndicesByList.entries()) {
787
+ if (order.length < 2) {
788
+ horizontalByList.set(listKey, false);
789
+ continue;
790
+ }
791
+ const nodes = firstNodeByListAndIndex.get(listKey);
792
+ const first = nodes?.get(order[0]);
793
+ const second = nodes?.get(order[1]);
794
+ if (!first || !second) {
795
+ horizontalByList.set(listKey, false);
796
+ continue;
797
+ }
798
+ const a = first.getBoundingClientRect();
799
+ const b = second.getBoundingClientRect();
800
+ const dx = Math.abs(b.left - a.left);
801
+ const dy = Math.abs(b.top - a.top);
802
+ horizontalByList.set(listKey, dx > dy);
803
+ }
804
+ const normalizedAfterIndex = (index, afterIndex) => {
805
+ if (typeof afterIndex !== "number")
806
+ return undefined;
807
+ return afterIndex > index ? afterIndex - 1 : afterIndex;
808
+ };
809
+ for (const group of resolved) {
810
+ const root = group.root;
811
+ if (root.querySelector(".editor-list-item-delete"))
812
+ continue;
813
+ root.classList.add("editor-item-has-delete");
814
+ if (horizontalByList.get(group.listKey))
815
+ root.classList.add("editor-list-horizontal");
816
+ root.setAttribute("data-editor-list-key", group.listKey);
817
+ root.setAttribute("data-editor-list-index", String(group.index));
818
+ const controls = document.createElement("div");
819
+ controls.className = "editor-list-item-controls";
820
+ const del = document.createElement("button");
821
+ del.type = "button";
822
+ del.className = "editor-list-item-delete";
823
+ del.setAttribute("aria-label", `Delete ${group.listKey} item`);
824
+ del.title = "Delete item";
825
+ del.innerHTML =
826
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
827
+ del.disabled = (perListCounts.get(group.listKey) ?? 0) <= 1;
828
+ del.addEventListener("click", (event) => {
829
+ event.preventDefault();
830
+ event.stopPropagation();
831
+ if (del.disabled)
832
+ return;
833
+ const existing = controls.querySelector(".editor-delete-confirm");
834
+ if (existing) {
835
+ existing.remove();
836
+ if (state.deleteConfirmTimer.current) {
837
+ window.clearTimeout(state.deleteConfirmTimer.current);
838
+ state.deleteConfirmTimer.current = null;
839
+ }
840
+ return;
841
+ }
842
+ document.querySelectorAll(".editor-list-item-controls .editor-delete-confirm").forEach((el) => el.remove());
843
+ const popover = document.createElement("div");
844
+ popover.className = "editor-delete-confirm editor-delete-confirm--list";
845
+ const label = document.createElement("span");
846
+ label.textContent = "Delete item?";
847
+ const confirmBtn = document.createElement("button");
848
+ confirmBtn.type = "button";
849
+ confirmBtn.className = "editor-delete-confirm-btn";
850
+ confirmBtn.textContent = "Confirm";
851
+ confirmBtn.addEventListener("click", (e) => {
852
+ e.preventDefault();
853
+ e.stopPropagation();
854
+ popover.remove();
855
+ if (state.deleteConfirmTimer.current) {
856
+ window.clearTimeout(state.deleteConfirmTimer.current);
857
+ state.deleteConfirmTimer.current = null;
858
+ }
859
+ callbacks.onListItemRemoveRequested({ slug, blockId, blockType, listKey: group.listKey, index: group.index });
860
+ });
861
+ popover.append(label, confirmBtn);
862
+ controls.append(popover);
863
+ state.deleteConfirmTimer.current = window.setTimeout(() => {
864
+ popover.remove();
865
+ state.deleteConfirmTimer.current = null;
866
+ }, 3500);
867
+ });
868
+ const order = sortedIndicesByList.get(group.listKey) ?? [];
869
+ const pos = order.findIndex((idx) => idx === group.index);
870
+ const canMoveUp = pos > 0;
871
+ const canMoveDown = pos >= 0 && pos < order.length - 1;
872
+ const upAfterIndex = pos - 2 >= 0 ? order[pos - 2] : undefined;
873
+ const downAfterIndex = canMoveDown ? normalizedAfterIndex(group.index, order[pos + 1]) : undefined;
874
+ const useHorizontalArrows = horizontalByList.get(group.listKey) === true;
875
+ const upIcon = useHorizontalArrows
876
+ ? '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>'
877
+ : '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>';
878
+ const downIcon = useHorizontalArrows
879
+ ? '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 5 7 7-7 7"/><path d="M5 12h14"/></svg>'
880
+ : '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m19 12-7 7-7-7"/><path d="M12 5v14"/></svg>';
881
+ const moveUp = document.createElement("button");
882
+ moveUp.type = "button";
883
+ moveUp.className = "editor-list-item-move editor-list-item-move-up";
884
+ moveUp.setAttribute("aria-label", `Move ${group.listKey} item ${useHorizontalArrows ? "left" : "up"}`);
885
+ moveUp.title = useHorizontalArrows ? "Move item left" : "Move item up";
886
+ moveUp.innerHTML = upIcon;
887
+ moveUp.disabled = !canMoveUp;
888
+ moveUp.addEventListener("click", (event) => {
889
+ event.preventDefault();
890
+ event.stopPropagation();
891
+ if (!canMoveUp)
892
+ return;
893
+ const targetPosition = Math.max(0, pos - 1);
894
+ const targetIndex = Number(order[targetPosition] ?? group.index);
895
+ const nextPath = `${group.listKey}[${targetIndex}]`;
896
+ state.pendingListItemMovePath = nextPath;
897
+ setChildSelectionLock({ blockId, listKey: group.listKey, index: targetIndex, position: targetPosition });
898
+ state.skipParentAnimationOnce = true;
899
+ state.selectedEditablePath = nextPath;
900
+ state.selectedBlockId = blockId;
901
+ setHoveredListItem(null);
902
+ callbacks.onListItemMoveRequested({ slug, blockId, blockType, listKey: group.listKey, index: group.index, afterIndex: upAfterIndex });
903
+ });
904
+ const moveDown = document.createElement("button");
905
+ moveDown.type = "button";
906
+ moveDown.className = "editor-list-item-move editor-list-item-move-down";
907
+ moveDown.setAttribute("aria-label", `Move ${group.listKey} item ${useHorizontalArrows ? "right" : "down"}`);
908
+ moveDown.title = useHorizontalArrows ? "Move item right" : "Move item down";
909
+ moveDown.innerHTML = downIcon;
910
+ moveDown.disabled = !canMoveDown;
911
+ moveDown.addEventListener("click", (event) => {
912
+ event.preventDefault();
913
+ event.stopPropagation();
914
+ if (!canMoveDown || typeof downAfterIndex !== "number")
915
+ return;
916
+ const targetPosition = Math.min(order.length - 1, pos + 1);
917
+ const targetIndex = Number(order[targetPosition] ?? group.index);
918
+ const nextPath = `${group.listKey}[${targetIndex}]`;
919
+ state.pendingListItemMovePath = nextPath;
920
+ setChildSelectionLock({ blockId, listKey: group.listKey, index: targetIndex, position: targetPosition });
921
+ state.skipParentAnimationOnce = true;
922
+ state.selectedEditablePath = nextPath;
923
+ state.selectedBlockId = blockId;
924
+ setHoveredListItem(null);
925
+ callbacks.onListItemMoveRequested({ slug, blockId, blockType, listKey: group.listKey, index: group.index, afterIndex: downAfterIndex });
926
+ });
927
+ controls.append(moveUp, moveDown, del);
928
+ root.append(controls);
929
+ }
930
+ for (const entry of lastByList.values()) {
931
+ if (entry.root.querySelector(".editor-list-item-add"))
932
+ continue;
933
+ const add = document.createElement("button");
934
+ add.type = "button";
935
+ add.className = "editor-list-item-add";
936
+ add.setAttribute("aria-label", `Add ${entry.listKey} item`);
937
+ add.title = "Add item";
938
+ add.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14"/><path d="M5 12h14"/></svg>';
939
+ add.addEventListener("click", (event) => {
940
+ event.preventDefault();
941
+ event.stopPropagation();
942
+ callbacks.onListItemAddRequested({ slug, blockId, blockType, listKey: entry.listKey, afterIndex: entry.index });
943
+ });
944
+ const isHorizontal = horizontalByList.get(entry.listKey);
945
+ if (isHorizontal) {
946
+ add.classList.add("editor-list-item-add--inline");
947
+ const controls = entry.root.querySelector(".editor-list-item-controls");
948
+ if (controls) {
949
+ controls.prepend(add);
950
+ }
951
+ else {
952
+ entry.root.append(add);
953
+ }
954
+ }
955
+ else {
956
+ entry.root.append(add);
957
+ }
958
+ }
959
+ };
960
+ // -- Image buttons -------------------------------------------------------
961
+ const mountGlobalImageButtons = () => {
962
+ state.observer?.disconnect();
963
+ document.querySelectorAll(".editor-image-change-btn").forEach((el) => el.remove());
964
+ if (!document.documentElement.hasAttribute("data-editor-selection-mode")) {
965
+ if (state.observer && document.body)
966
+ state.observer.observe(document.body, { childList: true, subtree: true });
967
+ return;
968
+ }
969
+ document.querySelectorAll(".editor-selectable [data-editable-target]").forEach((el) => {
970
+ const path = el.getAttribute("data-editable-target") ?? "";
971
+ if (!isImagePath(path))
972
+ return;
973
+ const block = el.closest("[data-block-id]");
974
+ if (!block)
975
+ return;
976
+ const blockId = block.getAttribute("data-block-id") ?? "";
977
+ const blockType = block.getAttribute("data-block-type") ?? "";
978
+ const currentSlug = pathname || "/";
979
+ const img = el.querySelector("img");
980
+ const currentUrl = img?.getAttribute("src") ?? undefined;
981
+ const style = window.getComputedStyle(el);
982
+ if (style.position === "static") {
983
+ el.style.position = "relative";
984
+ }
985
+ const btn = document.createElement("button");
986
+ btn.className = "editor-image-change-btn";
987
+ btn.title = "Change image";
988
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>';
989
+ btn.setAttribute("data-image-slug", currentSlug);
990
+ btn.setAttribute("data-image-block-id", blockId);
991
+ if (blockType)
992
+ btn.setAttribute("data-image-block-type", blockType);
993
+ btn.setAttribute("data-image-path", path);
994
+ if (currentUrl)
995
+ btn.setAttribute("data-image-current-url", currentUrl);
996
+ el.append(btn);
997
+ });
998
+ if (state.observer && document.body)
999
+ state.observer.observe(document.body, { childList: true, subtree: true });
1000
+ };
1001
+ // -- Block focus ---------------------------------------------------------
1002
+ const applyBlockFocus = (blockId, enter, editablePath, options) => {
1003
+ if (!blockId)
1004
+ return false;
1005
+ clearChildFocus();
1006
+ removeSelectedDeleteHandle();
1007
+ clearAllHighlights();
1008
+ const match = findBlockNode(blockId);
1009
+ if (!match)
1010
+ return false;
1011
+ const shouldAnimate = enter;
1012
+ if (shouldAnimate && !match.classList.contains("editor-block-entering"))
1013
+ match.classList.add("editor-enter");
1014
+ match.classList.add("editor-highlight");
1015
+ if (shouldAnimate)
1016
+ match.classList.add("editor-flash");
1017
+ if (shouldAnimate) {
1018
+ match.classList.remove("aifx-updated");
1019
+ void match.offsetWidth;
1020
+ match.classList.add("aifx-updated");
1021
+ window.setTimeout(() => { match.classList.remove("aifx-updated"); }, 2500);
1022
+ }
1023
+ if (options?.scrollIntoView !== false) {
1024
+ const anchorY = state.pendingScrollAnchorY;
1025
+ if (anchorY !== null) {
1026
+ const currentTop = match.getBoundingClientRect().top;
1027
+ window.scrollBy({ top: currentTop - anchorY, behavior: "instant" });
1028
+ state.pendingScrollAnchorY = null;
1029
+ }
1030
+ else {
1031
+ match.scrollIntoView({ behavior: "smooth", block: "center" });
1032
+ }
1033
+ }
1034
+ if (shouldAnimate) {
1035
+ window.setTimeout(() => {
1036
+ match.classList.remove("editor-flash");
1037
+ match.classList.remove("editor-enter");
1038
+ }, 620);
1039
+ }
1040
+ const moveUpBtn = document.createElement("button");
1041
+ moveUpBtn.type = "button";
1042
+ moveUpBtn.className = "editor-selected-move editor-selected-move-up";
1043
+ moveUpBtn.setAttribute("aria-label", `Move ${match.getAttribute("data-block-type") ?? "block"} up`);
1044
+ moveUpBtn.setAttribute("data-tooltip", "Move up");
1045
+ moveUpBtn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>';
1046
+ const upResult = computeMoveAfter(blockId, "up", match);
1047
+ moveUpBtn.disabled = !upResult.canMove;
1048
+ if (!upResult.canMove)
1049
+ moveUpBtn.setAttribute("data-tooltip", "Already at the top");
1050
+ moveUpBtn.addEventListener("click", (event) => {
1051
+ event.preventDefault();
1052
+ event.stopPropagation();
1053
+ const move = computeMoveAfter(blockId, "up", match);
1054
+ if (!move.canMove)
1055
+ return;
1056
+ state.pendingScrollAnchorY = match.getBoundingClientRect().top;
1057
+ state.pendingScrollIntoView = true;
1058
+ callbacks.onBlockReordered({ slug, blockId, afterBlockId: move.afterBlockId });
1059
+ });
1060
+ const moveDownBtn = document.createElement("button");
1061
+ moveDownBtn.type = "button";
1062
+ moveDownBtn.className = "editor-selected-move editor-selected-move-down";
1063
+ moveDownBtn.setAttribute("aria-label", `Move ${match.getAttribute("data-block-type") ?? "block"} down`);
1064
+ moveDownBtn.setAttribute("data-tooltip", "Move down");
1065
+ moveDownBtn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m19 12-7 7-7-7"/><path d="M12 5v14"/></svg>';
1066
+ const downResult = computeMoveAfter(blockId, "down", match);
1067
+ moveDownBtn.disabled = !downResult.canMove;
1068
+ if (!downResult.canMove)
1069
+ moveDownBtn.setAttribute("data-tooltip", "Already at the bottom");
1070
+ moveDownBtn.addEventListener("click", (event) => {
1071
+ event.preventDefault();
1072
+ event.stopPropagation();
1073
+ const move = computeMoveAfter(blockId, "down", match);
1074
+ if (!move.canMove)
1075
+ return;
1076
+ state.pendingScrollAnchorY = match.getBoundingClientRect().top;
1077
+ state.pendingScrollIntoView = true;
1078
+ callbacks.onBlockReordered({ slug, blockId, afterBlockId: move.afterBlockId });
1079
+ });
1080
+ const addBtn = document.createElement("button");
1081
+ addBtn.type = "button";
1082
+ addBtn.className = "editor-selected-add editor-selected-add-bottom";
1083
+ addBtn.setAttribute("aria-label", `Add block after ${match.getAttribute("data-block-type") ?? "block"}`);
1084
+ addBtn.title = "Add block below";
1085
+ addBtn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14"/><path d="M5 12h14"/></svg>';
1086
+ addBtn.addEventListener("click", (event) => {
1087
+ event.preventDefault();
1088
+ event.stopPropagation();
1089
+ callbacks.onBlockAddRequested({ slug, afterBlockId: blockId });
1090
+ });
1091
+ const addTopBtn = document.createElement("button");
1092
+ addTopBtn.type = "button";
1093
+ addTopBtn.className = "editor-selected-add editor-selected-add-top";
1094
+ addTopBtn.setAttribute("aria-label", `Add block before ${match.getAttribute("data-block-type") ?? "block"}`);
1095
+ addTopBtn.title = "Add block above";
1096
+ addTopBtn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14"/><path d="M5 12h14"/></svg>';
1097
+ addTopBtn.addEventListener("click", (event) => {
1098
+ event.preventDefault();
1099
+ event.stopPropagation();
1100
+ const anchor = computeInsertBefore(blockId, match);
1101
+ callbacks.onBlockAddRequested({
1102
+ slug,
1103
+ ...(anchor.afterBlockId ? { afterBlockId: anchor.afterBlockId } : {}),
1104
+ ...(anchor.beforeBlockId ? { beforeBlockId: anchor.beforeBlockId } : {}),
1105
+ });
1106
+ });
1107
+ const previousSelectedBlockId = state.selectedBlockId;
1108
+ const previousEditablePath = state.selectedEditablePath;
1109
+ const effectivePath = editablePath ?? (previousSelectedBlockId === blockId ? previousEditablePath ?? undefined : undefined);
1110
+ const isChromeBlock = match.querySelector("[data-block-chrome]") !== null || match.matches("[data-block-chrome]");
1111
+ if (!isChromeBlock) {
1112
+ // Move buttons live inside the floating badge above the block so they
1113
+ // don't overlap block content. mountSelectedDeleteHandle does the same.
1114
+ const badge = match.querySelector(":scope > .editor-block-badge");
1115
+ if (badge) {
1116
+ badge.append(moveUpBtn, moveDownBtn);
1117
+ }
1118
+ else {
1119
+ match.prepend(moveUpBtn, moveDownBtn);
1120
+ }
1121
+ match.prepend(addTopBtn);
1122
+ match.append(addBtn);
1123
+ mountListItemDeleteHandles(match, blockId);
1124
+ mountSelectedDeleteHandle();
1125
+ }
1126
+ applyListItemSelection(match, effectivePath);
1127
+ state.selectedBlockId = blockId;
1128
+ if (!effectivePath)
1129
+ state.selectedEditablePath = null;
1130
+ if (effectivePath)
1131
+ applyChildFocus(blockId, effectivePath);
1132
+ return true;
1133
+ };
1134
+ // -- Focus after refresh -------------------------------------------------
1135
+ const queueFocusAfterRefresh = () => {
1136
+ const targetId = state.pendingFocusId;
1137
+ if (!targetId)
1138
+ return;
1139
+ let attempts = 0;
1140
+ const timer = window.setInterval(() => {
1141
+ const shouldAnimate = !state.skipParentAnimationOnce;
1142
+ const done = applyBlockFocus(targetId, shouldAnimate, undefined, { scrollIntoView: state.pendingScrollIntoView });
1143
+ attempts += 1;
1144
+ if (done || attempts >= 20) {
1145
+ window.clearInterval(timer);
1146
+ state.pendingFocusId = null;
1147
+ state.pendingScrollIntoView = false;
1148
+ state.pendingScrollAnchorY = null;
1149
+ state.skipParentAnimationOnce = false;
1150
+ }
1151
+ }, 45);
1152
+ };
1153
+ // -- Smooth refresh ------------------------------------------------------
1154
+ const smoothRefresh = (useViewTransition = false) => {
1155
+ if (!state.pendingScrollIntoView) {
1156
+ state.pendingScrollRestore = { x: window.scrollX, y: window.scrollY };
1157
+ }
1158
+ else {
1159
+ state.pendingScrollRestore = null;
1160
+ }
1161
+ cancelInlineEdit();
1162
+ const restoreAndFocus = () => {
1163
+ if (state.pendingScrollRestore) {
1164
+ const { x, y } = state.pendingScrollRestore;
1165
+ window.scrollTo({ left: x, top: y, behavior: "auto" });
1166
+ state.pendingScrollRestore = null;
1167
+ }
1168
+ queueFocusAfterRefresh();
1169
+ // Re-apply shimmer after DOM settles — poll briefly since React
1170
+ // reconciliation after router.refresh() is async and rAF isn't enough.
1171
+ if (state.activeShimmer) {
1172
+ const { blockId, editablePath } = state.activeShimmer;
1173
+ let attempts = 0;
1174
+ const tryReapply = () => {
1175
+ if (!state.activeShimmer || state.activeShimmer.blockId !== blockId)
1176
+ return;
1177
+ applyAiFieldLoading(blockId, editablePath, true);
1178
+ const node = blockId ? findBlockNode(blockId) : null;
1179
+ const hasOverlay = node
1180
+ ? Boolean(node.querySelector(".aifx-shimmer-overlay"))
1181
+ : Boolean(document.querySelector(".aifx-shimmer-overlay"));
1182
+ if (!hasOverlay && attempts < 8) {
1183
+ attempts++;
1184
+ setTimeout(tryReapply, 60);
1185
+ }
1186
+ };
1187
+ setTimeout(tryReapply, 50);
1188
+ }
1189
+ };
1190
+ const doRefreshAndRestore = () => {
1191
+ refresh();
1192
+ requestAnimationFrame(() => {
1193
+ requestAnimationFrame(() => {
1194
+ restoreAndFocus();
1195
+ });
1196
+ });
1197
+ };
1198
+ if (useViewTransition && typeof document.startViewTransition === "function") {
1199
+ document.startViewTransition(() => {
1200
+ refresh();
1201
+ return new Promise((resolve) => {
1202
+ requestAnimationFrame(() => {
1203
+ requestAnimationFrame(() => {
1204
+ restoreAndFocus();
1205
+ resolve();
1206
+ });
1207
+ });
1208
+ });
1209
+ });
1210
+ }
1211
+ else {
1212
+ doRefreshAndRestore();
1213
+ }
1214
+ };
1215
+ // -- New block detection -------------------------------------------------
1216
+ const detectNewBlocks = () => {
1217
+ if (!state.expectingNewBlocks)
1218
+ return;
1219
+ const currentIds = new Set(Array.from(document.querySelectorAll("[data-block-id]"))
1220
+ .map((node) => node.getAttribute("data-block-id"))
1221
+ .filter((id) => Boolean(id)));
1222
+ let staggerIndex = 0;
1223
+ for (const id of currentIds) {
1224
+ if (!state.knownBlockIds.has(id)) {
1225
+ const node = findBlockNode(id);
1226
+ if (node && !node.classList.contains("editor-block-entering")) {
1227
+ const delay = staggerIndex * 120;
1228
+ node.style.animationDelay = `${delay}ms`;
1229
+ node.classList.add("editor-block-entering");
1230
+ node.addEventListener("animationend", () => {
1231
+ node.classList.remove("editor-block-entering");
1232
+ node.style.animationDelay = "";
1233
+ }, { once: true });
1234
+ staggerIndex++;
1235
+ }
1236
+ }
1237
+ }
1238
+ state.knownBlockIds = currentIds;
1239
+ state.expectingNewBlocks = false;
1240
+ };
1241
+ // -- Event handlers ------------------------------------------------------
1242
+ const onClick = (event) => {
1243
+ if (Date.now() < state.suppressClickUntil) {
1244
+ event.preventDefault();
1245
+ event.stopPropagation();
1246
+ return;
1247
+ }
1248
+ const target = event.target;
1249
+ // Ignore clicks inside editor widget overlays (immersive prompt, FAB, panel)
1250
+ if (target?.closest("[data-editor-widget-ignore]"))
1251
+ return;
1252
+ // Route internal anchor clicks through the app router. Otherwise they cause
1253
+ // a full-document navigation inside the preview iframe, which unloads the
1254
+ // document and flashes white for a frame before the new page paints.
1255
+ // Only hijack plain-modifier, same-tab, same-origin, non-download clicks —
1256
+ // modifier-clicks, target=_blank, downloads, mailto/tel/hash, and
1257
+ // cross-origin links fall through to their default behavior.
1258
+ const anchor = target?.closest("a");
1259
+ if (anchor &&
1260
+ event.button === 0 &&
1261
+ !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey &&
1262
+ (!anchor.target || anchor.target === "_self") &&
1263
+ !anchor.hasAttribute("download")) {
1264
+ const rel = anchor.getAttribute("rel");
1265
+ const hrefAttr = anchor.getAttribute("href");
1266
+ if (hrefAttr &&
1267
+ !(rel && /\bexternal\b/i.test(rel)) &&
1268
+ !/^(?:[a-z][a-z0-9+\-.]*:|mailto:|tel:|#)/i.test(hrefAttr)) {
1269
+ try {
1270
+ const url = new URL(hrefAttr, window.location.href);
1271
+ if (url.origin === window.location.origin) {
1272
+ event.preventDefault();
1273
+ event.stopPropagation();
1274
+ navigate(url.pathname + url.search + url.hash);
1275
+ return;
1276
+ }
1277
+ }
1278
+ catch {
1279
+ // malformed href — fall through to default handling
1280
+ }
1281
+ }
1282
+ }
1283
+ const editing = state.inlineEditing;
1284
+ if (editing && target && !editing.node.contains(target)) {
1285
+ commitInlineEdit();
1286
+ }
1287
+ if (target?.closest(".editor-block-delete") ||
1288
+ target?.closest(".editor-selected-delete") ||
1289
+ target?.closest(".editor-selected-edit") ||
1290
+ target?.closest(".editor-selected-move") ||
1291
+ target?.closest(".editor-selected-add") ||
1292
+ target?.closest(".editor-list-item-delete") ||
1293
+ target?.closest(".editor-list-item-add") ||
1294
+ target?.closest(".editor-list-item-move") ||
1295
+ target?.closest(".editor-delete-confirm")) {
1296
+ return;
1297
+ }
1298
+ const imgBtn = target?.closest(".editor-image-change-btn");
1299
+ if (imgBtn) {
1300
+ event.preventDefault();
1301
+ event.stopPropagation();
1302
+ callbacks.onOpenImagePicker({
1303
+ slug: imgBtn.getAttribute("data-image-slug") || pathname || "/",
1304
+ blockId: imgBtn.getAttribute("data-image-block-id") || "",
1305
+ blockType: imgBtn.getAttribute("data-image-block-type") || undefined,
1306
+ editablePath: imgBtn.getAttribute("data-image-path") || "",
1307
+ currentUrl: imgBtn.getAttribute("data-image-current-url") || undefined,
1308
+ });
1309
+ return;
1310
+ }
1311
+ // Tab switching
1312
+ const tabBtn = target?.closest(".tabs-block__tab");
1313
+ if (tabBtn) {
1314
+ const tabsBlock = tabBtn.closest(".tabs-block");
1315
+ if (tabsBlock) {
1316
+ const allTabs = tabsBlock.querySelectorAll(".tabs-block__tab");
1317
+ const allPanels = tabsBlock.querySelectorAll(".tabs-block__panel");
1318
+ const idx = Array.from(allTabs).indexOf(tabBtn);
1319
+ if (idx >= 0) {
1320
+ allTabs.forEach((b, j) => {
1321
+ b.classList.toggle("tabs-block__tab--active", idx === j);
1322
+ b.setAttribute("aria-selected", idx === j ? "true" : "false");
1323
+ });
1324
+ allPanels.forEach((p, j) => {
1325
+ p.style.display = idx === j ? "" : "none";
1326
+ });
1327
+ }
1328
+ }
1329
+ }
1330
+ const selectionModeOn = document.documentElement.hasAttribute("data-editor-selection-mode");
1331
+ if (!selectionModeOn)
1332
+ return;
1333
+ const node = target?.closest("[data-block-id]");
1334
+ if (!node) {
1335
+ const hadSelection = !!document.querySelector(".editor-highlight, .editor-item-selected");
1336
+ if (hadSelection) {
1337
+ clearChildFocus();
1338
+ removeSelectedDeleteHandle();
1339
+ clearAllHighlights();
1340
+ clearListItemSelection();
1341
+ state.selectedBlockId = null;
1342
+ state.selectedEditablePath = null;
1343
+ state.pendingListItemMovePath = null;
1344
+ setChildSelectionLock(null);
1345
+ callbacks.onBlockClicked({ slug, blockId: null, blockType: null, editablePath: null, editableValue: null, anchorRect: null });
1346
+ }
1347
+ return;
1348
+ }
1349
+ const childNode = target?.closest("[data-editable-target]");
1350
+ const isChrome = !!node.querySelector("[data-block-chrome]") || node.matches("[data-block-chrome]");
1351
+ if (!target?.closest("summary") && !isChrome) {
1352
+ event.preventDefault();
1353
+ }
1354
+ event.stopPropagation();
1355
+ const blockId = node.getAttribute("data-block-id");
1356
+ const blockType = node.getAttribute("data-block-type");
1357
+ if (!blockId || !blockType)
1358
+ return;
1359
+ let editablePath = childNode && node.contains(childNode) ? String(childNode.getAttribute("data-editable-target") ?? "") || undefined : undefined;
1360
+ if (!editablePath) {
1361
+ const itemRoot = target?.closest(".editor-item-has-delete");
1362
+ if (itemRoot && node.contains(itemRoot)) {
1363
+ const firstEditable = itemRoot.querySelector("[data-editable-target]");
1364
+ editablePath = String(firstEditable?.getAttribute("data-editable-target") ?? "") || undefined;
1365
+ }
1366
+ }
1367
+ if (state.selectedBlockId === blockId) {
1368
+ // Any click on the already-selected block toggles it off — including
1369
+ // clicks on its editable children. To edit a field on the same block,
1370
+ // click once to deselect, then click the field to re-select + focus.
1371
+ clearChildFocus();
1372
+ removeSelectedDeleteHandle();
1373
+ clearAllHighlights();
1374
+ clearListItemSelection();
1375
+ state.selectedBlockId = null;
1376
+ state.selectedEditablePath = null;
1377
+ state.pendingListItemMovePath = null;
1378
+ setChildSelectionLock(null);
1379
+ callbacks.onBlockClicked({ slug, blockId: null, blockType: null, editablePath: null, editableValue: null, anchorRect: null });
1380
+ return;
1381
+ }
1382
+ if (!editablePath) {
1383
+ state.selectedEditablePath = null;
1384
+ state.pendingListItemMovePath = null;
1385
+ setChildSelectionLock(null);
1386
+ }
1387
+ else {
1388
+ const parsed = parseListItemPath(editablePath);
1389
+ if (parsed) {
1390
+ setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index });
1391
+ }
1392
+ else {
1393
+ state.pendingListItemMovePath = null;
1394
+ setChildSelectionLock(null);
1395
+ }
1396
+ }
1397
+ applyBlockFocus(blockId, false, editablePath, { scrollIntoView: false });
1398
+ let editableValue = null;
1399
+ if (editablePath && isImagePath(editablePath) && childNode) {
1400
+ const img = childNode.querySelector("img");
1401
+ if (img?.src)
1402
+ editableValue = img.src;
1403
+ }
1404
+ callbacks.onBlockClicked({
1405
+ slug, blockId, blockType, editablePath: editablePath ?? null, editableValue,
1406
+ anchorRect: (() => {
1407
+ const r = node.getBoundingClientRect();
1408
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
1409
+ })(),
1410
+ });
1411
+ };
1412
+ const onDoubleClick = (event) => {
1413
+ if (!document.documentElement.hasAttribute("data-editor-selection-mode"))
1414
+ return;
1415
+ const target = event.target;
1416
+ const childNode = target?.closest("[data-editable-target]");
1417
+ if (!childNode)
1418
+ return;
1419
+ const node = childNode.closest("[data-block-id]");
1420
+ if (!node)
1421
+ return;
1422
+ const blockId = node.getAttribute("data-block-id");
1423
+ const blockType = node.getAttribute("data-block-type");
1424
+ const editablePath = String(childNode.getAttribute("data-editable-target") ?? "");
1425
+ if (!blockId || !blockType || !editablePath)
1426
+ return;
1427
+ if (!supportsInlineEditablePath(editablePath))
1428
+ return;
1429
+ event.preventDefault();
1430
+ event.stopPropagation();
1431
+ applyBlockFocus(blockId, false, editablePath, { scrollIntoView: false });
1432
+ startInlineEdit({ node: childNode, blockId, blockType, editablePath });
1433
+ };
1434
+ const onPointerMove = (event) => {
1435
+ if (!document.documentElement.hasAttribute("data-editor-selection-mode")) {
1436
+ setHoveredListItem(null);
1437
+ return;
1438
+ }
1439
+ if (state.childSelectionLock) {
1440
+ setHoveredListItem(null);
1441
+ return;
1442
+ }
1443
+ const target = event.target;
1444
+ const item = target?.closest(".editor-item-has-delete") ?? null;
1445
+ if (!item) {
1446
+ setHoveredListItem(null);
1447
+ return;
1448
+ }
1449
+ const block = item.closest(".editor-highlight");
1450
+ if (!block) {
1451
+ setHoveredListItem(null);
1452
+ return;
1453
+ }
1454
+ setHoveredListItem(item);
1455
+ };
1456
+ const onKeyDown = (event) => {
1457
+ const editing = state.inlineEditing;
1458
+ if (editing && editing.node.contains(event.target)) {
1459
+ if (event.key === "Escape") {
1460
+ event.preventDefault();
1461
+ event.stopPropagation();
1462
+ cancelInlineEdit();
1463
+ return;
1464
+ }
1465
+ if (event.key === "Enter" && (!editing.isMultiline || !event.shiftKey)) {
1466
+ event.preventDefault();
1467
+ event.stopPropagation();
1468
+ commitInlineEdit();
1469
+ return;
1470
+ }
1471
+ }
1472
+ if (!event.altKey)
1473
+ return;
1474
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown")
1475
+ return;
1476
+ const target = event.target;
1477
+ if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))
1478
+ return;
1479
+ if (target?.isContentEditable)
1480
+ return;
1481
+ const blockId = state.selectedBlockId;
1482
+ if (!blockId)
1483
+ return;
1484
+ const direction = event.key === "ArrowUp" ? "up" : "down";
1485
+ const selectedNode = document.querySelector(".editor-highlight[data-block-id]");
1486
+ const result = computeMoveAfter(blockId, direction, selectedNode);
1487
+ if (!result.canMove)
1488
+ return;
1489
+ event.preventDefault();
1490
+ event.stopPropagation();
1491
+ state.pendingScrollIntoView = true;
1492
+ callbacks.onBlockReordered({ slug, blockId, afterBlockId: result.afterBlockId });
1493
+ };
1494
+ return {
1495
+ // Live draft
1496
+ clearLiveDraft,
1497
+ restoreLiveDraftOriginals,
1498
+ discardLiveDraftOriginals,
1499
+ renderLiveDraft,
1500
+ renderLiveDraftChromeOnly,
1501
+ // Selection
1502
+ applyBlockFocus,
1503
+ applyChildFocus,
1504
+ applyListItemSelection,
1505
+ setChildSelectionLock,
1506
+ setHoveredListItem,
1507
+ // Overlay controls
1508
+ removeSelectedDeleteHandle,
1509
+ mountSelectedDeleteHandle,
1510
+ mountListItemDeleteHandles,
1511
+ mountGlobalImageButtons,
1512
+ // Inline editing
1513
+ startInlineEdit,
1514
+ commitInlineEdit,
1515
+ cancelInlineEdit,
1516
+ // Refresh
1517
+ smoothRefresh,
1518
+ queueFocusAfterRefresh,
1519
+ // New block detection
1520
+ detectNewBlocks,
1521
+ // Event handlers (to attach to document)
1522
+ onClick,
1523
+ onDoubleClick,
1524
+ onPointerMove,
1525
+ onKeyDown,
1526
+ };
1527
+ }