@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.
- package/LICENSE +201 -0
- package/dist/bridge-functions.d.ts +226 -0
- package/dist/bridge-functions.js +1527 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/live-preview-store-core.d.ts +49 -0
- package/dist/live-preview-store-core.js +99 -0
- package/dist/live-preview-store.d.ts +36 -0
- package/dist/live-preview-store.js +55 -0
- package/dist/preview-bridge-core.d.ts +19 -0
- package/dist/preview-bridge-core.js +377 -0
- package/dist/preview-bridge.d.ts +4 -0
- package/dist/preview-bridge.js +22 -0
- package/dist/selectable.d.ts +13 -0
- package/dist/selectable.js +10 -0
- package/package.json +59 -0
- package/src/styles.css +1207 -0
|
@@ -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, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|