@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.
- package/CHANGELOG.md +31 -0
- package/README.md +147 -6
- package/dist/esm/nextjs/overlay.d.ts +30 -0
- package/dist/esm/nextjs/overlay.js +75 -0
- package/dist/esm/overlay/index.d.ts +32 -0
- package/dist/esm/overlay/index.js +510 -0
- package/dist/esm/overlay/protocol.d.ts +134 -0
- package/dist/esm/overlay/protocol.js +173 -0
- package/dist/esm/package.json +4 -0
- package/dist/nextjs/overlay.d.ts +26 -1
- package/dist/nextjs/overlay.js +49 -6
- package/dist/overlay/index.d.ts +14 -2
- package/dist/overlay/index.js +168 -45
- package/package.json +12 -4
|
@@ -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
|
+
}
|
package/dist/nextjs/overlay.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/nextjs/overlay.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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,
|
|
57
|
+
(0, react_1.useEffect)(() => (0, index_js_1.startOverlay)({
|
|
31
58
|
adminOrigins: origins.split(",").filter(Boolean),
|
|
32
|
-
onRefresh:
|
|
33
|
-
|
|
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
|
}
|
package/dist/overlay/index.d.ts
CHANGED
|
@@ -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
|
package/dist/overlay/index.js
CHANGED
|
@@ -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
|
|
20
|
-
var
|
|
21
|
-
Object.defineProperty(exports, "acceptMessage", { enumerable: true, get: function () { return
|
|
22
|
-
Object.defineProperty(exports, "hoverMessage", { enumerable: true, get: function () { return
|
|
23
|
-
Object.defineProperty(exports, "normaliseOrigins", { enumerable: true, get: function () { return
|
|
24
|
-
Object.defineProperty(exports, "pickCentred", { enumerable: true, get: function () { return
|
|
25
|
-
Object.defineProperty(exports, "readyMessage", { enumerable: true, get: function () { return
|
|
26
|
-
Object.defineProperty(exports, "scrollEdge", { enumerable: true, get: function () { return
|
|
27
|
-
Object.defineProperty(exports, "selectMessage", { enumerable: true, get: function () { return
|
|
28
|
-
Object.defineProperty(exports, "visibleMessage", { enumerable: true, get: function () { return
|
|
29
|
-
Object.defineProperty(exports, "ADMIN_SOURCE", { enumerable: true, get: function () { return
|
|
30
|
-
Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return
|
|
31
|
-
Object.defineProperty(exports, "SITE_SOURCE", { enumerable: true, get: function () { return
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
226
|
-
const pick = (0,
|
|
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,
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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(() =>
|
|
281
|
-
.
|
|
282
|
-
|
|
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,
|
|
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
|
|
391
|
+
const closestFrom = (target, selector) => {
|
|
320
392
|
const el = target;
|
|
321
|
-
return el && typeof el.closest === "function" ? el.closest(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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,
|
|
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
|
},
|