@capacms/sdk 1.0.0-next.8 → 1.0.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The live preview protocol, site side. Pure: no DOM, no globals, so it can be
3
+ * tested under plain `node --test`.
4
+ *
5
+ * The Capa admin frames the site and the two talk over `postMessage`. Every
6
+ * message is a plain object `{ source, v: 1, type, ...fields }`:
7
+ *
8
+ * admin -> site (source "capa-admin")
9
+ * hello {} sent on the frame's load
10
+ * highlight { entryId, field: string | null } entryId "" clears
11
+ * outline { on: boolean } outline every tagged element
12
+ * refresh {} re-render the draft
13
+ *
14
+ * site -> admin (source "capa")
15
+ * ready { path, entries } after hello, and after every navigation
16
+ * select { entryId, field } a tagged element was clicked
17
+ * hover { entryId, field } | { entryId: "", field: null }
18
+ * visible { entryId, field } the tagged element at the centre of the
19
+ * viewport changed (throttled, on scroll)
20
+ *
21
+ * `visible` arrived in 1.0.0-next.2 and is OPTIONAL: it is still `v: 1`,
22
+ * because an admin that does not know it drops an unknown type, and an older
23
+ * overlay simply never sends it. Additive messages keep the version; only a
24
+ * change to an existing shape would move it.
25
+ *
26
+ * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
27
+ * this package). If you change one side, change the other.
28
+ */
29
+ export const PROTOCOL_VERSION = 1;
30
+ export const ADMIN_SOURCE = "capa-admin";
31
+ export const SITE_SOURCE = "capa";
32
+ /**
33
+ * `https://admin.example.com/` and `https://admin.example.com` are one origin,
34
+ * and a configured value that is not a URL at all is dropped rather than
35
+ * compared as a string, because `"*"` must never mean "anyone".
36
+ */
37
+ export function normaliseOrigins(origins) {
38
+ const out = [];
39
+ for (const raw of origins) {
40
+ if (typeof raw !== "string" || raw.trim() === "")
41
+ continue;
42
+ try {
43
+ const origin = new URL(raw.trim()).origin;
44
+ if (origin !== "null" && !out.includes(origin))
45
+ out.push(origin);
46
+ }
47
+ catch {
48
+ // Not a URL: never an origin anybody can send from.
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ /**
54
+ * The one gate every incoming message goes through. A message is accepted only
55
+ * when all of these hold, and is `null` otherwise:
56
+ *
57
+ * - it came from the window that framed this page (`parent`), not from a
58
+ * popup, a sibling frame or this page itself
59
+ * - its origin is one the site configured as its admin
60
+ * - it says it is from the admin, speaks version 1, and has a known type
61
+ * whose fields have the right shapes
62
+ *
63
+ * `allowedOrigins` is expected already normalised (`normaliseOrigins`).
64
+ */
65
+ export function acceptMessage(event, allowedOrigins, parent) {
66
+ if (!event || event.source !== parent || parent == null)
67
+ return null;
68
+ if (typeof event.origin !== "string" || !allowedOrigins.includes(event.origin))
69
+ return null;
70
+ const data = event.data;
71
+ if (typeof data !== "object" || data === null || Array.isArray(data))
72
+ return null;
73
+ const m = data;
74
+ if (m.source !== ADMIN_SOURCE || m.v !== PROTOCOL_VERSION)
75
+ return null;
76
+ switch (m.type) {
77
+ case "hello":
78
+ return { source: ADMIN_SOURCE, v: 1, type: "hello" };
79
+ case "refresh":
80
+ return { source: ADMIN_SOURCE, v: 1, type: "refresh" };
81
+ case "outline":
82
+ if (typeof m.on !== "boolean")
83
+ return null;
84
+ return { source: ADMIN_SOURCE, v: 1, type: "outline", on: m.on };
85
+ case "highlight":
86
+ if (typeof m.entryId !== "string")
87
+ return null;
88
+ if (m.field !== null && typeof m.field !== "string")
89
+ return null;
90
+ return { source: ADMIN_SOURCE, v: 1, type: "highlight", entryId: m.entryId, field: m.field };
91
+ default:
92
+ return null;
93
+ }
94
+ }
95
+ export function readyMessage(path, entries) {
96
+ return { source: SITE_SOURCE, v: 1, type: "ready", path, entries };
97
+ }
98
+ export function selectMessage(entryId, field) {
99
+ return { source: SITE_SOURCE, v: 1, type: "select", entryId, field };
100
+ }
101
+ export function hoverMessage(entryId, field) {
102
+ return entryId === ""
103
+ ? { source: SITE_SOURCE, v: 1, type: "hover", entryId: "", field: null }
104
+ : { source: SITE_SOURCE, v: 1, type: "hover", entryId, field };
105
+ }
106
+ export function visibleMessage(entryId, field) {
107
+ return { source: SITE_SOURCE, v: 1, type: "visible", entryId, field };
108
+ }
109
+ /** Above any element height, so a box that misses the centre line never outranks one that crosses it. */
110
+ const MISS_FLOOR = 1e9;
111
+ /**
112
+ * The tagged element a reader is looking at: the one that contains the
113
+ * viewport's horizontal centre line, and when several do (a field inside a
114
+ * card that is also tagged) the SMALLEST, because the innermost element is
115
+ * the specific one. When none crosses the line, the nearest one that is at
116
+ * least partly on screen. Null when nothing tagged is on screen at all.
117
+ *
118
+ * `edge` is where the page is scrolled to. A heading near the top of a page
119
+ * can never reach the centre line, because the page cannot scroll above its
120
+ * top; at the top the topmost visible element is the one being read, and at
121
+ * the bottom the bottommost. The same rule a table of contents' scroll spy
122
+ * uses.
123
+ *
124
+ * Pure, so the admin's "follow the page" can be tested without a DOM.
125
+ */
126
+ export function pickCentred(boxes, viewportHeight, edge = null) {
127
+ const centre = viewportHeight / 2;
128
+ let best = null;
129
+ let bestScore = Infinity;
130
+ for (const b of boxes) {
131
+ if (!b.entryId || !b.field)
132
+ continue;
133
+ if (b.bottom <= 0 || b.top >= viewportHeight || b.bottom <= b.top)
134
+ continue;
135
+ const height = b.bottom - b.top;
136
+ let score;
137
+ if (edge === "top") {
138
+ // Topmost first; of two that start together, the smaller (inner) one.
139
+ score = b.top * MISS_FLOOR + height;
140
+ }
141
+ else if (edge === "bottom") {
142
+ score = (viewportHeight - b.bottom) * MISS_FLOOR + height;
143
+ }
144
+ else {
145
+ // Crossing the line scores by height (smaller wins) and always beats a
146
+ // box that misses it, which scores by its distance from the line on top
147
+ // of a floor no height can reach.
148
+ score =
149
+ b.top <= centre && b.bottom >= centre
150
+ ? height
151
+ : MISS_FLOOR + Math.min(Math.abs(b.top - centre), Math.abs(b.bottom - centre));
152
+ }
153
+ if (score < bestScore) {
154
+ best = b;
155
+ bestScore = score;
156
+ }
157
+ }
158
+ return best;
159
+ }
160
+ /**
161
+ * Which edge a scroll position is at, for `pickCentred`. A page that does not
162
+ * scroll at all is at neither: nothing moves, and the centre rule stands.
163
+ */
164
+ export function scrollEdge(scrollY, viewportHeight, scrollHeight) {
165
+ const max = scrollHeight - viewportHeight;
166
+ if (max <= 1)
167
+ return null;
168
+ if (scrollY <= 1)
169
+ return "top";
170
+ if (scrollY >= max - 1)
171
+ return "bottom";
172
+ return null;
173
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": false
4
+ }
@@ -1,5 +1,30 @@
1
1
  export interface CapaOverlayProps {
2
2
  /** The Capa admin origins allowed to drive the overlay. */
3
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;
4
13
  }
5
- export declare function CapaOverlay({ adminOrigins }: CapaOverlayProps): null;
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;
@@ -18,18 +18,61 @@ exports.CapaOverlay = CapaOverlay;
18
18
  *
19
19
  * Its own entry point, apart from `@capacms/sdk/nextjs`, because it imports
20
20
  * `react` and `next/navigation` and is a client module: the server helpers must
21
- * stay free of both.
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.
22
24
  */
23
25
  const react_1 = require("react");
24
26
  const navigation_1 = require("next/navigation");
25
- const overlay_1 = require("../overlay");
26
- function CapaOverlay({ adminOrigins }) {
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 }) {
27
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)([]);
28
55
  // A string, so a new array with the same origins does not restart it.
29
56
  const origins = adminOrigins.join(",");
30
- (0, react_1.useEffect)(() => (0, overlay_1.startOverlay)({
57
+ (0, react_1.useEffect)(() => (0, index_js_1.startOverlay)({
31
58
  adminOrigins: origins.split(",").filter(Boolean),
32
- onRefresh: () => router.refresh(),
33
- }), [origins, router]);
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]);
34
77
  return null;
35
78
  }
@@ -1,5 +1,5 @@
1
- export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol";
2
- export type { AdminMessage, MessageLike, SiteMessage, TaggedBox } from "./protocol";
1
+ export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol.js";
2
+ export type { AdminMessage, MessageLike, 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.visibleMessage = exports.selectMessage = exports.scrollEdge = exports.readyMessage = exports.pickCentred = exports.normaliseOrigins = exports.hoverMessage = exports.acceptMessage = void 0;
3
+ exports.REFRESH_TIMEOUT_MS = exports.SITE_SOURCE = exports.PROTOCOL_VERSION = exports.ADMIN_SOURCE = exports.visibleMessage = exports.selectMessage = exports.scrollEdge = exports.readyMessage = exports.pickCentred = 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.
@@ -13,30 +13,52 @@ exports.startOverlay = startOverlay;
13
13
  * re-renders the draft after a save. Outside a frame it does nothing at all:
14
14
  * no listeners, no DOM, no cost.
15
15
  *
16
+ * A click on a tagged element selects its field. A ⌘-click (Ctrl-click on
17
+ * Windows and Linux) on a tagged element that is a link, or sits inside one,
18
+ * follows the link in the frame instead, so an editor can move between pages.
19
+ *
16
20
  * Vanilla DOM and no imports beyond the pure protocol module, so it works in
17
21
  * any framework and adds nothing to a bundle that does not call it.
18
22
  */
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, "pickCentred", { enumerable: true, get: function () { return protocol_2.pickCentred; } });
25
- Object.defineProperty(exports, "readyMessage", { enumerable: true, get: function () { return protocol_2.readyMessage; } });
26
- Object.defineProperty(exports, "scrollEdge", { enumerable: true, get: function () { return protocol_2.scrollEdge; } });
27
- Object.defineProperty(exports, "selectMessage", { enumerable: true, get: function () { return protocol_2.selectMessage; } });
28
- Object.defineProperty(exports, "visibleMessage", { enumerable: true, get: function () { return protocol_2.visibleMessage; } });
29
- Object.defineProperty(exports, "ADMIN_SOURCE", { enumerable: true, get: function () { return protocol_2.ADMIN_SOURCE; } });
30
- Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_2.PROTOCOL_VERSION; } });
31
- Object.defineProperty(exports, "SITE_SOURCE", { enumerable: true, get: function () { return protocol_2.SITE_SOURCE; } });
23
+ const protocol_js_1 = require("./protocol.js");
24
+ var protocol_js_2 = require("./protocol.js");
25
+ Object.defineProperty(exports, "acceptMessage", { enumerable: true, get: function () { return protocol_js_2.acceptMessage; } });
26
+ Object.defineProperty(exports, "hoverMessage", { enumerable: true, get: function () { return protocol_js_2.hoverMessage; } });
27
+ Object.defineProperty(exports, "normaliseOrigins", { enumerable: true, get: function () { return protocol_js_2.normaliseOrigins; } });
28
+ Object.defineProperty(exports, "pickCentred", { enumerable: true, get: function () { return protocol_js_2.pickCentred; } });
29
+ Object.defineProperty(exports, "readyMessage", { enumerable: true, get: function () { return protocol_js_2.readyMessage; } });
30
+ Object.defineProperty(exports, "scrollEdge", { enumerable: true, get: function () { return protocol_js_2.scrollEdge; } });
31
+ Object.defineProperty(exports, "selectMessage", { enumerable: true, get: function () { return protocol_js_2.selectMessage; } });
32
+ Object.defineProperty(exports, "visibleMessage", { enumerable: true, get: function () { return protocol_js_2.visibleMessage; } });
33
+ Object.defineProperty(exports, "ADMIN_SOURCE", { enumerable: true, get: function () { return protocol_js_2.ADMIN_SOURCE; } });
34
+ Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_js_2.PROTOCOL_VERSION; } });
35
+ Object.defineProperty(exports, "SITE_SOURCE", { enumerable: true, get: function () { return protocol_js_2.SITE_SOURCE; } });
32
36
  const BLUE = "#2563eb";
