@backmatter/next 0.0.25 → 0.0.26

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.26",
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.4"
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,
@@ -1,19 +1,16 @@
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";
14
12
  import { subscribeSiteDoc } from "./site-doc-bridge";
15
13
  import { readSiteDoc, sameSiteDoc, type SiteDocRef } from "./site-doc";
16
- import { installSitePointer } from "./site-pointer";
17
14
 
18
15
  const DEFAULT_PREVIEW_ENTRY_PATH = "/api/backmatter/preview";
19
16
  /** Slide the 12h site session while the bar is on screen. */
@@ -48,106 +45,41 @@ function useSiteDoc(): SiteDocRef | null {
48
45
  /** Default route the host mounts `@backmatter/studio` on. */
49
46
  export const DEFAULT_STUDIO_PATH = "/backmatter";
50
47
 
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
48
  export function SiteChrome({
62
49
  branch,
63
50
  tree,
64
51
  exiting,
65
52
  onExit,
66
- studioPath = DEFAULT_STUDIO_PATH,
67
53
  previewEntryPath = DEFAULT_PREVIEW_ENTRY_PATH,
68
54
  }: {
69
55
  branch?: string;
70
56
  tree?: string;
71
57
  exiting?: boolean;
72
58
  onExit: () => void;
73
- /** Where `@backmatter/studio` is mounted on this site. */
59
+ /** Kept so existing hosts can still pass it. The bar does not open the document editor. */
74
60
  studioPath?: string;
75
61
  /** Ticket route. `POST ?renew=1` slides the site session. A 401 drops the bar. */
76
62
  previewEntryPath?: string;
77
63
  }) {
78
64
  const router = useRouter();
79
65
  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());
66
+ const leaving = useRef(false);
67
+ const leave = useCallback(() => {
68
+ if (leaving.current) return;
69
+ leaving.current = true;
70
+ void (async () => {
71
+ try {
72
+ await fetch(previewEntryPath, { method: "POST", credentials: "same-origin", cache: "no-store" });
73
+ } catch {
74
+ // The cookies expire on their own. Still drop the bar.
129
75
  }
130
- }
131
- window.addEventListener("message", onMessage);
132
- return () => window.removeEventListener("message", onMessage);
133
- }, [origin, router]);
76
+ document.cookie = markerCookie(false, location.protocol === "https:");
77
+ location.reload();
78
+ })();
79
+ }, [previewEntryPath]);
134
80
 
135
81
  useEffect(() => {
136
82
  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
83
  async function renew() {
152
84
  try {
153
85
  const res = await fetch(`${previewEntryPath}?renew=1`, {
@@ -155,16 +87,16 @@ export function SiteChrome({
155
87
  credentials: "same-origin",
156
88
  cache: "no-store",
157
89
  });
158
- if (res.status === 401) leaveEditor();
90
+ if (!stopped && res.status === 401) leave();
159
91
  } catch {
160
- // Offline. The next send, or the next interval, tries again.
92
+ // Offline. The next interval tries again.
161
93
  }
162
94
  }
163
95
  void renew();
164
96
  const timer = setInterval(() => void renew(), SESSION_RENEW_MS);
165
97
  function onExpired(event: MessageEvent) {
166
- if (event.origin !== origin) return;
167
- if (event.data?.type === "backmatter:session-expired") leaveEditor();
98
+ if (event.origin !== location.origin) return;
99
+ if (event.data?.type === "backmatter:session-expired") leave();
168
100
  }
169
101
  window.addEventListener("message", onExpired);
170
102
  return () => {
@@ -172,300 +104,18 @@ export function SiteChrome({
172
104
  clearInterval(timer);
173
105
  window.removeEventListener("message", onExpired);
174
106
  };
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
- }
187
-
188
- 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]);
107
+ }, [leave, previewEntryPath]);
375
108
 
376
- if (!base) return null;
377
109
  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
- }}
110
+ <SiteWidget
111
+ doc={doc}
112
+ branch={branch}
113
+ tree={tree}
114
+ exiting={exiting}
115
+ onExit={onExit}
116
+ onDocChanged={() => startTransition(() => router.refresh())}
117
+ onSessionExpired={leave}
118
+ version={studioPkg.version}
387
119
  />
388
120
  );
389
121
  }
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
- }