@stack-mode/site-manager-kit 1.44.0 → 1.45.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.
Files changed (61) hide show
  1. package/README.md +618 -606
  2. package/audit/ts-hooks.mjs +68 -68
  3. package/audit/wiring-audit.d.mts +37 -37
  4. package/audit/wiring-audit.mjs +604 -604
  5. package/bin/site-manager-kit.mjs +10 -10
  6. package/package.json +73 -72
  7. package/src/announcements.ts +13 -13
  8. package/src/attribution-fields.ts +52 -52
  9. package/src/attribution.tsx +47 -47
  10. package/src/blog.ts +122 -122
  11. package/src/chat-route.ts +136 -136
  12. package/src/chat-types.ts +76 -76
  13. package/src/client.ts +116 -116
  14. package/src/config.ts +78 -78
  15. package/src/copy-keys-route.ts +21 -21
  16. package/src/copy-keys.ts +77 -77
  17. package/src/data.ts +189 -189
  18. package/src/downloads.ts +7 -7
  19. package/src/faq.ts +21 -21
  20. package/src/floor-plans.ts +45 -45
  21. package/src/form-guard.tsx +37 -37
  22. package/src/form-types.ts +13 -13
  23. package/src/forms.ts +76 -76
  24. package/src/glossary.ts +19 -19
  25. package/src/health-route.ts +34 -34
  26. package/src/index.ts +36 -36
  27. package/src/indexnow.ts +40 -40
  28. package/src/listings.ts +38 -38
  29. package/src/llms-route.ts +45 -0
  30. package/src/metadata.ts +55 -55
  31. package/src/not-found-reporter.tsx +16 -16
  32. package/src/not-found.ts +53 -53
  33. package/src/payment.ts +70 -70
  34. package/src/phone-input.tsx +26 -26
  35. package/src/phone.ts +37 -37
  36. package/src/preview-route.ts +48 -48
  37. package/src/preview.ts +94 -94
  38. package/src/residents.ts +23 -23
  39. package/src/revalidate-route.ts +34 -34
  40. package/src/robots.ts +5 -1
  41. package/src/rss-route.ts +26 -26
  42. package/src/services.ts +52 -52
  43. package/src/site-chat-panel.tsx +537 -537
  44. package/src/site-chat.tsx +26 -26
  45. package/src/sitemap.ts +26 -26
  46. package/src/structured-data.tsx +207 -207
  47. package/src/types.ts +581 -580
  48. package/src/version.ts +1 -1
  49. package/src/visual-accents.ts +126 -126
  50. package/src/visual-bridge-client.tsx +1070 -1070
  51. package/src/visual-bridge.tsx +30 -30
  52. package/src/visual-clicks.ts +13 -13
  53. package/src/visual-lists.ts +37 -37
  54. package/src/visual-match.ts +77 -77
  55. package/src/visual-messages.ts +160 -160
  56. package/src/visual-mode.ts +18 -18
  57. package/src/visual-preview.ts +69 -69
  58. package/src/visual-tags.ts +90 -90
  59. package/src/visual.ts +59 -59
  60. package/src/waitlist-form.ts +39 -39
  61. package/src/waitlist.ts +48 -48