33
37
  const TAGGED = "[data-capa-entry][data-capa-field]";
38
+ /** What a ⌘-click on a tagged element follows when it sits in one. */
39
+ const LINK = "a[href]";
34
40
  /** Survives the `location.reload()` fallback so the page comes back where it was. */
35
41
  const SCROLL_KEY = "capa-overlay:scrollY";
36
- /** How long a refresh keeps putting the scroll position back while the page re-renders. */
42
+ /** How long a refresh keeps putting the scroll position back once the page has re-rendered. */
37
43
  const RESTORE_WINDOW_MS = 1500;
44
+ /** How long an in-place refresh may take before the page reloads instead. */
45
+ exports.REFRESH_TIMEOUT_MS = 10_000;
38
46
  /** `visible` is sent at most this often while the page scrolls. */
39
47
  const VISIBLE_THROTTLE_MS = 150;
48
+ /**
49
+ * Apple platforms open a link with ⌘, Windows and Linux with Ctrl. Read from
50
+ * the browser's own platform report, falling back to the user agent.
51
+ */
52
+ function isApple(nav) {
53
+ if (!nav)
54
+ return false;
55
+ const data = nav.userAgentData;
56
+ return /mac|iphone|ipad|ipod/i.test(data?.platform || nav.platform || nav.userAgent || "");
57
+ }
58
+ /** A usable refresh timeout: a positive number of milliseconds, or the default. */
59
+ function timeoutOf(ms) {
60
+ return typeof ms === "number" && ms > 0 && Number.isFinite(ms) ? ms : exports.REFRESH_TIMEOUT_MS;
61
+ }
40
62
  const noop = () => { };
