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

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/README.md CHANGED
@@ -199,6 +199,19 @@ so that moving a folder cannot silently split one page's telemetry in two. Set
199
199
  `page` on the config instead when a client serves exactly one page; a value on
200
200
  the call wins over one on the config.
201
201
 
202
+ A layout is not a page. `app/layout.tsx` (and any nested `layout.*` or
203
+ `template.*`) renders around every page below it, and Next does not tell it
204
+ which one, so its reads cannot be charged to the page being rendered. `routeOf`
205
+ returns `"(layout)"` for these files, exported as `LAYOUT_PAGE`, and a read that
206
+ names it sends no `Capa-Page` header at all, even when the client was created
207
+ with a `page`. So a Site singleton or a nav read in your root layout is simply
208
+ not attributed, instead of making `/` look as if it read everything:
209
+
210
+ ```ts
211
+ // In app/layout.tsx: same call as in a page, and no page is recorded.
212
+ const site = await capa.entries.list("site", { page: routeOf(import.meta.url) });
213
+ ```
214
+
202
215
  A malformed value throws a `TypeError`. Capa itself ignores a header it cannot
203
216
  store, because a mangled page identity must never take a blog down, so the SDK
204
217
  is the place a typo surfaces.
@@ -419,6 +432,14 @@ every navigation, `select { entryId, field }` on a click, and `hover`.
419
432
  `acceptMessage(event, allowedOrigins, parent)` is the site's filter, exported
420
433
  for your own tests.
421
434
 
435
+ Since 1.0.0-next.2 the site also sends `visible { entryId, field }` while the
436
+ page scrolls (at most every 150ms, and only when it changes): the tagged element
437
+ at the centre of the viewport, chosen by `pickCentred`. At the very top of a
438
+ page, where a heading can never reach the centre, it is the topmost visible
439
+ element instead, and at the very bottom the bottommost. The editor's "Follow the
440
+ page" scrolls the form to that field. It is additive and still `v: 1`: an older
441
+ overlay never sends it and the editor simply does not follow.
442
+
422
443
  ## Not yet
423
444
 
424
445
  `--select-from-depth`, `capa convert-url`, `capa persist`, GraphQL, and `/api/`
@@ -287,6 +287,20 @@ export declare class CapaError extends Error {
287
287
  }
288
288
  export declare function isCapaError(error: unknown): error is CapaError;
289
289
  export declare function resolveNextConfig(config: CapaNextConfig): ResolvedNextConfig;
290
+ /**
291
+ * The page a layout (or template) reads for: none.
292
+ *
293
+ * A root layout renders around every page on the site, so charging its reads
294
+ * to `/`, the route its file sits at, made the home page look as if it read
295
+ * every Site singleton and nav on the site. `routeOf` returns this for a
296
+ * `layout.*` or `template.*` file, and a read that names it sends NO
297
+ * `Capa-Page` at all, even when the client was built with a `page`.
298
+ *
299
+ * Not sent as a value, because the API would drop it anyway: it is not a
300
+ * `PAGE_ID`, and the API ignores what it cannot store. Staying absent keeps a
301
+ * layout read byte-identical to one from a client that never named a page.
302
+ */
303
+ export declare const LAYOUT_PAGE = "(layout)";
290
304
  type SelectInput = string | ReadonlyArray<unknown>;
291
305
  /** Serialize the SDK object form into the canonical `/api/entries` grammar. */
292
306
  export declare function serializeSelect(select: SelectInput): string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CapaError = void 0;
3
+ exports.LAYOUT_PAGE = exports.CapaError = void 0;
4
4
  exports.isCapaError = isCapaError;
5
5
  exports.resolveNextConfig = resolveNextConfig;
6
6
  exports.serializeSelect = serializeSelect;
