@backmatter/next 0.0.25 → 0.0.27

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/dist/config.cjs CHANGED
@@ -827,7 +827,12 @@ function withBackmatter(nextConfig = {}, options = {}) {
827
827
  ensureRegistryTsconfig(loaderOptions.root, loaderOptions.outDir);
828
828
  const cacheComponents = cfg.cacheComponents ?? true;
829
829
  const transpilePackages = [
830
- ...new Set([...cfg.transpilePackages ?? [], "@backmatter/next", "@backmatter/core"])
830
+ ...new Set([
831
+ ...cfg.transpilePackages ?? [],
832
+ "@backmatter/next",
833
+ "@backmatter/core",
834
+ "@backmatter/studio"
835
+ ])
831
836
  ];
832
837
  return {
833
838
  ...nextConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/next",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "Experimental Next.js adapter for Backmatter. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -50,7 +50,7 @@
50
50
  "@backmatter/core": "^0.0.21",
51
51
  "@mdx-js/mdx": "^3.1.1",
52
52
  "remark-gfm": "^4.0.1",
53
- "@backmatter/studio": "^0.0.3"
53
+ "@backmatter/studio": "^0.0.5"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": ">=16.3.0",
package/src/config.ts CHANGED
@@ -187,7 +187,12 @@ export function withBackmatter<T extends object>(
187
187
  // files with one shared shell for every CMS URL, so a normal link would
188
188
  // prefetch the shell instead of the finished page. Hosts can still opt in.
189
189
  const transpilePackages = [
190
- ...new Set([...(cfg.transpilePackages ?? []), "@backmatter/next", "@backmatter/core"]),
190
+ ...new Set([
191
+ ...(cfg.transpilePackages ?? []),
192
+ "@backmatter/next",
193
+ "@backmatter/core",
194
+ "@backmatter/studio",
195
+ ]),
191
196
  ];
192
197
  return {
193
198
  ...nextConfig,
@@ -0,0 +1,39 @@
1
+ // packages/next/src/preview-hold.tsx
2
+ "use client";
3
+
4
+ // A route refresh suspends the preview branch. Its Suspense fallback would
5
+ // otherwise be empty for the whole fetch. The last painted preview is kept
6
+ // here, outside that branch, and shown until the next one is ready.
7
+ import { useEffect, useSyncExternalStore, type ReactNode } from "react";
8
+
9
+ let held: ReactNode = null;
10
+ const listeners = new Set<() => void>();
11
+
12
+ function emit() {
13
+ for (const listener of listeners) listener();
14
+ }
15
+
16
+ function subscribe(listener: () => void) {
17
+ listeners.add(listener);
18
+ return () => listeners.delete(listener);
19
+ }
20
+
21
+ export function clearHeldPreview() {
22
+ if (held === null) return;
23
+ held = null;
24
+ emit();
25
+ }
26
+
27
+ /** Remember the preview that just painted, so a later refresh can keep it. */
28
+ export function HoldPreview({ children }: { children: ReactNode }) {
29
+ useEffect(() => {
30
+ held = children;
31
+ emit();
32
+ }, [children]);
33
+ return children;
34
+ }
35
+
36
+ /** Suspense fallback: the previous preview, or nothing on the first load. */
37
+ export function HeldPreview() {
38
+ return useSyncExternalStore(subscribe, () => held, () => null);
39
+ }
package/src/routes.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // `route` template (including `/` via indexSlug), loads it and renders MDX
5
5
  // or JSON pages. An optional catch-all is required so `/` is a CMS route.
6
6
  import { Fragment, Suspense, createElement, type ReactNode } from "react";
7
+ import { HeldPreview, HoldPreview } from "./preview-hold";
7
8
  import {
8
9
  DEFAULT_TEMPLATE_SLUG,
9
10
  isRoutable,
@@ -425,15 +426,19 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
425
426
  const preview = await resolvePreviewState(cms, options.preview, searchParams);
426
427
  if (!previewReplacesPublished(preview)) return null;
427
428
  return createElement(
428
- Fragment,
429
+ HoldPreview,
429
430
  null,
430
- createElement("style", {
431
- dangerouslySetInnerHTML: {
432
- __html: "[data-backmatter-published]{display:none!important}",
433
- },
434
- }),
435
- docBridge(hit),
436
- await loadPreviewDoc(hit, preview),
431
+ createElement(
432
+ Fragment,
433
+ null,
434
+ createElement("style", {
435
+ dangerouslySetInnerHTML: {
436
+ __html: "[data-backmatter-published]{display:none!important}",
437
+ },
438
+ }),
439
+ docBridge(hit),
440
+ await loadPreviewDoc(hit, preview),
441
+ ),
437
442
  );
438
443
  };
439
444
 
@@ -454,7 +459,7 @@ export function createCatchAll<Models extends readonly ModelDef[]>(
454
459
  Fragment,
455
460
  null,
456
461
  createElement(PublishedRoute, props),
457
- createElement(Suspense, { fallback: null }, createElement(PreviewBranch, props)),
462
+ createElement(Suspense, { fallback: createElement(HeldPreview) }, createElement(PreviewBranch, props)),
458
463
  );
459
464
 
460
465
  const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
@@ -1,19 +1,17 @@
1
1
  // packages/next/src/site-editor.tsx
2
+ // Host adapter for the on-site widget. The widget import is the studio build
3
+ // (`@backmatter/studio/widget` → dist), where StyleX is already compiled.
2
4
  "use client";
3
5
 
4
6
  import { usePathname, useRouter } from "next/navigation";
5
- import { startTransition, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
6
- import {
7
- DEVBAR_CLASS,
8
- DEVBAR_CLEARANCE,
9
- DEVBAR_ENTER_CSS,
10
- DEVBAR_RIGHT,
11
- devbarShellStyle,
12
- } from "./devbar-motion";
7
+ import { startTransition, useCallback, useEffect, useRef, useState } from "react";
8
+ import "@backmatter/studio/studio.css";
9
+ import studioPkg from "@backmatter/studio/package.json";
10
+ import { SiteWidget } from "@backmatter/studio/widget";
13
11
  import { markerCookie } from "./gate";
12
+ import { clearHeldPreview } from "./preview-hold";
14
13
  import { subscribeSiteDoc } from "./site-doc-bridge";
15
14
  import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
16
- import { installSitePointer } from "./site-pointer";
17
15
 
18
16
  const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
19
17
  /** Slide the 12h site session while the bar is on screen. */
@@ -22,10 +20,23 @@ const SESSION_RENEW_MS = 30 * 60 * 1000;
22
20
  function useSiteDoc(): SiteDocRef | null {
23
21
  const pathname = usePathname();
24
22
  const [doc, setDoc] = useState<SiteDocRef | null>(null);
23
+ const pathRef = useRef(pathname);
24
+
25
+ useEffect(() => {
26
+ if (pathRef.current === pathname) return;
27
+ pathRef.current = pathname;
28
+ clearHeldPreview();
29
+ setDoc(null);
30
+ }, [pathname]);
25
31
 
26
32
  useEffect(() => {
27
33
  const apply = (next: SiteDocRef | null) => {
28
- setDoc((prev) => (sameSiteDoc(prev, next) ? prev : next));
34
+ setDoc((prev) => {
35
+ // A refresh unmounts the doc marker for a moment. Keep the document
36
+ // the bar already has so the chat stays mounted across that gap.
37
+ if (!next) return prev;
38
+ return sameSiteDoc(prev, next) ? prev : next;
39
+ });
29
40
  };
30
41
  apply(readSiteDoc());
31
42
  const stop = subscribeSiteDoc(apply);
@@ -48,106 +59,41 @@ function useSiteDoc(): SiteDocRef | null {
48
59
  /** Default route the host mounts `@backmatter/studio` on. */
49
60
  export const DEFAULT_STUDIO_PATH = "/backmatter";
50
61
 
51
- /**
52
- * The panels are frames of the Studio mounted on this same origin. Same
53
- * origin means the site session cookie is first-party and postMessage needs
54
- * no foreign origin.
55
- */
56
- function studioBase(studioPath: string): string {
57
- if (typeof location === "undefined") return "";
58
- return `${location.origin}${studioPath.replace(/\/+$/, "")}`;
59
- }
60
-
61
62
  export function SiteChrome({
62
63
  branch,
63
64
  tree,
64
65
  exiting,
65
66
  onExit,
66
- studioPath = DEFAULT_STUDIO_PATH,
67
67
  previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
68
68
  }: {
69
69
  branch?: string;
70
70
  tree?: string;
71
71
  exiting?: boolean;
72
72
  onExit: () => void;
73
- /** Where `@backmatter/studio` is mounted on this site. */
73
+ /** Kept so existing hosts can still pass it. The bar does not open the document editor. */
74
74
  studioPath?: string;
75
75
  /** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
76
76
  previewEntryPath?: string;
77
77
  }) {
78
78
  const router = useRouter();
79
79
  const doc = useSiteDoc();
80
- const [editorMounted, setEditorMounted] = useState(false);
81
- const [editorOpen, setEditorOpen] = useState(false);
82
- const [chatOpen, setChatOpen] = useState(false);
83
- const [publishOpen, setPublishOpen] = useState(false);
84
- const [changeCount, setChangeCount] = useState(0);
85
- // Set in an effect so SSR and the first client render agree.
86
- const [base, setBase] = useState("");
87
- const origin = typeof location === "undefined" ? "" : location.origin;
88
- const editorFrame = useRef<HTMLIFrameElement>(null);
89
-
90
- useEffect(() => {
91
- setBase(studioBase(studioPath));
92
- }, [studioPath]);
93
-
94
- useEffect(() => {
95
- function onKey(event: KeyboardEvent) {
96
- if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "j") return;
97
- event.preventDefault();
98
- setChatOpen((open) => !open);
99
- }
100
- document.addEventListener("keydown", onKey, true);
101
- return () => document.removeEventListener("keydown", onKey, true);
102
- }, []);
103
-
104
- useEffect(() => {
105
- function onMessage(event: MessageEvent) {
106
- if (event.origin !== origin) return;
107
- const data = event.data as {
108
- type?: string;
109
- changeCount?: number;
110
- key?: string;
111
- metaKey?: boolean;
112
- ctrlKey?: boolean;
113
- };
114
- if (data?.type === "backmatter:git" && typeof data.changeCount === "number") {
115
- setChangeCount(data.changeCount);
116
- }
117
- if (
118
- data?.type === "backmatter:hotkey" &&
119
- data.key?.toLowerCase() === "j" &&
120
- (data.metaKey || data.ctrlKey)
121
- ) {
122
- setChatOpen((open) => !open);
123
- }
124
- if (data?.type === "backmatter:published") {
125
- setPublishOpen(false);
126
- setChangeCount(0);
127
- editorFrame.current?.contentWindow?.postMessage({ type: "backmatter:reload-doc" }, origin);
128
- startTransition(() => router.refresh());
80
+ const leaving = useRef(false);
81
+ const leave = useCallback(() => {
82
+ if (leaving.current) return;
83
+ leaving.current = true;
84
+ void (async () => {
85
+ try {
86
+ await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
87
+ } catch {
88
+ // The cookies expire on their own. Still drop the bar.
129
89
  }
130
- }
131
- window.addEventListener("message", onMessage);
132
- return () => window.removeEventListener("message", onMessage);
133
- }, [origin, router]);
90
+ document.cookie = markerCookie(false, location.protocol === "https:");
91
+ location.reload();
92
+ })();
93
+ }, [previewEntryPath]);
134
94
 
135
95
  useEffect(() => {
136
96
  let stopped = false;
137
- let left = false;
138
- function leaveEditor() {
139
- if (left || stopped) return;
140
- left = true;
141
- void (async () => {
142
- try {
143
- await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
144
- } catch {
145
- // The cookies expire on their own. Still drop the bar.
146
- }
147
- document.cookie = markerCookie(false, location.protocol === "https:");
148
- location.reload();
149
- })();
150
- }
151
97
  async function renew() {
152
98
  try {
153
99
  const res = await fetch(`${previewEntryPath}?renew=1`, {
@@ -155,16 +101,16 @@ export function SiteChrome({
155
101
  credentials: "same-origin",
156
102
  cache: "no-store",
157
103
  });
158
- if (res.status === 401) leaveEditor();
104
+ if (!stopped && res.status === 401) leave();
159
105
  } catch {
160
- // Offline. The next send, or the next interval, tries again.
106
+ // Offline. The next interval tries again.
161
107
  }
162
108
  }
163
109
  void renew();
164
110
  const timer = setInterval(() => void renew(), SESSION_RENEW_MS);
165
111
  function onExpired(event: MessageEvent) {
166
- if (event.origin !== origin) return;
167
- if (event.data?.type === "backmatter:session-expired") leaveEditor();
112
+ if (event.origin !== location.origin) return;
113
+ if (event.data?.type === "backmatter:session-expired") leave();
168
114
  }
169
115
  window.addEventListener("message", onExpired);
170
116
  return () => {
@@ -172,300 +118,18 @@ export function SiteChrome({
172
118
  clearInterval(timer);
173
119
  window.removeEventListener("message", onExpired);
174
120
  };
175
- }, [origin, previewEntryPath]);
176
-
177
- const label = doc ? `${doc.type}/${doc.slug}` : "No document";
178
- const editorSrc =
179
- doc && base
180
- ? `${base}/workspaces/${doc.workspaceId}/docs/${doc.locale}/${doc.type}/${doc.slug}?embed=1`
181
- : "";
182
-
183
- function onChatWrote() {
184
- editorFrame.current?.contentWindow?.postMessage({ type: "backmatter:reload-doc" }, origin);
185
- startTransition(() => router.refresh());
186
- }
121
+ }, [leave, previewEntryPath]);
187
122
 
188
123
  return (
189
- <>
190
- <style>{`
191
- ${DEVBAR_ENTER_CSS}
192
- @media (hover: hover) {
193
- .${DEVBAR_CLASS} button:hover:not(:disabled) {
194
- background: #f4f4f5;
195
- }
196
- }
197
- .${DEVBAR_CLASS} button:focus-visible {
198
- outline: 2px solid #18181b;
199
- outline-offset: 2px;
200
- }
201
- .${DEVBAR_CLASS} button:disabled {
202
- cursor: default;
203
- opacity: 0.5;
204
- }
205
- html.backmatter-pointing,
206
- html.backmatter-pointing * {
207
- cursor: crosshair !important;
208
- }
209
- html.backmatter-pointing .${DEVBAR_CLASS},
210
- html.backmatter-pointing .${DEVBAR_CLASS} * {
211
- cursor: pointer !important;
212
- }
213
- html.backmatter-pointing .${DEVBAR_CLASS} button:disabled {
214
- cursor: default !important;
215
- }
216
- `}</style>
217
- {editorMounted && doc && editorSrc ? (
218
- <iframe
219
- ref={editorFrame}
220
- title={`Edit ${label}`}
221
- src={editorSrc}
222
- style={{
223
- ...editorStyle,
224
- visibility: editorOpen ? "visible" : "hidden",
225
- pointerEvents: editorOpen ? "auto" : "none",
226
- }}
227
- />
228
- ) : null}
229
- {doc && base ? (
230
- <CmsChat
231
- doc={doc}
232
- hidden={!chatOpen}
233
- onWrote={onChatWrote}
234
- onClosed={() => setChatOpen(false)}
235
- base={base}
236
- />
237
- ) : null}
238
- {doc && base ? (
239
- <iframe
240
- title="Publish"
241
- src={`${base}/embed/publish/${doc.workspaceId}`}
242
- style={{
243
- ...publishStyle,
244
- visibility: publishOpen ? "visible" : "hidden",
245
- pointerEvents: publishOpen ? "auto" : "none",
246
- }}
247
- />
248
- ) : null}
249
- <footer aria-label="Backmatter" className={DEVBAR_CLASS} style={barStyle}>
250
- <button
251
- type="button"
252
- aria-pressed={editorOpen}
253
- disabled={!doc}
254
- onClick={() => {
255
- setEditorMounted(true);
256
- setEditorOpen((open) => !open);
257
- }}
258
- style={docButton}
259
- >
260
- {label}
261
- </button>
262
- <BarText>Preview</BarText>
263
- {branch ? <BarText>{branch}</BarText> : null}
264
- {tree ? <BarText>draft</BarText> : null}
265
- <button type="button" aria-pressed={publishOpen} onClick={() => setPublishOpen((open) => !open)} style={barButton}>
266
- Publish{changeCount ? ` ${changeCount}` : ""}
267
- </button>
268
- <button
269
- type="button"
270
- aria-pressed={chatOpen}
271
- onClick={() => setChatOpen((open) => !open)}
272
- title="Toggle AI chat (⌘J)"
273
- style={barButton}
274
- >
275
- Agent
276
- </button>
277
- <button type="button" aria-label="Exit preview" disabled={exiting} onClick={onExit} style={barButton}>
278
- Exit
279
- </button>
280
- </footer>
281
- </>
282
- );
283
- }
284
-
285
- function CmsChat({
286
- doc,
287
- hidden,
288
- onWrote,
289
- onClosed,
290
- base,
291
- }: {
292
- doc: SiteDocRef;
293
- hidden: boolean;
294
- onWrote: () => void;
295
- onClosed: () => void;
296
- /** Studio origin plus base path. */
297
- base: string;
298
- }) {
299
- const frame = useRef<HTMLIFrameElement>(null);
300
- const onWroteRef = useRef(onWrote);
301
- const onClosedRef = useRef(onClosed);
302
- const hiddenRef = useRef(hidden);
303
- onWroteRef.current = onWrote;
304
- onClosedRef.current = onClosed;
305
- hiddenRef.current = hidden;
306
- const origin = base ? new URL(base).origin : "";
307
-
308
- function openChat() {
309
- const frameEl = frame.current;
310
- if (!frameEl) return;
311
- frameEl.focus();
312
- frameEl.contentWindow?.postMessage({ type: "backmatter:chat-open" }, origin);
313
- }
314
-
315
- useEffect(() => {
316
- if (!hidden) openChat();
317
- }, [hidden, origin]);
318
-
319
- const docRef = useRef(doc);
320
- docRef.current = doc;
321
-
322
- useEffect(() => {
323
- const focus = { locale: doc.locale, type: doc.type, slug: doc.slug };
324
- function postFocus() {
325
- frame.current?.contentWindow?.postMessage({ type: "backmatter:focus", doc: focus }, origin);
326
- }
327
- function onMessage(event: MessageEvent) {
328
- if (event.origin !== origin) return;
329
- if (event.data?.type === "backmatter:chat-ready") {
330
- postFocus();
331
- if (!hiddenRef.current) openChat();
332
- }
333
- if (event.data?.type === "backmatter:doc-changed") onWroteRef.current();
334
- if (event.data?.type === "backmatter:chat-closed") onClosedRef.current();
335
- }
336
- window.addEventListener("message", onMessage);
337
- postFocus();
338
- return () => window.removeEventListener("message", onMessage);
339
- }, [doc.locale, doc.type, doc.slug, origin]);
340
-
341
- useEffect(() => {
342
- if (!origin) return;
343
- const pointer = installSitePointer(document, {
344
- bar: () => document.querySelector(`.${DEVBAR_CLASS}`),
345
- ignore: (target) => {
346
- if (!(target instanceof Element)) return true;
347
- const frameEl = frame.current;
348
- if (frameEl && (target === frameEl || frameEl.contains(target))) return true;
349
- return target.closest("iframe") !== null;
350
- },
351
- onGrab: (grab) => {
352
- frame.current?.contentWindow?.postMessage(
353
- { type: "backmatter:pointer-grab", grab, doc: docRef.current },
354
- origin,
355
- );
356
- },
357
- onEscape: () => {
358
- frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
359
- },
360
- });
361
- function onMessage(event: MessageEvent) {
362
- if (event.origin !== origin || event.source !== frame.current?.contentWindow) return;
363
- const data = event.data as { type?: string; on?: boolean };
364
- if (data?.type !== "backmatter:pointer" || typeof data.on !== "boolean") return;
365
- if (data.on && !hiddenRef.current) pointer.arm();
366
- else pointer.disarm();
367
- }
368
- window.addEventListener("message", onMessage);
369
- return () => {
370
- window.removeEventListener("message", onMessage);
371
- pointer.dispose();
372
- frame.current?.contentWindow?.postMessage({ type: "backmatter:pointer", on: false }, origin);
373
- };
374
- }, [origin, hidden]);
375
-
376
- if (!base) return null;
377
- return (
378
- <iframe
379
- ref={frame}
380
- title="AI chat"
381
- src={`${base}/embed/chat/${doc.workspaceId}`}
382
- style={{
383
- ...chatStyle,
384
- visibility: hidden ? "hidden" : "visible",
385
- pointerEvents: hidden ? "none" : "auto",
386
- }}
124
+ <SiteWidget
125
+ doc={doc}
126
+ branch={branch}
127
+ tree={tree}
128
+ exiting={exiting}
129
+ onExit={onExit}
130
+ onDocChanged={() => startTransition(() => router.refresh())}
131
+ onSessionExpired={leave}
132
+ version={studioPkg.version}
387
133
  />
388
134
  );
389
135
  }
390
-
391
- function BarText({ children }: { children: ReactNode }) {
392
- return <span style={mutedStyle}>{children}</span>;
393
- }
394
-
395
- const barStyle: CSSProperties = {
396
- ...devbarShellStyle,
397
- gap: 2,
398
- padding: "0 6px 0 10px",
399
- font: '12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
400
- };
401
-
402
- const barButton: CSSProperties = {
403
- border: 0,
404
- borderRadius: 8,
405
- background: "transparent",
406
- color: "#18181b",
407
- font: "inherit",
408
- cursor: "pointer",
409
- padding: "0 10px",
410
- minHeight: 44,
411
- };
412
-
413
- const docButton: CSSProperties = {
414
- ...barButton,
415
- fontWeight: 600,
416
- maxWidth: 200,
417
- overflow: "hidden",
418
- textOverflow: "ellipsis",
419
- whiteSpace: "nowrap",
420
- };
421
-
422
- const mutedStyle: CSSProperties = {
423
- color: "#71717a",
424
- fontSize: 12,
425
- margin: 0,
426
- padding: "0 6px",
427
- maxWidth: 140,
428
- overflow: "hidden",
429
- textOverflow: "ellipsis",
430
- whiteSpace: "nowrap",
431
- };
432
-
433
- const editorStyle: CSSProperties = {
434
- position: "fixed",
435
- left: 0,
436
- right: 0,
437
- bottom: DEVBAR_CLEARANCE,
438
- zIndex: 2147482999,
439
- width: "100%",
440
- height: "min(78vh, calc(100vh - 104px))",
441
- border: 0,
442
- borderTop: "1px solid #e4e4e7",
443
- background: "#ffffff",
444
- boxShadow: "0 -8px 24px rgba(24, 24, 27, 0.08)",
445
- };
446
-
447
- const chatStyle: CSSProperties = {
448
- position: "fixed",
449
- right: DEVBAR_RIGHT,
450
- bottom: DEVBAR_CLEARANCE,
451
- zIndex: 2147483001,
452
- width: "min(440px, calc(100vw - 32px))",
453
- height: "min(640px, calc(100vh - 148px))",
454
- border: 0,
455
- borderRadius: 14,
456
- background: "#f4f4f5",
457
- boxShadow: "0 16px 48px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.08)",
458
- };
459
-
460
- const publishStyle: CSSProperties = {
461
- position: "fixed",
462
- right: DEVBAR_RIGHT,
463
- bottom: DEVBAR_CLEARANCE,
464
- zIndex: 2147483001,
465
- width: "min(420px, calc(100vw - 32px))",
466
- height: "min(520px, calc(100vh - 148px))",
467
- border: "1px solid #e4e4e7",
468
- borderRadius: 8,
469
- background: "#ffffff",
470
- boxShadow: "0 12px 40px rgba(24, 24, 27, 0.16)",
471
- };
@@ -1,199 +0,0 @@
1
- // packages/next/src/site-pointer.ts
2
- // Hit-test a click on the host page and draw the armed pointer outline.
3
-
4
- export const POINTER_QUOTE_MAX = 240;
5
-
6
- export type PointerGrab = {
7
- blockId: string | null;
8
- blockType: string | null;
9
- blockIndex: number | null;
10
- quote: string;
11
- };
12
-
13
- function quoteOf(target: Element): string {
14
- return (target.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, POINTER_QUOTE_MAX);
15
- }
16
-
17
- function presentAttr(el: Element, name: string): string | null {
18
- const value = el.getAttribute(name);
19
- return value ? value : null;
20
- }
21
-
22
- /** Resolve the block marker and visible text under a click. Clicks on the bar are ignored. */
23
- export function grabAt(target: Element | null, bar: Element | null): PointerGrab | null {
24
- if (!target) return null;
25
- const doc = target.ownerDocument;
26
- if (target === doc.documentElement || target === doc.body) return null;
27
- if (bar && (target === bar || bar.contains(target))) return null;
28
- if (target.closest("[data-backmatter-pointer]")) return null;
29
-
30
- const marker = target.closest("[data-backmatter-block]");
31
- const quote = quoteOf(target);
32
- if (!marker) {
33
- if (!quote) return null;
34
- return { blockId: null, blockType: null, blockIndex: null, quote };
35
- }
36
- const indexRaw = marker.getAttribute("data-backmatter-index");
37
- const blockIndex = indexRaw !== null && /^\d+$/.test(indexRaw) ? Number(indexRaw) : null;
38
- return {
39
- blockId: presentAttr(marker, "data-backmatter-block"),
40
- blockType: presentAttr(marker, "data-backmatter-type"),
41
- blockIndex,
42
- quote,
43
- };
44
- }
45
-
46
- export type SitePointer = {
47
- arm: () => void;
48
- disarm: () => void;
49
- dispose: () => void;
50
- };
51
-
52
- /**
53
- * While armed, the page shows a crosshair and an outline. A click reports the
54
- * element and does not follow links. Escape disarms. The picker stays armed
55
- * after a click so another element can be added.
56
- */
57
- export function installSitePointer(
58
- doc: Document,
59
- options: {
60
- bar: () => Element | null;
61
- ignore?: (target: EventTarget | null) => boolean;
62
- onGrab: (grab: PointerGrab) => void;
63
- onEscape?: () => void;
64
- },
65
- ): SitePointer {
66
- let armed = false;
67
- let current: Element | null = null;
68
- let outline: HTMLDivElement | null = null;
69
- const root = doc.documentElement;
70
-
71
- function ignored(target: EventTarget | null): boolean {
72
- if (!(target instanceof Element)) return true;
73
- if (target === root || target === doc.body) return true;
74
- if (target.closest("[data-backmatter-pointer]")) return true;
75
- const bar = options.bar();
76
- if (bar && (target === bar || bar.contains(target))) return true;
77
- return options.ignore?.(target) ?? false;
78
- }
79
-
80
- function ensureOutline(): HTMLDivElement {
81
- if (outline?.isConnected) return outline;
82
- outline?.remove();
83
- outline = null;
84
- const el = doc.createElement("div");
85
- el.setAttribute("data-backmatter-pointer", "");
86
- el.style.position = "fixed";
87
- el.style.pointerEvents = "none";
88
- el.style.zIndex = "2147482980";
89
- el.style.boxSizing = "border-box";
90
- el.style.border = "1.5px solid #18181b";
91
- el.style.borderRadius = "4px";
92
- el.style.background = "rgba(24, 24, 27, 0.06)";
93
- const label = doc.createElement("div");
94
- label.setAttribute("data-backmatter-pointer-label", "");
95
- label.style.position = "absolute";
96
- label.style.left = "0";
97
- label.style.top = "0";
98
- label.style.maxWidth = "240px";
99
- label.style.overflow = "hidden";
100
- label.style.textOverflow = "ellipsis";
101
- label.style.whiteSpace = "nowrap";
102
- label.style.padding = "2px 6px";
103
- label.style.borderRadius = "4px";
104
- label.style.background = "#18181b";
105
- label.style.color = "#ffffff";
106
- label.style.font = '11px/1.4 ui-sans-serif, system-ui, sans-serif';
107
- label.style.display = "none";
108
- el.appendChild(label);
109
- doc.body.appendChild(el);
110
- outline = el;
111
- return el;
112
- }
113
-
114
- function place(target: Element | null) {
115
- current = target;
116
- if (!target) {
117
- if (outline) outline.style.display = "none";
118
- return;
119
- }
120
- const el = ensureOutline();
121
- const rect = target.getBoundingClientRect();
122
- if (rect.width < 1 && rect.height < 1) {
123
- el.style.display = "none";
124
- return;
125
- }
126
- el.style.display = "block";
127
- el.style.left = `${rect.left}px`;
128
- el.style.top = `${rect.top}px`;
129
- el.style.width = `${rect.width}px`;
130
- el.style.height = `${rect.height}px`;
131
- const label = el.firstElementChild as HTMLElement | null;
132
- const name = target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ?? "";
133
- if (!label) return;
134
- if (!name) {
135
- label.textContent = "";
136
- label.style.display = "none";
137
- return;
138
- }
139
- label.textContent = name;
140
- label.style.display = "block";
141
- label.style.transform = rect.top < 28 ? "none" : "translateY(calc(-100% - 4px))";
142
- }
143
-
144
- function onMove(event: PointerEvent) {
145
- const target = event.target instanceof Element ? event.target : null;
146
- place(target && !ignored(target) ? target : null);
147
- }
148
-
149
- function onScroll() {
150
- if (current && !ignored(current)) place(current);
151
- else place(null);
152
- }
153
-
154
- function onClick(event: MouseEvent) {
155
- if (!armed || ignored(event.target)) return;
156
- event.preventDefault();
157
- event.stopPropagation();
158
- const target = event.target instanceof Element ? event.target : null;
159
- const grab = grabAt(target, options.bar());
160
- if (grab) options.onGrab(grab);
161
- }
162
-
163
- function onKey(event: KeyboardEvent) {
164
- if (!armed || event.key !== "Escape") return;
165
- event.preventDefault();
166
- event.stopPropagation();
167
- disarm();
168
- options.onEscape?.();
169
- }
170
-
171
- function arm() {
172
- if (armed) return;
173
- armed = true;
174
- root.classList.add("backmatter-pointing");
175
- doc.addEventListener("pointermove", onMove, true);
176
- doc.addEventListener("scroll", onScroll, true);
177
- doc.addEventListener("click", onClick, true);
178
- doc.addEventListener("keydown", onKey, true);
179
- }
180
-
181
- function disarm() {
182
- if (!armed) return;
183
- armed = false;
184
- current = null;
185
- root.classList.remove("backmatter-pointing");
186
- doc.removeEventListener("pointermove", onMove, true);
187
- doc.removeEventListener("scroll", onScroll, true);
188
- doc.removeEventListener("click", onClick, true);
189
- doc.removeEventListener("keydown", onKey, true);
190
- outline?.remove();
191
- outline = null;
192
- }
193
-
194
- return {
195
- arm,
196
- disarm,
197
- dispose: disarm,
198
- };
199
- }