@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 +6 -1
- package/package.json +2 -2
- package/src/config.ts +6 -1
- package/src/preview-hold.tsx +39 -0
- package/src/routes.ts +14 -9
- package/src/site-editor.tsx +49 -385
- 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.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.
|
|
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([
|
|
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
|
-
|
|
429
|
+
HoldPreview,
|
|
429
430
|
null,
|
|
430
|
-
createElement(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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:
|
|
462
|
+
createElement(Suspense, { fallback: createElement(HeldPreview) }, createElement(PreviewBranch, props)),
|
|
458
463
|
);
|
|
459
464
|
|
|
460
465
|
const generateMetadata: CatchAllRoute["generateMetadata"] = async ({ params }) => {
|
package/src/site-editor.tsx
CHANGED
|
@@ -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
|
|
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";
|
|
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) =>
|
|
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
|
-
/**
|
|
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
|
|
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());
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
}, [
|
|
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)
|
|
104
|
+
if (!stopped && res.status === 401) leave();
|
|
159
105
|
} catch {
|
|
160
|
-
// Offline. The next
|
|
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")
|
|
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
|
-
}, [
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
};
|
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
|
-
}
|