41
63
  let running = null;
42
64
  /**
@@ -58,15 +80,26 @@ function startOverlay(options) {
58
80
  return running.dispose;
59
81
  }
60
82
  function createOverlay(initial) {
61
- let origins = (0, protocol_1.normaliseOrigins)(initial.adminOrigins ?? []);
83
+ let origins = (0, protocol_js_1.normaliseOrigins)(initial.adminOrigins ?? []);
62
84
  let onRefresh = initial.onRefresh;
85
+ let refreshTimeout = timeoutOf(initial.refreshTimeoutMs);
86
+ const apple = isApple(typeof navigator === "undefined" ? undefined : navigator);
87
+ const linkHint = apple ? "⌘-click to open link" : "Ctrl-click to open link";
63
88
  /** The origin that said hello. Every message this page sends goes there and nowhere else. */
64
89
  let adminOrigin = null;
65
90
  let highlight = null;
66
91
  let outlineAll = false;
67
92
  let hovered = null;
93
+ /** The link the pointer is in, when it is also over a tagged element: a ⌘-click follows it. */
94
+ let hoveredLink = null;
68
95
  let lastReady = "";
96
+ /** Where a refresh keeps the page. `until` is Infinity while the refresh is still pending. */
69
97
  let pendingScroll = null;
98
+ /** Counts refreshes, so only the latest one settles the scroll or reloads. */
99
+ let refreshes = 0;
100
+ let refreshTimer = 0;
101
+ /** True while this overlay replays a ⌘-click as a plain click, which it must let through. */
102
+ let following = false;
70
103
  let layer = null;
