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

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
@@ -419,6 +419,14 @@ every navigation, `select { entryId, field }` on a click, and `hover`.
419
419
  `acceptMessage(event, allowedOrigins, parent)` is the site's filter, exported
420
420
  for your own tests.
421
421
 
422
+ Since 1.0.0-next.2 the site also sends `visible { entryId, field }` while the
423
+ page scrolls (at most every 150ms, and only when it changes): the tagged element
424
+ at the centre of the viewport, chosen by `pickCentred`. At the very top of a
425
+ page, where a heading can never reach the centre, it is the topmost visible
426
+ element instead, and at the very bottom the bottommost. The editor's "Follow the
427
+ page" scrolls the form to that field. It is additive and still `v: 1`: an older
428
+ overlay never sends it and the editor simply does not follow.
429
+
422
430
  ## Not yet
423
431
 
424
432
  `--select-from-depth`, `capa convert-url`, `capa persist`, GraphQL, and `/api/`
@@ -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.2",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",