@@ -85,6 +85,20 @@ function resolveNextConfig(config) {
85
85
  * quietly stops arriving rather than as an error.
86
86
  */
87
87
  const PAGE_ID = /^\/[A-Za-z0-9._\-[\]/]{0,199}$/;
88
+ /**
89
+ * The page a layout (or template) reads for: none.
90
+ *
91
+ * A root layout renders around every page on the site, so charging its reads
92
+ * to `/`, the route its file sits at, made the home page look as if it read
93
+ * every Site singleton and nav on the site. `routeOf` returns this for a
94
+ * `layout.*` or `template.*` file, and a read that names it sends NO
95
+ * `Capa-Page` at all, even when the client was built with a `page`.
96
+ *
97
+ * Not sent as a value, because the API would drop it anyway: it is not a
98
+ * `PAGE_ID`, and the API ignores what it cannot store. Staying absent keeps a
99
+ * layout read byte-identical to one from a client that never named a page.
100
+ */
101
+ exports.LAYOUT_PAGE = "(layout)";
88
102
  /**
89
103
  * The page for one call: the call's own value, else the client's, else none.
90
104
  *
@@ -97,6 +111,10 @@ function resolvePage(configPage, callPage) {
97
111
  const value = callPage !== undefined ? callPage : configPage;
98
112
  if (value === undefined || value === null)
99
113
  return undefined;
114
+ // A layout's read, named on purpose: no header, and the config's page does
115
+ // not stand in for it either.
116
+ if (value === exports.LAYOUT_PAGE)
117
+ return undefined;
100
118
  if (typeof value !== "string" || !PAGE_ID.test(value)) {
101
119
  throw new TypeError(`@capacms/sdk/next: page must be a path such as "/blog/[slug]" or "/blog/hello". Got ${JSON.stringify(value)}.`);
102
120
  }
@@ -1,4 +1,4 @@
1
- export { CapaError, createClient, isCapaError, resolveNextConfig, serializeSelect, } from "./client";
1
+ export { CapaError, createClient, isCapaError, LAYOUT_PAGE, resolveNextConfig, serializeSelect, } from "./client";
2
2
  export { capaAttrs } from "./attrs";
3
3
  export type { CapaAttrs } from "./attrs";
4
4
  export type { CallOptions, CapaNextClient, CapaNextConfig, EntriesResource, Entry, Filter, FilterOperator, FilterScalar, FilterValue, GetOptions, ListOptions, Page, PageDetail, PageInfo, PageSummary, PagesListOptions, PagesResource, PreviewClaim, ResponseMeta, Single, } from "./client";
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.capaAttrs = exports.serializeSelect = exports.resolveNextConfig = exports.isCapaError = exports.createClient = exports.CapaError = void 0;
3
+ exports.capaAttrs = exports.serializeSelect = exports.resolveNextConfig = exports.LAYOUT_PAGE = exports.isCapaError = exports.createClient = exports.CapaError = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "CapaError", { enumerable: true, get: function () { return client_1.CapaError; } });
6
6
  Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
7
7
  Object.defineProperty(exports, "isCapaError", { enumerable: true, get: function () { return client_1.isCapaError; } });
8
+ Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function () { return client_1.LAYOUT_PAGE; } });
8
9
  Object.defineProperty(exports, "resolveNextConfig", { enumerable: true, get: function () { return client_1.resolveNextConfig; } });
9
10
  Object.defineProperty(exports, "serializeSelect", { enumerable: true, get: function () { return client_1.serializeSelect; } });
10
11
  var attrs_1 = require("./attrs");
@@ -1,4 +1,4 @@
1
- import { type CapaNextClient, type CapaNextConfig, type PagesResource, type PreviewClaim } from "../next";
1
+ import { LAYOUT_PAGE, type CapaNextClient, type CapaNextConfig, type PagesResource, type PreviewClaim } from "../next";
2
2
  export interface CacheOptions {
3
3
  tags?: string[];
4
4
  revalidate?: number | false;
@@ -50,4 +50,5 @@ export declare function preview(token: string, client: CapaNextClient): Promise<
50
50
  * this and never hold the whole client.
51
51
  */
52
52
  export declare function pagesFor(client: CapaNextClient): PagesResource;
53
+ export { LAYOUT_PAGE };
53
54
  export type { PreviewClaim };
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LAYOUT_PAGE = void 0;
3
4
  exports.withCache = withCache;
4
5
  exports.tagsFor = tagsFor;
5
6
  exports.revalidateFromWebhook = revalidateFromWebhook;
@@ -8,6 +9,7 @@ exports.routeOf = routeOf;
8
9
  exports.preview = preview;
9
10
  exports.pagesFor = pagesFor;
10
11
  const next_1 = require("../next");
12
+ Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function () { return next_1.LAYOUT_PAGE; } });
11
13
  /** Add Next.js fetch-cache options without importing `next/*`. */