71
104
  let frame = 0;
72
105
  let readyFrame = 0;
@@ -161,13 +194,16 @@ function createOverlay(initial) {
161
194
  boxes.push(b);
162
195
  }
163
196
  }
197
+ // Over a link, the label says how to follow it.
198
+ const hint = hovered && hoveredLink ? ` · ${linkHint}` : "";
164
199
  if (hovered && hovered.isConnected && !active.includes(hovered)) {
165
- const b = box(hovered, "hover", hovered.getAttribute("data-capa-field"));
200
+ const b = box(hovered, "hover", `${hovered.getAttribute("data-capa-field") ?? ""}${hint}`);
166
201
  if (b)
167
202
  boxes.push(b);
168
203
  }
169
204
  active.forEach((el, i) => {
170
- const label = i === 0 ? (highlight?.field ?? "entry") : null;
205
+ const name = i === 0 ? (highlight?.field ?? "entry") : null;
206
+ const label = el === hovered && hint ? `${name ?? el.getAttribute("data-capa-field") ?? ""}${hint}` : name;
171
207
  const b = box(el, "active", label);
172
208
  if (b)
173
209
  boxes.push(b);
@@ -195,7 +231,7 @@ function createOverlay(initial) {
195
231
  if (!force && key === lastReady)
196
232
  return;
197
233
  lastReady = key;
198
- post((0, protocol_1.readyMessage)(path, entries));
234
+ post((0, protocol_js_1.readyMessage)(path, entries));
199
235
  };
200
236
  const scheduleReady = () => {
201
237
  if (readyFrame)
@@ -222,15 +258,15 @@ function createOverlay(initial) {
222
258
  bottom: rect.bottom,
223
259
  };
224
260
  });
225
- const edge = (0, protocol_1.scrollEdge)(window.scrollY, window.innerHeight, document.documentElement.scrollHeight);
226
- const pick = (0, protocol_1.pickCentred)(boxes, window.innerHeight, edge);
261
+ const edge = (0, protocol_js_1.scrollEdge)(window.scrollY, window.innerHeight, document.documentElement.scrollHeight);
262
+ const pick = (0, protocol_js_1.pickCentred)(boxes, window.innerHeight, edge);
227
263
  if (!pick)
228
264
  return;
229
265
  const key = `${pick.entryId}\n${pick.field}`;
230
266
  if (key === lastVisible)
231
267
  return;
232
268
  lastVisible = key;
233
- post((0, protocol_1.visibleMessage)(pick.entryId, pick.field));
269
+ post((0, protocol_js_1.visibleMessage)(pick.entryId, pick.field));
234
270
  };
