@capacms/sdk 1.0.0-next.1 → 1.0.0-next.10

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 (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1698 -193
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +235 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -36
  12. package/dist/config.js +47 -1
  13. package/dist/esm/image/index.d.ts +120 -0
  14. package/dist/esm/image/index.js +250 -0
  15. package/dist/esm/image/shared-params.generated.d.ts +190 -0
  16. package/dist/esm/image/shared-params.generated.js +461 -0
  17. package/dist/esm/nextjs/image-loader.d.ts +60 -0
  18. package/dist/esm/nextjs/image-loader.js +67 -0
  19. package/dist/esm/nextjs/overlay.d.ts +30 -0
  20. package/dist/esm/nextjs/overlay.js +75 -0
  21. package/dist/esm/overlay/index.d.ts +32 -0
  22. package/dist/esm/overlay/index.js +576 -0
  23. package/dist/esm/overlay/protocol.d.ts +187 -0
  24. package/dist/esm/overlay/protocol.js +240 -0
  25. package/dist/esm/package.json +4 -0
  26. package/dist/graphql-codegen.d.ts +117 -0
  27. package/dist/graphql-codegen.js +705 -0
  28. package/dist/http.js +1 -1
  29. package/dist/image/index.d.ts +120 -0
  30. package/dist/image/index.js +257 -0
  31. package/dist/image/shared-params.generated.d.ts +190 -0
  32. package/dist/image/shared-params.generated.js +471 -0
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -1
  35. package/dist/next/attrs.d.ts +84 -10
  36. package/dist/next/attrs.js +119 -2
  37. package/dist/next/client.d.ts +176 -32
  38. package/dist/next/client.js +212 -90
  39. package/dist/next/entry-fields.d.ts +162 -0
  40. package/dist/next/entry-fields.js +2 -0
  41. package/dist/next/errors.d.ts +136 -0
  42. package/dist/next/errors.js +214 -0
  43. package/dist/next/field-names.d.ts +37 -0
  44. package/dist/next/field-names.js +145 -0
  45. package/dist/next/graphql/build.d.ts +27 -0
  46. package/dist/next/graphql/build.js +98 -0
  47. package/dist/next/graphql/documents.d.ts +67 -0
  48. package/dist/next/graphql/documents.js +35 -0
  49. package/dist/next/graphql/edit-mode.d.ts +16 -0
  50. package/dist/next/graphql/edit-mode.js +93 -0
  51. package/dist/next/graphql/filter-values.d.ts +34 -0
  52. package/dist/next/graphql/filter-values.js +96 -0
  53. package/dist/next/graphql/introspection.d.ts +89 -0
  54. package/dist/next/graphql/introspection.js +102 -0
  55. package/dist/next/graphql/plan.d.ts +115 -0
  56. package/dist/next/graphql/plan.js +531 -0
  57. package/dist/next/graphql/request.d.ts +228 -0
  58. package/dist/next/graphql/request.js +283 -0
  59. package/dist/next/graphql/rest.d.ts +66 -0
  60. package/dist/next/graphql/rest.js +502 -0
  61. package/dist/next/graphql/selection.d.ts +55 -0
  62. package/dist/next/graphql/selection.js +212 -0
  63. package/dist/next/graphql/sha256.d.ts +13 -0
  64. package/dist/next/graphql/sha256.js +86 -0
  65. package/dist/next/graphql/summary.d.ts +83 -0
  66. package/dist/next/graphql/summary.js +151 -0
  67. package/dist/next/graphql/tree-layout.d.ts +36 -0
  68. package/dist/next/graphql/tree-layout.js +20 -0
  69. package/dist/next/graphql/tree.d.ts +171 -0
  70. package/dist/next/graphql/tree.js +249 -0
  71. package/dist/next/graphql/typed.d.ts +261 -0
  72. package/dist/next/graphql/typed.js +146 -0
  73. package/dist/next/index.d.ts +30 -5
  74. package/dist/next/index.js +32 -1
  75. package/dist/next/inflate.d.ts +51 -0
  76. package/dist/next/inflate.js +243 -0
  77. package/dist/next/key-family.d.ts +31 -0
  78. package/dist/next/key-family.js +66 -0
  79. package/dist/next/select-types.d.ts +58 -5
  80. package/dist/next/system-keys.d.ts +27 -0
  81. package/dist/next/system-keys.js +42 -0
  82. package/dist/nextjs/image-loader.d.ts +60 -0
  83. package/dist/nextjs/image-loader.js +71 -0
  84. package/dist/nextjs/index.d.ts +484 -5
  85. package/dist/nextjs/index.js +688 -6
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +14 -2
  89. package/dist/overlay/index.js +282 -43
  90. package/dist/overlay/protocol.d.ts +98 -2
  91. package/dist/overlay/protocol.js +151 -4
  92. package/package.json +63 -15
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ /**
3
+ * `<CapaOverlay />`: Capa's live preview overlay as one Next.js client
4
+ * component (M6).
5
+ *
6
+ * // app/layout.tsx (a server component)
7
+ * import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";
8
+ * {edit ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
9
+ *
10
+ * Render it only in edit mode (`editMode()` from `@capacms/sdk/nextjs`), so a
11
+ * visitor never downloads it. Inside the Capa editor it outlines the focused
12
+ * field, reports clicks back, and on a save re-renders the page with
13
+ * `router.refresh()`, keeping the scroll position. Outside a Capa frame it does
14
+ * nothing at all.
15
+ *
16
+ * Its own entry point, apart from `@capacms/sdk/nextjs`, because it imports
17
+ * `react` and `next/navigation` and is a client module: the server helpers must
18
+ * stay free of both. A bundler that imports it gets an ES module, so it sees
19
+ * that only `useRouter` is used from `next/navigation` and leaves the chunks a
20
+ * visitor loads as they were.
21
+ */
22
+ import { useEffect, useRef, useState, useTransition } from "react";
23
+ import { useRouter } from "next/navigation";
24
+ import { startOverlay } from "../overlay/index.js";
25
+ /**
26
+ * While a refresh is pending, the overlay updates its own unused state this
27
+ * often. See `CapaOverlay` for why.
28
+ */
29
+ const REFRESH_NUDGE_MS = 300;
30
+ /**
31
+ * The refresh runs in a transition, so `isPending` says when the new draft
32
+ * has been committed to the screen, and the overlay settles the refresh
33
+ * then: it puts the scroll position back, and a refresh that has not
34
+ * committed within `refreshTimeoutMs` reloads.
35
+ *
36
+ * While the transition is pending the overlay makes a no-op state update
37
+ * every `REFRESH_NUDGE_MS`. React 19.2 under Next 15.5 can leave a draft
38
+ * refresh suspended after every part of its streamed response has arrived:
39
+ * the page suspends inside an already visible Suspense boundary (a root
40
+ * `loading.tsx` makes one around every page), and the signal that its data
41
+ * is ready is lost, so nothing commits until some other state update. Any
42
+ * state update makes React retry the suspended render, which then completes.
43
+ * A refresh that commits on its own stops the nudges at once. Draft mode
44
+ * only: a visitor never runs this component.
45
+ */
46
+ export function CapaOverlay({ adminOrigins, refresh = "in-place", refreshTimeoutMs }) {
47
+ const router = useRouter();
48
+ const [refreshing, startRefresh] = useTransition();
49
+ const [, nudge] = useState(0);
50
+ /** One resolver per refresh waiting for its transition to commit. */
51
+ const waiting = useRef([]);
52
+ // A string, so a new array with the same origins does not restart it.
53
+ const origins = adminOrigins.join(",");
54
+ useEffect(() => startOverlay({
55
+ adminOrigins: origins.split(",").filter(Boolean),
56
+ onRefresh: refresh === "reload"
57
+ ? undefined
58
+ : () => new Promise((resolve) => {
59
+ waiting.current.push(resolve);
60
+ startRefresh(() => router.refresh());
61
+ }),
62
+ refreshTimeoutMs,
63
+ }), [origins, router, refresh, refreshTimeoutMs]);
64
+ useEffect(() => {
65
+ if (!refreshing) {
66
+ // Committed: every refresh started before now is on screen.
67
+ for (const settle of waiting.current.splice(0))
68
+ settle();
69
+ return;
70
+ }
71
+ const timer = window.setInterval(() => nudge((n) => n + 1), REFRESH_NUDGE_MS);
72
+ return () => window.clearInterval(timer);
73
+ }, [refreshing]);
74
+ return null;
75
+ }
@@ -0,0 +1,32 @@
1
+ export { acceptMessage, hoverMessage, normaliseOrigins, patchedText, pickCentred, readyMessage, scrollEdge, selectMessage, textAround, visibleMessage, ADMIN_SOURCE, PATCH_MAX_FIELDS, PATCH_MAX_LENGTH, PROTOCOL_VERSION, SITE_FEATURES, SITE_SOURCE, } from "./protocol.js";
2
+ export type { AdminMessage, MessageLike, PatchField, SiteMessage, TaggedBox } from "./protocol.js";
3
+ export interface OverlayOptions {
4
+ /**
5
+ * The Capa admin origins allowed to drive this page, for example
6
+ * `["https://app.capacms.com"]`. A message from anywhere else is ignored.
7
+ */
8
+ adminOrigins: string[];
9
+ /**
10
+ * How to re-render the draft after the editor saves. `router.refresh()` in a
11
+ * Next app. Without it the page reloads. Scroll position is kept either way.
12
+ *
13
+ * Return a promise that settles once the new draft is on screen, and the
14
+ * overlay waits for it: a promise that rejects, or is still pending after
15
+ * `refreshTimeoutMs`, falls back to a reload, which keeps the scroll
16
+ * position too. A function that returns nothing counts as done at once.
17
+ */
18
+ onRefresh?: () => void | Promise<void>;
19
+ /**
20
+ * How long `onRefresh`'s promise may stay pending before the page reloads
21
+ * instead. 10 seconds.
22
+ */
23
+ refreshTimeoutMs?: number;
24
+ }
25
+ /** How long an in-place refresh may take before the page reloads instead. */
26
+ export declare const REFRESH_TIMEOUT_MS = 10000;
27
+ /**
28
+ * Start the overlay. Returns a disposer that removes every listener and the
29
+ * drawing layer. Calling it again while it runs updates the options and returns
30
+ * the same disposer, so a React effect that runs twice does not listen twice.
31
+ */
32
+ export declare function startOverlay(options: OverlayOptions): () => void;
@@ -0,0 +1,576 @@
1
+ /**
2
+ * `@capacms/sdk/overlay`: the site half of Capa's live preview.
3
+ *
4
+ * "use client";
5
+ * useEffect(() => startOverlay({ adminOrigins: ["https://app.capacms.com"] }), []);
6
+ *
7
+ * Inside the Capa editor's preview frame this outlines the element an editor is
8
+ * working on, reports clicks on tagged elements back to the editor, shows the
9
+ * text being typed in the elements that show it verbatim, and re-renders the
10
+ * draft after a save. Outside a frame it does nothing at all:
11
+ * no listeners, no DOM, no cost.
12
+ *
13
+ * A click on a tagged element selects its field. A ⌘-click (Ctrl-click on
14
+ * Windows and Linux) on a tagged element that is a link, or sits inside one,
15
+ * follows the link in the frame instead, so an editor can move between pages.
16
+ *
17
+ * Vanilla DOM and no imports beyond the pure protocol module, so it works in
18
+ * any framework and adds nothing to a bundle that does not call it.
19
+ */
20
+ import { acceptMessage, hoverMessage, normaliseOrigins, patchedText, pickCentred, readyMessage, scrollEdge, selectMessage, SITE_FEATURES, textAround, visibleMessage, } from "./protocol.js";
21
+ export { acceptMessage, hoverMessage, normaliseOrigins, patchedText, pickCentred, readyMessage, scrollEdge, selectMessage, textAround, visibleMessage, ADMIN_SOURCE, PATCH_MAX_FIELDS, PATCH_MAX_LENGTH, PROTOCOL_VERSION, SITE_FEATURES, SITE_SOURCE, } from "./protocol.js";
22
+ const BLUE = "#2563eb";
23
+ const TAGGED = "[data-capa-entry][data-capa-field]";
24
+ /** What a ⌘-click on a tagged element follows when it sits in one. */
25
+ const LINK = "a[href]";
26
+ /** Survives the `location.reload()` fallback so the page comes back where it was. */
27
+ const SCROLL_KEY = "capa-overlay:scrollY";
28
+ /** How long a refresh keeps putting the scroll position back once the page has re-rendered. */
29
+ const RESTORE_WINDOW_MS = 1500;
30
+ /** How long an in-place refresh may take before the page reloads instead. */
31
+ export const REFRESH_TIMEOUT_MS = 10_000;
32
+ /** `visible` is sent at most this often while the page scrolls. */
33
+ const VISIBLE_THROTTLE_MS = 150;
34
+ /**
35
+ * Apple platforms open a link with ⌘, Windows and Linux with Ctrl. Read from
36
+ * the browser's own platform report, falling back to the user agent.
37
+ */
38
+ function isApple(nav) {
39
+ if (!nav)
40
+ return false;
41
+ const data = nav.userAgentData;
42
+ return /mac|iphone|ipad|ipod/i.test(data?.platform || nav.platform || nav.userAgent || "");
43
+ }
44
+ /** A usable refresh timeout: a positive number of milliseconds, or the default. */
45
+ function timeoutOf(ms) {
46
+ return typeof ms === "number" && ms > 0 && Number.isFinite(ms) ? ms : REFRESH_TIMEOUT_MS;
47
+ }
48
+ const noop = () => { };
49
+ let running = null;
50
+ /**
51
+ * Start the overlay. Returns a disposer that removes every listener and the
52
+ * drawing layer. Calling it again while it runs updates the options and returns
53
+ * the same disposer, so a React effect that runs twice does not listen twice.
54
+ */
55
+ export function startOverlay(options) {
56
+ if (typeof window === "undefined" || typeof document === "undefined")
57
+ return noop;
58
+ // Not framed: this is an ordinary visit to the site.
59
+ if (window.parent === window)
60
+ return noop;
61
+ if (running) {
62
+ running.setOptions(options);
63
+ return running.dispose;
64
+ }
65
+ running = createOverlay(options);
66
+ return running.dispose;
67
+ }
68
+ function createOverlay(initial) {
69
+ let origins = normaliseOrigins(initial.adminOrigins ?? []);
70
+ let onRefresh = initial.onRefresh;
71
+ let refreshTimeout = timeoutOf(initial.refreshTimeoutMs);
72
+ const apple = isApple(typeof navigator === "undefined" ? undefined : navigator);
73
+ const linkHint = apple ? "⌘-click to open link" : "Ctrl-click to open link";
74
+ /** The origin that said hello. Every message this page sends goes there and nowhere else. */
75
+ let adminOrigin = null;
76
+ let highlight = null;
77
+ let outlineAll = false;
78
+ let hovered = null;
79
+ /** The link the pointer is in, when it is also over a tagged element: a ⌘-click follows it. */
80
+ let hoveredLink = null;
81
+ let lastReady = "";
82
+ /** Where a refresh keeps the page. `until` is Infinity while the refresh is still pending. */
83
+ let pendingScroll = null;
84
+ /** Counts refreshes, so only the latest one settles the scroll or reloads. */
85
+ let refreshes = 0;
86
+ let refreshTimer = 0;
87
+ /** True while this overlay replays a ⌘-click as a plain click, which it must let through. */
88
+ let following = false;
89
+ let layer = null;
90
+ let frame = 0;
91
+ let readyFrame = 0;
92
+ let hoverFrame = 0;
93
+ let lastPointer = null;
94
+ let visibleTimer = 0;
95
+ let lastVisible = "";
96
+ const post = (message) => {
97
+ if (!adminOrigin)
98
+ return;
99
+ window.parent.postMessage(message, adminOrigin);
100
+ };
101
+ // ------------------------------------------------------------- drawing ---
102
+ const ensureLayer = () => {
103
+ if (layer && layer.isConnected)
104
+ return layer;
105
+ layer = document.createElement("div");
106
+ layer.setAttribute("data-capa-overlay", "");
107
+ layer.setAttribute("aria-hidden", "true");
108
+ Object.assign(layer.style, {
109
+ position: "fixed",
110
+ inset: "0",
111
+ pointerEvents: "none",
112
+ zIndex: "2147483647",
113
+ overflow: "hidden",
114
+ });
115
+ document.body.appendChild(layer);
116
+ return layer;
117
+ };
118
+ const tagged = () => Array.from(document.querySelectorAll(TAGGED));
119
+ const matching = (entryId, field) => tagged().filter((el) => el.getAttribute("data-capa-entry") === entryId &&
120
+ (field === null || el.getAttribute("data-capa-field") === field));
121
+ const box = (el, style, label) => {
122
+ const rect = el.getBoundingClientRect();
123
+ if (rect.width === 0 && rect.height === 0)
124
+ return null;
125
+ const pad = style === "faint" ? 1 : 3;
126
+ const div = document.createElement("div");
127
+ Object.assign(div.style, {
128
+ position: "absolute",
129
+ left: `${rect.left - pad}px`,
130
+ top: `${rect.top - pad}px`,
131
+ width: `${rect.width + pad * 2}px`,
132
+ height: `${rect.height + pad * 2}px`,
133
+ boxSizing: "border-box",
134
+ borderRadius: "3px",
135
+ border: style === "faint"
136
+ ? "1px solid rgba(37, 99, 235, 0.35)"
137
+ : style === "hover"
138
+ ? `2px dashed ${BLUE}`
139
+ : `2px solid ${BLUE}`,
140
+ });
141
+ if (label) {
142
+ const chip = document.createElement("span");
143
+ chip.textContent = label;
144
+ // Above the box when there is room, tucked inside its top edge when the
145
+ // element sits at the very top of the viewport.
146
+ const above = rect.top - pad >= 20;
147
+ Object.assign(chip.style, {
148
+ position: "absolute",
149
+ left: "-2px",
150
+ top: above ? "-20px" : "0px",
151
+ background: BLUE,
152
+ color: "#fff",
153
+ font: "500 11px/18px system-ui, -apple-system, 'Segoe UI', sans-serif",
154
+ padding: "0 6px",
155
+ borderRadius: "3px",
156
+ whiteSpace: "nowrap",
157
+ letterSpacing: "0",
158
+ });
159
+ div.appendChild(chip);
160
+ }
161
+ return div;
162
+ };
163
+ const draw = () => {
164
+ frame = 0;
165
+ const nothing = !outlineAll && !highlight && !hovered;
166
+ if (nothing) {
167
+ if (layer)
168
+ layer.replaceChildren();
169
+ return;
170
+ }
171
+ const target = ensureLayer();
172
+ const boxes = [];
173
+ const active = highlight ? matching(highlight.entryId, highlight.field) : [];
174
+ if (outlineAll) {
175
+ for (const el of tagged()) {
176
+ if (active.includes(el))
177
+ continue;
178
+ const b = box(el, "faint", null);
179
+ if (b)
180
+ boxes.push(b);
181
+ }
182
+ }
183
+ // Over a link, the label says how to follow it.
184
+ const hint = hovered && hoveredLink ? ` · ${linkHint}` : "";
185
+ if (hovered && hovered.isConnected && !active.includes(hovered)) {
186
+ const b = box(hovered, "hover", `${hovered.getAttribute("data-capa-field") ?? ""}${hint}`);
187
+ if (b)
188
+ boxes.push(b);
189
+ }
190
+ active.forEach((el, i) => {
191
+ const name = i === 0 ? (highlight?.field ?? "entry") : null;
192
+ const label = el === hovered && hint ? `${name ?? el.getAttribute("data-capa-field") ?? ""}${hint}` : name;
193
+ const b = box(el, "active", label);
194
+ if (b)
195
+ boxes.push(b);
196
+ });
197
+ target.replaceChildren(...boxes);
198
+ };
199
+ const render = () => {
200
+ if (frame)
201
+ return;
202
+ frame = window.requestAnimationFrame(draw);
203
+ };
204
+ // --------------------------------------------------------------- ready ---
205
+ const sendReady = (force) => {
206
+ readyFrame = 0;
207
+ if (!adminOrigin)
208
+ return;
209
+ const entries = [];
210
+ for (const el of tagged()) {
211
+ const id = el.getAttribute("data-capa-entry");
212
+ if (id && !entries.includes(id))
213
+ entries.push(id);
214
+ }
215
+ const path = window.location.pathname;
216
+ const key = `${path}\n${entries.join(",")}`;
217
+ if (!force && key === lastReady)
218
+ return;
219
+ lastReady = key;
220
+ post(readyMessage(path, entries, SITE_FEATURES));
221
+ };
222
+ const scheduleReady = () => {
223
+ if (readyFrame)
224
+ return;
225
+ readyFrame = window.requestAnimationFrame(() => sendReady(false));
226
+ };
227
+ // ------------------------------------------------------------- visible ---
228
+ /**
229
+ * Which tagged element sits at the centre of the viewport, reported when it
230
+ * changes. The editor's "Follow the page" scrolls the form to match. Sent
231
+ * only to an admin that has said hello, and only on a change, so a still
232
+ * page sends nothing.
233
+ */
234
+ const reportVisible = () => {
235
+ visibleTimer = 0;
236
+ if (!adminOrigin)
237
+ return;
238
+ const boxes = tagged().map((el) => {
239
+ const rect = el.getBoundingClientRect();
240
+ return {
241
+ entryId: el.getAttribute("data-capa-entry") ?? "",
242
+ field: el.getAttribute("data-capa-field") ?? "",
243
+ top: rect.top,
244
+ bottom: rect.bottom,
245
+ };
246
+ });
247
+ const edge = scrollEdge(window.scrollY, window.innerHeight, document.documentElement.scrollHeight);
248
+ const pick = pickCentred(boxes, window.innerHeight, edge);
249
+ if (!pick)
250
+ return;
251
+ const key = `${pick.entryId}\n${pick.field}`;
252
+ if (key === lastVisible)
253
+ return;
254
+ lastVisible = key;
255
+ post(visibleMessage(pick.entryId, pick.field));
256
+ };
257
+ const onScroll = () => {
258
+ render();
259
+ if (!visibleTimer)
260
+ visibleTimer = window.setTimeout(reportVisible, VISIBLE_THROTTLE_MS);
261
+ };
262
+ // -------------------------------------------------------------- scroll ---
263
+ const restoreScroll = () => {
264
+ if (!pendingScroll)
265
+ return;
266
+ if (Date.now() > pendingScroll.until) {
267
+ pendingScroll = null;
268
+ return;
269
+ }
270
+ if (Math.abs(window.scrollY - pendingScroll.y) > 1)
271
+ window.scrollTo(0, pendingScroll.y);
272
+ };
273
+ // A `location.reload()` fallback left the position behind before it went.
274
+ try {
275
+ const saved = window.sessionStorage.getItem(SCROLL_KEY);
276
+ if (saved !== null) {
277
+ window.sessionStorage.removeItem(SCROLL_KEY);
278
+ const y = Number(saved);
279
+ if (Number.isFinite(y)) {
280
+ pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
281
+ window.requestAnimationFrame(restoreScroll);
282
+ }
283
+ }
284
+ }
285
+ catch {
286
+ // Storage blocked (a sandboxed or third-party frame): start at the top.
287
+ }
288
+ /** The editor scrolled on purpose: stop putting the old position back. */
289
+ const onScrollIntent = () => {
290
+ pendingScroll = null;
291
+ };
292
+ const reload = (y) => {
293
+ try {
294
+ window.sessionStorage.setItem(SCROLL_KEY, String(y));
295
+ }
296
+ catch {
297
+ // Nowhere to keep it; the reload lands at the top.
298
+ }
299
+ window.location.reload();
300
+ };
301
+ /**
302
+ * Re-render the draft in place, or reload. While an in-place refresh is
303
+ * pending the position is held (a page whose re-render moves things keeps
304
+ * its place), and for a moment after it lands. A refresh that fails, or is
305
+ * still pending after `refreshTimeout`, reloads from where the editor is.
306
+ */
307
+ const refresh = () => {
308
+ const y = window.scrollY;
309
+ const refreshFn = onRefresh;
310
+ if (!refreshFn) {
311
+ reload(y);
312
+ return;
313
+ }
314
+ const id = ++refreshes;
315
+ pendingScroll = { y, until: Infinity };
316
+ if (refreshTimer)
317
+ window.clearTimeout(refreshTimer);
318
+ refreshTimer = window.setTimeout(() => {
319
+ refreshTimer = 0;
320
+ if (id === refreshes)
321
+ reload(pendingScroll?.y ?? window.scrollY);
322
+ }, refreshTimeout);
323
+ Promise.resolve()
324
+ .then(() => refreshFn())
325
+ .then(() => {
326
+ // A later refresh owns the timer and the scroll from here on.
327
+ if (id !== refreshes)
328
+ return;
329
+ window.clearTimeout(refreshTimer);
330
+ refreshTimer = 0;
331
+ if (pendingScroll)
332
+ pendingScroll = { y: pendingScroll.y, until: Date.now() + RESTORE_WINDOW_MS };
333
+ window.requestAnimationFrame(restoreScroll);
334
+ }, () => {
335
+ if (id !== refreshes)
336
+ return;
337
+ window.clearTimeout(refreshTimer);
338
+ refreshTimer = 0;
339
+ reload(pendingScroll?.y ?? window.scrollY);
340
+ });
341
+ };
342
+ // ---------------------------------------------------- unsaved text, live ---
343
+ /** What this overlay last wrote into a text node, and the whitespace it found around the text. */
344
+ const written = new WeakMap();
345
+ /** The text node a patch wrote into, by element, so one typed away to nothing is found again. */
346
+ const writtenIn = new WeakMap();
347
+ /**
348
+ * The one text node that holds a tagged element's text: through a chain of
349
+ * single elements (`<a><span>Book now</span></a>`), and only when there is
350
+ * exactly one. Text split across elements is not one field's text, and is
351
+ * left alone. Comments (React's text separators) do not count.
352
+ */
353
+ const textNodeOf = (el) => {
354
+ const kept = writtenIn.get(el);
355
+ if (kept && kept.parentNode && el.contains(kept))
356
+ return kept;
357
+ let node = el;
358
+ for (;;) {
359
+ const kids = Array.from(node.childNodes).filter((c) => c.nodeType === 1 || (c.nodeType === 3 && c.data.trim() !== ""));
360
+ if (kids.length === 1 && kids[0].nodeType === 3)
361
+ return kids[0];
362
+ if (kids.length === 1 && kids[0].nodeType === 1) {
363
+ node = kids[0];
364
+ continue;
365
+ }
366
+ if (kids.length === 0 && node === el) {
367
+ return Array.from(el.childNodes).find((c) => c.nodeType === 3) ?? null;
368
+ }
369
+ return null;
370
+ }
371
+ };
372
+ /**
373
+ * Put the text being typed into the tagged elements of these fields, while
374
+ * they show the saved text or what this page last patched them to
375
+ * (`patchedText`). As text, through a text node's `data`, never as markup.
376
+ */
377
+ const applyPatch = (entryId, fields) => {
378
+ for (const change of fields) {
379
+ for (const el of matching(entryId, change.field)) {
380
+ let node = textNodeOf(el);
381
+ // An empty field shows as an empty element: it takes the first text.
382
+ if (!node && change.saved.trim() === "" && el.childNodes.length === 0) {
383
+ node = document.createTextNode("");
384
+ el.appendChild(node);
385
+ }
386
+ if (!node)
387
+ continue;
388
+ const before = written.get(node);
389
+ const around = before ?? textAround(node.data);
390
+ const next = patchedText(node.data, change, before ? before.text : null, around);
391
+ if (next === null)
392
+ continue;
393
+ node.data = next;
394
+ written.set(node, { text: change.value, lead: around.lead, trail: around.trail });
395
+ writtenIn.set(el, node);
396
+ }
397
+ }
398
+ // The boxes follow the text's new size.
399
+ render();
400
+ };
401
+ // ------------------------------------------------------------ messages ---
402
+ const onMessage = (event) => {
403
+ const message = acceptMessage(event, origins, window.parent);
404
+ if (!message)
405
+ return;
406
+ switch (message.type) {
407
+ case "hello":
408
+ adminOrigin = event.origin;
409
+ lastVisible = "";
410
+ sendReady(true);
411
+ render();
412
+ return;
413
+ case "highlight": {
414
+ if (message.entryId === "") {
415
+ highlight = null;
416
+ render();
417
+ return;
418
+ }
419
+ highlight = { entryId: message.entryId, field: message.field };
420
+ const first = matching(message.entryId, message.field)[0];
421
+ if (first)
422
+ first.scrollIntoView({ block: "center", behavior: "smooth" });
423
+ render();
424
+ return;
425
+ }
426
+ case "outline":
427
+ outlineAll = message.on;
428
+ render();
429
+ return;
430
+ case "refresh":
431
+ refresh();
432
+ return;
433
+ case "patch":
434
+ // Only from the admin this page is talking to.
435
+ if (!adminOrigin || event.origin !== adminOrigin)
436
+ return;
437
+ applyPatch(message.entryId, message.fields);
438
+ return;
439
+ }
440
+ };
441
+ // --------------------------------------------------------- interaction ---
442
+ const closestFrom = (target, selector) => {
443
+ const el = target;
444
+ return el && typeof el.closest === "function" ? el.closest(selector) : null;
445
+ };
446
+ const taggedFrom = (target) => closestFrom(target, TAGGED);
447
+ /**
448
+ * A ⌘-click on a tagged link: click the same spot again, without the
449
+ * modifier, and let it through. The site then follows the link in this
450
+ * frame exactly as it would for a visitor's click: a Next `<Link>`
451
+ * navigates client-side, a plain `<a>` loads the page, `target` and the
452
+ * site's own handlers are honoured. The browser's own ⌘-click would open a
453
+ * new tab, outside the editor.
454
+ */
455
+ const follow = (event) => {
456
+ const target = event.target;
457
+ const click = new MouseEvent("click", {
458
+ bubbles: true,
459
+ cancelable: true,
460
+ composed: true,
461
+ view: window,
462
+ detail: event.detail,
463
+ screenX: event.screenX,
464
+ screenY: event.screenY,
465
+ clientX: event.clientX,
466
+ clientY: event.clientY,
467
+ button: 0,
468
+ });
469
+ following = true;
470
+ try {
471
+ target.dispatchEvent(click);
472
+ }
473
+ finally {
474
+ following = false;
475
+ }
476
+ };
477
+ const onClick = (event) => {
478
+ // Only once the admin has said hello: a framed page that is not talking to
479
+ // Capa keeps its links working. And never the plain click `follow` sends.
480
+ if (!adminOrigin || following)
481
+ return;
482
+ const el = taggedFrom(event.target);
483
+ if (!el)
484
+ return;
485
+ event.preventDefault();
486
+ event.stopPropagation();
487
+ if ((apple ? event.metaKey : event.ctrlKey) && closestFrom(event.target, LINK)) {
488
+ follow(event);
489
+ return;
490
+ }
491
+ post(selectMessage(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field") ?? ""));
492
+ };
493
+ const updateHover = () => {
494
+ hoverFrame = 0;
495
+ const el = taggedFrom(lastPointer);
496
+ const link = el ? closestFrom(lastPointer, LINK) : null;
497
+ if (el === hovered && link === hoveredLink)
498
+ return;
499
+ const moved = el !== hovered;
500
+ hovered = el;
501
+ hoveredLink = link;
502
+ if (moved) {
503
+ post(el
504
+ ? hoverMessage(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field"))
505
+ : hoverMessage("", null));
506
+ }
507
+ render();
508
+ };
509
+ const onPointerMove = (event) => {
510
+ if (!adminOrigin)
511
+ return;
512
+ lastPointer = event.target;
513
+ if (!hoverFrame)
514
+ hoverFrame = window.requestAnimationFrame(updateHover);
515
+ };
516
+ const onPointerLeave = () => {
517
+ lastPointer = null;
518
+ if (!hoverFrame)
519
+ hoverFrame = window.requestAnimationFrame(updateHover);
520
+ };
521
+ const observer = new MutationObserver((records) => {
522
+ // Our own layer redrawing is not the page changing.
523
+ if (records.every((r) => layer !== null && (r.target === layer || layer.contains(r.target))))
524
+ return;
525
+ scheduleReady();
526
+ render();
527
+ restoreScroll();
528
+ });
529
+ const INTENT = ["wheel", "touchmove", "keydown"];
530
+ window.addEventListener("message", onMessage);
531
+ window.addEventListener("scroll", onScroll, { capture: true, passive: true });
532
+ window.addEventListener("resize", render, { passive: true });
533
+ window.addEventListener("popstate", scheduleReady);
534
+ for (const type of INTENT)
535
+ window.addEventListener(type, onScrollIntent, { capture: true, passive: true });
536
+ document.addEventListener("click", onClick, true);
537
+ document.addEventListener("pointermove", onPointerMove, { capture: true, passive: true });
538
+ document.documentElement.addEventListener("pointerleave", onPointerLeave);
539
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
540
+ const dispose = () => {
541
+ window.removeEventListener("message", onMessage);
542
+ window.removeEventListener("scroll", onScroll, { capture: true });
543
+ window.removeEventListener("resize", render);
544
+ window.removeEventListener("popstate", scheduleReady);
545
+ for (const type of INTENT)
546
+ window.removeEventListener(type, onScrollIntent, { capture: true });
547
+ document.removeEventListener("click", onClick, true);
548
+ document.removeEventListener("pointermove", onPointerMove, { capture: true });
549
+ document.documentElement.removeEventListener("pointerleave", onPointerLeave);
550
+ observer.disconnect();
551
+ for (const id of [frame, readyFrame, hoverFrame])
552
+ if (id)
553
+ window.cancelAnimationFrame(id);
554
+ if (visibleTimer)
555
+ window.clearTimeout(visibleTimer);
556
+ // A refresh still pending when the overlay goes must not reload the page later.
557
+ if (refreshTimer)
558
+ window.clearTimeout(refreshTimer);
559
+ refreshTimer = 0;
560
+ refreshes++;
561
+ layer?.remove();
562
+ layer = null;
563
+ if (running?.dispose === dispose)
564
+ running = null;
565
+ };
566
+ return {
567
+ dispose,
568
+ setOptions: (next) => {
569
+ origins = normaliseOrigins(next.adminOrigins ?? []);
570
+ onRefresh = next.onRefresh;
571
+ refreshTimeout = timeoutOf(next.refreshTimeoutMs);
572
+ if (adminOrigin && !origins.includes(adminOrigin))
573
+ adminOrigin = null;
574
+ },
575
+ };
576
+ }