@backmatter/studio 0.0.3 → 0.0.5
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/chunks/EmbedPublishPage-hx1xoAhI.js +149 -0
- package/dist/chunks/{UiKitPage-CTZtLwJ0.js → UiKitPage-BOunq6mr.js} +12 -11
- package/dist/chunks/WorkspaceRoutes-CTFTWu3L.js +57731 -0
- package/dist/chunks/{dashboard-nvFBzpZv.js → dashboard-upmiawx5.js} +216 -215
- package/dist/chunks/{index-Cr-IOgdC.js → index-CBvevwfT.js} +9 -8
- package/dist/chunks/status-bar-action-DiXhfcj3.js +30254 -0
- package/dist/dashboard.js +2 -2
- package/dist/index.js +139 -228
- package/dist/site-widget.js +294 -0
- package/dist/studio.css +46 -6
- package/package.json +6 -1
- package/src/chat/ChangeCard.tsx +11 -6
- package/src/chat/ChatMessage.tsx +27 -2
- package/src/chat/ChatPanel.tsx +13 -3
- package/src/chat/ChatProvider.tsx +6 -2
- package/src/chat/Composer.tsx +33 -24
- package/src/chat/DocLink.tsx +31 -0
- package/src/chat/DocListCard.tsx +3 -3
- package/src/chat/PointerChip.tsx +139 -0
- package/src/chat/chat-context.ts +14 -0
- package/src/chat/icons.tsx +10 -0
- package/src/chat/session-expiry.ts +8 -5
- package/src/chrome/OpenSiteButton.tsx +33 -25
- package/src/pages/EmbedPublishPage.tsx +279 -84
- package/src/pages/SettingsGeneralPage.tsx +2 -0
- package/src/pages/host-env.ts +6 -1
- package/src/site-pointer.ts +271 -0
- package/src/site-widget.tsx +395 -0
- package/dist/chunks/WorkspaceRoutes-DyRVES9t.js +0 -87308
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// packages/studio/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 = createPointerOutline(doc);
|
|
84
|
+
return outline;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function place(target: Element | null) {
|
|
88
|
+
current = target;
|
|
89
|
+
if (!target) {
|
|
90
|
+
if (outline) outline.style.display = "none";
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
positionPointerOutline(ensureOutline(), target);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function onMove(event: PointerEvent) {
|
|
97
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
98
|
+
place(target && !ignored(target) ? target : null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onScroll() {
|
|
102
|
+
if (current && !ignored(current)) place(current);
|
|
103
|
+
else place(null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function onClick(event: MouseEvent) {
|
|
107
|
+
if (!armed || ignored(event.target)) return;
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
111
|
+
const grab = grabAt(target, options.bar());
|
|
112
|
+
if (grab) options.onGrab(grab);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onKey(event: KeyboardEvent) {
|
|
116
|
+
if (!armed || event.key !== "Escape") return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
event.stopPropagation();
|
|
119
|
+
disarm();
|
|
120
|
+
options.onEscape?.();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function arm() {
|
|
124
|
+
if (armed) return;
|
|
125
|
+
armed = true;
|
|
126
|
+
root.classList.add("backmatter-pointing");
|
|
127
|
+
doc.addEventListener("pointermove", onMove, true);
|
|
128
|
+
doc.addEventListener("scroll", onScroll, true);
|
|
129
|
+
doc.addEventListener("click", onClick, true);
|
|
130
|
+
doc.addEventListener("keydown", onKey, true);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function disarm() {
|
|
134
|
+
if (!armed) return;
|
|
135
|
+
armed = false;
|
|
136
|
+
current = null;
|
|
137
|
+
root.classList.remove("backmatter-pointing");
|
|
138
|
+
doc.removeEventListener("pointermove", onMove, true);
|
|
139
|
+
doc.removeEventListener("scroll", onScroll, true);
|
|
140
|
+
doc.removeEventListener("click", onClick, true);
|
|
141
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
142
|
+
outline?.remove();
|
|
143
|
+
outline = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
arm,
|
|
148
|
+
disarm,
|
|
149
|
+
dispose: disarm,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createPointerOutline(doc: Document): HTMLDivElement {
|
|
154
|
+
const el = doc.createElement("div");
|
|
155
|
+
el.setAttribute("data-backmatter-pointer", "");
|
|
156
|
+
el.style.position = "fixed";
|
|
157
|
+
el.style.pointerEvents = "none";
|
|
158
|
+
el.style.zIndex = "2147482980";
|
|
159
|
+
el.style.boxSizing = "border-box";
|
|
160
|
+
el.style.border = "1.5px solid #18181b";
|
|
161
|
+
el.style.borderRadius = "4px";
|
|
162
|
+
el.style.background = "rgba(24, 24, 27, 0.06)";
|
|
163
|
+
const label = doc.createElement("div");
|
|
164
|
+
label.setAttribute("data-backmatter-pointer-label", "");
|
|
165
|
+
label.style.position = "absolute";
|
|
166
|
+
label.style.left = "0";
|
|
167
|
+
label.style.top = "0";
|
|
168
|
+
label.style.maxWidth = "240px";
|
|
169
|
+
label.style.overflow = "hidden";
|
|
170
|
+
label.style.textOverflow = "ellipsis";
|
|
171
|
+
label.style.whiteSpace = "nowrap";
|
|
172
|
+
label.style.padding = "2px 6px";
|
|
173
|
+
label.style.borderRadius = "4px";
|
|
174
|
+
label.style.background = "#18181b";
|
|
175
|
+
label.style.color = "#ffffff";
|
|
176
|
+
label.style.font = "11px/1.4 ui-sans-serif, system-ui, sans-serif";
|
|
177
|
+
label.style.display = "none";
|
|
178
|
+
el.appendChild(label);
|
|
179
|
+
doc.body.appendChild(el);
|
|
180
|
+
return el;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function positionPointerOutline(outline: HTMLDivElement, target: Element) {
|
|
184
|
+
const rect = target.getBoundingClientRect();
|
|
185
|
+
if (rect.width < 1 && rect.height < 1) {
|
|
186
|
+
outline.style.display = "none";
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
outline.style.display = "block";
|
|
190
|
+
outline.style.left = `${rect.left}px`;
|
|
191
|
+
outline.style.top = `${rect.top}px`;
|
|
192
|
+
outline.style.width = `${rect.width}px`;
|
|
193
|
+
outline.style.height = `${rect.height}px`;
|
|
194
|
+
const label = outline.firstElementChild as HTMLElement | null;
|
|
195
|
+
const name = target.closest("[data-backmatter-block]")?.getAttribute("data-backmatter-type") ?? "";
|
|
196
|
+
if (!label) return;
|
|
197
|
+
if (!name) {
|
|
198
|
+
label.textContent = "";
|
|
199
|
+
label.style.display = "none";
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
label.textContent = name;
|
|
203
|
+
label.style.display = "block";
|
|
204
|
+
label.style.transform = rect.top < 28 ? "none" : "translateY(calc(-100% - 4px))";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isSiteChrome(el: Element): boolean {
|
|
208
|
+
return el.closest("[data-backmatter-panel], .backmatter-devbar, [data-backmatter-pointer]") !== null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The page element a stored pointer refers to. Chat chrome is ignored. */
|
|
212
|
+
export function findPointedElement(
|
|
213
|
+
doc: Document,
|
|
214
|
+
pointer: { blockId: string | null; quote: string },
|
|
215
|
+
): Element | null {
|
|
216
|
+
if (pointer.blockId) {
|
|
217
|
+
for (const el of doc.querySelectorAll("[data-backmatter-block]")) {
|
|
218
|
+
if (el.getAttribute("data-backmatter-block") !== pointer.blockId) continue;
|
|
219
|
+
if (!isSiteChrome(el)) return el;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const quote = pointer.quote.replace(/\s+/g, " ").trim();
|
|
223
|
+
if (quote.length < 2) return null;
|
|
224
|
+
let best: Element | null = null;
|
|
225
|
+
let bestLen = Infinity;
|
|
226
|
+
for (const el of doc.querySelectorAll("[data-backmatter-block]")) {
|
|
227
|
+
if (isSiteChrome(el)) continue;
|
|
228
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ");
|
|
229
|
+
if (!text.includes(quote) || text.length >= bestLen) continue;
|
|
230
|
+
best = el;
|
|
231
|
+
bestLen = text.length;
|
|
232
|
+
}
|
|
233
|
+
return best;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let hoverOutline: HTMLDivElement | null = null;
|
|
237
|
+
let hoverTarget: Element | null = null;
|
|
238
|
+
let hoverDoc: Document | null = null;
|
|
239
|
+
|
|
240
|
+
function repositionHover() {
|
|
241
|
+
if (!hoverOutline || !hoverTarget) return;
|
|
242
|
+
positionPointerOutline(hoverOutline, hoverTarget);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Outline a page element while a pointer chip is hovered. Pass null to clear. */
|
|
246
|
+
export function highlightPointedElement(doc: Document, target: Element | null) {
|
|
247
|
+
if (!target) {
|
|
248
|
+
hoverOutline?.remove();
|
|
249
|
+
hoverOutline = null;
|
|
250
|
+
hoverTarget = null;
|
|
251
|
+
if (hoverDoc) {
|
|
252
|
+
hoverDoc.removeEventListener("scroll", repositionHover, true);
|
|
253
|
+
hoverDoc.defaultView?.removeEventListener("resize", repositionHover);
|
|
254
|
+
hoverDoc = null;
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const sameDoc = hoverDoc === doc;
|
|
259
|
+
if (!sameDoc) highlightPointedElement(doc, null);
|
|
260
|
+
hoverDoc = doc;
|
|
261
|
+
hoverTarget = target;
|
|
262
|
+
if (!hoverOutline?.isConnected) hoverOutline = createPointerOutline(doc);
|
|
263
|
+
if (!sameDoc) {
|
|
264
|
+
doc.addEventListener("scroll", repositionHover, true);
|
|
265
|
+
doc.defaultView?.addEventListener("resize", repositionHover);
|
|
266
|
+
}
|
|
267
|
+
target.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
268
|
+
requestAnimationFrame(() => {
|
|
269
|
+
if (hoverTarget === target && hoverOutline) positionPointerOutline(hoverOutline, target);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// packages/studio/src/site-widget.tsx
|
|
2
|
+
// The on-site bar: agent and publish in one tree. The host page mounts this
|
|
3
|
+
// after the site session verifies. Calls go through the host's same-origin
|
|
4
|
+
// CMS proxy.
|
|
5
|
+
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
6
|
+
import * as stylex from "@stylexjs/stylex";
|
|
7
|
+
import { ChatPanel } from "./chat/ChatPanel";
|
|
8
|
+
import { PublishIcon, SparkIcon } from "./chat/icons";
|
|
9
|
+
import { chatPointerFromGrab } from "./chat/chat-context";
|
|
10
|
+
import { POINTER_ARM_EVENT } from "./chat/Composer";
|
|
11
|
+
import { ChatProvider, ChatSessionScope, useChatUI } from "./chat/ChatProvider";
|
|
12
|
+
import { isPanelVisible } from "./chat/chat-state";
|
|
13
|
+
import { DOC_CHANGED_EVENT, EditorFocusProvider, useEditorFocus } from "./chrome/EditorFocusContext";
|
|
14
|
+
import { matchAppHotkey, modifierSymbol } from "./chrome/hotkeys";
|
|
15
|
+
import { EmbedPublishPage } from "./pages/EmbedPublishPage";
|
|
16
|
+
import { installSitePointer } from "./site-pointer";
|
|
17
|
+
import { lightTheme } from "./styles/themes";
|
|
18
|
+
import { configureStudio } from "./studio-config";
|
|
19
|
+
import { applyThemeClass } from "./theme/apply-theme-class";
|
|
20
|
+
import { TooltipProvider } from "./ui/Tooltip";
|
|
21
|
+
|
|
22
|
+
export type SiteWidgetDoc = {
|
|
23
|
+
locale: string;
|
|
24
|
+
type: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SiteWidgetProps = {
|
|
30
|
+
doc: SiteWidgetDoc | null;
|
|
31
|
+
branch?: string;
|
|
32
|
+
tree?: string;
|
|
33
|
+
exiting?: boolean;
|
|
34
|
+
onExit: () => void;
|
|
35
|
+
/** The host re-renders the page after a write. */
|
|
36
|
+
onDocChanged?: () => void;
|
|
37
|
+
/** The site session was rejected. The host drops the widget. */
|
|
38
|
+
onSessionExpired?: () => void;
|
|
39
|
+
/** Same-origin CMS proxy. Default `/api/backmatter/cms`. */
|
|
40
|
+
proxyPath?: string;
|
|
41
|
+
version?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DEVBAR_CLASS = "backmatter-devbar";
|
|
45
|
+
|
|
46
|
+
export function SiteWidget({
|
|
47
|
+
doc,
|
|
48
|
+
branch,
|
|
49
|
+
tree,
|
|
50
|
+
exiting,
|
|
51
|
+
onExit,
|
|
52
|
+
onDocChanged,
|
|
53
|
+
onSessionExpired,
|
|
54
|
+
proxyPath = "/api/backmatter/cms",
|
|
55
|
+
version,
|
|
56
|
+
}: SiteWidgetProps) {
|
|
57
|
+
const expiredRef = useRef(onSessionExpired);
|
|
58
|
+
expiredRef.current = onSessionExpired;
|
|
59
|
+
useState(() => {
|
|
60
|
+
configureStudio({
|
|
61
|
+
baseUrl: proxyPath.replace(/\/+$/, ""),
|
|
62
|
+
credentials: "same-origin",
|
|
63
|
+
dev: false,
|
|
64
|
+
version,
|
|
65
|
+
onUnauthorized: ({ status }) => {
|
|
66
|
+
if (status === 401) expiredRef.current?.();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
return applyThemeClass(document.documentElement, stylex.props(lightTheme).className);
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const [chatOpen, setChatOpen] = useState(false);
|
|
77
|
+
const [publishOpen, setPublishOpen] = useState(false);
|
|
78
|
+
const [changeCount, setChangeCount] = useState(0);
|
|
79
|
+
const label = doc ? `${doc.type}/${doc.slug}` : "No document";
|
|
80
|
+
|
|
81
|
+
function wrote() {
|
|
82
|
+
onDocChanged?.();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
function onKey(event: KeyboardEvent) {
|
|
87
|
+
if (matchAppHotkey(event) !== "chat") return;
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setPublishOpen(false);
|
|
90
|
+
setChatOpen((open) => !open);
|
|
91
|
+
}
|
|
92
|
+
document.addEventListener("keydown", onKey, true);
|
|
93
|
+
return () => document.removeEventListener("keydown", onKey, true);
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
const bar = (
|
|
97
|
+
<footer aria-label="Backmatter" className={DEVBAR_CLASS} style={barStyle}>
|
|
98
|
+
<span style={docLabel}>{label}</span>
|
|
99
|
+
<BarText>Preview</BarText>
|
|
100
|
+
{branch ? <BarText>{branch}</BarText> : null}
|
|
101
|
+
{tree ? <BarText>draft</BarText> : null}
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className={`${DEVBAR_CLASS}-btn${publishOpen ? ` ${DEVBAR_CLASS}-on` : ""}`}
|
|
105
|
+
aria-pressed={publishOpen}
|
|
106
|
+
disabled={!doc}
|
|
107
|
+
onClick={() => {
|
|
108
|
+
setChatOpen(false);
|
|
109
|
+
setPublishOpen((open) => !open);
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<PublishIcon />
|
|
113
|
+
<span>Publish{changeCount ? ` ${changeCount}` : ""}</span>
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
className={`${DEVBAR_CLASS}-btn ${DEVBAR_CLASS}-agent${chatOpen ? ` ${DEVBAR_CLASS}-on` : ""}`}
|
|
118
|
+
aria-pressed={chatOpen}
|
|
119
|
+
aria-keyshortcuts="Meta+J Control+J"
|
|
120
|
+
disabled={!doc}
|
|
121
|
+
onClick={() => {
|
|
122
|
+
setPublishOpen(false);
|
|
123
|
+
setChatOpen((open) => !open);
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<SparkIcon />
|
|
127
|
+
<span>Agent</span>
|
|
128
|
+
<kbd>
|
|
129
|
+
{modifierSymbol()}J
|
|
130
|
+
</kbd>
|
|
131
|
+
</button>
|
|
132
|
+
<button type="button" aria-label="Exit preview" disabled={exiting} onClick={onExit} style={barButton}>
|
|
133
|
+
Exit
|
|
134
|
+
</button>
|
|
135
|
+
</footer>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
<style>{BAR_CSS}</style>
|
|
141
|
+
{doc ? (
|
|
142
|
+
<TooltipProvider>
|
|
143
|
+
<EditorFocusProvider>
|
|
144
|
+
<ChatProvider workspaceId={doc.workspaceId} pointer>
|
|
145
|
+
<DocFocus doc={doc} />
|
|
146
|
+
<DocChanged onDocChanged={wrote} />
|
|
147
|
+
<ChatOpenSync open={chatOpen} />
|
|
148
|
+
<ChatCloseSync onClose={() => setChatOpen(false)} />
|
|
149
|
+
<SitePointer doc={doc} />
|
|
150
|
+
<ChatSessionScope>{chatOpen ? <ChatPanel site /> : null}</ChatSessionScope>
|
|
151
|
+
</ChatProvider>
|
|
152
|
+
</EditorFocusProvider>
|
|
153
|
+
</TooltipProvider>
|
|
154
|
+
) : null}
|
|
155
|
+
{doc ? (
|
|
156
|
+
<div data-backmatter-panel="" hidden={!publishOpen} style={publishStyle}>
|
|
157
|
+
<EmbedPublishPage
|
|
158
|
+
workspaceId={doc.workspaceId}
|
|
159
|
+
onStatus={({ changeCount: count }) => setChangeCount(count)}
|
|
160
|
+
onPublished={() => {
|
|
161
|
+
setPublishOpen(false);
|
|
162
|
+
setChangeCount(0);
|
|
163
|
+
wrote();
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
{bar}
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function DocFocus({ doc }: { doc: SiteWidgetDoc }) {
|
|
174
|
+
const { setFocus } = useEditorFocus();
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
setFocus({
|
|
177
|
+
docRef: { locale: doc.locale, type: doc.type, slug: doc.slug },
|
|
178
|
+
frontmatter: {},
|
|
179
|
+
body: "",
|
|
180
|
+
selection: null,
|
|
181
|
+
});
|
|
182
|
+
}, [doc.locale, doc.type, doc.slug, setFocus]);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function DocChanged({ onDocChanged }: { onDocChanged: () => void }) {
|
|
187
|
+
const onDocChangedRef = useRef(onDocChanged);
|
|
188
|
+
onDocChangedRef.current = onDocChanged;
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
function onDoc() {
|
|
191
|
+
onDocChangedRef.current();
|
|
192
|
+
}
|
|
193
|
+
window.addEventListener(DOC_CHANGED_EVENT, onDoc);
|
|
194
|
+
return () => window.removeEventListener(DOC_CHANGED_EVENT, onDoc);
|
|
195
|
+
}, []);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ChatOpenSync({ open }: { open: boolean }) {
|
|
200
|
+
const { dispatch } = useChatUI();
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
dispatch({ type: open ? "open" : "close" });
|
|
203
|
+
if (!open) return;
|
|
204
|
+
requestAnimationFrame(() => {
|
|
205
|
+
window.dispatchEvent(new Event("backmatter:chat-focus"));
|
|
206
|
+
});
|
|
207
|
+
}, [open, dispatch]);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function ChatCloseSync({ onClose }: { onClose: () => void }) {
|
|
212
|
+
const { panel } = useChatUI();
|
|
213
|
+
const mode = panel.mode;
|
|
214
|
+
const previous = useRef(mode);
|
|
215
|
+
const onCloseRef = useRef(onClose);
|
|
216
|
+
onCloseRef.current = onClose;
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
const wasVisible = isPanelVisible(previous.current);
|
|
219
|
+
previous.current = mode;
|
|
220
|
+
if (wasVisible && !isPanelVisible(mode)) onCloseRef.current();
|
|
221
|
+
}, [mode]);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function SitePointer({ doc }: { doc: SiteWidgetDoc }) {
|
|
226
|
+
const { addPointer, panel } = useChatUI();
|
|
227
|
+
const visible = isPanelVisible(panel.mode);
|
|
228
|
+
const visibleRef = useRef(visible);
|
|
229
|
+
const docRef = useRef(doc);
|
|
230
|
+
visibleRef.current = visible;
|
|
231
|
+
docRef.current = doc;
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const pointer = installSitePointer(document, {
|
|
235
|
+
bar: () => document.querySelector(`.${DEVBAR_CLASS}`),
|
|
236
|
+
ignore: (target) => {
|
|
237
|
+
if (!(target instanceof Element)) return true;
|
|
238
|
+
if (target.closest("[data-backmatter-panel]")) return true;
|
|
239
|
+
return target.closest("iframe") !== null;
|
|
240
|
+
},
|
|
241
|
+
onGrab: (grab) => {
|
|
242
|
+
const next = chatPointerFromGrab(docRef.current, grab);
|
|
243
|
+
if (!next) return;
|
|
244
|
+
addPointer(next);
|
|
245
|
+
requestAnimationFrame(() => {
|
|
246
|
+
window.dispatchEvent(new Event("backmatter:chat-focus"));
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
onEscape: () => {
|
|
250
|
+
window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
function onArm(event: Event) {
|
|
254
|
+
const on = (event as CustomEvent<{ on?: boolean }>).detail?.on;
|
|
255
|
+
if (on && visibleRef.current) pointer.arm();
|
|
256
|
+
else pointer.disarm();
|
|
257
|
+
}
|
|
258
|
+
window.addEventListener(POINTER_ARM_EVENT, onArm);
|
|
259
|
+
return () => {
|
|
260
|
+
window.removeEventListener(POINTER_ARM_EVENT, onArm);
|
|
261
|
+
pointer.dispose();
|
|
262
|
+
};
|
|
263
|
+
}, [addPointer]);
|
|
264
|
+
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function BarText({ children }: { children: ReactNode }) {
|
|
269
|
+
return <span style={mutedStyle}>{children}</span>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const barStyle: CSSProperties = {
|
|
273
|
+
position: "fixed",
|
|
274
|
+
right: "max(16px, env(safe-area-inset-right, 0px))",
|
|
275
|
+
bottom: 0,
|
|
276
|
+
zIndex: 2147483000,
|
|
277
|
+
isolation: "isolate",
|
|
278
|
+
display: "flex",
|
|
279
|
+
alignItems: "center",
|
|
280
|
+
gap: 2,
|
|
281
|
+
width: "max-content",
|
|
282
|
+
maxWidth: "calc(100vw - 32px)",
|
|
283
|
+
minHeight: 36,
|
|
284
|
+
padding: "0 6px 0 10px",
|
|
285
|
+
boxSizing: "border-box",
|
|
286
|
+
border: "1px solid #e4e4e7",
|
|
287
|
+
borderRadius: "12px 12px 0 0",
|
|
288
|
+
background: "#ffffff",
|
|
289
|
+
color: "#71717a",
|
|
290
|
+
boxShadow: "0 10px 28px rgba(24, 24, 27, 0.12), 0 1px 2px rgba(24, 24, 27, 0.06)",
|
|
291
|
+
font: '12px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
292
|
+
WebkitFontSmoothing: "antialiased",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const barButton: CSSProperties = {
|
|
296
|
+
border: 0,
|
|
297
|
+
borderRadius: 8,
|
|
298
|
+
background: "transparent",
|
|
299
|
+
color: "#18181b",
|
|
300
|
+
font: "inherit",
|
|
301
|
+
cursor: "pointer",
|
|
302
|
+
padding: "0 8px",
|
|
303
|
+
minHeight: 28,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const docLabel: CSSProperties = {
|
|
307
|
+
fontWeight: 600,
|
|
308
|
+
color: "#18181b",
|
|
309
|
+
maxWidth: 200,
|
|
310
|
+
overflow: "hidden",
|
|
311
|
+
textOverflow: "ellipsis",
|
|
312
|
+
whiteSpace: "nowrap",
|
|
313
|
+
padding: "0 6px",
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const mutedStyle: CSSProperties = {
|
|
317
|
+
color: "#71717a",
|
|
318
|
+
fontSize: 12,
|
|
319
|
+
margin: 0,
|
|
320
|
+
padding: "0 6px",
|
|
321
|
+
maxWidth: 140,
|
|
322
|
+
overflow: "hidden",
|
|
323
|
+
textOverflow: "ellipsis",
|
|
324
|
+
whiteSpace: "nowrap",
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const publishStyle: CSSProperties = {
|
|
328
|
+
position: "fixed",
|
|
329
|
+
right: "max(16px, env(safe-area-inset-right, 0px))",
|
|
330
|
+
bottom: "calc(env(safe-area-inset-bottom, 0px) + 48px)",
|
|
331
|
+
zIndex: 2147483001,
|
|
332
|
+
width: "min(320px, calc(100vw - 32px))",
|
|
333
|
+
maxHeight: "min(420px, calc(100vh - 80px))",
|
|
334
|
+
overflow: "auto",
|
|
335
|
+
border: "1px solid #e4e4e7",
|
|
336
|
+
borderRadius: 10,
|
|
337
|
+
background: "#ffffff",
|
|
338
|
+
boxShadow: "0 16px 40px rgba(24, 24, 27, 0.14), 0 2px 8px rgba(24, 24, 27, 0.06)",
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const BAR_CSS = `
|
|
342
|
+
@keyframes backmatter-devbar-in {
|
|
343
|
+
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
|
344
|
+
to { opacity: 1; transform: none; }
|
|
345
|
+
}
|
|
346
|
+
.${DEVBAR_CLASS} { animation: backmatter-devbar-in 240ms cubic-bezier(0.23, 1, 0.32, 1) both; }
|
|
347
|
+
@media (prefers-reduced-motion: reduce) {
|
|
348
|
+
.${DEVBAR_CLASS} { animation: none; }
|
|
349
|
+
}
|
|
350
|
+
.${DEVBAR_CLASS}-btn {
|
|
351
|
+
display: inline-flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
gap: 6px;
|
|
354
|
+
height: 26px;
|
|
355
|
+
margin-inline: 4px;
|
|
356
|
+
padding: 0 8px;
|
|
357
|
+
border: 1px solid #e4e4e7;
|
|
358
|
+
border-radius: 6px;
|
|
359
|
+
background: #ffffff;
|
|
360
|
+
color: #18181b;
|
|
361
|
+
font-size: 11px;
|
|
362
|
+
font-weight: 500;
|
|
363
|
+
line-height: 1;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
}
|
|
366
|
+
.${DEVBAR_CLASS}-agent kbd {
|
|
367
|
+
font-family: Geist Mono, ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
368
|
+
font-size: 11px;
|
|
369
|
+
font-weight: 400;
|
|
370
|
+
opacity: 0.7;
|
|
371
|
+
}
|
|
372
|
+
.${DEVBAR_CLASS} button.${DEVBAR_CLASS}-on,
|
|
373
|
+
.${DEVBAR_CLASS} button.${DEVBAR_CLASS}-on:hover:not(:disabled) {
|
|
374
|
+
background: #18181b;
|
|
375
|
+
border-color: #18181b;
|
|
376
|
+
color: #fafafa;
|
|
377
|
+
}
|
|
378
|
+
@media (hover: hover) {
|
|
379
|
+
.${DEVBAR_CLASS} button:hover:not(:disabled) { background: #f4f4f5; }
|
|
380
|
+
.${DEVBAR_CLASS} button.${DEVBAR_CLASS}-btn:hover:not(:disabled) {
|
|
381
|
+
background: rgba(24, 24, 27, 0.05);
|
|
382
|
+
}
|
|
383
|
+
.${DEVBAR_CLASS} button.${DEVBAR_CLASS}-on:hover:not(:disabled) {
|
|
384
|
+
background: #18181b;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
.${DEVBAR_CLASS} button:focus-visible { outline: 2px solid #18181b; outline-offset: 2px; }
|
|
388
|
+
.${DEVBAR_CLASS} button:disabled { cursor: default; opacity: 0.5; }
|
|
389
|
+
html.backmatter-pointing, html.backmatter-pointing * { cursor: crosshair !important; }
|
|
390
|
+
html.backmatter-pointing .${DEVBAR_CLASS},
|
|
391
|
+
html.backmatter-pointing .${DEVBAR_CLASS} *,
|
|
392
|
+
html.backmatter-pointing [data-backmatter-panel],
|
|
393
|
+
html.backmatter-pointing [data-backmatter-panel] * { cursor: pointer !important; }
|
|
394
|
+
html.backmatter-pointing .${DEVBAR_CLASS} button:disabled { cursor: default !important; }
|
|
395
|
+
`;
|