235
271
  const onScroll = () => {
236
272
  render();
@@ -263,27 +299,63 @@ function createOverlay(initial) {
263
299
  catch {
264
300
  // Storage blocked (a sandboxed or third-party frame): start at the top.
265
301
  }
302
+ /** The editor scrolled on purpose: stop putting the old position back. */
303
+ const onScrollIntent = () => {
304
+ pendingScroll = null;
305
+ };
306
+ const reload = (y) => {
307
+ try {
308
+ window.sessionStorage.setItem(SCROLL_KEY, String(y));
309
+ }
310
+ catch {
311
+ // Nowhere to keep it; the reload lands at the top.
312
+ }
313
+ window.location.reload();
314
+ };
315
+ /**
316
+ * Re-render the draft in place, or reload. While an in-place refresh is
317
+ * pending the position is held (a page whose re-render moves things keeps
318
+ * its place), and for a moment after it lands. A refresh that fails, or is
319
+ * still pending after `refreshTimeout`, reloads from where the editor is.
320
+ */
266
321
  const refresh = () => {
267
322
  const y = window.scrollY;
268
- if (!onRefresh) {
269
- try {
270
- window.sessionStorage.setItem(SCROLL_KEY, String(y));
271
- }
272
- catch {
273
- // Nowhere to keep it; the reload lands at the top.
274
- }
275
- window.location.reload();
323
+ const refreshFn = onRefresh;
324
+ if (!refreshFn) {
325
+ reload(y);
276
326
  return;
277
327
  }
278
- pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
328
+ const id = ++refreshes;
329
+ pendingScroll = { y, until: Infinity };
330
+ if (refreshTimer)
331
+ window.clearTimeout(refreshTimer);
332
+ refreshTimer = window.setTimeout(() => {
333
+ refreshTimer = 0;
334
+ if (id === refreshes)
335
+ reload(pendingScroll?.y ?? window.scrollY);
336
+ }, refreshTimeout);
279
337
  Promise.resolve()
280
- .then(() => onRefresh?.())
281
- .catch(() => window.location.reload())
282
- .finally(() => window.requestAnimationFrame(restoreScroll));
338
+ .then(() => refreshFn())
339
+ .then(() => {
340
+ // A later refresh owns the timer and the scroll from here on.
341
+ if (id !== refreshes)
342
+ return;
343
+ window.clearTimeout(refreshTimer);
344
+ refreshTimer = 0;
345
+ if (pendingScroll)
346
+ pendingScroll = { y: pendingScroll.y, until: Date.now() + RESTORE_WINDOW_MS };
347
+ window.requestAnimationFrame(restoreScroll);
348
+ }, () => {
349
+ if (id !== refreshes)
350
+ return;
351
+ window.clearTimeout(refreshTimer);
352
+ refreshTimer = 0;
353
+ reload(pendingScroll?.y ?? window.scrollY);
354
+ });
283
355
  };
284
356
  // ------------------------------------------------------------ messages ---
285
357
  const onMessage = (event) => {
286
- const message = (0, protocol_1.acceptMessage)(event, origins, window.parent);
358
+ const message = (0, protocol_js_1.acceptMessage)(event, origins, window.parent);
287
359
  if (!message)
288
360
  return;
289
361
  switch (message.type) {
@@ -316,31 +388,71 @@ function createOverlay(initial) {
316
388
  }
317
389
  };
318
390
  // --------------------------------------------------------- interaction ---
319
- const taggedFrom = (target) => {
391
+ const closestFrom = (target, selector) => {
320
392
  const el = target;
321
- return el && typeof el.closest === "function" ? el.closest(TAGGED) : null;
393
+ return el && typeof el.closest === "function" ? el.closest(selector) : null;
394
+ };
395
+ const taggedFrom = (target) => closestFrom(target, TAGGED);
396
+ /**
397
+ * A ⌘-click on a tagged link: click the same spot again, without the
398
+ * modifier, and let it through. The site then follows the link in this
399
+ * frame exactly as it would for a visitor's click: a Next `<Link>`
400
+ * navigates client-side, a plain `<a>` loads the page, `target` and the
401
+ * site's own handlers are honoured. The browser's own ⌘-click would open a
402
+ * new tab, outside the editor.
403
+ */
404
+ const follow = (event) => {
405
+ const target = event.target;
406
+ const click = new MouseEvent("click", {
407
+ bubbles: true,
408
+ cancelable: true,
409
+ composed: true,
410
+ view: window,
411
+ detail: event.detail,
412
+ screenX: event.screenX,
413
+ screenY: event.screenY,
414
+ clientX: event.clientX,
415
+ clientY: event.clientY,
416
+ button: 0,
417
+ });
418
+ following = true;
419
+ try {
420
+ target.dispatchEvent(click);
421
+ }
422
+ finally {
423
+ following = false;
424
+ }
322
425
  };
323
426
  const onClick = (event) => {
324
427
  // Only once the admin has said hello: a framed page that is not talking to
325
- // Capa keeps its links working.
326
- if (!adminOrigin)
428
+ // Capa keeps its links working. And never the plain click `follow` sends.
429
+ if (!adminOrigin || following)
327
430
  return;
328
431
  const el = taggedFrom(event.target);
329
432
  if (!el)
330
433
  return;
331
434
  event.preventDefault();
332
435
  event.stopPropagation();
333
- post((0, protocol_1.selectMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field") ?? ""));
436
+ if ((apple ? event.metaKey : event.ctrlKey) && closestFrom(event.target, LINK)) {
437
+ follow(event);
438
+ return;
439
+ }
440
+ post((0, protocol_js_1.selectMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field") ?? ""));
334
441
  };
335
442
  const updateHover = () => {
336
443
  hoverFrame = 0;
337
444
  const el = taggedFrom(lastPointer);
338
- if (el === hovered)
445
+ const link = el ? closestFrom(lastPointer, LINK) : null;
446
+ if (el === hovered && link === hoveredLink)
339
447
  return;
448
+ const moved = el !== hovered;
340
449
  hovered = el;
341
- post(el
342
- ? (0, protocol_1.hoverMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field"))
343
- : (0, protocol_1.hoverMessage)("", null));
450
+ hoveredLink = link;
451
+ if (moved) {
452
+ post(el
453
+ ? (0, protocol_js_1.hoverMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field"))
454
+ : (0, protocol_js_1.hoverMessage)("", null));
455
+ }
344
456
  render();
345
457
  };
346
458
  const onPointerMove = (event) => {
@@ -363,10 +475,13 @@ function createOverlay(initial) {
363
475
  render();
364
476
  restoreScroll();
365
477
  });
478
+ const INTENT = ["wheel", "touchmove", "keydown"];
366
479
  window.addEventListener("message", onMessage);
367
480
  window.addEventListener("scroll", onScroll, { capture: true, passive: true });
368
481
  window.addEventListener("resize", render, { passive: true });
369
482
  window.addEventListener("popstate", scheduleReady);
483
+ for (const type of INTENT)
484
+ window.addEventListener(type, onScrollIntent, { capture: true, passive: true });
370
485
  document.addEventListener("click", onClick, true);
371
486
  document.addEventListener("pointermove", onPointerMove, { capture: true, passive: true });
372
487
  document.documentElement.addEventListener("pointerleave", onPointerLeave);
@@ -376,6 +491,8 @@ function createOverlay(initial) {
376
491
  window.removeEventListener("scroll", onScroll, { capture: true });
377
492
  window.removeEventListener("resize", render);
378
493
  window.removeEventListener("popstate", scheduleReady);
494
+ for (const type of INTENT)
495
+ window.removeEventListener(type, onScrollIntent, { capture: true });
379
496
  document.removeEventListener("click", onClick, true);
380
497
  document.removeEventListener("pointermove", onPointerMove, { capture: true });
381
498
  document.documentElement.removeEventListener("pointerleave", onPointerLeave);
@@ -385,6 +502,11 @@ function createOverlay(initial) {
385
502
  window.cancelAnimationFrame(id);
386
503
  if (visibleTimer)
387
504
  window.clearTimeout(visibleTimer);
505
+ // A refresh still pending when the overlay goes must not reload the page later.
506
+ if (refreshTimer)
507
+ window.clearTimeout(refreshTimer);
508
+ refreshTimer = 0;
509
+ refreshes++;
388
510
  layer?.remove();
389
511
  layer = null;
390
512
  if (running?.dispose === dispose)
@@ -393,8 +515,9 @@ function createOverlay(initial) {
393
515
  return {
394
516
  dispose,
395
517
  setOptions: (next) => {
396
- origins = (0, protocol_1.normaliseOrigins)(next.adminOrigins ?? []);
518
+ origins = (0, protocol_js_1.normaliseOrigins)(next.adminOrigins ?? []);
397
519
  onRefresh = next.onRefresh;
520
+ refreshTimeout = timeoutOf(next.refreshTimeoutMs);
398
521
  if (adminOrigin && !origins.includes(adminOrigin))
399
522
  adminOrigin = null;
400
523
  },