12
14
  function withCache(fetchImpl, options) {
13
15
  return (async (input, init = {}) => {
@@ -64,6 +66,16 @@ async function draftClient(input) {
64
66
  * src/app/(marketing)/pricing/page.tsx -> /pricing
65
67
  * pages/blog/[slug].tsx -> /blog/[slug]
66
68
  * app/page.tsx -> /
69
+ * app/layout.tsx, app/blog/template.tsx -> (layout)
70
+ *
71
+ * A LAYOUT IS NOT A PAGE. `layout.*` and `template.*` render around every page
72
+ * below them, and a layout is not told which one: Next hands it no pathname.
73
+ * So its reads cannot be charged to the page being rendered, and charging them
74
+ * to the route its own file sits at is wrong: a root layout's Site singleton
75
+ * and nav would all be recorded as reads of `/`. `routeOf` returns
76
+ * `LAYOUT_PAGE` (`"(layout)"`) for these files instead, and a read that names
77
+ * it sends no `Capa-Page` at all. Pass `routeOf(import.meta.url)` from a layout
78
+ * exactly as from a page and it does the right thing.
67
79
  *
68
80
  * WHAT IS DROPPED, and why each one:
69
81
  *
@@ -74,8 +86,7 @@ async function draftClient(input) {
74
86
  * parallel slots `@modal` the same
75
87
  * the `(.)` intercept marker an intercepting route at the SAME
76
88
  * level renders the segment beside it
77
- * `page.*`, `layout.*`, `route.*`, leaf files, not segments
78
- * `default.*`, `template.*`
89
+ * `page.*`, `route.*`, `default.*` leaf files, not segments
79
90
  * `index` in the pages router the folder IS the route
80
91
  * the extension never in a URL
81
92
  *
@@ -87,7 +98,9 @@ async function draftClient(input) {
87
98
  * case the message says to pass the page string yourself.
88
99
  */
89
100
  const ROUTER_ROOTS = new Set(["app", "pages"]);
90
- const LEAF_FILES = new Set(["page", "layout", "route", "default", "template"]);
101
+ const LEAF_FILES = new Set(["page", "route", "default"]);
102
+ /** Files that render around many routes, so they name none. */
103
+ const LAYOUT_FILES = new Set(["layout", "template"]);
91
104
  /** `(..)photo`, `(..)(..)photo`, `(...)photo`: the URL is not here. */
92
105
  const OUTER_INTERCEPT = /^(\(\.\.\.\)|(\(\.\.\))+)/;
93
106
  function routeOf(file) {
@@ -131,6 +144,9 @@ function routeOf(file) {
131
144
  const dot = part.lastIndexOf(".");
132
145
  if (dot > 0)
133
146
  part = part.slice(0, dot);
147
+ // Only in the app router: `pages/layout.tsx` is a page at `/layout`.
148
+ if (parts[rootIndex] === "app" && LAYOUT_FILES.has(part))
149
+ return next_1.LAYOUT_PAGE;
134
150
  if (LEAF_FILES.has(part))
135
151
  continue;
136
152
  // The pages router: `pages/blog/index.tsx` is `/blog`.
@@ -1,5 +1,5 @@
1
- export { acceptMessage, hoverMessage, normaliseOrigins, readyMessage, selectMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol";
2
- export type { AdminMessage, MessageLike, SiteMessage } from "./protocol";
1
+ export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol";
2
+ export type { AdminMessage, MessageLike, SiteMessage, TaggedBox } from "./protocol";
3
3
  export interface OverlayOptions {
4
4
  /**
5
5
  * The Capa admin origins allowed to drive this page, for example
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SITE_SOURCE = exports.PROTOCOL_VERSION = exports.ADMIN_SOURCE = exports.selectMessage = exports.readyMessage = exports.normaliseOrigins = exports.hoverMessage = exports.acceptMessage = void 0;
3
+ exports.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.
@@ -21,8 +21,11 @@ var protocol_2 = require("./protocol");
21
21
  Object.defineProperty(exports, "acceptMessage", { enumerable: true, get: function () { return protocol_2.acceptMessage; } });
22
22
  Object.defineProperty(exports, "hoverMessage", { enumerable: true, get: function () { return protocol_2.hoverMessage; } });
23
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; } });
24
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; } });
25
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; } });
26
29
  Object.defineProperty(exports, "ADMIN_SOURCE", { enumerable: true, get: function () { return protocol_2.ADMIN_SOURCE; } });
27
30
  Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_2.PROTOCOL_VERSION; } });
28
31
  Object.defineProperty(exports, "SITE_SOURCE", { enumerable: true, get: function () { return protocol_2.SITE_SOURCE; } });
@@ -32,6 +35,8 @@ const TAGGED = "[data-capa-entry][data-capa-field]";
32
35
  const SCROLL_KEY = "capa-overlay:scrollY";
33
36
  /** How long a refresh keeps putting the scroll position back while the page re-renders. */
34
37
  const RESTORE_WINDOW_MS = 1500;
38
+ /** `visible` is sent at most this often while the page scrolls. */
39
+ const VISIBLE_THROTTLE_MS = 150;
35
40
  const noop = () => { };
36
41
  let running = null;
37
42
  /**
@@ -67,6 +72,8 @@ function createOverlay(initial) {
67
72
  let readyFrame = 0;
68
73
  let hoverFrame = 0;
69
74
  let lastPointer = null;
75
+ let visibleTimer = 0;
76
+ let lastVisible = "";
70
77
  const post = (message) => {
71
78
  if (!adminOrigin)
72
79
  return;
@@ -195,6 +202,41 @@ function createOverlay(initial) {
195
202
  return;
196
203
  readyFrame = window.requestAnimationFrame(() => sendReady(false));
197
204
  };
205
+ // ------------------------------------------------------------- visible ---
206
+ /**
207
+ * Which tagged element sits at the centre of the viewport, reported when it
208
+ * changes. The editor's "Follow the page" scrolls the form to match. Sent
209
+ * only to an admin that has said hello, and only on a change, so a still
210
+ * page sends nothing.
211
+ */
212
+ const reportVisible = () => {
213
+ visibleTimer = 0;
214
+ if (!adminOrigin)
215
+ return;
216
+ const boxes = tagged().map((el) => {
217
+ const rect = el.getBoundingClientRect();
218
+ return {
219
+ entryId: el.getAttribute("data-capa-entry") ?? "",
220
+ field: el.getAttribute("data-capa-field") ?? "",
221
+ top: rect.top,
222
+ bottom: rect.bottom,
223
+ };
224
+ });
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);
227
+ if (!pick)
228
+ return;
229
+ const key = `${pick.entryId}\n${pick.field}`;
230
+ if (key === lastVisible)
231
+ return;
232
+ lastVisible = key;
233
+ post((0, protocol_1.visibleMessage)(pick.entryId, pick.field));
234
+ };
235
+ const onScroll = () => {
236
+ render();
237
+ if (!visibleTimer)
238
+ visibleTimer = window.setTimeout(reportVisible, VISIBLE_THROTTLE_MS);
239
+ };
198
240
  // -------------------------------------------------------------- scroll ---
199
241
  const restoreScroll = () => {
200
242
  if (!pendingScroll)
@@ -247,6 +289,7 @@ function createOverlay(initial) {
247
289
  switch (message.type) {
248
290
  case "hello":
249
291
  adminOrigin = event.origin;
292
+ lastVisible = "";
250
293
  sendReady(true);
251
294
  render();
252
295
  return;
@@ -321,7 +364,7 @@ function createOverlay(initial) {
321
364
  restoreScroll();
322
365
  });
323
366
  window.addEventListener("message", onMessage);
324
- window.addEventListener("scroll", render, { capture: true, passive: true });
367
+ window.addEventListener("scroll", onScroll, { capture: true, passive: true });
325
368
  window.addEventListener("resize", render, { passive: true });
326
369
  window.addEventListener("popstate", scheduleReady);
327
370
  document.addEventListener("click", onClick, true);
@@ -330,7 +373,7 @@ function createOverlay(initial) {
330
373
  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
331
374
  const dispose = () => {
332
375
  window.removeEventListener("message", onMessage);
333
- window.removeEventListener("scroll", render, { capture: true });
376
+ window.removeEventListener("scroll", onScroll, { capture: true });
334
377
  window.removeEventListener("resize", render);
335
378
  window.removeEventListener("popstate", scheduleReady);
336
379
  document.removeEventListener("click", onClick, true);
@@ -340,6 +383,8 @@ function createOverlay(initial) {
340
383
  for (const id of [frame, readyFrame, hoverFrame])
341
384
  if (id)
342
385
  window.cancelAnimationFrame(id);
386
+ if (visibleTimer)
387
+ window.clearTimeout(visibleTimer);
343
388
  layer?.remove();
344
389
  layer = null;
345
390
  if (running?.dispose === dispose)
@@ -15,6 +15,13 @@
15
15
  * ready { path, entries } after hello, and after every navigation
16
16
  * select { entryId, field } a tagged element was clicked
17
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.
18
25
  *
19
26
  * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
20
27
  * this package). If you change one side, change the other.
@@ -60,6 +67,12 @@ export type SiteMessage = {
60
67
  type: "hover";
61
68
  entryId: string;
62
69
  field: string | null;
70
+ } | {
71
+ source: typeof SITE_SOURCE;
72
+ v: 1;
73
+ type: "visible";
74
+ entryId: string;
75
+ field: string;
63
76
  };
64
77
  /** The parts of a `MessageEvent` the filter reads, so a test can hand in a literal. */
65
78
  export interface MessageLike {
@@ -89,3 +102,33 @@ export declare function acceptMessage(event: MessageLike, allowedOrigins: readon
89
102
  export declare function readyMessage(path: string, entries: string[]): SiteMessage;
90
103
  export declare function selectMessage(entryId: string, field: string): SiteMessage;
91
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;
@@ -16,6 +16,13 @@
16
16
  * ready { path, entries } after hello, and after every navigation
17
17
  * select { entryId, field } a tagged element was clicked
18
18
  * hover { entryId, field } | { entryId: "", field: null }
19
+ * visible { entryId, field } the tagged element at the centre of the
20
+ * viewport changed (throttled, on scroll)
21
+ *
22
+ * `visible` arrived in 1.0.0-next.2 and is OPTIONAL: it is still `v: 1`,
23
+ * because an admin that does not know it drops an unknown type, and an older
24
+ * overlay simply never sends it. Additive messages keep the version; only a
25
+ * change to an existing shape would move it.
19
26
  *
20
27
  * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
21
28
  * this package). If you change one side, change the other.
@@ -27,6 +34,9 @@ exports.acceptMessage = acceptMessage;
27
34
  exports.readyMessage = readyMessage;
28
35
  exports.selectMessage = selectMessage;
29
36
  exports.hoverMessage = hoverMessage;
37
+ exports.visibleMessage = visibleMessage;
38
+ exports.pickCentred = pickCentred;
39
+ exports.scrollEdge = scrollEdge;
30
40
  exports.PROTOCOL_VERSION = 1;
31
41
  exports.ADMIN_SOURCE = "capa-admin";
32
42
  exports.SITE_SOURCE = "capa";
@@ -104,3 +114,71 @@ function hoverMessage(entryId, field) {
104
114
  ? { source: exports.SITE_SOURCE, v: 1, type: "hover", entryId: "", field: null }
105
115
  : { source: exports.SITE_SOURCE, v: 1, type: "hover", entryId, field };
106
116
  }
117
+ function visibleMessage(entryId, field) {
118
+ return { source: exports.SITE_SOURCE, v: 1, type: "visible", entryId, field };
119
+ }
120
+ /** Above any element height, so a box that misses the centre line never outranks one that crosses it. */
121
+ const MISS_FLOOR = 1e9;
122
+ /**
123
+ * The tagged element a reader is looking at: the one that contains the
124
+ * viewport's horizontal centre line, and when several do (a field inside a
125
+ * card that is also tagged) the SMALLEST, because the innermost element is
126
+ * the specific one. When none crosses the line, the nearest one that is at
127
+ * least partly on screen. Null when nothing tagged is on screen at all.
128
+ *
129
+ * `edge` is where the page is scrolled to. A heading near the top of a page
130
+ * can never reach the centre line, because the page cannot scroll above its
131
+ * top; at the top the topmost visible element is the one being read, and at
132
+ * the bottom the bottommost. The same rule a table of contents' scroll spy
133
+ * uses.
134
+ *
135
+ * Pure, so the admin's "follow the page" can be tested without a DOM.
136
+ */
137
+ function pickCentred(boxes, viewportHeight, edge = null) {
138
+ const centre = viewportHeight / 2;
139
+ let best = null;
140
+ let bestScore = Infinity;
141
+ for (const b of boxes) {
142
+ if (!b.entryId || !b.field)
143
+ continue;
144
+ if (b.bottom <= 0 || b.top >= viewportHeight || b.bottom <= b.top)
145
+ continue;
146
+ const height = b.bottom - b.top;
147
+ let score;
148
+ if (edge === "top") {
149
+ // Topmost first; of two that start together, the smaller (inner) one.
150
+ score = b.top * MISS_FLOOR + height;
151
+ }
152
+ else if (edge === "bottom") {
153
+ score = (viewportHeight - b.bottom) * MISS_FLOOR + height;
154
+ }
155
+ else {
156
+ // Crossing the line scores by height (smaller wins) and always beats a
157
+ // box that misses it, which scores by its distance from the line on top
158
+ // of a floor no height can reach.
159
+ score =
160
+ b.top <= centre && b.bottom >= centre
161
+ ? height
162
+ : MISS_FLOOR + Math.min(Math.abs(b.top - centre), Math.abs(b.bottom - centre));
163
+ }
164
+ if (score < bestScore) {
165
+ best = b;
166
+ bestScore = score;
167
+ }
168
+ }
169
+ return best;
170
+ }
171
+ /**
172
+ * Which edge a scroll position is at, for `pickCentred`. A page that does not
173
+ * scroll at all is at neither: nothing moves, and the centre rule stands.
174
+ */
175
+ function scrollEdge(scrollY, viewportHeight, scrollHeight) {
176
+ const max = scrollHeight - viewportHeight;
177
+ if (max <= 1)
178
+ return null;
179
+ if (scrollY <= 1)
180
+ return "top";
181
+ if (scrollY >= max - 1)
182
+ return "bottom";
183
+ return null;
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacms/sdk",
3
- "version": "1.0.0-next.1",
3
+ "version": "1.0.0-next.3",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",