@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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@capacms/sdk/overlay`: the site half of Capa's live preview.
|
|
3
|
+
*
|
|
4
|
+
* "use client";
|
|
5
|
+
* useEffect(() => startOverlay({ adminOrigins: ["https://app.capacms.com"] }), []);
|
|
6
|
+
*
|
|
7
|
+
* Inside the Capa editor's preview frame this outlines the element an editor is
|
|
8
|
+
* working on, reports clicks on tagged elements back to the editor, and
|
|
9
|
+
* re-renders the draft after a save. Outside a frame it does nothing at all:
|
|
10
|
+
* no listeners, no DOM, no cost.
|
|
11
|
+
*
|
|
12
|
+
* A click on a tagged element selects its field. A ⌘-click (Ctrl-click on
|
|
13
|
+
* Windows and Linux) on a tagged element that is a link, or sits inside one,
|
|
14
|
+
* follows the link in the frame instead, so an editor can move between pages.
|
|
15
|
+
*
|
|
16
|
+
* Vanilla DOM and no imports beyond the pure protocol module, so it works in
|
|
17
|
+
* any framework and adds nothing to a bundle that does not call it.
|
|
18
|
+
*/
|
|
19
|
+
import { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, } from "./protocol.js";
|
|
20
|
+
export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol.js";
|
|
21
|
+
const BLUE = "#2563eb";
|
|
22
|
+
const TAGGED = "[data-capa-entry][data-capa-field]";
|
|
23
|
+
/** What a ⌘-click on a tagged element follows when it sits in one. */
|
|
24
|
+
const LINK = "a[href]";
|
|
25
|
+
/** Survives the `location.reload()` fallback so the page comes back where it was. */
|
|
26
|
+
const SCROLL_KEY = "capa-overlay:scrollY";
|
|
27
|
+
/** How long a refresh keeps putting the scroll position back once the page has re-rendered. */
|
|
28
|
+
const RESTORE_WINDOW_MS = 1500;
|
|
29
|
+
/** How long an in-place refresh may take before the page reloads instead. */
|
|
30
|
+
export const REFRESH_TIMEOUT_MS = 10_000;
|
|
31
|
+
/** `visible` is sent at most this often while the page scrolls. */
|
|
32
|
+
const VISIBLE_THROTTLE_MS = 150;
|
|
33
|
+
/**
|
|
34
|
+
* Apple platforms open a link with ⌘, Windows and Linux with Ctrl. Read from
|
|
35
|
+
* the browser's own platform report, falling back to the user agent.
|
|
36
|
+
*/
|
|
37
|
+
function isApple(nav) {
|
|
38
|
+
if (!nav)
|
|
39
|
+
return false;
|
|
40
|
+
const data = nav.userAgentData;
|
|
41
|
+
return /mac|iphone|ipad|ipod/i.test(data?.platform || nav.platform || nav.userAgent || "");
|
|
42
|
+
}
|
|
43
|
+
/** A usable refresh timeout: a positive number of milliseconds, or the default. */
|
|
44
|
+
function timeoutOf(ms) {
|
|
45
|
+
return typeof ms === "number" && ms > 0 && Number.isFinite(ms) ? ms : REFRESH_TIMEOUT_MS;
|
|
46
|
+
}
|
|
47
|
+
const noop = () => { };
|
|
48
|
+
let running = null;
|
|
49
|
+
/**
|
|
50
|
+
* Start the overlay. Returns a disposer that removes every listener and the
|
|
51
|
+
* drawing layer. Calling it again while it runs updates the options and returns
|
|
52
|
+
* the same disposer, so a React effect that runs twice does not listen twice.
|
|
53
|
+
*/
|
|
54
|
+
export function startOverlay(options) {
|
|
55
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
56
|
+
return noop;
|
|
57
|
+
// Not framed: this is an ordinary visit to the site.
|
|
58
|
+
if (window.parent === window)
|
|
59
|
+
return noop;
|
|
60
|
+
if (running) {
|
|
61
|
+
running.setOptions(options);
|
|
62
|
+
return running.dispose;
|
|
63
|
+
}
|
|
64
|
+
running = createOverlay(options);
|
|
65
|
+
return running.dispose;
|
|
66
|
+
}
|
|
67
|
+
function createOverlay(initial) {
|
|
68
|
+
let origins = normaliseOrigins(initial.adminOrigins ?? []);
|
|
69
|
+
let onRefresh = initial.onRefresh;
|
|
70
|
+
let refreshTimeout = timeoutOf(initial.refreshTimeoutMs);
|
|
71
|
+
const apple = isApple(typeof navigator === "undefined" ? undefined : navigator);
|
|
72
|
+
const linkHint = apple ? "⌘-click to open link" : "Ctrl-click to open link";
|
|
73
|
+
/** The origin that said hello. Every message this page sends goes there and nowhere else. */
|
|
74
|
+
let adminOrigin = null;
|
|
75
|
+
let highlight = null;
|
|
76
|
+
let outlineAll = false;
|
|
77
|
+
let hovered = null;
|
|
78
|
+
/** The link the pointer is in, when it is also over a tagged element: a ⌘-click follows it. */
|
|
79
|
+
let hoveredLink = null;
|
|
80
|
+
let lastReady = "";
|
|
81
|
+
/** Where a refresh keeps the page. `until` is Infinity while the refresh is still pending. */
|
|
82
|
+
let pendingScroll = null;
|
|
83
|
+
/** Counts refreshes, so only the latest one settles the scroll or reloads. */
|
|
84
|
+
let refreshes = 0;
|
|
85
|
+
let refreshTimer = 0;
|
|
86
|
+
/** True while this overlay replays a ⌘-click as a plain click, which it must let through. */
|
|
87
|
+
let following = false;
|
|
88
|
+
let layer = null;
|
|
89
|
+
let frame = 0;
|
|
90
|
+
let readyFrame = 0;
|
|
91
|
+
let hoverFrame = 0;
|
|
92
|
+
let lastPointer = null;
|
|
93
|
+
let visibleTimer = 0;
|
|
94
|
+
let lastVisible = "";
|
|
95
|
+
const post = (message) => {
|
|
96
|
+
if (!adminOrigin)
|
|
97
|
+
return;
|
|
98
|
+
window.parent.postMessage(message, adminOrigin);
|
|
99
|
+
};
|
|
100
|
+
// ------------------------------------------------------------- drawing ---
|
|
101
|
+
const ensureLayer = () => {
|
|
102
|
+
if (layer && layer.isConnected)
|
|
103
|
+
return layer;
|
|
104
|
+
layer = document.createElement("div");
|
|
105
|
+
layer.setAttribute("data-capa-overlay", "");
|
|
106
|
+
layer.setAttribute("aria-hidden", "true");
|
|
107
|
+
Object.assign(layer.style, {
|
|
108
|
+
position: "fixed",
|
|
109
|
+
inset: "0",
|
|
110
|
+
pointerEvents: "none",
|
|
111
|
+
zIndex: "2147483647",
|
|
112
|
+
overflow: "hidden",
|
|
113
|
+
});
|
|
114
|
+
document.body.appendChild(layer);
|
|
115
|
+
return layer;
|
|
116
|
+
};
|
|
117
|
+
const tagged = () => Array.from(document.querySelectorAll(TAGGED));
|
|
118
|
+
const matching = (entryId, field) => tagged().filter((el) => el.getAttribute("data-capa-entry") === entryId &&
|
|
119
|
+
(field === null || el.getAttribute("data-capa-field") === field));
|
|
120
|
+
const box = (el, style, label) => {
|
|
121
|
+
const rect = el.getBoundingClientRect();
|
|
122
|
+
if (rect.width === 0 && rect.height === 0)
|
|
123
|
+
return null;
|
|
124
|
+
const pad = style === "faint" ? 1 : 3;
|
|
125
|
+
const div = document.createElement("div");
|
|
126
|
+
Object.assign(div.style, {
|
|
127
|
+
position: "absolute",
|
|
128
|
+
left: `${rect.left - pad}px`,
|
|
129
|
+
top: `${rect.top - pad}px`,
|
|
130
|
+
width: `${rect.width + pad * 2}px`,
|
|
131
|
+
height: `${rect.height + pad * 2}px`,
|
|
132
|
+
boxSizing: "border-box",
|
|
133
|
+
borderRadius: "3px",
|
|
134
|
+
border: style === "faint"
|
|
135
|
+
? "1px solid rgba(37, 99, 235, 0.35)"
|
|
136
|
+
: style === "hover"
|
|
137
|
+
? `2px dashed ${BLUE}`
|
|
138
|
+
: `2px solid ${BLUE}`,
|
|
139
|
+
});
|
|
140
|
+
if (label) {
|
|
141
|
+
const chip = document.createElement("span");
|
|
142
|
+
chip.textContent = label;
|
|
143
|
+
// Above the box when there is room, tucked inside its top edge when the
|
|
144
|
+
// element sits at the very top of the viewport.
|
|
145
|
+
const above = rect.top - pad >= 20;
|
|
146
|
+
Object.assign(chip.style, {
|
|
147
|
+
position: "absolute",
|
|
148
|
+
left: "-2px",
|
|
149
|
+
top: above ? "-20px" : "0px",
|
|
150
|
+
background: BLUE,
|
|
151
|
+
color: "#fff",
|
|
152
|
+
font: "500 11px/18px system-ui, -apple-system, 'Segoe UI', sans-serif",
|
|
153
|
+
padding: "0 6px",
|
|
154
|
+
borderRadius: "3px",
|
|
155
|
+
whiteSpace: "nowrap",
|
|
156
|
+
letterSpacing: "0",
|
|
157
|
+
});
|
|
158
|
+
div.appendChild(chip);
|
|
159
|
+
}
|
|
160
|
+
return div;
|
|
161
|
+
};
|
|
162
|
+
const draw = () => {
|
|
163
|
+
frame = 0;
|
|
164
|
+
const nothing = !outlineAll && !highlight && !hovered;
|
|
165
|
+
if (nothing) {
|
|
166
|
+
if (layer)
|
|
167
|
+
layer.replaceChildren();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const target = ensureLayer();
|
|
171
|
+
const boxes = [];
|
|
172
|
+
const active = highlight ? matching(highlight.entryId, highlight.field) : [];
|
|
173
|
+
if (outlineAll) {
|
|
174
|
+
for (const el of tagged()) {
|
|
175
|
+
if (active.includes(el))
|
|
176
|
+
continue;
|
|
177
|
+
const b = box(el, "faint", null);
|
|
178
|
+
if (b)
|
|
179
|
+
boxes.push(b);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Over a link, the label says how to follow it.
|
|
183
|
+
const hint = hovered && hoveredLink ? ` · ${linkHint}` : "";
|
|
184
|
+
if (hovered && hovered.isConnected && !active.includes(hovered)) {
|
|
185
|
+
const b = box(hovered, "hover", `${hovered.getAttribute("data-capa-field") ?? ""}${hint}`);
|
|
186
|
+
if (b)
|
|
187
|
+
boxes.push(b);
|
|
188
|
+
}
|
|
189
|
+
active.forEach((el, i) => {
|
|
190
|
+
const name = i === 0 ? (highlight?.field ?? "entry") : null;
|
|
191
|
+
const label = el === hovered && hint ? `${name ?? el.getAttribute("data-capa-field") ?? ""}${hint}` : name;
|
|
192
|
+
const b = box(el, "active", label);
|
|
193
|
+
if (b)
|
|
194
|
+
boxes.push(b);
|
|
195
|
+
});
|
|
196
|
+
target.replaceChildren(...boxes);
|
|
197
|
+
};
|
|
198
|
+
const render = () => {
|
|
199
|
+
if (frame)
|
|
200
|
+
return;
|
|
201
|
+
frame = window.requestAnimationFrame(draw);
|
|
202
|
+
};
|
|
203
|
+
// --------------------------------------------------------------- ready ---
|
|
204
|
+
const sendReady = (force) => {
|
|
205
|
+
readyFrame = 0;
|
|
206
|
+
if (!adminOrigin)
|
|
207
|
+
return;
|
|
208
|
+
const entries = [];
|
|
209
|
+
for (const el of tagged()) {
|
|
210
|
+
const id = el.getAttribute("data-capa-entry");
|
|
211
|
+
if (id && !entries.includes(id))
|
|
212
|
+
entries.push(id);
|
|
213
|
+
}
|
|
214
|
+
const path = window.location.pathname;
|
|
215
|
+
const key = `${path}\n${entries.join(",")}`;
|
|
216
|
+
if (!force && key === lastReady)
|
|
217
|
+
return;
|
|
218
|
+
lastReady = key;
|
|
219
|
+
post(readyMessage(path, entries));
|
|
220
|
+
};
|
|
221
|
+
const scheduleReady = () => {
|
|
222
|
+
if (readyFrame)
|
|
223
|
+
return;
|
|
224
|
+
readyFrame = window.requestAnimationFrame(() => sendReady(false));
|
|
225
|
+
};
|
|
226
|
+
// ------------------------------------------------------------- visible ---
|
|
227
|
+
/**
|
|
228
|
+
* Which tagged element sits at the centre of the viewport, reported when it
|
|
229
|
+
* changes. The editor's "Follow the page" scrolls the form to match. Sent
|
|
230
|
+
* only to an admin that has said hello, and only on a change, so a still
|
|
231
|
+
* page sends nothing.
|
|
232
|
+
*/
|
|
233
|
+
const reportVisible = () => {
|
|
234
|
+
visibleTimer = 0;
|
|
235
|
+
if (!adminOrigin)
|
|
236
|
+
return;
|
|
237
|
+
const boxes = tagged().map((el) => {
|
|
238
|
+
const rect = el.getBoundingClientRect();
|
|
239
|
+
return {
|
|
240
|
+
entryId: el.getAttribute("data-capa-entry") ?? "",
|
|
241
|
+
field: el.getAttribute("data-capa-field") ?? "",
|
|
242
|
+
top: rect.top,
|
|
243
|
+
bottom: rect.bottom,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
const edge = scrollEdge(window.scrollY, window.innerHeight, document.documentElement.scrollHeight);
|
|
247
|
+
const pick = pickCentred(boxes, window.innerHeight, edge);
|
|
248
|
+
if (!pick)
|
|
249
|
+
return;
|
|
250
|
+
const key = `${pick.entryId}\n${pick.field}`;
|
|
251
|
+
if (key === lastVisible)
|
|
252
|
+
return;
|
|
253
|
+
lastVisible = key;
|
|
254
|
+
post(visibleMessage(pick.entryId, pick.field));
|
|
255
|
+
};
|
|
256
|
+
const onScroll = () => {
|
|
257
|
+
render();
|
|
258
|
+
if (!visibleTimer)
|
|
259
|
+
visibleTimer = window.setTimeout(reportVisible, VISIBLE_THROTTLE_MS);
|
|
260
|
+
};
|
|
261
|
+
// -------------------------------------------------------------- scroll ---
|
|
262
|
+
const restoreScroll = () => {
|
|
263
|
+
if (!pendingScroll)
|
|
264
|
+
return;
|
|
265
|
+
if (Date.now() > pendingScroll.until) {
|
|
266
|
+
pendingScroll = null;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (Math.abs(window.scrollY - pendingScroll.y) > 1)
|
|
270
|
+
window.scrollTo(0, pendingScroll.y);
|
|
271
|
+
};
|
|
272
|
+
// A `location.reload()` fallback left the position behind before it went.
|
|
273
|
+
try {
|
|
274
|
+
const saved = window.sessionStorage.getItem(SCROLL_KEY);
|
|
275
|
+
if (saved !== null) {
|
|
276
|
+
window.sessionStorage.removeItem(SCROLL_KEY);
|
|
277
|
+
const y = Number(saved);
|
|
278
|
+
if (Number.isFinite(y)) {
|
|
279
|
+
pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
|
|
280
|
+
window.requestAnimationFrame(restoreScroll);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Storage blocked (a sandboxed or third-party frame): start at the top.
|
|
286
|
+
}
|
|
287
|
+
/** The editor scrolled on purpose: stop putting the old position back. */
|
|
288
|
+
const onScrollIntent = () => {
|
|
289
|
+
pendingScroll = null;
|
|
290
|
+
};
|
|
291
|
+
const reload = (y) => {
|
|
292
|
+
try {
|
|
293
|
+
window.sessionStorage.setItem(SCROLL_KEY, String(y));
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Nowhere to keep it; the reload lands at the top.
|
|
297
|
+
}
|
|
298
|
+
window.location.reload();
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Re-render the draft in place, or reload. While an in-place refresh is
|
|
302
|
+
* pending the position is held (a page whose re-render moves things keeps
|
|
303
|
+
* its place), and for a moment after it lands. A refresh that fails, or is
|
|
304
|
+
* still pending after `refreshTimeout`, reloads from where the editor is.
|
|
305
|
+
*/
|
|
306
|
+
const refresh = () => {
|
|
307
|
+
const y = window.scrollY;
|
|
308
|
+
const refreshFn = onRefresh;
|
|
309
|
+
if (!refreshFn) {
|
|
310
|
+
reload(y);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const id = ++refreshes;
|
|
314
|
+
pendingScroll = { y, until: Infinity };
|
|
315
|
+
if (refreshTimer)
|
|
316
|
+
window.clearTimeout(refreshTimer);
|
|
317
|
+
refreshTimer = window.setTimeout(() => {
|
|
318
|
+
refreshTimer = 0;
|
|
319
|
+
if (id === refreshes)
|
|
320
|
+
reload(pendingScroll?.y ?? window.scrollY);
|
|
321
|
+
}, refreshTimeout);
|
|
322
|
+
Promise.resolve()
|
|
323
|
+
.then(() => refreshFn())
|
|
324
|
+
.then(() => {
|
|
325
|
+
// A later refresh owns the timer and the scroll from here on.
|
|
326
|
+
if (id !== refreshes)
|
|
327
|
+
return;
|
|
328
|
+
window.clearTimeout(refreshTimer);
|
|
329
|
+
refreshTimer = 0;
|
|
330
|
+
if (pendingScroll)
|
|
331
|
+
pendingScroll = { y: pendingScroll.y, until: Date.now() + RESTORE_WINDOW_MS };
|
|
332
|
+
window.requestAnimationFrame(restoreScroll);
|
|
333
|
+
}, () => {
|
|
334
|
+
if (id !== refreshes)
|
|
335
|
+
return;
|
|
336
|
+
window.clearTimeout(refreshTimer);
|
|
337
|
+
refreshTimer = 0;
|
|
338
|
+
reload(pendingScroll?.y ?? window.scrollY);
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
// ------------------------------------------------------------ messages ---
|
|
342
|
+
const onMessage = (event) => {
|
|
343
|
+
const message = acceptMessage(event, origins, window.parent);
|
|
344
|
+
if (!message)
|
|
345
|
+
return;
|
|
346
|
+
switch (message.type) {
|
|
347
|
+
case "hello":
|
|
348
|
+
adminOrigin = event.origin;
|
|
349
|
+
lastVisible = "";
|
|
350
|
+
sendReady(true);
|
|
351
|
+
render();
|
|
352
|
+
return;
|
|
353
|
+
case "highlight": {
|
|
354
|
+
if (message.entryId === "") {
|
|
355
|
+
highlight = null;
|
|
356
|
+
render();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
highlight = { entryId: message.entryId, field: message.field };
|
|
360
|
+
const first = matching(message.entryId, message.field)[0];
|
|
361
|
+
if (first)
|
|
362
|
+
first.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
363
|
+
render();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
case "outline":
|
|
367
|
+
outlineAll = message.on;
|
|
368
|
+
render();
|
|
369
|
+
return;
|
|
370
|
+
case "refresh":
|
|
371
|
+
refresh();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
// --------------------------------------------------------- interaction ---
|
|
376
|
+
const closestFrom = (target, selector) => {
|
|
377
|
+
const el = target;
|
|
378
|
+
return el && typeof el.closest === "function" ? el.closest(selector) : null;
|
|
379
|
+
};
|
|
380
|
+
const taggedFrom = (target) => closestFrom(target, TAGGED);
|
|
381
|
+
/**
|
|
382
|
+
* A ⌘-click on a tagged link: click the same spot again, without the
|
|
383
|
+
* modifier, and let it through. The site then follows the link in this
|
|
384
|
+
* frame exactly as it would for a visitor's click: a Next `<Link>`
|
|
385
|
+
* navigates client-side, a plain `<a>` loads the page, `target` and the
|
|
386
|
+
* site's own handlers are honoured. The browser's own ⌘-click would open a
|
|
387
|
+
* new tab, outside the editor.
|
|
388
|
+
*/
|
|
389
|
+
const follow = (event) => {
|
|
390
|
+
const target = event.target;
|
|
391
|
+
const click = new MouseEvent("click", {
|
|
392
|
+
bubbles: true,
|
|
393
|
+
cancelable: true,
|
|
394
|
+
composed: true,
|
|
395
|
+
view: window,
|
|
396
|
+
detail: event.detail,
|
|
397
|
+
screenX: event.screenX,
|
|
398
|
+
screenY: event.screenY,
|
|
399
|
+
clientX: event.clientX,
|
|
400
|
+
clientY: event.clientY,
|
|
401
|
+
button: 0,
|
|
402
|
+
});
|
|
403
|
+
following = true;
|
|
404
|
+
try {
|
|
405
|
+
target.dispatchEvent(click);
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
following = false;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const onClick = (event) => {
|
|
412
|
+
// Only once the admin has said hello: a framed page that is not talking to
|
|
413
|
+
// Capa keeps its links working. And never the plain click `follow` sends.
|
|
414
|
+
if (!adminOrigin || following)
|
|
415
|
+
return;
|
|
416
|
+
const el = taggedFrom(event.target);
|
|
417
|
+
if (!el)
|
|
418
|
+
return;
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
event.stopPropagation();
|
|
421
|
+
if ((apple ? event.metaKey : event.ctrlKey) && closestFrom(event.target, LINK)) {
|
|
422
|
+
follow(event);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
post(selectMessage(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field") ?? ""));
|
|
426
|
+
};
|
|
427
|
+
const updateHover = () => {
|
|
428
|
+
hoverFrame = 0;
|
|
429
|
+
const el = taggedFrom(lastPointer);
|
|
430
|
+
const link = el ? closestFrom(lastPointer, LINK) : null;
|
|
431
|
+
if (el === hovered && link === hoveredLink)
|
|
432
|
+
return;
|
|
433
|
+
const moved = el !== hovered;
|
|
434
|
+
hovered = el;
|
|
435
|
+
hoveredLink = link;
|
|
436
|
+
if (moved) {
|
|
437
|
+
post(el
|
|
438
|
+
? hoverMessage(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field"))
|
|
439
|
+
: hoverMessage("", null));
|
|
440
|
+
}
|
|
441
|
+
render();
|
|
442
|
+
};
|
|
443
|
+
const onPointerMove = (event) => {
|
|
444
|
+
if (!adminOrigin)
|
|
445
|
+
return;
|
|
446
|
+
lastPointer = event.target;
|
|
447
|
+
if (!hoverFrame)
|
|
448
|
+
hoverFrame = window.requestAnimationFrame(updateHover);
|
|
449
|
+
};
|
|
450
|
+
const onPointerLeave = () => {
|
|
451
|
+
lastPointer = null;
|
|
452
|
+
if (!hoverFrame)
|
|
453
|
+
hoverFrame = window.requestAnimationFrame(updateHover);
|
|
454
|
+
};
|
|
455
|
+
const observer = new MutationObserver((records) => {
|
|
456
|
+
// Our own layer redrawing is not the page changing.
|
|
457
|
+
if (records.every((r) => layer !== null && (r.target === layer || layer.contains(r.target))))
|
|
458
|
+
return;
|
|
459
|
+
scheduleReady();
|
|
460
|
+
render();
|
|
461
|
+
restoreScroll();
|
|
462
|
+
});
|
|
463
|
+
const INTENT = ["wheel", "touchmove", "keydown"];
|
|
464
|
+
window.addEventListener("message", onMessage);
|
|
465
|
+
window.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
|
466
|
+
window.addEventListener("resize", render, { passive: true });
|
|
467
|
+
window.addEventListener("popstate", scheduleReady);
|
|
468
|
+
for (const type of INTENT)
|
|
469
|
+
window.addEventListener(type, onScrollIntent, { capture: true, passive: true });
|
|
470
|
+
document.addEventListener("click", onClick, true);
|
|
471
|
+
document.addEventListener("pointermove", onPointerMove, { capture: true, passive: true });
|
|
472
|
+
document.documentElement.addEventListener("pointerleave", onPointerLeave);
|
|
473
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
474
|
+
const dispose = () => {
|
|
475
|
+
window.removeEventListener("message", onMessage);
|
|
476
|
+
window.removeEventListener("scroll", onScroll, { capture: true });
|
|
477
|
+
window.removeEventListener("resize", render);
|
|
478
|
+
window.removeEventListener("popstate", scheduleReady);
|
|
479
|
+
for (const type of INTENT)
|
|
480
|
+
window.removeEventListener(type, onScrollIntent, { capture: true });
|
|
481
|
+
document.removeEventListener("click", onClick, true);
|
|
482
|
+
document.removeEventListener("pointermove", onPointerMove, { capture: true });
|
|
483
|
+
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
|
|
484
|
+
observer.disconnect();
|
|
485
|
+
for (const id of [frame, readyFrame, hoverFrame])
|
|
486
|
+
if (id)
|
|
487
|
+
window.cancelAnimationFrame(id);
|
|
488
|
+
if (visibleTimer)
|
|
489
|
+
window.clearTimeout(visibleTimer);
|
|
490
|
+
// A refresh still pending when the overlay goes must not reload the page later.
|
|
491
|
+
if (refreshTimer)
|
|
492
|
+
window.clearTimeout(refreshTimer);
|
|
493
|
+
refreshTimer = 0;
|
|
494
|
+
refreshes++;
|
|
495
|
+
layer?.remove();
|
|
496
|
+
layer = null;
|
|
497
|
+
if (running?.dispose === dispose)
|
|
498
|
+
running = null;
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
dispose,
|
|
502
|
+
setOptions: (next) => {
|
|
503
|
+
origins = normaliseOrigins(next.adminOrigins ?? []);
|
|
504
|
+
onRefresh = next.onRefresh;
|
|
505
|
+
refreshTimeout = timeoutOf(next.refreshTimeoutMs);
|
|
506
|
+
if (adminOrigin && !origins.includes(adminOrigin))
|
|
507
|
+
adminOrigin = null;
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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 declare const PROTOCOL_VERSION = 1;
|
|
30
|
+
export declare const ADMIN_SOURCE = "capa-admin";
|
|
31
|
+
export declare const SITE_SOURCE = "capa";
|
|
32
|
+
export type AdminMessage = {
|
|
33
|
+
source: typeof ADMIN_SOURCE;
|
|
34
|
+
v: 1;
|
|
35
|
+
type: "hello";
|
|
36
|
+
} | {
|
|
37
|
+
source: typeof ADMIN_SOURCE;
|
|
38
|
+
v: 1;
|
|
39
|
+
type: "highlight";
|
|
40
|
+
entryId: string;
|
|
41
|
+
field: string | null;
|
|
42
|
+
} | {
|
|
43
|
+
source: typeof ADMIN_SOURCE;
|
|
44
|
+
v: 1;
|
|
45
|
+
type: "outline";
|
|
46
|
+
on: boolean;
|
|
47
|
+
} | {
|
|
48
|
+
source: typeof ADMIN_SOURCE;
|
|
49
|
+
v: 1;
|
|
50
|
+
type: "refresh";
|
|
51
|
+
};
|
|
52
|
+
export type SiteMessage = {
|
|
53
|
+
source: typeof SITE_SOURCE;
|
|
54
|
+
v: 1;
|
|
55
|
+
type: "ready";
|
|
56
|
+
path: string;
|
|
57
|
+
entries: string[];
|
|
58
|
+
} | {
|
|
59
|
+
source: typeof SITE_SOURCE;
|
|
60
|
+
v: 1;
|
|
61
|
+
type: "select";
|
|
62
|
+
entryId: string;
|
|
63
|
+
field: string;
|
|
64
|
+
} | {
|
|
65
|
+
source: typeof SITE_SOURCE;
|
|
66
|
+
v: 1;
|
|
67
|
+
type: "hover";
|
|
68
|
+
entryId: string;
|
|
69
|
+
field: string | null;
|
|
70
|
+
} | {
|
|
71
|
+
source: typeof SITE_SOURCE;
|
|
72
|
+
v: 1;
|
|
73
|
+
type: "visible";
|
|
74
|
+
entryId: string;
|
|
75
|
+
field: string;
|
|
76
|
+
};
|
|
77
|
+
/** The parts of a `MessageEvent` the filter reads, so a test can hand in a literal. */
|
|
78
|
+
export interface MessageLike {
|
|
79
|
+
origin: string;
|
|
80
|
+
source: unknown;
|
|
81
|
+
data: unknown;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* `https://admin.example.com/` and `https://admin.example.com` are one origin,
|
|
85
|
+
* and a configured value that is not a URL at all is dropped rather than
|
|
86
|
+
* compared as a string, because `"*"` must never mean "anyone".
|
|
87
|
+
*/
|
|
88
|
+
export declare function normaliseOrigins(origins: readonly string[]): string[];
|
|
89
|
+
/**
|
|
90
|
+
* The one gate every incoming message goes through. A message is accepted only
|
|
91
|
+
* when all of these hold, and is `null` otherwise:
|
|
92
|
+
*
|
|
93
|
+
* - it came from the window that framed this page (`parent`), not from a
|
|
94
|
+
* popup, a sibling frame or this page itself
|
|
95
|
+
* - its origin is one the site configured as its admin
|
|
96
|
+
* - it says it is from the admin, speaks version 1, and has a known type
|
|
97
|
+
* whose fields have the right shapes
|
|
98
|
+
*
|
|
99
|
+
* `allowedOrigins` is expected already normalised (`normaliseOrigins`).
|
|
100
|
+
*/
|
|
101
|
+
export declare function acceptMessage(event: MessageLike, allowedOrigins: readonly string[], parent: unknown): AdminMessage | null;
|
|
102
|
+
export declare function readyMessage(path: string, entries: string[]): SiteMessage;
|
|
103
|
+
export declare function selectMessage(entryId: string, field: string): SiteMessage;
|
|
104
|
+
export declare function hoverMessage(entryId: string, field: string | null): SiteMessage;
|
|
105
|
+
export declare function visibleMessage(entryId: string, field: string): SiteMessage;
|
|
106
|
+
/** The part of a tagged element `pickCentred` reads, so a test can hand in literals. */
|
|
107
|
+
export interface TaggedBox {
|
|
108
|
+
entryId: string;
|
|
109
|
+
field: string;
|
|
110
|
+
/** `getBoundingClientRect()`, viewport coordinates. */
|
|
111
|
+
top: number;
|
|
112
|
+
bottom: number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The tagged element a reader is looking at: the one that contains the
|
|
116
|
+
* viewport's horizontal centre line, and when several do (a field inside a
|
|
117
|
+
* card that is also tagged) the SMALLEST, because the innermost element is
|
|
118
|
+
* the specific one. When none crosses the line, the nearest one that is at
|
|
119
|
+
* least partly on screen. Null when nothing tagged is on screen at all.
|
|
120
|
+
*
|
|
121
|
+
* `edge` is where the page is scrolled to. A heading near the top of a page
|
|
122
|
+
* can never reach the centre line, because the page cannot scroll above its
|
|
123
|
+
* top; at the top the topmost visible element is the one being read, and at
|
|
124
|
+
* the bottom the bottommost. The same rule a table of contents' scroll spy
|
|
125
|
+
* uses.
|
|
126
|
+
*
|
|
127
|
+
* Pure, so the admin's "follow the page" can be tested without a DOM.
|
|
128
|
+
*/
|
|
129
|
+
export declare function pickCentred(boxes: readonly TaggedBox[], viewportHeight: number, edge?: "top" | "bottom" | null): TaggedBox | null;
|
|
130
|
+
/**
|
|
131
|
+
* Which edge a scroll position is at, for `pickCentred`. A page that does not
|
|
132
|
+
* scroll at all is at neither: nothing moves, and the centre rule stands.
|
|
133
|
+
*/
|
|
134
|
+
export declare function scrollEdge(scrollY: number, viewportHeight: number, scrollHeight: number): "top" | "bottom" | null;
|