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