@@ -1,1070 +1,1070 @@
1
- "use client";
2
-
3
- import { usePathname, useRouter } from "next/navigation";
4
- import { useEffect, useRef } from "react";
5
- import { fromMarkedRuns, markedRuns, normalizeAccents, type Mark, type MarkedRun } from "./visual-accents";
6
- import { ACTIVATES, blocksActivation } from "./visual-clicks";
7
- import { actionsFor, itemElement, type ListAction, type PageList } from "./visual-lists";
8
- import { comparable, longest, matchIndex, type CopyText } from "./visual-match";
9
- import { adminMessageFrom, collectRefs, type Editable, type KitMessage } from "./visual-messages";
10
- import { LivePreview } from "./visual-preview";
11
- import { mayHaveTag, readTags, stripTags } from "./visual-tags";
12
-
13
- const MARK = "data-bp-edit";
14
- // Set beside MARK on the marks the bridge adds itself, so marking again clears only those and never the site's own.
15
- const AUTO = "data-bp-auto";
16
- // Set beside MARK on a place an exact tag named. Kept when marking again: the tag was removed when it was read.
17
- const TAGGED = "data-bp-tagged";
18
- // Attributes that may carry Site Copy (a photo's alt, a button's label); their tags are removed, never shown.
19
- const TEXT_ATTRS = ["alt", "title", "aria-label", "placeholder", "content", "value"];
20
-
21
- // Never matched: code, form controls and embedded documents aren't words a visitor reads as copy.
22
- const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TEXTAREA", "SELECT", "INPUT", "IFRAME", "svg"]);
23
-
24
- /**
25
- * A value that is a bare run of text beside other elements (a heading's words before its accent span). whole: the run
26
- * is the value's words and nothing else, so it can be edited in place.
27
- */
28
- type TextMark = { node: Text; ref: string; whole: boolean };
29
- type Target = Element | Text;
30
-
31
- const rectOf = (t: Target): DOMRect => {
32
- if (t instanceof Element) return t.getBoundingClientRect();
33
- const range = document.createRange();
34
- range.selectNodeContents(t);
35
- return range.getBoundingClientRect();
36
- };
37
-
38
- type Overlay = { show: (t: Target, label: string) => void; hide: () => void; dots: (ts: Target[]) => void; destroy: () => void };
39
-
40
- // Drawn in a closed shadow root so the page's CSS can't reach it and it can't reach the page. System colors only.
41
- function makeOverlay(): Overlay {
42
- const host = document.createElement("div");
43
- host.setAttribute("aria-hidden", "true");
44
- const root = host.attachShadow({ mode: "closed" });
45
- const box = document.createElement("div");
46
- box.style.cssText =
47
- "position:fixed;z-index:2147483646;pointer-events:none;outline:2px solid Highlight;outline-offset:2px;border-radius:2px;display:none;";
48
- const tag = document.createElement("span");
49
- tag.style.cssText =
50
- "position:absolute;left:-2px;bottom:100%;margin-bottom:4px;padding:2px 6px;border-radius:3px;background:Highlight;color:HighlightText;font:600 11px/1.4 system-ui,sans-serif;white-space:nowrap;";
51
- box.append(tag);
52
- // Small dots on values with unsaved drafts, so what's waiting to publish shows on the page itself.
53
- const dotLayer = document.createElement("div");
54
- root.append(box, dotLayer);
55
- let dotted: Target[] = [];
56
- document.body.append(host);
57
- let current: Target | null = null;
58
- let frame = 0;
59
- const place = () => {
60
- frame = 0;
61
- if (!current || !current.isConnected) {
62
- box.style.display = "none";
63
- return;
64
- }
65
- const r = rectOf(current);
66
- Object.assign(box.style, { display: "block", left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
67
- };
68
- const placeDots = () => {
69
- dotLayer.replaceChildren(
70
- ...dotted.filter((t) => t.isConnected).map((t) => {
71
- const r = rectOf(t);
72
- const d = document.createElement("span");
73
- d.title = "Unpublished change";
74
- d.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;width:8px;height:8px;border-radius:50%;background:Highlight;box-shadow:0 0 0 2px Canvas;left:${r.right - 2}px;top:${r.top - 6}px;`;
75
- return d;
76
- }),
77
- );
78
- };
79
- const schedule = () => {
80
- if (!frame)
81
- frame = requestAnimationFrame(() => {
82
- place();
83
- placeDots();
84
- });
85
- };
86
- addEventListener("scroll", schedule, true);
87
- addEventListener("resize", schedule);
88
- return {
89
- show(t, label) {
90
- current = t;
91
- tag.textContent = label;
92
- place();
93
- },
94
- hide() {
95
- current = null;
96
- box.style.display = "none";
97
- },
98
- dots(ts) {
99
- dotted = ts;
100
- placeDots();
101
- },
102
- destroy() {
103
- removeEventListener("scroll", schedule, true);
104
- removeEventListener("resize", schedule);
105
- if (frame) cancelAnimationFrame(frame);
106
- host.remove();
107
- },
108
- };
109
- }
110
-
111
- // Formatted text has no length limit of its own here; Site Manager checks the saved value.
112
- const LIMITS_TEXT = Number.MAX_SAFE_INTEGER;
113
-
114
- type FormatBar = { show: (el: HTMLElement, mode: "rich" | "heading" | "plain", marks?: Mark[]) => void; hide: () => void; busy: () => boolean; destroy: () => void };
115
-
116
- /**
117
- * The Bold, Italic, Link and Done bar shown above a formatted value while it's edited in place. In its own closed
118
- * shadow root like the outline, with system colors only. Buttons act on mousedown and keep the page's selection.
119
- */
120
- function makeFormatBar(done: () => void, mark: (m: Mark) => void, expand: () => void): FormatBar {
121
- const host = document.createElement("div");
122
- host.setAttribute("aria-hidden", "false");
123
- const root = host.attachShadow({ mode: "closed" });
124
- const bar = document.createElement("div");
125
- bar.setAttribute("role", "toolbar");
126
- bar.setAttribute("aria-label", "Formatting");
127
- bar.style.cssText =
128
- "position:fixed;z-index:2147483647;display:none;gap:2px;padding:3px;border-radius:6px;background:Canvas;color:CanvasText;border:1px solid GrayText;box-shadow:0 2px 8px rgba(0,0,0,.2);font:600 12px/1 system-ui,sans-serif;";
129
- let asking = false;
130
- let target: HTMLElement | null = null;
131
- const button = (label: string, title: string, act: () => void) => {
132
- const b = document.createElement("button");
133
- b.type = "button";
134
- b.textContent = label;
135
- b.title = title;
136
- b.style.cssText = "all:unset;cursor:pointer;padding:5px 8px;border-radius:4px;";
137
- b.addEventListener("mousedown", (e) => {
138
- e.preventDefault();
139
- act();
140
- });
141
- return b;
142
- };
143
- const accentButton = button("Accent", "Show the selected words in the accent color, or plain again (Ctrl+E)", () => mark("accent"));
144
- // Bold on a plain text value marks the words as important, so the site renders them as <strong>.
145
- const strongButton = button("B", "Bold: mark the selected words as important (Ctrl+B)", () => mark("strong"));
146
- strongButton.style.fontWeight = "800";
147
- // Italic marks the words as emphasized, so the site renders them as <em>.
148
- const emButton = button("I", "Italic: mark the selected words as emphasized (Ctrl+I)", () => mark("em"));
149
- emButton.style.fontStyle = "italic";
150
- const richButtons = [
151
- button("B", "Bold (Ctrl+B)", () => document.execCommand("bold")),
152
- button("I", "Italic (Ctrl+I)", () => document.execCommand("italic")),
153
- button("Link", "Link the selected words", () => {
154
- asking = true;
155
- const url = window.prompt("Link address (https://, mailto: or a page like /about)");
156
- asking = false;
157
- target?.focus();
158
- // Only addresses a visitor can follow; Site Manager sanitizes the HTML again when it saves.
159
- if (url && /^(https?:\/\/|mailto:|tel:|\/)/i.test(url.trim())) document.execCommand("createLink", false, url.trim());
160
- }),
161
- ];
162
- const doneButton = button("Done", "Save (Ctrl+Enter)", done);
163
- // Moves this value into Site Manager's side panel, for long text or a bigger box.
164
- const expandButton = button("\u2922", "Edit in the side panel", expand);
165
- expandButton.setAttribute("aria-label", "Edit in the side panel");
166
- bar.append(...richButtons, strongButton, emButton, accentButton, expandButton, doneButton);
167
- root.append(bar);
168
- document.body.append(host);
169
- const place = () => {
170
- if (!target) return;
171
- const r = target.getBoundingClientRect();
172
- // Below the words, so it never covers the hint shown above them; above the hint when there's no room below.
173
- const below = r.bottom + 8;
174
- const top = below + 40 > innerHeight ? Math.max(4, r.top - 72) : below;
175
- Object.assign(bar.style, { display: "flex", left: `${Math.max(4, r.left)}px`, top: `${top}px` });
176
- };
177
- addEventListener("scroll", place, true);
178
- return {
179
- show(el, mode, marks = ["accent", "strong"]) {
180
- target = el;
181
- for (const b of richButtons) b.style.display = mode === "rich" ? "" : "none";
182
- accentButton.style.display = mode === "heading" && marks.includes("accent") ? "" : "none";
183
- strongButton.style.display = mode === "heading" && marks.includes("strong") ? "" : "none";
184
- emButton.style.display = mode === "heading" && marks.includes("em") ? "" : "none";
185
- doneButton.title = mode === "rich" ? "Save (Ctrl+Enter)" : "Save (Enter)";
186
- place();
187
- },
188
- hide() {
189
- target = null;
190
- bar.style.display = "none";
191
- },
192
- busy: () => asking,
193
- destroy() {
194
- removeEventListener("scroll", place, true);
195
- host.remove();
196
- },
197
- };
198
- }
199
-
200
- type ListBar = { show: (el: Element, actions: ListAction[], act: (a: ListAction) => void) => void; hide: () => void; over: () => boolean; destroy: () => void };
201
-
202
- const ACTION_LABELS: Record<ListAction, [string, string]> = {
203
- up: ["\u2191", "Move up"],
204
- down: ["\u2193", "Move down"],
205
- remove: ["\u2715", "Remove this item"],
206
- add: ["+", "Add an item after this one"],
207
- };
208
-
209
- /** The small buttons at the top right of a list item, in their own closed shadow root with system colors. */
210
- function makeListBar(): ListBar {
211
- const host = document.createElement("div");
212
- const root = host.attachShadow({ mode: "closed" });
213
- const bar = document.createElement("div");
214
- bar.setAttribute("role", "toolbar");
215
- bar.setAttribute("aria-label", "List item");
216
- bar.style.cssText =
217
- "position:fixed;z-index:2147483647;display:none;gap:2px;padding:2px;border-radius:6px;background:Canvas;color:CanvasText;border:1px solid GrayText;box-shadow:0 2px 8px rgba(0,0,0,.2);font:600 13px/1 system-ui,sans-serif;";
218
- root.append(bar);
219
- document.body.append(host);
220
- let hovering = false;
221
- bar.addEventListener("mouseenter", () => (hovering = true));
222
- bar.addEventListener("mouseleave", () => (hovering = false));
223
- let target: Element | null = null;
224
- const place = () => {
225
- if (!target?.isConnected) return void (bar.style.display = "none");
226
- const r = target.getBoundingClientRect();
227
- Object.assign(bar.style, { display: "flex", left: `${Math.max(4, r.right - bar.offsetWidth - 4)}px`, top: `${Math.max(4, r.top + 4)}px` });
228
- };
229
- addEventListener("scroll", place, true);
230
- return {
231
- show(el, actions, act) {
232
- if (target === el && bar.childElementCount === actions.length) return place();
233
- target = el;
234
- bar.replaceChildren(
235
- ...actions.map((a) => {
236
- const b = document.createElement("button");
237
- b.type = "button";
238
- b.textContent = ACTION_LABELS[a][0];
239
- b.title = ACTION_LABELS[a][1];
240
- b.setAttribute("aria-label", ACTION_LABELS[a][1]);
241
- b.style.cssText = "all:unset;cursor:pointer;padding:4px 8px;border-radius:4px;";
242
- b.addEventListener("click", (e) => {
243
- e.preventDefault();
244
- e.stopPropagation();
245
- act(a);
246
- });
247
- return b;
248
- }),
249
- );
250
- bar.style.display = "flex";
251
- place();
252
- },
253
- hide() {
254
- if (hovering) return;
255
- target = null;
256
- bar.style.display = "none";
257
- },
258
- over: () => hovering,
259
- destroy() {
260
- removeEventListener("scroll", place, true);
261
- host.remove();
262
- },
263
- };
264
- }
265
-
266
- /** Puts an editing copy exactly over the original, with its look, for words the page can't edit where they are. */
267
- function floatOver(copy: HTMLElement, original: HTMLElement) {
268
- const cs = getComputedStyle(original);
269
- const r = original.getBoundingClientRect();
270
- // The nearest background behind the words, so the copy reads the same; the system canvas color if none is set.
271
- let bg = "Canvas";
272
- for (let e: Element | null = original; e; e = e.parentElement) {
273
- const c = getComputedStyle(e).backgroundColor;
274
- if (c && c !== "transparent" && !/rgba\(.*,\s*0\)$/.test(c)) {
275
- bg = c;
276
- break;
277
- }
278
- }
279
- Object.assign(copy.style, {
280
- position: "absolute",
281
- left: `${r.left + scrollX}px`,
282
- top: `${r.top + scrollY}px`,
283
- minWidth: `${r.width}px`,
284
- maxWidth: `${Math.max(r.width, innerWidth - r.left - 8)}px`,
285
- margin: "0",
286
- padding: "0",
287
- zIndex: "2147483640",
288
- font: cs.font,
289
- letterSpacing: cs.letterSpacing,
290
- textTransform: cs.textTransform,
291
- lineHeight: cs.lineHeight,
292
- textAlign: cs.textAlign,
293
- color: cs.color,
294
- background: bg,
295
- whiteSpace: "normal",
296
- userSelect: "text",
297
- outline: "none",
298
- });
299
- }
300
-
301
- /**
302
- * Reads the exact tags getCopy() put in each value (visual mode only), removes them from the page and marks the place
303
- * each one names: the element whose words are exactly the value, or the run of text itself when the element holds
304
- * more (a heading's words beside its accent). Returns the runs found this time; tagged elements keep their mark.
305
- */
306
- function readPageTags(values: Map<string, string>): TextMark[] {
307
- const runs: TextMark[] = [];
308
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
309
- const found: Text[] = [];
310
- for (let n = walker.nextNode(); n; n = walker.nextNode()) if (mayHaveTag(n.nodeValue ?? "")) found.push(n as Text);
311
- for (const node of found) {
312
- const parent = node.parentElement;
313
- if (!parent || parent.tagName === "SCRIPT" || parent.tagName === "STYLE") continue;
314
- const { clean, tags } = readTags(node.nodeValue ?? "");
315
- // Only nodeValue, so React keeps owning the node and a later change to the value still reaches it.
316
- node.nodeValue = clean;
317
- // Two values in one run of text (a template string) can't be told apart; both stay unmarked.
318
- if (tags.length !== 1) continue;
319
- const ref = tags[0]!.ref;
320
- const value = values.get(ref);
321
- const words = comparable(clean);
322
- if (value === undefined || words === value) {
323
- // Inside a place the site marked itself with editMarks(): the site's mark wins.
324
- if (parent.closest(`[${MARK}]:not([${TAGGED}]):not([${AUTO}])`)) continue;
325
- if (comparable(parent.textContent ?? "") === words) {
326
- parent.setAttribute(MARK, ref);
327
- parent.setAttribute(TAGGED, "");
328
- parent.removeAttribute(AUTO);
329
- } else runs.push({ node, ref, whole: true });
330
- continue;
331
- }
332
- // Rich text: the tag sits in its last paragraph, so the value is the nearest element holding all its words.
333
- let el: Element | null = parent;
334
- for (let i = 0; el && i < 8; i++, el = el.parentElement) {
335
- if (comparable(el.textContent ?? "") === value) {
336
- el.setAttribute(MARK, ref);
337
- el.setAttribute(TAGGED, "");
338
- el.removeAttribute(AUTO);
339
- break;
340
- }
341
- }
342
- // The value with other words around it in one run: shown, but only the side panel can change it.
343
- if (!el && words.includes(value)) runs.push({ node, ref, whole: false });
344
- }
345
- for (const el of Array.from(document.querySelectorAll(TEXT_ATTRS.map((a) => `[${a}]`).join(",")))) {
346
- for (const a of TEXT_ATTRS) {
347
- const v = el.getAttribute(a);
348
- if (!v || !mayHaveTag(v)) continue;
349
- const { clean, tags } = readTags(v);
350
- el.setAttribute(a, clean);
351
- // A photo's tag is in its alt text: the photo itself is the place.
352
- if (a === "alt" && el.tagName === "IMG" && tags.length === 1 && !el.closest(`[${MARK}]:not([${TAGGED}]):not([${AUTO}])`)) {
353
- el.setAttribute(MARK, tags[0]!.ref);
354
- el.setAttribute(TAGGED, "");
355
- }
356
- }
357
- }
358
- if (mayHaveTag(document.title)) document.title = stripTags(document.title);
359
- return runs;
360
- }
361
-
362
- /**
363
- * Marks the page's copy from the index: the deepest element whose words are exactly one value gets MARK, and a bare
364
- * run of text that is one value, in an element that holds other elements too, comes back as a text mark. Elements
365
- * the site marked itself, and everything inside them, are left as they are.
366
- */
367
- function markPage(index: Map<string, string>): TextMark[] {
368
- for (const el of Array.from(document.querySelectorAll(`[${AUTO}]`))) {
369
- el.removeAttribute(MARK);
370
- el.removeAttribute(AUTO);
371
- }
372
- const texts: TextMark[] = [];
373
- if (!index.size) return texts;
374
- const max = longest(index);
375
- const words = (n: Node) => comparable(n.textContent ?? "");
376
- const visit = (el: Element) => {
377
- if (SKIP.has(el.tagName) || el.hasAttribute(MARK) || el.shadowRoot) return;
378
- const w = words(el);
379
- if (!w) return;
380
- if (w.length <= max) {
381
- const ref = index.get(w);
382
- if (ref && !Array.from(el.children).some((c) => words(c) === w)) {
383
- el.setAttribute(MARK, ref);
384
- el.setAttribute(AUTO, "");
385
- return;
386
- }
387
- }
388
- for (const child of Array.from(el.childNodes)) {
389
- if (child instanceof Element) visit(child);
390
- else if (child instanceof Text) {
391
- const ref = index.get(words(child));
392
- if (ref) texts.push({ node: child, ref, whole: true });
393
- }
394
- }
395
- };
396
- visit(document.body);
397
- return texts;
398
- }
399
-
400
- const marked = (t: EventTarget | null) => (t instanceof Element ? t.closest(`[${MARK}]`) : null);
401
-
402
- /**
403
- * The client half of VisualEditing. Talks only to the window that showed it (the frame's parent, or the window that
404
- * opened it where the page has its own window) and only at the signed admin origin.
405
- */
406
- export function VisualBridge({ adminOrigin, kit, copy }: { adminOrigin: string; kit: string; copy: CopyText[] }) {
407
- const router = useRouter();
408
- const pathname = usePathname();
409
- const target = useRef<Window | null>(null);
410
- // The path and refs last reported, so a page isn't reported twice (Strict Mode, marking again that found nothing new).
411
- const reported = useRef<{ path: string; refs: string } | null>(null);
412
- const routerRef = useRef(router);
413
- // What Site Manager said, kept outside the effect: React may set the bridge up again (Strict Mode, Fast Refresh)
414
- // without Site Manager saying it twice.
415
- const said = useRef({ editable: new Map<string, Editable>(), editing: false });
416
- const texts = useRef<TextMark[]>([]);
417
- // Runs of text an exact tag named. Their tags are gone once read, so they're kept until they leave the page.
418
- const tagRuns = useRef<TextMark[]>([]);
419
- const words = useRef(copy);
420
- const preview = useRef(new LivePreview());
421
- // Set up by the main effect: marks the page again and reports it when its refs changed.
422
- const rescan = useRef<() => void>(() => {});
423
- useEffect(() => {
424
- routerRef.current = router;
425
- }, [router]);
426
-
427
- useEffect(() => {
428
- target.current = window.parent !== window ? window.parent : window.opener;
429
- if (!target.current) return;
430
- const send = (m: KitMessage) => target.current?.postMessage(m, adminOrigin);
431
- const { editable } = said.current;
432
- const overlay = makeOverlay();
433
- const bar = makeFormatBar(
434
- () => finish(true),
435
- (m) => toggleMark(m),
436
- () => {
437
- // Puts the words back as they were and lets Site Manager open the same value in its side panel.
438
- if (!active) return;
439
- const ref = active.ref;
440
- finish(false);
441
- send({ bp: 1, type: "expand", ref });
442
- },
443
- );
444
- const listBar = makeListBar();
445
- // The lists Site Manager said are on the page, and where each shown item is, found again after each scan.
446
- let lists: PageList[] = [];
447
- let items: { el: Element; path: string; index: number; actions: ListAction[] }[] = [];
448
- const placeItems = () => {
449
- const elementOf = (ref: string) => targetsOf(ref).map((t) => (t instanceof Element ? t : t.parentElement)).filter((e): e is Element => !!e);
450
- items = lists.flatMap((l) => {
451
- const shown = l.items.slice(0, Math.min(l.shown, l.items.length));
452
- return shown.flatMap((it, index) => {
453
- const mine = it.refs.flatMap(elementOf);
454
- const others = shown.filter((_, n) => n !== index).flatMap((o) => o.refs.flatMap(elementOf));
455
- const el = itemElement(mine, others);
456
- const actions = actionsFor(l, index);
457
- return el && actions.length ? [{ el, path: l.path, index, actions }] : [];
458
- });
459
- });
460
- };
461
- let selected: Element | null = null;
462
- let selectedRun: TextMark | null = null;
463
- // run: a heading's words beside its accent, edited in a span put in for the edit; the page's own node goes back after.
464
- let active: {
465
- el: HTMLElement;
466
- ref: string;
467
- before: string;
468
- maxLength: number;
469
- // A formatted value: before is its HTML, Enter makes a new paragraph, and the bar is shown.
470
- rich?: boolean;
471
- // A heading edited as a copy of its element beside the hidden original, which React keeps owning.
472
- heading?: { original: HTMLElement; restore: () => void; template: HTMLElement; marks: Mark[] };
473
- run?: { node: Text; lead: string; trail: string; original: string };
474
- } | null = null;
475
- let flash = 0;
476
- let pending = 0;
477
- // Values with unsaved drafts (from Site Manager) and the value Tab last moved to.
478
- let draftRefs: string[] = [];
479
- // Photos shown in place of a value's photo while one is chosen, with what they showed before.
480
- const photos = new Map<HTMLImageElement, { src: string; srcset: string | null }>();
481
- let focused: Target | null = null;
482
-
483
- rescan.current = () => {
484
- // Never while someone is typing: the words they're changing no longer match the value.
485
- if (active || preview.current.active) return;
486
- const path = location.pathname + location.search;
487
- const values = new Map(words.current.map((c) => [`copy/${c.k}`, comparable(c.t)] as const));
488
- tagRuns.current = [...tagRuns.current.filter((m) => m.node.isConnected), ...readPageTags(values)];
489
- // Values found by their tag aren't looked for by their words too.
490
- const tagged = new Set([...Array.from(document.querySelectorAll(`[${TAGGED}]`), (el) => el.getAttribute(MARK)), ...tagRuns.current.map((m) => m.ref)]);
491
- const index = new Map([...matchIndex(words.current, location.pathname)].filter(([, ref]) => !tagged.has(ref)));
492
- texts.current = [...tagRuns.current, ...markPage(index)];
493
- drawDots();
494
- placeItems();
495
- const refs = collectRefs([...Array.from(document.querySelectorAll(`[${MARK}]`), (el) => el.getAttribute(MARK)), ...texts.current.map((t) => t.ref)]);
496
- const last = reported.current;
497
- const now = { path, refs: refs.join("\n") };
498
- if (last && last.path === now.path && last.refs === now.refs) return;
499
- send(last === null ? { bp: 1, type: "ready", kit, path, refs } : { bp: 1, type: "navigated", path, refs });
500
- reported.current = now;
501
- };
502
-
503
- // Content the page shows after it loads (tabs, accordions, a refresh with new drafts) is marked too.
504
- const observer = new MutationObserver(() => {
505
- clearTimeout(pending);
506
- pending = window.setTimeout(() => rescan.current(), 250);
507
- });
508
- observer.observe(document.body, { childList: true, subtree: true, characterData: true });
509
-
510
- // Every place a value shows: its marked elements and its runs of text.
511
- const targetsOf = (ref: string): Target[] => [
512
- ...Array.from(document.querySelectorAll(`[${MARK}="${CSS.escape(ref)}"]`)),
513
- ...texts.current.filter((m) => m.ref === ref && m.node.isConnected).map((m) => m.node),
514
- ];
515
- function drawDots() {
516
- overlay.dots(draftRefs.flatMap(targetsOf));
517
- }
518
- // The values that can be changed, in page order, for Tab.
519
- const ordered = (): { t: Target; ref: string }[] => {
520
- const all = [
521
- ...Array.from(document.querySelectorAll(`[${MARK}]`), (el) => ({ t: el as Target, ref: el.getAttribute(MARK) ?? "" })),
522
- ...texts.current.filter((m) => m.node.isConnected).map((m) => ({ t: m.node as Target, ref: m.ref })),
523
- ].filter((x) => editable.has(x.ref));
524
- return all.sort((a, b) => (a.t.compareDocumentPosition(b.t) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
525
- };
526
- const known = (el: Element | null) => (el && editable.has(el.getAttribute(MARK) ?? "") ? el : null);
527
- const textAt = (e: MouseEvent): TextMark | null => {
528
- const within = e.target instanceof Element ? e.target : null;
529
- if (!within) return null;
530
- for (const m of texts.current) {
531
- if (!m.node.isConnected || !editable.has(m.ref) || !within.contains(m.node)) continue;
532
- const range = document.createRange();
533
- range.selectNodeContents(m.node);
534
- for (const r of Array.from(range.getClientRects())) {
535
- if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return m;
536
- }
537
- }
538
- return null;
539
- };
540
- const textFor = (ref: string) => texts.current.find((m) => m.ref === ref && m.node.isConnected)?.node ?? null;
541
- // Every plain text place a value shows: marked elements that hold only text, and its bare runs of text. A marked
542
- // element with other elements inside (rich text, a link with an icon) isn't previewed; it changes on save.
543
- const placesOf = (ref: string): Text[][] => [
544
- ...Array.from(document.querySelectorAll(`[${MARK}="${CSS.escape(ref)}"]`))
545
- .filter((el) => el.children.length === 0)
546
- .map((el) => Array.from(el.childNodes).filter((n): n is Text => n instanceof Text)),
547
- ...texts.current.filter((m) => m.ref === ref && m.node.isConnected).map((m) => [m.node]),
548
- ];
549
-
550
- const finish = (commit: boolean) => {
551
- if (!active) return;
552
- const { el, before, ref, run, rich, heading } = active;
553
- active = null;
554
- bar.hide();
555
- el.removeAttribute("contenteditable");
556
- const text = (el.textContent ?? "").trim();
557
- if (heading) {
558
- bar.hide();
559
- const value = fromMarkedRuns(runsIn(el));
560
- el.remove();
561
- heading.restore();
562
- // Site Manager saves and refreshes the page, which shows the new words in the page's own element.
563
- send(commit ? { bp: 1, type: "commit", ref, text: value.text, accents: value.accents, strong: value.strong, em: value.em } : { bp: 1, type: "cancel", ref });
564
- overlay.hide();
565
- return;
566
- }
567
- if (rich) {
568
- bar.hide();
569
- if (commit) send({ bp: 1, type: "commit", ref, text, html: stripTags(el.innerHTML) });
570
- else {
571
- // Formatted values come from the site's HTML, which a refresh sets again, so putting it back is safe.
572
- el.innerHTML = before;
573
- send({ bp: 1, type: "cancel", ref });
574
- }
575
- overlay.hide();
576
- return;
577
- }
578
- if (run) {
579
- // The page's own node goes back with the words, so React still owns it and the saved draft reaches it.
580
- run.node.nodeValue = commit ? run.lead + text + run.trail : run.original;
581
- el.replaceWith(run.node);
582
- } else if (!commit) el.textContent = before;
583
- send(commit ? { bp: 1, type: "commit", ref, text } : { bp: 1, type: "cancel", ref });
584
- overlay.hide();
585
- };
586
-
587
- // mousemove, not mouseover: a run of text shares its element with others, so moving within it matters.
588
- const onMove = (e: MouseEvent) => {
589
- if (!said.current.editing || active) return;
590
- // The innermost list item under the pointer gets its buttons.
591
- const t = e.target instanceof Node ? e.target : null;
592
- const item = t ? items.filter((i) => i.el.contains(t)).sort((a, b) => (a.el.contains(b.el) ? 1 : -1))[0] : undefined;
593
- if (item) listBar.show(item.el, item.actions, (action) => send({ bp: 1, type: "listAction", path: item.path, index: item.index, action }));
594
- else if (!listBar.over()) listBar.hide();
595
- const el = known(marked(e.target));
596
- const text = el ? null : textAt(e);
597
- const ref = el ? el.getAttribute(MARK) : text?.ref;
598
- if (!ref) return overlay.hide();
599
- // The value's own name tells people what they're about to change; "Click to edit" for an older Site Manager.
600
- overlay.show(el ?? text!.node, editable.get(ref)?.label ?? "Click to edit");
601
- };
602
- const onClick = (e: MouseEvent) => {
603
- if (!said.current.editing) return;
604
- if (active && active.el.contains(e.target as Node)) return;
605
- // Edit mode: a link or button never acts, whether or not its words can be changed.
606
- if (blocksActivation(e.target)) {
607
- e.preventDefault();
608
- e.stopPropagation();
609
- }
610
- const el = known(marked(e.target));
611
- const text = el ? null : textAt(e);
612
- const ref = el ? (el.getAttribute(MARK) ?? "") : text?.ref;
613
- if (!ref) return;
614
- e.preventDefault();
615
- e.stopPropagation();
616
- choose(ref, el, text);
617
- };
618
- const choose = (ref: string, el: Element | null, text: TextMark | null) => {
619
- selected = el;
620
- selectedRun = text;
621
- const r = rectOf(el ?? text!.node);
622
- send({
623
- bp: 1,
624
- type: "select",
625
- ref,
626
- rect: { x: r.left, y: r.top, w: r.width, h: r.height },
627
- // A run that is the value's words alone edits in place too; one with other words around it opens the panel.
628
- plain: el ? el.children.length === 0 : text!.whole,
629
- });
630
- };
631
- const onSubmit = (e: SubmitEvent) => {
632
- if (!said.current.editing) return;
633
- e.preventDefault();
634
- e.stopPropagation();
635
- };
636
- const onKey = (e: KeyboardEvent) => {
637
- const mod = e.ctrlKey || e.metaKey;
638
- if (!active && mod && (e.key === "z" || e.key === "Z" || e.key === "y")) {
639
- // Undo and redo belong to Site Manager's history of this page's edits, not the page.
640
- e.preventDefault();
641
- send({ bp: 1, type: "key", key: e.key === "y" || e.shiftKey ? "redo" : "undo" });
642
- return;
643
- }
644
- if (!active && said.current.editing && e.key === "Tab") {
645
- // Tab moves between the values on the page instead of the page's links.
646
- const list = ordered();
647
- if (!list.length) return;
648
- e.preventDefault();
649
- const at = focused ? list.findIndex((x) => x.t === focused) : -1;
650
- const next = list[(at + (e.shiftKey ? -1 : 1) + list.length) % list.length]!;
651
- focused = next.t;
652
- (next.t instanceof Element ? next.t : next.t.parentElement)?.scrollIntoView({ block: "nearest" });
653
- overlay.show(next.t, `${editable.get(next.ref)?.label ?? "Value"}: Enter to edit`);
654
- return;
655
- }
656
- if (!active && said.current.editing && focused && e.key === "Enter" && focused.isConnected) {
657
- e.preventDefault();
658
- e.stopPropagation();
659
- const t = focused;
660
- const run = t instanceof Text ? (texts.current.find((m) => m.node === t) ?? null) : null;
661
- const el = t instanceof Element ? t : null;
662
- const ref = el ? el.getAttribute(MARK) : run?.ref;
663
- if (ref) choose(ref, el, run);
664
- return;
665
- }
666
- if (!active && focused && e.key === "Escape") {
667
- focused = null;
668
- overlay.hide();
669
- return;
670
- }
671
- if (!active && said.current.editing && (e.key === "Enter" || e.key === " ") && blocksActivation(e.target)) {
672
- e.preventDefault();
673
- e.stopPropagation();
674
- return;
675
- }
676
- if (!active || !active.el.contains(e.target as Node)) return;
677
- if (active.heading) {
678
- if (e.key === "Enter") {
679
- e.preventDefault();
680
- finish(true);
681
- } else if (e.key === "Escape") {
682
- e.preventDefault();
683
- finish(false);
684
- } else if (mod && e.key === "e" && active.heading.marks.includes("accent")) {
685
- e.preventDefault();
686
- toggleMark("accent");
687
- } else if (mod && e.key === "b") {
688
- // Never the browser's own bold, which would type markup the value doesn't keep.
689
- e.preventDefault();
690
- if (active.heading.marks.includes("strong")) toggleMark("strong");
691
- } else if (mod && e.key === "i") {
692
- // The same for italic: only the value's own mark, never the browser's markup.
693
- e.preventDefault();
694
- if (active.heading.marks.includes("em")) toggleMark("em");
695
- }
696
- return;
697
- }
698
- if (active.rich) {
699
- // Enter starts a new paragraph; Ctrl+Enter saves, Escape cancels.
700
- if (e.key === "Enter" && mod) {
701
- e.preventDefault();
702
- finish(true);
703
- } else if (e.key === "Escape") {
704
- e.preventDefault();
705
- finish(false);
706
- } else if (mod && (e.key === "b" || e.key === "i")) {
707
- e.preventDefault();
708
- document.execCommand(e.key === "b" ? "bold" : "italic");
709
- }
710
- return;
711
- }
712
- if (e.key === "Enter" && !e.shiftKey) {
713
- e.preventDefault();
714
- finish(true);
715
- } else if (e.key === "Escape") {
716
- e.preventDefault();
717
- finish(false);
718
- }
719
- };
720
- const onBeforeInput = (e: InputEvent) => {
721
- if (!active || active.rich || !active.el.contains(e.target as Node) || !e.inputType.startsWith("insert")) return;
722
- if (e.inputType === "insertParagraph" || e.inputType === "insertLineBreak") {
723
- e.preventDefault();
724
- return;
725
- }
726
- const adding = (e.data ?? e.dataTransfer?.getData("text/plain") ?? "").length;
727
- const replacing = getSelection()?.toString().length ?? 0;
728
- if ((active.el.textContent ?? "").length - replacing + adding > active.maxLength) e.preventDefault();
729
- };
730
- const onInput = (e: Event) => {
731
- if (active && e.target === active.el) send({ bp: 1, type: "typing", ref: active.ref, text: (active.el.textContent ?? "").trim() });
732
- };
733
- const onBlur = (e: FocusEvent) => {
734
- // The Link button asks for an address, which takes focus for a moment; that isn't leaving the edit.
735
- if (active && e.target === active.el && !bar.busy()) finish(true);
736
- };
737
- // The words being edited as runs: inside an accent element is gold, inside <strong> is important.
738
- const runsIn = (root: HTMLElement): MarkedRun[] => {
739
- const runs: MarkedRun[] = [];
740
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
741
- for (let n = walker.nextNode(); n; n = walker.nextNode()) {
742
- const p = n.parentElement && n.parentElement !== root ? n.parentElement : null;
743
- const within = (sel: string) => {
744
- const hit = p?.closest(sel);
745
- return !!hit && root.contains(hit) && hit !== root;
746
- };
747
- runs.push({ text: n.nodeValue ?? "", accent: within("[data-bp-accent]"), strong: within("strong, b"), em: within("em, i") });
748
- }
749
- return runs;
750
- };
751
- // Lays the runs into the editing copy: important words in <strong>, gold ones in copies of the page's own accent
752
- // element so they look the same.
753
- const layRuns = (root: HTMLElement, template: HTMLElement, runs: MarkedRun[]) => {
754
- root.replaceChildren(
755
- ...runs.map((r) => {
756
- let node: Node = document.createTextNode(r.text);
757
- if (r.accent) {
758
- const a = template.cloneNode(false) as HTMLElement;
759
- a.setAttribute("data-bp-accent", "");
760
- a.append(node);
761
- node = a;
762
- }
763
- if (r.em) {
764
- const i = document.createElement("em");
765
- i.append(node);
766
- node = i;
767
- }
768
- if (r.strong) {
769
- const b = document.createElement("strong");
770
- b.append(node);
771
- node = b;
772
- }
773
- return node;
774
- }),
775
- );
776
- };
777
- // Character offset of a point in the editing copy, so the selection survives laying the runs again.
778
- const offsetOf = (root: HTMLElement, node: Node, at: number): number => {
779
- let total = 0;
780
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
781
- for (let n = walker.nextNode(); n; n = walker.nextNode()) {
782
- if (n === node) return total + at;
783
- total += (n.nodeValue ?? "").length;
784
- }
785
- return node === root ? (at === 0 ? 0 : total) : total;
786
- };
787
- const pointAt = (root: HTMLElement, offset: number): [Node, number] => {
788
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
789
- let last: Node = root;
790
- for (let n = walker.nextNode(); n; n = walker.nextNode()) {
791
- const len = (n.nodeValue ?? "").length;
792
- if (offset <= len) return [n, offset];
793
- offset -= len;
794
- last = n;
795
- }
796
- return [last, last === root ? 0 : (last.nodeValue ?? "").length];
797
- };
798
- function toggleMark(mark: Mark) {
799
- if (!active?.heading || !active.heading.marks.includes(mark)) return;
800
- const el = active.el;
801
- const sel = getSelection();
802
- if (!sel || sel.rangeCount === 0) return;
803
- const range = sel.getRangeAt(0);
804
- if (!el.contains(range.commonAncestorContainer)) return;
805
- const start = offsetOf(el, range.startContainer, range.startOffset);
806
- const end = offsetOf(el, range.endContainer, range.endOffset);
807
- if (end <= start) return;
808
- const value = fromMarkedRuns(runsIn(el));
809
- const text = value.text;
810
- const current = mark === "accent" ? value.accents : mark === "strong" ? value.strong : value.em;
811
- const flags = Array.from(text, (_, i) => current.some(([s, e]) => i >= s && i < e));
812
- // Like Bold in any editor: all marked already turns plain, anything else turns marked.
813
- const all = flags.slice(start, end).every(Boolean);
814
- for (let i = start; i < end; i++) flags[i] = !all;
815
- const next: [number, number][] = [];
816
- flags.forEach((f, i) => {
817
- if (!f) return;
818
- const last = next[next.length - 1];
819
- if (last && last[1] === i) last[1] = i + 1;
820
- else next.push([i, i + 1]);
821
- });
822
- const marks = { accents: value.accents, strong: value.strong, em: value.em, [mark === "accent" ? "accents" : mark]: next };
823
- layRuns(el, active.heading.template, markedRuns(text, marks));
824
- const [sn, so] = pointAt(el, start);
825
- const [en, eo] = pointAt(el, end);
826
- const r = document.createRange();
827
- r.setStart(sn, so);
828
- r.setEnd(en, eo);
829
- sel.removeAllRanges();
830
- sel.addRange(r);
831
- send({ bp: 1, type: "typing", ref: active.ref, text });
832
- }
833
- const startInlineHeading = (ref: string, maxLength: number, text: string, accents: [number, number][], strong: [number, number][], marks: Mark[], em: [number, number][] = []): void => {
834
- if (active) return;
835
- const original = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
836
- // A heading not yet converted is words beside its separate accent value: those words edit in place as before.
837
- if (!(original instanceof HTMLElement) || (selectedRun?.ref === ref && selected !== original)) return startInline(ref, maxLength);
838
- // The page's own accent element (the first element the gold words sit in) is the look for every gold run.
839
- const shown = normalizeAccents(text, accents) ?? [];
840
- let template: HTMLElement | null = null;
841
- if (shown.length) {
842
- let at = 0;
843
- const walker = document.createTreeWalker(original, NodeFilter.SHOW_TEXT);
844
- for (let n = walker.nextNode(); n && !template; n = walker.nextNode()) {
845
- const len = (n.nodeValue ?? "").length;
846
- if (shown.some(([s, e]) => at < e && at + len > s) && n.parentElement && n.parentElement !== original) {
847
- let t: HTMLElement = n.parentElement;
848
- while (t.parentElement && t.parentElement !== original) t = t.parentElement;
849
- template = t;
850
- }
851
- at += len;
852
- }
853
- }
854
- if (!template) {
855
- // No gold words yet: an accent shows in the highlight color until the page refreshes with the site's own.
856
- template = document.createElement("span");
857
- template.style.color = "Highlight";
858
- }
859
- const copy = original.cloneNode(false) as HTMLElement;
860
- copy.removeAttribute("id");
861
- copy.removeAttribute(MARK);
862
- copy.removeAttribute(TAGGED);
863
- copy.removeAttribute(AUTO);
864
- layRuns(copy, template, markedRuns(text, { accents: shown, strong, em }));
865
- let restore: () => void;
866
- if (original.parentElement?.closest(ACTIVATES)) {
867
- // Inside a button or link the browser won't select words and a click would press it, so the words are edited in
868
- // a copy floating exactly over them, with the same look, while the original keeps its place.
869
- floatOver(copy, original);
870
- document.body.append(copy);
871
- const visibility = original.style.visibility;
872
- original.style.visibility = "hidden";
873
- restore = () => {
874
- original.style.visibility = visibility;
875
- };
876
- } else {
877
- const display = original.style.display;
878
- original.after(copy);
879
- original.style.display = "none";
880
- restore = () => {
881
- original.style.display = display;
882
- };
883
- }
884
- active = { el: copy, ref, before: text, maxLength, heading: { original, restore, template, marks } };
885
- copy.setAttribute("contenteditable", "true");
886
- copy.focus();
887
- const range = document.createRange();
888
- range.selectNodeContents(copy);
889
- getSelection()?.removeAllRanges();
890
- getSelection()?.addRange(range);
891
- overlay.show(copy, `Select words, then ${marks.map((m) => (m === "accent" ? "Accent for gold" : m === "strong" ? "B for bold" : "I for italic")).join(", ")}. Enter to save, Esc to cancel`);
892
- bar.show(copy, "heading", marks);
893
- };
894
- const startInlineRich = (ref: string) => {
895
- if (active) return;
896
- const el = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
897
- if (!(el instanceof HTMLElement)) return;
898
- active = { el, ref, before: el.innerHTML, maxLength: LIMITS_TEXT, rich: true };
899
- el.setAttribute("contenteditable", "true");
900
- el.focus();
901
- overlay.show(el, "Ctrl+Enter to save, Esc to cancel");
902
- bar.show(el, "rich");
903
- };
904
-
905
- const startInline = (ref: string, maxLength: number): void => {
906
- if (active) return;
907
- // Words inside a button or link (a step title, a tab) are edited in a floating copy; see startInlineHeading.
908
- const marked = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
909
- if (marked instanceof HTMLElement && marked.children.length === 0 && marked.parentElement?.closest(ACTIVATES)) {
910
- return startInlineHeading(ref, maxLength, (marked.textContent ?? "").trim(), [], [], []);
911
- }
912
- const run = selectedRun?.ref === ref && selectedRun.whole && selectedRun.node.isConnected ? selectedRun : null;
913
- let el: Element | null;
914
- if (run) {
915
- const original = run.node.nodeValue ?? "";
916
- const span = document.createElement("span");
917
- span.textContent = original.trim();
918
- run.node.replaceWith(span);
919
- el = span;
920
- active = {
921
- el: span,
922
- ref,
923
- before: span.textContent,
924
- maxLength,
925
- run: { node: run.node, lead: /^\s*/.exec(original)![0], trail: /\s*$/.exec(original)![0], original },
926
- };
927
- } else {
928
- el = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
929
- if (!(el instanceof HTMLElement) || el.children.length > 0) return;
930
- active = { el, ref, before: el.textContent ?? "", maxLength };
931
- }
932
- if (!(el instanceof HTMLElement)) return;
933
- el.setAttribute("contenteditable", "plaintext-only");
934
- el.focus();
935
- const range = document.createRange();
936
- range.selectNodeContents(el);
937
- getSelection()?.removeAllRanges();
938
- getSelection()?.addRange(range);
939
- overlay.show(el, "Enter to save, Esc to cancel");
940
- bar.show(el, "plain");
941
- };
942
-
943
- const onMessage = (e: MessageEvent) => {
944
- const m = adminMessageFrom(e, target.current, adminOrigin);
945
- if (!m) return;
946
- switch (m.type) {
947
- case "hello":
948
- editable.clear();
949
- for (const item of m.editable) editable.set(item.ref, item);
950
- return;
951
- case "mode":
952
- said.current.editing = m.editing;
953
- if (!m.editing) {
954
- finish(true);
955
- overlay.hide();
956
- }
957
- return;
958
- case "inline":
959
- startInline(m.ref, m.maxLength);
960
- return;
961
- case "inlineRich":
962
- startInlineRich(m.ref);
963
- return;
964
- case "inlineHeading":
965
- startInlineHeading(m.ref, m.maxLength, m.text, m.accents, m.strong ?? [], m.marks ?? ["accent", "strong"], m.em ?? []);
966
- return;
967
- case "refresh":
968
- // The saved words arrive with the refresh, in the same nodes, so the preview is let go, not undone.
969
- preview.current.forget();
970
- routerRef.current.refresh();
971
- return;
972
- case "navigate":
973
- preview.current.restoreAll();
974
- routerRef.current.push(m.path);
975
- return;
976
- case "saved":
977
- // The page already shows these words: keep them, and match them from now on.
978
- preview.current.keep(m.ref);
979
- words.current = words.current.map((c) => (`copy/${c.k}` === m.ref ? { ...c, t: m.text } : c));
980
- rescan.current();
981
- return;
982
- case "drafts":
983
- draftRefs = m.refs;
984
- drawDots();
985
- return;
986
- case "lists":
987
- lists = m.lists;
988
- placeItems();
989
- return;
990
- case "previewImage": {
991
- const imgs = Array.from(document.querySelectorAll(`img[${MARK}="${CSS.escape(m.ref)}"]`)) as HTMLImageElement[];
992
- for (const img of imgs) {
993
- if (m.url === null) {
994
- const was = photos.get(img);
995
- if (was) {
996
- if (was.srcset !== null) img.setAttribute("srcset", was.srcset);
997
- img.src = was.src;
998
- photos.delete(img);
999
- }
1000
- } else {
1001
- if (!photos.has(img)) photos.set(img, { src: img.src, srcset: img.getAttribute("srcset") });
1002
- // The site's own sizes point at the old photo, so the chosen one shows at its own address.
1003
- img.removeAttribute("srcset");
1004
- img.src = m.url;
1005
- }
1006
- }
1007
- return;
1008
- }
1009
- case "preview":
1010
- if (m.text === null) {
1011
- preview.current.restore(m.ref);
1012
- rescan.current();
1013
- } else if (editable.has(m.ref)) {
1014
- preview.current.show(m.ref, m.text, () => placesOf(m.ref));
1015
- }
1016
- return;
1017
- case "focus": {
1018
- const t: Target | null = document.querySelector(`[${MARK}="${CSS.escape(m.ref)}"]`) ?? textFor(m.ref);
1019
- if (!t) return;
1020
- (t instanceof Element ? t : t.parentElement)?.scrollIntoView({ block: "center", behavior: "smooth" });
1021
- overlay.show(t, "Changed");
1022
- clearTimeout(flash);
1023
- flash = window.setTimeout(() => {
1024
- if (!active) overlay.hide();
1025
- }, 2000);
1026
- return;
1027
- }
1028
- }
1029
- };
1030
-
1031
- addEventListener("message", onMessage);
1032
- document.addEventListener("mousemove", onMove, true);
1033
- document.addEventListener("click", onClick, true);
1034
- document.addEventListener("submit", onSubmit, true);
1035
- document.addEventListener("keydown", onKey, true);
1036
- document.addEventListener("beforeinput", onBeforeInput, true);
1037
- document.addEventListener("input", onInput, true);
1038
- document.addEventListener("blur", onBlur, true);
1039
- return () => {
1040
- removeEventListener("message", onMessage);
1041
- document.removeEventListener("mousemove", onMove, true);
1042
- document.removeEventListener("click", onClick, true);
1043
- document.removeEventListener("submit", onSubmit, true);
1044
- document.removeEventListener("keydown", onKey, true);
1045
- document.removeEventListener("beforeinput", onBeforeInput, true);
1046
- document.removeEventListener("input", onInput, true);
1047
- document.removeEventListener("blur", onBlur, true);
1048
- observer.disconnect();
1049
- preview.current.restoreAll();
1050
- clearTimeout(flash);
1051
- clearTimeout(pending);
1052
- rescan.current = () => {};
1053
- overlay.destroy();
1054
- bar.destroy();
1055
- listBar.destroy();
1056
- };
1057
- }, [adminOrigin, kit]);
1058
-
1059
- // After each move within the site, and when a refresh brings new words, mark the page and say which values it has.
1060
- // Effects run once the new page is in the document. Not in requestAnimationFrame: browsers pause that in background
1061
- // tabs, and the editor would give up waiting.
1062
- useEffect(() => {
1063
- words.current = copy;
1064
- // A new page or freshly saved words: anything previewed is gone or now real.
1065
- preview.current.forget();
1066
- rescan.current();
1067
- }, [pathname, copy]);
1068
-
1069
- return null;
1070
- }
1
+ "use client";
2
+
3
+ import { usePathname, useRouter } from "next/navigation";
4
+ import { useEffect, useRef } from "react";
5
+ import { fromMarkedRuns, markedRuns, normalizeAccents, type Mark, type MarkedRun } from "./visual-accents";
6
+ import { ACTIVATES, blocksActivation } from "./visual-clicks";
7
+ import { actionsFor, itemElement, type ListAction, type PageList } from "./visual-lists";
8
+ import { comparable, longest, matchIndex, type CopyText } from "./visual-match";
9
+ import { adminMessageFrom, collectRefs, type Editable, type KitMessage } from "./visual-messages";
10
+ import { LivePreview } from "./visual-preview";
11
+ import { mayHaveTag, readTags, stripTags } from "./visual-tags";
12
+
13
+ const MARK = "data-bp-edit";
14
+ // Set beside MARK on the marks the bridge adds itself, so marking again clears only those and never the site's own.
15
+ const AUTO = "data-bp-auto";
16
+ // Set beside MARK on a place an exact tag named. Kept when marking again: the tag was removed when it was read.
17
+ const TAGGED = "data-bp-tagged";
18
+ // Attributes that may carry Site Copy (a photo's alt, a button's label); their tags are removed, never shown.
19
+ const TEXT_ATTRS = ["alt", "title", "aria-label", "placeholder", "content", "value"];
20
+
21
+ // Never matched: code, form controls and embedded documents aren't words a visitor reads as copy.
22
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TEXTAREA", "SELECT", "INPUT", "IFRAME", "svg"]);
23
+
24
+ /**
25
+ * A value that is a bare run of text beside other elements (a heading's words before its accent span). whole: the run
26
+ * is the value's words and nothing else, so it can be edited in place.
27
+ */
28
+ type TextMark = { node: Text; ref: string; whole: boolean };
29
+ type Target = Element | Text;
30
+
31
+ const rectOf = (t: Target): DOMRect => {
32
+ if (t instanceof Element) return t.getBoundingClientRect();
33
+ const range = document.createRange();
34
+ range.selectNodeContents(t);
35
+ return range.getBoundingClientRect();
36
+ };
37
+
38
+ type Overlay = { show: (t: Target, label: string) => void; hide: () => void; dots: (ts: Target[]) => void; destroy: () => void };
39
+
40
+ // Drawn in a closed shadow root so the page's CSS can't reach it and it can't reach the page. System colors only.
41
+ function makeOverlay(): Overlay {
42
+ const host = document.createElement("div");
43
+ host.setAttribute("aria-hidden", "true");
44
+ const root = host.attachShadow({ mode: "closed" });
45
+ const box = document.createElement("div");
46
+ box.style.cssText =
47
+ "position:fixed;z-index:2147483646;pointer-events:none;outline:2px solid Highlight;outline-offset:2px;border-radius:2px;display:none;";
48
+ const tag = document.createElement("span");
49
+ tag.style.cssText =
50
+ "position:absolute;left:-2px;bottom:100%;margin-bottom:4px;padding:2px 6px;border-radius:3px;background:Highlight;color:HighlightText;font:600 11px/1.4 system-ui,sans-serif;white-space:nowrap;";
51
+ box.append(tag);
52
+ // Small dots on values with unsaved drafts, so what's waiting to publish shows on the page itself.
53
+ const dotLayer = document.createElement("div");
54
+ root.append(box, dotLayer);
55
+ let dotted: Target[] = [];
56
+ document.body.append(host);
57
+ let current: Target | null = null;
58
+ let frame = 0;
59
+ const place = () => {
60
+ frame = 0;
61
+ if (!current || !current.isConnected) {
62
+ box.style.display = "none";
63
+ return;
64
+ }
65
+ const r = rectOf(current);
66
+ Object.assign(box.style, { display: "block", left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
67
+ };
68
+ const placeDots = () => {
69
+ dotLayer.replaceChildren(
70
+ ...dotted.filter((t) => t.isConnected).map((t) => {
71
+ const r = rectOf(t);
72
+ const d = document.createElement("span");
73
+ d.title = "Unpublished change";
74
+ d.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;width:8px;height:8px;border-radius:50%;background:Highlight;box-shadow:0 0 0 2px Canvas;left:${r.right - 2}px;top:${r.top - 6}px;`;
75
+ return d;
76
+ }),
77
+ );
78
+ };
79
+ const schedule = () => {
80
+ if (!frame)
81
+ frame = requestAnimationFrame(() => {
82
+ place();
83
+ placeDots();
84
+ });
85
+ };
86
+ addEventListener("scroll", schedule, true);
87
+ addEventListener("resize", schedule);
88
+ return {
89
+ show(t, label) {
90
+ current = t;
91
+ tag.textContent = label;
92
+ place();
93
+ },
94
+ hide() {
95
+ current = null;
96
+ box.style.display = "none";
97
+ },
98
+ dots(ts) {
99
+ dotted = ts;
100
+ placeDots();
101
+ },
102
+ destroy() {
103
+ removeEventListener("scroll", schedule, true);
104
+ removeEventListener("resize", schedule);
105
+ if (frame) cancelAnimationFrame(frame);
106
+ host.remove();
107
+ },
108
+ };
109
+ }
110
+
111
+ // Formatted text has no length limit of its own here; Site Manager checks the saved value.
112
+ const LIMITS_TEXT = Number.MAX_SAFE_INTEGER;
113
+
114
+ type FormatBar = { show: (el: HTMLElement, mode: "rich" | "heading" | "plain", marks?: Mark[]) => void; hide: () => void; busy: () => boolean; destroy: () => void };
115
+
116
+ /**
117
+ * The Bold, Italic, Link and Done bar shown above a formatted value while it's edited in place. In its own closed
118
+ * shadow root like the outline, with system colors only. Buttons act on mousedown and keep the page's selection.
119
+ */
120
+ function makeFormatBar(done: () => void, mark: (m: Mark) => void, expand: () => void): FormatBar {
121
+ const host = document.createElement("div");
122
+ host.setAttribute("aria-hidden", "false");
123
+ const root = host.attachShadow({ mode: "closed" });
124
+ const bar = document.createElement("div");
125
+ bar.setAttribute("role", "toolbar");
126
+ bar.setAttribute("aria-label", "Formatting");
127
+ bar.style.cssText =
128
+ "position:fixed;z-index:2147483647;display:none;gap:2px;padding:3px;border-radius:6px;background:Canvas;color:CanvasText;border:1px solid GrayText;box-shadow:0 2px 8px rgba(0,0,0,.2);font:600 12px/1 system-ui,sans-serif;";
129
+ let asking = false;
130
+ let target: HTMLElement | null = null;
131
+ const button = (label: string, title: string, act: () => void) => {
132
+ const b = document.createElement("button");
133
+ b.type = "button";
134
+ b.textContent = label;
135
+ b.title = title;
136
+ b.style.cssText = "all:unset;cursor:pointer;padding:5px 8px;border-radius:4px;";
137
+ b.addEventListener("mousedown", (e) => {
138
+ e.preventDefault();
139
+ act();
140
+ });
141
+ return b;
142
+ };
143
+ const accentButton = button("Accent", "Show the selected words in the accent color, or plain again (Ctrl+E)", () => mark("accent"));
144
+ // Bold on a plain text value marks the words as important, so the site renders them as <strong>.
145
+ const strongButton = button("B", "Bold: mark the selected words as important (Ctrl+B)", () => mark("strong"));
146
+ strongButton.style.fontWeight = "800";
147
+ // Italic marks the words as emphasized, so the site renders them as <em>.
148
+ const emButton = button("I", "Italic: mark the selected words as emphasized (Ctrl+I)", () => mark("em"));
149
+ emButton.style.fontStyle = "italic";
150
+ const richButtons = [
151
+ button("B", "Bold (Ctrl+B)", () => document.execCommand("bold")),
152
+ button("I", "Italic (Ctrl+I)", () => document.execCommand("italic")),
153
+ button("Link", "Link the selected words", () => {
154
+ asking = true;
155
+ const url = window.prompt("Link address (https://, mailto: or a page like /about)");
156
+ asking = false;
157
+ target?.focus();
158
+ // Only addresses a visitor can follow; Site Manager sanitizes the HTML again when it saves.
159
+ if (url && /^(https?:\/\/|mailto:|tel:|\/)/i.test(url.trim())) document.execCommand("createLink", false, url.trim());
160
+ }),
161
+ ];
162
+ const doneButton = button("Done", "Save (Ctrl+Enter)", done);
163
+ // Moves this value into Site Manager's side panel, for long text or a bigger box.
164
+ const expandButton = button("\u2922", "Edit in the side panel", expand);
165
+ expandButton.setAttribute("aria-label", "Edit in the side panel");
166
+ bar.append(...richButtons, strongButton, emButton, accentButton, expandButton, doneButton);
167
+ root.append(bar);
168
+ document.body.append(host);
169
+ const place = () => {
170
+ if (!target) return;
171
+ const r = target.getBoundingClientRect();
172
+ // Below the words, so it never covers the hint shown above them; above the hint when there's no room below.
173
+ const below = r.bottom + 8;
174
+ const top = below + 40 > innerHeight ? Math.max(4, r.top - 72) : below;
175
+ Object.assign(bar.style, { display: "flex", left: `${Math.max(4, r.left)}px`, top: `${top}px` });
176
+ };
177
+ addEventListener("scroll", place, true);
178
+ return {
179
+ show(el, mode, marks = ["accent", "strong"]) {
180
+ target = el;
181
+ for (const b of richButtons) b.style.display = mode === "rich" ? "" : "none";
182
+ accentButton.style.display = mode === "heading" && marks.includes("accent") ? "" : "none";
183
+ strongButton.style.display = mode === "heading" && marks.includes("strong") ? "" : "none";
184
+ emButton.style.display = mode === "heading" && marks.includes("em") ? "" : "none";
185
+ doneButton.title = mode === "rich" ? "Save (Ctrl+Enter)" : "Save (Enter)";
186
+ place();
187
+ },
188
+ hide() {
189
+ target = null;
190
+ bar.style.display = "none";
191
+ },
192
+ busy: () => asking,
193
+ destroy() {
194
+ removeEventListener("scroll", place, true);
195
+ host.remove();
196
+ },
197
+ };
198
+ }
199
+
200
+ type ListBar = { show: (el: Element, actions: ListAction[], act: (a: ListAction) => void) => void; hide: () => void; over: () => boolean; destroy: () => void };
201
+
202
+ const ACTION_LABELS: Record<ListAction, [string, string]> = {
203
+ up: ["\u2191", "Move up"],
204
+ down: ["\u2193", "Move down"],
205
+ remove: ["\u2715", "Remove this item"],
206
+ add: ["+", "Add an item after this one"],
207
+ };
208
+
209
+ /** The small buttons at the top right of a list item, in their own closed shadow root with system colors. */
210
+ function makeListBar(): ListBar {
211
+ const host = document.createElement("div");
212
+ const root = host.attachShadow({ mode: "closed" });
213
+ const bar = document.createElement("div");
214
+ bar.setAttribute("role", "toolbar");
215
+ bar.setAttribute("aria-label", "List item");
216
+ bar.style.cssText =
217
+ "position:fixed;z-index:2147483647;display:none;gap:2px;padding:2px;border-radius:6px;background:Canvas;color:CanvasText;border:1px solid GrayText;box-shadow:0 2px 8px rgba(0,0,0,.2);font:600 13px/1 system-ui,sans-serif;";
218
+ root.append(bar);
219
+ document.body.append(host);
220
+ let hovering = false;
221
+ bar.addEventListener("mouseenter", () => (hovering = true));
222
+ bar.addEventListener("mouseleave", () => (hovering = false));
223
+ let target: Element | null = null;
224
+ const place = () => {
225
+ if (!target?.isConnected) return void (bar.style.display = "none");
226
+ const r = target.getBoundingClientRect();
227
+ Object.assign(bar.style, { display: "flex", left: `${Math.max(4, r.right - bar.offsetWidth - 4)}px`, top: `${Math.max(4, r.top + 4)}px` });
228
+ };
229
+ addEventListener("scroll", place, true);
230
+ return {
231
+ show(el, actions, act) {
232
+ if (target === el && bar.childElementCount === actions.length) return place();
233
+ target = el;
234
+ bar.replaceChildren(
235
+ ...actions.map((a) => {
236
+ const b = document.createElement("button");
237
+ b.type = "button";
238
+ b.textContent = ACTION_LABELS[a][0];
239
+ b.title = ACTION_LABELS[a][1];
240
+ b.setAttribute("aria-label", ACTION_LABELS[a][1]);
241
+ b.style.cssText = "all:unset;cursor:pointer;padding:4px 8px;border-radius:4px;";
242
+ b.addEventListener("click", (e) => {
243
+ e.preventDefault();
244
+ e.stopPropagation();
245
+ act(a);
246
+ });
247
+ return b;
248
+ }),
249
+ );
250
+ bar.style.display = "flex";
251
+ place();
252
+ },
253
+ hide() {
254
+ if (hovering) return;
255
+ target = null;
256
+ bar.style.display = "none";
257
+ },
258
+ over: () => hovering,
259
+ destroy() {
260
+ removeEventListener("scroll", place, true);
261
+ host.remove();
262
+ },
263
+ };
264
+ }
265
+
266
+ /** Puts an editing copy exactly over the original, with its look, for words the page can't edit where they are. */
267
+ function floatOver(copy: HTMLElement, original: HTMLElement) {
268
+ const cs = getComputedStyle(original);
269
+ const r = original.getBoundingClientRect();
270
+ // The nearest background behind the words, so the copy reads the same; the system canvas color if none is set.
271
+ let bg = "Canvas";
272
+ for (let e: Element | null = original; e; e = e.parentElement) {
273
+ const c = getComputedStyle(e).backgroundColor;
274
+ if (c && c !== "transparent" && !/rgba\(.*,\s*0\)$/.test(c)) {
275
+ bg = c;
276
+ break;
277
+ }
278
+ }
279
+ Object.assign(copy.style, {
280
+ position: "absolute",
281
+ left: `${r.left + scrollX}px`,
282
+ top: `${r.top + scrollY}px`,
283
+ minWidth: `${r.width}px`,
284
+ maxWidth: `${Math.max(r.width, innerWidth - r.left - 8)}px`,
285
+ margin: "0",
286
+ padding: "0",
287
+ zIndex: "2147483640",
288
+ font: cs.font,
289
+ letterSpacing: cs.letterSpacing,
290
+ textTransform: cs.textTransform,
291
+ lineHeight: cs.lineHeight,
292
+ textAlign: cs.textAlign,
293
+ color: cs.color,
294
+ background: bg,
295
+ whiteSpace: "normal",
296
+ userSelect: "text",
297
+ outline: "none",
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Reads the exact tags getCopy() put in each value (visual mode only), removes them from the page and marks the place
303
+ * each one names: the element whose words are exactly the value, or the run of text itself when the element holds
304
+ * more (a heading's words beside its accent). Returns the runs found this time; tagged elements keep their mark.
305
+ */
306
+ function readPageTags(values: Map<string, string>): TextMark[] {
307
+ const runs: TextMark[] = [];
308
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
309
+ const found: Text[] = [];
310
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) if (mayHaveTag(n.nodeValue ?? "")) found.push(n as Text);
311
+ for (const node of found) {
312
+ const parent = node.parentElement;
313
+ if (!parent || parent.tagName === "SCRIPT" || parent.tagName === "STYLE") continue;
314
+ const { clean, tags } = readTags(node.nodeValue ?? "");
315
+ // Only nodeValue, so React keeps owning the node and a later change to the value still reaches it.
316
+ node.nodeValue = clean;
317
+ // Two values in one run of text (a template string) can't be told apart; both stay unmarked.
318
+ if (tags.length !== 1) continue;
319
+ const ref = tags[0]!.ref;
320
+ const value = values.get(ref);
321
+ const words = comparable(clean);
322
+ if (value === undefined || words === value) {
323
+ // Inside a place the site marked itself with editMarks(): the site's mark wins.
324
+ if (parent.closest(`[${MARK}]:not([${TAGGED}]):not([${AUTO}])`)) continue;
325
+ if (comparable(parent.textContent ?? "") === words) {
326
+ parent.setAttribute(MARK, ref);
327
+ parent.setAttribute(TAGGED, "");
328
+ parent.removeAttribute(AUTO);
329
+ } else runs.push({ node, ref, whole: true });
330
+ continue;
331
+ }
332
+ // Rich text: the tag sits in its last paragraph, so the value is the nearest element holding all its words.
333
+ let el: Element | null = parent;
334
+ for (let i = 0; el && i < 8; i++, el = el.parentElement) {
335
+ if (comparable(el.textContent ?? "") === value) {
336
+ el.setAttribute(MARK, ref);
337
+ el.setAttribute(TAGGED, "");
338
+ el.removeAttribute(AUTO);
339
+ break;
340
+ }
341
+ }
342
+ // The value with other words around it in one run: shown, but only the side panel can change it.
343
+ if (!el && words.includes(value)) runs.push({ node, ref, whole: false });
344
+ }
345
+ for (const el of Array.from(document.querySelectorAll(TEXT_ATTRS.map((a) => `[${a}]`).join(",")))) {
346
+ for (const a of TEXT_ATTRS) {
347
+ const v = el.getAttribute(a);
348
+ if (!v || !mayHaveTag(v)) continue;
349
+ const { clean, tags } = readTags(v);
350
+ el.setAttribute(a, clean);
351
+ // A photo's tag is in its alt text: the photo itself is the place.
352
+ if (a === "alt" && el.tagName === "IMG" && tags.length === 1 && !el.closest(`[${MARK}]:not([${TAGGED}]):not([${AUTO}])`)) {
353
+ el.setAttribute(MARK, tags[0]!.ref);
354
+ el.setAttribute(TAGGED, "");
355
+ }
356
+ }
357
+ }
358
+ if (mayHaveTag(document.title)) document.title = stripTags(document.title);
359
+ return runs;
360
+ }
361
+
362
+ /**
363
+ * Marks the page's copy from the index: the deepest element whose words are exactly one value gets MARK, and a bare
364
+ * run of text that is one value, in an element that holds other elements too, comes back as a text mark. Elements
365
+ * the site marked itself, and everything inside them, are left as they are.
366
+ */
367
+ function markPage(index: Map<string, string>): TextMark[] {
368
+ for (const el of Array.from(document.querySelectorAll(`[${AUTO}]`))) {
369
+ el.removeAttribute(MARK);
370
+ el.removeAttribute(AUTO);
371
+ }
372
+ const texts: TextMark[] = [];
373
+ if (!index.size) return texts;
374
+ const max = longest(index);
375
+ const words = (n: Node) => comparable(n.textContent ?? "");
376
+ const visit = (el: Element) => {
377
+ if (SKIP.has(el.tagName) || el.hasAttribute(MARK) || el.shadowRoot) return;
378
+ const w = words(el);
379
+ if (!w) return;
380
+ if (w.length <= max) {
381
+ const ref = index.get(w);
382
+ if (ref && !Array.from(el.children).some((c) => words(c) === w)) {
383
+ el.setAttribute(MARK, ref);
384
+ el.setAttribute(AUTO, "");
385
+ return;
386
+ }
387
+ }
388
+ for (const child of Array.from(el.childNodes)) {
389
+ if (child instanceof Element) visit(child);
390
+ else if (child instanceof Text) {
391
+ const ref = index.get(words(child));
392
+ if (ref) texts.push({ node: child, ref, whole: true });
393
+ }
394
+ }
395
+ };
396
+ visit(document.body);
397
+ return texts;
398
+ }
399
+
400
+ const marked = (t: EventTarget | null) => (t instanceof Element ? t.closest(`[${MARK}]`) : null);
401
+
402
+ /**
403
+ * The client half of VisualEditing. Talks only to the window that showed it (the frame's parent, or the window that
404
+ * opened it where the page has its own window) and only at the signed admin origin.
405
+ */
406
+ export function VisualBridge({ adminOrigin, kit, copy }: { adminOrigin: string; kit: string; copy: CopyText[] }) {
407
+ const router = useRouter();
408
+ const pathname = usePathname();
409
+ const target = useRef<Window | null>(null);
410
+ // The path and refs last reported, so a page isn't reported twice (Strict Mode, marking again that found nothing new).
411
+ const reported = useRef<{ path: string; refs: string } | null>(null);
412
+ const routerRef = useRef(router);
413
+ // What Site Manager said, kept outside the effect: React may set the bridge up again (Strict Mode, Fast Refresh)
414
+ // without Site Manager saying it twice.
415
+ const said = useRef({ editable: new Map<string, Editable>(), editing: false });
416
+ const texts = useRef<TextMark[]>([]);
417
+ // Runs of text an exact tag named. Their tags are gone once read, so they're kept until they leave the page.
418
+ const tagRuns = useRef<TextMark[]>([]);
419
+ const words = useRef(copy);
420
+ const preview = useRef(new LivePreview());
421
+ // Set up by the main effect: marks the page again and reports it when its refs changed.
422
+ const rescan = useRef<() => void>(() => {});
423
+ useEffect(() => {
424
+ routerRef.current = router;
425
+ }, [router]);
426
+
427
+ useEffect(() => {
428
+ target.current = window.parent !== window ? window.parent : window.opener;
429
+ if (!target.current) return;
430
+ const send = (m: KitMessage) => target.current?.postMessage(m, adminOrigin);
431
+ const { editable } = said.current;
432
+ const overlay = makeOverlay();
433
+ const bar = makeFormatBar(
434
+ () => finish(true),
435
+ (m) => toggleMark(m),
436
+ () => {
437
+ // Puts the words back as they were and lets Site Manager open the same value in its side panel.
438
+ if (!active) return;
439
+ const ref = active.ref;
440
+ finish(false);
441
+ send({ bp: 1, type: "expand", ref });
442
+ },
443
+ );
444
+ const listBar = makeListBar();
445
+ // The lists Site Manager said are on the page, and where each shown item is, found again after each scan.
446
+ let lists: PageList[] = [];
447
+ let items: { el: Element; path: string; index: number; actions: ListAction[] }[] = [];
448
+ const placeItems = () => {
449
+ const elementOf = (ref: string) => targetsOf(ref).map((t) => (t instanceof Element ? t : t.parentElement)).filter((e): e is Element => !!e);
450
+ items = lists.flatMap((l) => {
451
+ const shown = l.items.slice(0, Math.min(l.shown, l.items.length));
452
+ return shown.flatMap((it, index) => {
453
+ const mine = it.refs.flatMap(elementOf);
454
+ const others = shown.filter((_, n) => n !== index).flatMap((o) => o.refs.flatMap(elementOf));
455
+ const el = itemElement(mine, others);
456
+ const actions = actionsFor(l, index);
457
+ return el && actions.length ? [{ el, path: l.path, index, actions }] : [];
458
+ });
459
+ });
460
+ };
461
+ let selected: Element | null = null;
462
+ let selectedRun: TextMark | null = null;
463
+ // run: a heading's words beside its accent, edited in a span put in for the edit; the page's own node goes back after.
464
+ let active: {
465
+ el: HTMLElement;
466
+ ref: string;
467
+ before: string;
468
+ maxLength: number;
469
+ // A formatted value: before is its HTML, Enter makes a new paragraph, and the bar is shown.
470
+ rich?: boolean;
471
+ // A heading edited as a copy of its element beside the hidden original, which React keeps owning.
472
+ heading?: { original: HTMLElement; restore: () => void; template: HTMLElement; marks: Mark[] };
473
+ run?: { node: Text; lead: string; trail: string; original: string };
474
+ } | null = null;
475
+ let flash = 0;
476
+ let pending = 0;
477
+ // Values with unsaved drafts (from Site Manager) and the value Tab last moved to.
478
+ let draftRefs: string[] = [];
479
+ // Photos shown in place of a value's photo while one is chosen, with what they showed before.
480
+ const photos = new Map<HTMLImageElement, { src: string; srcset: string | null }>();
481
+ let focused: Target | null = null;
482
+
483
+ rescan.current = () => {
484
+ // Never while someone is typing: the words they're changing no longer match the value.
485
+ if (active || preview.current.active) return;
486
+ const path = location.pathname + location.search;
487
+ const values = new Map(words.current.map((c) => [`copy/${c.k}`, comparable(c.t)] as const));
488
+ tagRuns.current = [...tagRuns.current.filter((m) => m.node.isConnected), ...readPageTags(values)];
489
+ // Values found by their tag aren't looked for by their words too.
490
+ const tagged = new Set([...Array.from(document.querySelectorAll(`[${TAGGED}]`), (el) => el.getAttribute(MARK)), ...tagRuns.current.map((m) => m.ref)]);
491
+ const index = new Map([...matchIndex(words.current, location.pathname)].filter(([, ref]) => !tagged.has(ref)));
492
+ texts.current = [...tagRuns.current, ...markPage(index)];
493
+ drawDots();
494
+ placeItems();
495
+ const refs = collectRefs([...Array.from(document.querySelectorAll(`[${MARK}]`), (el) => el.getAttribute(MARK)), ...texts.current.map((t) => t.ref)]);
496
+ const last = reported.current;
497
+ const now = { path, refs: refs.join("\n") };
498
+ if (last && last.path === now.path && last.refs === now.refs) return;
499
+ send(last === null ? { bp: 1, type: "ready", kit, path, refs } : { bp: 1, type: "navigated", path, refs });
500
+ reported.current = now;
501
+ };
502
+
503
+ // Content the page shows after it loads (tabs, accordions, a refresh with new drafts) is marked too.
504
+ const observer = new MutationObserver(() => {
505
+ clearTimeout(pending);
506
+ pending = window.setTimeout(() => rescan.current(), 250);
507
+ });
508
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
509
+
510
+ // Every place a value shows: its marked elements and its runs of text.
511
+ const targetsOf = (ref: string): Target[] => [
512
+ ...Array.from(document.querySelectorAll(`[${MARK}="${CSS.escape(ref)}"]`)),
513
+ ...texts.current.filter((m) => m.ref === ref && m.node.isConnected).map((m) => m.node),
514
+ ];
515
+ function drawDots() {
516
+ overlay.dots(draftRefs.flatMap(targetsOf));
517
+ }
518
+ // The values that can be changed, in page order, for Tab.
519
+ const ordered = (): { t: Target; ref: string }[] => {
520
+ const all = [
521
+ ...Array.from(document.querySelectorAll(`[${MARK}]`), (el) => ({ t: el as Target, ref: el.getAttribute(MARK) ?? "" })),
522
+ ...texts.current.filter((m) => m.node.isConnected).map((m) => ({ t: m.node as Target, ref: m.ref })),
523
+ ].filter((x) => editable.has(x.ref));
524
+ return all.sort((a, b) => (a.t.compareDocumentPosition(b.t) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
525
+ };
526
+ const known = (el: Element | null) => (el && editable.has(el.getAttribute(MARK) ?? "") ? el : null);
527
+ const textAt = (e: MouseEvent): TextMark | null => {
528
+ const within = e.target instanceof Element ? e.target : null;
529
+ if (!within) return null;
530
+ for (const m of texts.current) {
531
+ if (!m.node.isConnected || !editable.has(m.ref) || !within.contains(m.node)) continue;
532
+ const range = document.createRange();
533
+ range.selectNodeContents(m.node);
534
+ for (const r of Array.from(range.getClientRects())) {
535
+ if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return m;
536
+ }
537
+ }
538
+ return null;
539
+ };
540
+ const textFor = (ref: string) => texts.current.find((m) => m.ref === ref && m.node.isConnected)?.node ?? null;
541
+ // Every plain text place a value shows: marked elements that hold only text, and its bare runs of text. A marked
542
+ // element with other elements inside (rich text, a link with an icon) isn't previewed; it changes on save.
543
+ const placesOf = (ref: string): Text[][] => [
544
+ ...Array.from(document.querySelectorAll(`[${MARK}="${CSS.escape(ref)}"]`))
545
+ .filter((el) => el.children.length === 0)
546
+ .map((el) => Array.from(el.childNodes).filter((n): n is Text => n instanceof Text)),
547
+ ...texts.current.filter((m) => m.ref === ref && m.node.isConnected).map((m) => [m.node]),
548
+ ];
549
+
550
+ const finish = (commit: boolean) => {
551
+ if (!active) return;
552
+ const { el, before, ref, run, rich, heading } = active;
553
+ active = null;
554
+ bar.hide();
555
+ el.removeAttribute("contenteditable");
556
+ const text = (el.textContent ?? "").trim();
557
+ if (heading) {
558
+ bar.hide();
559
+ const value = fromMarkedRuns(runsIn(el));
560
+ el.remove();
561
+ heading.restore();
562
+ // Site Manager saves and refreshes the page, which shows the new words in the page's own element.
563
+ send(commit ? { bp: 1, type: "commit", ref, text: value.text, accents: value.accents, strong: value.strong, em: value.em } : { bp: 1, type: "cancel", ref });
564
+ overlay.hide();
565
+ return;
566
+ }
567
+ if (rich) {
568
+ bar.hide();
569
+ if (commit) send({ bp: 1, type: "commit", ref, text, html: stripTags(el.innerHTML) });
570
+ else {
571
+ // Formatted values come from the site's HTML, which a refresh sets again, so putting it back is safe.
572
+ el.innerHTML = before;
573
+ send({ bp: 1, type: "cancel", ref });
574
+ }
575
+ overlay.hide();
576
+ return;
577
+ }
578
+ if (run) {
579
+ // The page's own node goes back with the words, so React still owns it and the saved draft reaches it.
580
+ run.node.nodeValue = commit ? run.lead + text + run.trail : run.original;
581
+ el.replaceWith(run.node);
582
+ } else if (!commit) el.textContent = before;
583
+ send(commit ? { bp: 1, type: "commit", ref, text } : { bp: 1, type: "cancel", ref });
584
+ overlay.hide();
585
+ };
586
+
587
+ // mousemove, not mouseover: a run of text shares its element with others, so moving within it matters.
588
+ const onMove = (e: MouseEvent) => {
589
+ if (!said.current.editing || active) return;
590
+ // The innermost list item under the pointer gets its buttons.
591
+ const t = e.target instanceof Node ? e.target : null;
592
+ const item = t ? items.filter((i) => i.el.contains(t)).sort((a, b) => (a.el.contains(b.el) ? 1 : -1))[0] : undefined;
593
+ if (item) listBar.show(item.el, item.actions, (action) => send({ bp: 1, type: "listAction", path: item.path, index: item.index, action }));
594
+ else if (!listBar.over()) listBar.hide();
595
+ const el = known(marked(e.target));
596
+ const text = el ? null : textAt(e);
597
+ const ref = el ? el.getAttribute(MARK) : text?.ref;
598
+ if (!ref) return overlay.hide();
599
+ // The value's own name tells people what they're about to change; "Click to edit" for an older Site Manager.
600
+ overlay.show(el ?? text!.node, editable.get(ref)?.label ?? "Click to edit");
601
+ };
602
+ const onClick = (e: MouseEvent) => {
603
+ if (!said.current.editing) return;
604
+ if (active && active.el.contains(e.target as Node)) return;
605
+ // Edit mode: a link or button never acts, whether or not its words can be changed.
606
+ if (blocksActivation(e.target)) {
607
+ e.preventDefault();
608
+ e.stopPropagation();
609
+ }
610
+ const el = known(marked(e.target));
611
+ const text = el ? null : textAt(e);
612
+ const ref = el ? (el.getAttribute(MARK) ?? "") : text?.ref;
613
+ if (!ref) return;
614
+ e.preventDefault();
615
+ e.stopPropagation();
616
+ choose(ref, el, text);
617
+ };
618
+ const choose = (ref: string, el: Element | null, text: TextMark | null) => {
619
+ selected = el;
620
+ selectedRun = text;
621
+ const r = rectOf(el ?? text!.node);
622
+ send({
623
+ bp: 1,
624
+ type: "select",
625
+ ref,
626
+ rect: { x: r.left, y: r.top, w: r.width, h: r.height },
627
+ // A run that is the value's words alone edits in place too; one with other words around it opens the panel.
628
+ plain: el ? el.children.length === 0 : text!.whole,
629
+ });
630
+ };
631
+ const onSubmit = (e: SubmitEvent) => {
632
+ if (!said.current.editing) return;
633
+ e.preventDefault();
634
+ e.stopPropagation();
635
+ };
636
+ const onKey = (e: KeyboardEvent) => {
637
+ const mod = e.ctrlKey || e.metaKey;
638
+ if (!active && mod && (e.key === "z" || e.key === "Z" || e.key === "y")) {
639
+ // Undo and redo belong to Site Manager's history of this page's edits, not the page.
640
+ e.preventDefault();
641
+ send({ bp: 1, type: "key", key: e.key === "y" || e.shiftKey ? "redo" : "undo" });
642
+ return;
643
+ }
644
+ if (!active && said.current.editing && e.key === "Tab") {
645
+ // Tab moves between the values on the page instead of the page's links.
646
+ const list = ordered();
647
+ if (!list.length) return;
648
+ e.preventDefault();
649
+ const at = focused ? list.findIndex((x) => x.t === focused) : -1;
650
+ const next = list[(at + (e.shiftKey ? -1 : 1) + list.length) % list.length]!;
651
+ focused = next.t;
652
+ (next.t instanceof Element ? next.t : next.t.parentElement)?.scrollIntoView({ block: "nearest" });
653
+ overlay.show(next.t, `${editable.get(next.ref)?.label ?? "Value"}: Enter to edit`);
654
+ return;
655
+ }
656
+ if (!active && said.current.editing && focused && e.key === "Enter" && focused.isConnected) {
657
+ e.preventDefault();
658
+ e.stopPropagation();
659
+ const t = focused;
660
+ const run = t instanceof Text ? (texts.current.find((m) => m.node === t) ?? null) : null;
661
+ const el = t instanceof Element ? t : null;
662
+ const ref = el ? el.getAttribute(MARK) : run?.ref;
663
+ if (ref) choose(ref, el, run);
664
+ return;
665
+ }
666
+ if (!active && focused && e.key === "Escape") {
667
+ focused = null;
668
+ overlay.hide();
669
+ return;
670
+ }
671
+ if (!active && said.current.editing && (e.key === "Enter" || e.key === " ") && blocksActivation(e.target)) {
672
+ e.preventDefault();
673
+ e.stopPropagation();
674
+ return;
675
+ }
676
+ if (!active || !active.el.contains(e.target as Node)) return;
677
+ if (active.heading) {
678
+ if (e.key === "Enter") {
679
+ e.preventDefault();
680
+ finish(true);
681
+ } else if (e.key === "Escape") {
682
+ e.preventDefault();
683
+ finish(false);
684
+ } else if (mod && e.key === "e" && active.heading.marks.includes("accent")) {
685
+ e.preventDefault();
686
+ toggleMark("accent");
687
+ } else if (mod && e.key === "b") {
688
+ // Never the browser's own bold, which would type markup the value doesn't keep.
689
+ e.preventDefault();
690
+ if (active.heading.marks.includes("strong")) toggleMark("strong");
691
+ } else if (mod && e.key === "i") {
692
+ // The same for italic: only the value's own mark, never the browser's markup.
693
+ e.preventDefault();
694
+ if (active.heading.marks.includes("em")) toggleMark("em");
695
+ }
696
+ return;
697
+ }
698
+ if (active.rich) {
699
+ // Enter starts a new paragraph; Ctrl+Enter saves, Escape cancels.
700
+ if (e.key === "Enter" && mod) {
701
+ e.preventDefault();
702
+ finish(true);
703
+ } else if (e.key === "Escape") {
704
+ e.preventDefault();
705
+ finish(false);
706
+ } else if (mod && (e.key === "b" || e.key === "i")) {
707
+ e.preventDefault();
708
+ document.execCommand(e.key === "b" ? "bold" : "italic");
709
+ }
710
+ return;
711
+ }
712
+ if (e.key === "Enter" && !e.shiftKey) {
713
+ e.preventDefault();
714
+ finish(true);
715
+ } else if (e.key === "Escape") {
716
+ e.preventDefault();
717
+ finish(false);
718
+ }
719
+ };
720
+ const onBeforeInput = (e: InputEvent) => {
721
+ if (!active || active.rich || !active.el.contains(e.target as Node) || !e.inputType.startsWith("insert")) return;
722
+ if (e.inputType === "insertParagraph" || e.inputType === "insertLineBreak") {
723
+ e.preventDefault();
724
+ return;
725
+ }
726
+ const adding = (e.data ?? e.dataTransfer?.getData("text/plain") ?? "").length;
727
+ const replacing = getSelection()?.toString().length ?? 0;
728
+ if ((active.el.textContent ?? "").length - replacing + adding > active.maxLength) e.preventDefault();
729
+ };
730
+ const onInput = (e: Event) => {
731
+ if (active && e.target === active.el) send({ bp: 1, type: "typing", ref: active.ref, text: (active.el.textContent ?? "").trim() });
732
+ };
733
+ const onBlur = (e: FocusEvent) => {
734
+ // The Link button asks for an address, which takes focus for a moment; that isn't leaving the edit.
735
+ if (active && e.target === active.el && !bar.busy()) finish(true);
736
+ };
737
+ // The words being edited as runs: inside an accent element is gold, inside <strong> is important.
738
+ const runsIn = (root: HTMLElement): MarkedRun[] => {
739
+ const runs: MarkedRun[] = [];
740
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
741
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
742
+ const p = n.parentElement && n.parentElement !== root ? n.parentElement : null;
743
+ const within = (sel: string) => {
744
+ const hit = p?.closest(sel);
745
+ return !!hit && root.contains(hit) && hit !== root;
746
+ };
747
+ runs.push({ text: n.nodeValue ?? "", accent: within("[data-bp-accent]"), strong: within("strong, b"), em: within("em, i") });
748
+ }
749
+ return runs;
750
+ };
751
+ // Lays the runs into the editing copy: important words in <strong>, gold ones in copies of the page's own accent
752
+ // element so they look the same.
753
+ const layRuns = (root: HTMLElement, template: HTMLElement, runs: MarkedRun[]) => {
754
+ root.replaceChildren(
755
+ ...runs.map((r) => {
756
+ let node: Node = document.createTextNode(r.text);
757
+ if (r.accent) {
758
+ const a = template.cloneNode(false) as HTMLElement;
759
+ a.setAttribute("data-bp-accent", "");
760
+ a.append(node);
761
+ node = a;
762
+ }
763
+ if (r.em) {
764
+ const i = document.createElement("em");
765
+ i.append(node);
766
+ node = i;
767
+ }
768
+ if (r.strong) {
769
+ const b = document.createElement("strong");
770
+ b.append(node);
771
+ node = b;
772
+ }
773
+ return node;
774
+ }),
775
+ );
776
+ };
777
+ // Character offset of a point in the editing copy, so the selection survives laying the runs again.
778
+ const offsetOf = (root: HTMLElement, node: Node, at: number): number => {
779
+ let total = 0;
780
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
781
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
782
+ if (n === node) return total + at;
783
+ total += (n.nodeValue ?? "").length;
784
+ }
785
+ return node === root ? (at === 0 ? 0 : total) : total;
786
+ };
787
+ const pointAt = (root: HTMLElement, offset: number): [Node, number] => {
788
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
789
+ let last: Node = root;
790
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
791
+ const len = (n.nodeValue ?? "").length;
792
+ if (offset <= len) return [n, offset];
793
+ offset -= len;
794
+ last = n;
795
+ }
796
+ return [last, last === root ? 0 : (last.nodeValue ?? "").length];
797
+ };
798
+ function toggleMark(mark: Mark) {
799
+ if (!active?.heading || !active.heading.marks.includes(mark)) return;
800
+ const el = active.el;
801
+ const sel = getSelection();
802
+ if (!sel || sel.rangeCount === 0) return;
803
+ const range = sel.getRangeAt(0);
804
+ if (!el.contains(range.commonAncestorContainer)) return;
805
+ const start = offsetOf(el, range.startContainer, range.startOffset);
806
+ const end = offsetOf(el, range.endContainer, range.endOffset);
807
+ if (end <= start) return;
808
+ const value = fromMarkedRuns(runsIn(el));
809
+ const text = value.text;
810
+ const current = mark === "accent" ? value.accents : mark === "strong" ? value.strong : value.em;
811
+ const flags = Array.from(text, (_, i) => current.some(([s, e]) => i >= s && i < e));
812
+ // Like Bold in any editor: all marked already turns plain, anything else turns marked.
813
+ const all = flags.slice(start, end).every(Boolean);
814
+ for (let i = start; i < end; i++) flags[i] = !all;
815
+ const next: [number, number][] = [];
816
+ flags.forEach((f, i) => {
817
+ if (!f) return;
818
+ const last = next[next.length - 1];
819
+ if (last && last[1] === i) last[1] = i + 1;
820
+ else next.push([i, i + 1]);
821
+ });
822
+ const marks = { accents: value.accents, strong: value.strong, em: value.em, [mark === "accent" ? "accents" : mark]: next };
823
+ layRuns(el, active.heading.template, markedRuns(text, marks));
824
+ const [sn, so] = pointAt(el, start);
825
+ const [en, eo] = pointAt(el, end);
826
+ const r = document.createRange();
827
+ r.setStart(sn, so);
828
+ r.setEnd(en, eo);
829
+ sel.removeAllRanges();
830
+ sel.addRange(r);
831
+ send({ bp: 1, type: "typing", ref: active.ref, text });
832
+ }
833
+ const startInlineHeading = (ref: string, maxLength: number, text: string, accents: [number, number][], strong: [number, number][], marks: Mark[], em: [number, number][] = []): void => {
834
+ if (active) return;
835
+ const original = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
836
+ // A heading not yet converted is words beside its separate accent value: those words edit in place as before.
837
+ if (!(original instanceof HTMLElement) || (selectedRun?.ref === ref && selected !== original)) return startInline(ref, maxLength);
838
+ // The page's own accent element (the first element the gold words sit in) is the look for every gold run.
839
+ const shown = normalizeAccents(text, accents) ?? [];
840
+ let template: HTMLElement | null = null;
841
+ if (shown.length) {
842
+ let at = 0;
843
+ const walker = document.createTreeWalker(original, NodeFilter.SHOW_TEXT);
844
+ for (let n = walker.nextNode(); n && !template; n = walker.nextNode()) {
845
+ const len = (n.nodeValue ?? "").length;
846
+ if (shown.some(([s, e]) => at < e && at + len > s) && n.parentElement && n.parentElement !== original) {
847
+ let t: HTMLElement = n.parentElement;
848
+ while (t.parentElement && t.parentElement !== original) t = t.parentElement;
849
+ template = t;
850
+ }
851
+ at += len;
852
+ }
853
+ }
854
+ if (!template) {
855
+ // No gold words yet: an accent shows in the highlight color until the page refreshes with the site's own.
856
+ template = document.createElement("span");
857
+ template.style.color = "Highlight";
858
+ }
859
+ const copy = original.cloneNode(false) as HTMLElement;
860
+ copy.removeAttribute("id");
861
+ copy.removeAttribute(MARK);
862
+ copy.removeAttribute(TAGGED);
863
+ copy.removeAttribute(AUTO);
864
+ layRuns(copy, template, markedRuns(text, { accents: shown, strong, em }));
865
+ let restore: () => void;
866
+ if (original.parentElement?.closest(ACTIVATES)) {
867
+ // Inside a button or link the browser won't select words and a click would press it, so the words are edited in
868
+ // a copy floating exactly over them, with the same look, while the original keeps its place.
869
+ floatOver(copy, original);
870
+ document.body.append(copy);
871
+ const visibility = original.style.visibility;
872
+ original.style.visibility = "hidden";
873
+ restore = () => {
874
+ original.style.visibility = visibility;
875
+ };
876
+ } else {
877
+ const display = original.style.display;
878
+ original.after(copy);
879
+ original.style.display = "none";
880
+ restore = () => {
881
+ original.style.display = display;
882
+ };
883
+ }
884
+ active = { el: copy, ref, before: text, maxLength, heading: { original, restore, template, marks } };
885
+ copy.setAttribute("contenteditable", "true");
886
+ copy.focus();
887
+ const range = document.createRange();
888
+ range.selectNodeContents(copy);
889
+ getSelection()?.removeAllRanges();
890
+ getSelection()?.addRange(range);
891
+ overlay.show(copy, `Select words, then ${marks.map((m) => (m === "accent" ? "Accent for gold" : m === "strong" ? "B for bold" : "I for italic")).join(", ")}. Enter to save, Esc to cancel`);
892
+ bar.show(copy, "heading", marks);
893
+ };
894
+ const startInlineRich = (ref: string) => {
895
+ if (active) return;
896
+ const el = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
897
+ if (!(el instanceof HTMLElement)) return;
898
+ active = { el, ref, before: el.innerHTML, maxLength: LIMITS_TEXT, rich: true };
899
+ el.setAttribute("contenteditable", "true");
900
+ el.focus();
901
+ overlay.show(el, "Ctrl+Enter to save, Esc to cancel");
902
+ bar.show(el, "rich");
903
+ };
904
+
905
+ const startInline = (ref: string, maxLength: number): void => {
906
+ if (active) return;
907
+ // Words inside a button or link (a step title, a tab) are edited in a floating copy; see startInlineHeading.
908
+ const marked = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
909
+ if (marked instanceof HTMLElement && marked.children.length === 0 && marked.parentElement?.closest(ACTIVATES)) {
910
+ return startInlineHeading(ref, maxLength, (marked.textContent ?? "").trim(), [], [], []);
911
+ }
912
+ const run = selectedRun?.ref === ref && selectedRun.whole && selectedRun.node.isConnected ? selectedRun : null;
913
+ let el: Element | null;
914
+ if (run) {
915
+ const original = run.node.nodeValue ?? "";
916
+ const span = document.createElement("span");
917
+ span.textContent = original.trim();
918
+ run.node.replaceWith(span);
919
+ el = span;
920
+ active = {
921
+ el: span,
922
+ ref,
923
+ before: span.textContent,
924
+ maxLength,
925
+ run: { node: run.node, lead: /^\s*/.exec(original)![0], trail: /\s*$/.exec(original)![0], original },
926
+ };
927
+ } else {
928
+ el = selected?.getAttribute(MARK) === ref && selected.isConnected ? selected : document.querySelector(`[${MARK}="${CSS.escape(ref)}"]`);
929
+ if (!(el instanceof HTMLElement) || el.children.length > 0) return;
930
+ active = { el, ref, before: el.textContent ?? "", maxLength };
931
+ }
932
+ if (!(el instanceof HTMLElement)) return;
933
+ el.setAttribute("contenteditable", "plaintext-only");
934
+ el.focus();
935
+ const range = document.createRange();
936
+ range.selectNodeContents(el);
937
+ getSelection()?.removeAllRanges();
938
+ getSelection()?.addRange(range);
939
+ overlay.show(el, "Enter to save, Esc to cancel");
940
+ bar.show(el, "plain");
941
+ };
942
+
943
+ const onMessage = (e: MessageEvent) => {
944
+ const m = adminMessageFrom(e, target.current, adminOrigin);
945
+ if (!m) return;
946
+ switch (m.type) {
947
+ case "hello":
948
+ editable.clear();
949
+ for (const item of m.editable) editable.set(item.ref, item);
950
+ return;
951
+ case "mode":
952
+ said.current.editing = m.editing;
953
+ if (!m.editing) {
954
+ finish(true);
955
+ overlay.hide();
956
+ }
957
+ return;
958
+ case "inline":
959
+ startInline(m.ref, m.maxLength);
960
+ return;
961
+ case "inlineRich":
962
+ startInlineRich(m.ref);
963
+ return;
964
+ case "inlineHeading":
965
+ startInlineHeading(m.ref, m.maxLength, m.text, m.accents, m.strong ?? [], m.marks ?? ["accent", "strong"], m.em ?? []);
966
+ return;
967
+ case "refresh":
968
+ // The saved words arrive with the refresh, in the same nodes, so the preview is let go, not undone.
969
+ preview.current.forget();
970
+ routerRef.current.refresh();
971
+ return;
972
+ case "navigate":
973
+ preview.current.restoreAll();
974
+ routerRef.current.push(m.path);
975
+ return;
976
+ case "saved":
977
+ // The page already shows these words: keep them, and match them from now on.
978
+ preview.current.keep(m.ref);
979
+ words.current = words.current.map((c) => (`copy/${c.k}` === m.ref ? { ...c, t: m.text } : c));
980
+ rescan.current();
981
+ return;
982
+ case "drafts":
983
+ draftRefs = m.refs;
984
+ drawDots();
985
+ return;
986
+ case "lists":
987
+ lists = m.lists;
988
+ placeItems();
989
+ return;
990
+ case "previewImage": {
991
+ const imgs = Array.from(document.querySelectorAll(`img[${MARK}="${CSS.escape(m.ref)}"]`)) as HTMLImageElement[];
992
+ for (const img of imgs) {
993
+ if (m.url === null) {
994
+ const was = photos.get(img);
995
+ if (was) {
996
+ if (was.srcset !== null) img.setAttribute("srcset", was.srcset);
997
+ img.src = was.src;
998
+ photos.delete(img);
999
+ }
1000
+ } else {
1001
+ if (!photos.has(img)) photos.set(img, { src: img.src, srcset: img.getAttribute("srcset") });
1002
+ // The site's own sizes point at the old photo, so the chosen one shows at its own address.
1003
+ img.removeAttribute("srcset");
1004
+ img.src = m.url;
1005
+ }
1006
+ }
1007
+ return;
1008
+ }
1009
+ case "preview":
1010
+ if (m.text === null) {
1011
+ preview.current.restore(m.ref);
1012
+ rescan.current();
1013
+ } else if (editable.has(m.ref)) {
1014
+ preview.current.show(m.ref, m.text, () => placesOf(m.ref));
1015
+ }
1016
+ return;
1017
+ case "focus": {
1018
+ const t: Target | null = document.querySelector(`[${MARK}="${CSS.escape(m.ref)}"]`) ?? textFor(m.ref);
1019
+ if (!t) return;
1020
+ (t instanceof Element ? t : t.parentElement)?.scrollIntoView({ block: "center", behavior: "smooth" });
1021
+ overlay.show(t, "Changed");
1022
+ clearTimeout(flash);
1023
+ flash = window.setTimeout(() => {
1024
+ if (!active) overlay.hide();
1025
+ }, 2000);
1026
+ return;
1027
+ }
1028
+ }
1029
+ };
1030
+
1031
+ addEventListener("message", onMessage);
1032
+ document.addEventListener("mousemove", onMove, true);
1033
+ document.addEventListener("click", onClick, true);
1034
+ document.addEventListener("submit", onSubmit, true);
1035
+ document.addEventListener("keydown", onKey, true);
1036
+ document.addEventListener("beforeinput", onBeforeInput, true);
1037
+ document.addEventListener("input", onInput, true);
1038
+ document.addEventListener("blur", onBlur, true);
1039
+ return () => {
1040
+ removeEventListener("message", onMessage);
1041
+ document.removeEventListener("mousemove", onMove, true);
1042
+ document.removeEventListener("click", onClick, true);
1043
+ document.removeEventListener("submit", onSubmit, true);
1044
+ document.removeEventListener("keydown", onKey, true);
1045
+ document.removeEventListener("beforeinput", onBeforeInput, true);
1046
+ document.removeEventListener("input", onInput, true);
1047
+ document.removeEventListener("blur", onBlur, true);
1048
+ observer.disconnect();
1049
+ preview.current.restoreAll();
1050
+ clearTimeout(flash);
1051
+ clearTimeout(pending);
1052
+ rescan.current = () => {};
1053
+ overlay.destroy();
1054
+ bar.destroy();
1055
+ listBar.destroy();
1056
+ };
1057
+ }, [adminOrigin, kit]);
1058
+
1059
+ // After each move within the site, and when a refresh brings new words, mark the page and say which values it has.
1060
+ // Effects run once the new page is in the document. Not in requestAnimationFrame: browsers pause that in background
1061
+ // tabs, and the editor would give up waiting.
1062
+ useEffect(() => {
1063
+ words.current = copy;
1064
+ // A new page or freshly saved words: anything previewed is gone or now real.
1065
+ preview.current.forget();
1066
+ rescan.current();
1067
+ }, [pathname, copy]);
1068
+
1069
+ return null;
1070
+ }