@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 +6 -1
- package/package.json +2 -2
- package/src/config.ts +6 -1
- package/src/site-editor.tsx +34 -384
- package/src/site-pointer.ts +0 -199
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([
|
|
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.
|
|
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.
|
|
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([
|
|
190
|
+
...new Set([
|
|
191
|
+
...(cfg.transpilePackages ?? []),
|
|
192
|
+
"@backmatter/next",
|
|
193
|
+
"@backmatter/core",
|
|
194
|
+
"@backmatter/studio",
|
|
195
|
+
]),
|
|
191
196
|
];
|
|
192
197
|
return {
|
|
193
198
|
...nextConfig,
|
package/src/site-editor.tsx
CHANGED
|
@@ -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
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
/**
|
|
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
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
}, [
|
|
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)
|
|
90
|
+
if (!stopped && res.status === 401) leave();
|
|
159
91
|
} catch {
|
|
160
|
-
// Offline. The next
|
|
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")
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
};
|
package/src/site-pointer.ts
DELETED
|
@@ -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
|
-
}
|