@backmatter/studio 0.0.11 → 0.0.12
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/{UiKitApp-Cu0CS1NU.js → UiKitApp-ZZXQt3zy.js} +29 -29
- package/dist/chunks/{WorkspaceRoutes-Dzpaj8ov.js → WorkspaceRoutes-CCDPtLla.js} +22396 -22185
- package/dist/chunks/{dashboard-kWmpeuAP.js → dashboard-Dw0zfTXg.js} +35 -35
- package/dist/chunks/{index-Bryt_VJQ.js → index-DhZGCAru.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/index.js +199 -193
- package/dist/site-widget.css +9 -1
- package/dist/site-widget.js +6060 -5936
- package/dist/studio.css +5 -25
- package/package.json +5 -1
- package/src/chat/ChatProvider.tsx +13 -2
- package/src/chat/Composer.tsx +2 -1
- package/src/chat/PointerChip.tsx +15 -2
- package/src/chat/chat-helpers.ts +6 -0
- package/src/chat/pointer-arm.ts +3 -0
- package/src/chrome/WorkspaceStatusBar.tsx +57 -19
- package/src/chrome/commit-error.ts +82 -0
- package/src/chrome/commit-failure.tsx +111 -0
- package/src/install-preview-pointer.ts +116 -0
- package/src/pages/BlocksList.tsx +17 -2
- package/src/pages/DocEditorPage.tsx +95 -1
- package/src/pages/EmbedPublishPage.tsx +27 -24
- package/src/pages/RenderedBody.tsx +141 -322
- package/src/pages/document-paint.ts +79 -0
- package/src/pages/editor-grab.ts +39 -0
- package/src/pages/html-to-markdown.ts +26 -0
- package/src/pages/preview-bridge.ts +8 -1
- package/src/pages/selection-format.ts +5 -2
- package/src/preview-highlight.ts +16 -0
- package/src/preview-pointer-host.ts +30 -0
- package/src/shell-resets.css +13 -0
- package/src/site-changes.ts +46 -0
- package/src/site-pointer.ts +39 -6
- package/src/site-widget.tsx +66 -23
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// packages/studio/src/pages/document-paint.ts
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import type { BlockSnapshot } from "../api";
|
|
5
|
+
import { ContentBlockView, contentBlockProps } from "../content-blocks";
|
|
6
|
+
import { codeParts, type BodyBlock } from "./body-blocks";
|
|
7
|
+
import { editorMdxComponents } from "./body-mdx";
|
|
8
|
+
import { compileBody } from "./compile-body";
|
|
9
|
+
|
|
10
|
+
/** Paints the body into one editing host. Host blocks are objects inside that host. */
|
|
11
|
+
export async function documentNodes(
|
|
12
|
+
blocks: BodyBlock[],
|
|
13
|
+
catalog: Array<BlockSnapshot & { label?: string }>,
|
|
14
|
+
): Promise<HTMLElement[]> {
|
|
15
|
+
if (blocks.length === 0) return [emptyParagraph()];
|
|
16
|
+
const nodes: HTMLElement[] = [];
|
|
17
|
+
for (const block of blocks) nodes.push(...(await nodesForBlock(block, catalog)));
|
|
18
|
+
return nodes;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function nodesForBlock(
|
|
22
|
+
block: BodyBlock,
|
|
23
|
+
catalog: Array<BlockSnapshot & { label?: string }>,
|
|
24
|
+
): Promise<HTMLElement[]> {
|
|
25
|
+
const catalogBlock =
|
|
26
|
+
block.kind === "jsx" ? catalog.find((item) => item.name === block.jsxName) : undefined;
|
|
27
|
+
if (catalogBlock) return [atom(block, catalogBlock.name)];
|
|
28
|
+
if (block.kind === "code") return [codeBlock(block)];
|
|
29
|
+
return textBlocks(block);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function atom(block: BodyBlock, name: string): HTMLElement {
|
|
33
|
+
const node = document.createElement("div");
|
|
34
|
+
node.setAttribute("contenteditable", "false");
|
|
35
|
+
node.setAttribute("data-body-atom", block.id);
|
|
36
|
+
node.setAttribute("data-body-markdown", encodeURIComponent(block.markdown));
|
|
37
|
+
node.innerHTML = renderToStaticMarkup(
|
|
38
|
+
createElement(ContentBlockView, {
|
|
39
|
+
name,
|
|
40
|
+
props: contentBlockProps(block.markdown, name),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function codeBlock(block: BodyBlock): HTMLElement {
|
|
47
|
+
const pre = document.createElement("pre");
|
|
48
|
+
pre.setAttribute("data-lang", block.lang ?? codeParts(block.markdown).lang);
|
|
49
|
+
const code = document.createElement("code");
|
|
50
|
+
code.textContent = codeParts(block.markdown).code;
|
|
51
|
+
pre.append(code);
|
|
52
|
+
return pre;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function textBlocks(block: BodyBlock): Promise<HTMLElement[]> {
|
|
56
|
+
if (!block.markdown.trim()) return [emptyParagraph()];
|
|
57
|
+
const result = await compileBody(block.markdown, editorMdxComponents(block.jsxName)).catch(
|
|
58
|
+
() => ({ node: null, error: "Could not render this body" }),
|
|
59
|
+
);
|
|
60
|
+
const html =
|
|
61
|
+
result.node == null
|
|
62
|
+
? ""
|
|
63
|
+
: renderToStaticMarkup(result.node).replace(/<link\b[^>]*>/g, "");
|
|
64
|
+
const wrap = document.createElement("div");
|
|
65
|
+
wrap.innerHTML = html;
|
|
66
|
+
const elements = [...wrap.children].filter(
|
|
67
|
+
(node): node is HTMLElement => node instanceof HTMLElement,
|
|
68
|
+
);
|
|
69
|
+
if (elements.length > 0) return elements;
|
|
70
|
+
const paragraph = document.createElement("p");
|
|
71
|
+
paragraph.textContent = block.markdown;
|
|
72
|
+
return [paragraph];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emptyParagraph(): HTMLElement {
|
|
76
|
+
const paragraph = document.createElement("p");
|
|
77
|
+
paragraph.append(document.createElement("br"));
|
|
78
|
+
return paragraph;
|
|
79
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// packages/studio/src/pages/editor-grab.ts
|
|
2
|
+
import { samePreviewRef, type PreviewChannelRef } from "@backmatter/core/preview";
|
|
3
|
+
|
|
4
|
+
export type EditorGrab = {
|
|
5
|
+
blockId: string;
|
|
6
|
+
blockType: string | null;
|
|
7
|
+
blockIndex: number | null;
|
|
8
|
+
quote: string;
|
|
9
|
+
shift: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type EditorGrabDecision =
|
|
13
|
+
| { action: "ignore" }
|
|
14
|
+
| {
|
|
15
|
+
action: "add";
|
|
16
|
+
blockId: string;
|
|
17
|
+
blockType: string | null;
|
|
18
|
+
blockIndex: number | null;
|
|
19
|
+
quote: string;
|
|
20
|
+
}
|
|
21
|
+
| { action: "open"; blockId: string };
|
|
22
|
+
|
|
23
|
+
export function editorGrabAction(input: {
|
|
24
|
+
grabRef: PreviewChannelRef;
|
|
25
|
+
openRef: PreviewChannelRef;
|
|
26
|
+
blockIds: readonly string[];
|
|
27
|
+
grab: EditorGrab;
|
|
28
|
+
}): EditorGrabDecision {
|
|
29
|
+
if (!samePreviewRef(input.grabRef, input.openRef)) return { action: "ignore" };
|
|
30
|
+
if (!input.blockIds.includes(input.grab.blockId)) return { action: "ignore" };
|
|
31
|
+
if (input.grab.shift) return { action: "open", blockId: input.grab.blockId };
|
|
32
|
+
return {
|
|
33
|
+
action: "add",
|
|
34
|
+
blockId: input.grab.blockId,
|
|
35
|
+
blockType: input.grab.blockType,
|
|
36
|
+
blockIndex: input.grab.blockIndex,
|
|
37
|
+
quote: input.grab.quote,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
// packages/studio/src/pages/html-to-markdown.ts
|
|
2
2
|
import { codeParts, replaceJsxChildren, type BodyBlock } from "./body-blocks";
|
|
3
3
|
|
|
4
|
+
/** Reads one body field, including a host block stored on its object. */
|
|
5
|
+
export function markdownFromDocument(root: HTMLElement): string {
|
|
6
|
+
const parts: string[] = [];
|
|
7
|
+
for (const node of root.childNodes) {
|
|
8
|
+
if (!(node instanceof HTMLElement)) {
|
|
9
|
+
const text = (node.textContent ?? "").replace(/\u00a0/g, " ").trim();
|
|
10
|
+
if (text) parts.push(text);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (node.hasAttribute("data-body-atom")) {
|
|
14
|
+
const stored = node.getAttribute("data-body-markdown");
|
|
15
|
+
if (stored) parts.push(decodeURIComponent(stored));
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (node.tagName === "PRE") {
|
|
19
|
+
const code = (node.querySelector("code")?.textContent ?? "").replace(/\n$/, "");
|
|
20
|
+
const lang = node.getAttribute("data-lang") ?? "";
|
|
21
|
+
parts.push("```" + lang + "\n" + code + "\n```");
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const markdown = elementToMarkdown(node);
|
|
25
|
+
if (markdown) parts.push(markdown);
|
|
26
|
+
}
|
|
27
|
+
return parts.join("\n\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
4
30
|
export function markdownFromEditable(root: HTMLElement, block: BodyBlock): string {
|
|
5
31
|
if (block.kind === "code") {
|
|
6
32
|
const code = textOf(root.querySelector("code") ?? root).replace(/\n$/, "");
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
samePreviewRef,
|
|
8
8
|
type PreviewChannelRef,
|
|
9
9
|
type PreviewEditorSession,
|
|
10
|
+
type PreviewGrabMessage,
|
|
10
11
|
type PreviewSchedule,
|
|
11
12
|
type PreviewTransport,
|
|
12
13
|
type PreviewWindowLike,
|
|
@@ -17,15 +18,21 @@ export function bindPreviewEditor(options: {
|
|
|
17
18
|
ref: () => PreviewChannelRef;
|
|
18
19
|
onOffer: (ref: PreviewChannelRef | null) => void;
|
|
19
20
|
onStatus: (state: "rendered" | "error") => void;
|
|
21
|
+
onHello?: (ref: PreviewChannelRef, capabilities?: string[]) => void;
|
|
22
|
+
onGrab?: (grab: PreviewGrabMessage) => void;
|
|
23
|
+
onPoint?: (on: boolean) => void;
|
|
20
24
|
schedule?: PreviewSchedule;
|
|
21
25
|
}): PreviewEditorSession {
|
|
22
26
|
return createPreviewEditor({
|
|
23
27
|
transport: options.transport,
|
|
24
28
|
ref: options.ref,
|
|
25
29
|
schedule: options.schedule,
|
|
26
|
-
onHello: (hello) => {
|
|
30
|
+
onHello: (hello, capabilities) => {
|
|
27
31
|
options.onOffer(samePreviewRef(options.ref(), hello) ? null : hello);
|
|
32
|
+
options.onHello?.(hello, capabilities);
|
|
28
33
|
},
|
|
34
|
+
onGrab: (grab) => options.onGrab?.(grab),
|
|
35
|
+
onPoint: (on) => options.onPoint?.(on),
|
|
29
36
|
onStatus: (status) => options.onStatus(status.state),
|
|
30
37
|
});
|
|
31
38
|
}
|
|
@@ -9,10 +9,13 @@ export function blockFormatOf(host: HTMLElement, range?: Range): BlockFormat {
|
|
|
9
9
|
return formatOfTag(current.tagName);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function applyBlockFormat(host: HTMLElement, format: BlockFormat, range?: Range):
|
|
12
|
+
export function applyBlockFormat(host: HTMLElement, format: BlockFormat, range?: Range): HTMLElement {
|
|
13
13
|
const current = blockElement(host, range);
|
|
14
14
|
const html = current ? contentsHtml(current) : host.innerHTML;
|
|
15
|
-
|
|
15
|
+
const next = createBlock(format, html);
|
|
16
|
+
if (current?.parentElement) current.replaceWith(next);
|
|
17
|
+
else host.replaceChildren(next);
|
|
18
|
+
return next;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function marksAt(range: Range): { bold: boolean; italic: boolean; href: string | null } {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// packages/studio/src/preview-highlight.ts
|
|
2
|
+
|
|
3
|
+
type SendHighlight = (blockId: string | null) => void;
|
|
4
|
+
|
|
5
|
+
let sendHighlight: SendHighlight | null = null;
|
|
6
|
+
|
|
7
|
+
export function registerPreviewHighlight(send: SendHighlight): () => void {
|
|
8
|
+
sendHighlight = send;
|
|
9
|
+
return () => {
|
|
10
|
+
if (sendHighlight === send) sendHighlight = null;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function emitPreviewHighlight(blockId: string | null) {
|
|
15
|
+
sendHighlight?.(blockId);
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// packages/studio/src/preview-pointer-host.ts
|
|
2
|
+
|
|
3
|
+
export type PreviewPointerHost = {
|
|
4
|
+
armFromDevbar: () => void;
|
|
5
|
+
disarmFromDevbar: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
let host: PreviewPointerHost | null = null;
|
|
9
|
+
const listeners = new Set<(next: PreviewPointerHost | null) => void>();
|
|
10
|
+
|
|
11
|
+
export function currentPreviewPointerHost(): PreviewPointerHost | null {
|
|
12
|
+
return host;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerPreviewPointerHost(next: PreviewPointerHost): () => void {
|
|
16
|
+
host = next;
|
|
17
|
+
for (const listener of listeners) listener(next);
|
|
18
|
+
return () => {
|
|
19
|
+
if (host !== next) return;
|
|
20
|
+
host = null;
|
|
21
|
+
for (const listener of listeners) listener(null);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function subscribePreviewPointerHost(
|
|
26
|
+
listener: (next: PreviewPointerHost | null) => void,
|
|
27
|
+
): () => void {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => listeners.delete(listener);
|
|
30
|
+
}
|
package/src/shell-resets.css
CHANGED
|
@@ -153,3 +153,16 @@ html[data-theme="dark"]:has(.backmatter-studio)
|
|
|
153
153
|
[data-no-ring]:focus-visible {
|
|
154
154
|
outline: none;
|
|
155
155
|
}
|
|
156
|
+
|
|
157
|
+
:is(
|
|
158
|
+
.backmatter-studio,
|
|
159
|
+
[data-backmatter-panel],
|
|
160
|
+
[data-base-ui-portal],
|
|
161
|
+
[data-selection-bar]
|
|
162
|
+
)
|
|
163
|
+
[data-no-ring]:focus-visible,
|
|
164
|
+
html[data-theme="dark"]:has(.backmatter-studio)
|
|
165
|
+
:is(.backmatter-studio, [data-base-ui-portal], [data-selection-bar])
|
|
166
|
+
[data-no-ring]:focus-visible {
|
|
167
|
+
outline: none;
|
|
168
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// packages/studio/src/site-changes.ts
|
|
2
|
+
// Uncommitted files for the on-site bar. The publish control reads this count
|
|
3
|
+
// before its panel is opened, and a chat write refreshes it.
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { fetchGit } from "./api";
|
|
6
|
+
|
|
7
|
+
export function useUncommittedCount(workspaceId: string | null): {
|
|
8
|
+
count: number;
|
|
9
|
+
setCount: (count: number) => void;
|
|
10
|
+
} {
|
|
11
|
+
const [count, setCountState] = useState(0);
|
|
12
|
+
const request = useRef(0);
|
|
13
|
+
|
|
14
|
+
const setCount = useCallback((next: number) => {
|
|
15
|
+
request.current += 1;
|
|
16
|
+
setCountState(next);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!workspaceId) {
|
|
21
|
+
setCount(0);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let stopped = false;
|
|
25
|
+
const load = () => {
|
|
26
|
+
const id = ++request.current;
|
|
27
|
+
void fetchGit(workspaceId)
|
|
28
|
+
.then((next) => {
|
|
29
|
+
if (stopped || id !== request.current) return;
|
|
30
|
+
setCountState(next.changes.length);
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {
|
|
33
|
+
/* A failed status read leaves the last count in place. */
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
load();
|
|
37
|
+
window.addEventListener("backmatter:tree-changed", load);
|
|
38
|
+
return () => {
|
|
39
|
+
stopped = true;
|
|
40
|
+
request.current += 1;
|
|
41
|
+
window.removeEventListener("backmatter:tree-changed", load);
|
|
42
|
+
};
|
|
43
|
+
}, [workspaceId, setCount]);
|
|
44
|
+
|
|
45
|
+
return { count, setCount };
|
|
46
|
+
}
|
package/src/site-pointer.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function installSitePointer(
|
|
|
59
59
|
options: {
|
|
60
60
|
bar: () => Element | null;
|
|
61
61
|
ignore?: (target: EventTarget | null) => boolean;
|
|
62
|
-
onGrab: (grab: PointerGrab) => void;
|
|
62
|
+
onGrab: (grab: PointerGrab, shiftKey: boolean) => void;
|
|
63
63
|
onEscape?: () => void;
|
|
64
64
|
},
|
|
65
65
|
): SitePointer {
|
|
@@ -109,7 +109,7 @@ export function installSitePointer(
|
|
|
109
109
|
event.stopPropagation();
|
|
110
110
|
const target = event.target instanceof Element ? event.target : null;
|
|
111
111
|
const grab = grabAt(target, options.bar());
|
|
112
|
-
if (grab) options.onGrab(grab);
|
|
112
|
+
if (grab) options.onGrab(grab, isOpenModifier(event));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function onKey(event: KeyboardEvent) {
|
|
@@ -180,9 +180,33 @@ function createPointerOutline(doc: Document): HTMLDivElement {
|
|
|
180
180
|
return el;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/** Command on macOS, Ctrl elsewhere. The grab still carries this as `shift`. */
|
|
184
|
+
export function isOpenModifier(event: { metaKey: boolean; ctrlKey: boolean }): boolean {
|
|
185
|
+
return event.metaKey || event.ctrlKey;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function visibleRect(target: Element): { left: number; top: number; width: number; height: number } | null {
|
|
189
|
+
const own = target.getBoundingClientRect();
|
|
190
|
+
if (own.width >= 1 || own.height >= 1) return own;
|
|
191
|
+
let left = Infinity;
|
|
192
|
+
let top = Infinity;
|
|
193
|
+
let right = -Infinity;
|
|
194
|
+
let bottom = -Infinity;
|
|
195
|
+
for (const node of target.querySelectorAll("*")) {
|
|
196
|
+
const rect = node.getBoundingClientRect();
|
|
197
|
+
if (rect.width < 1 && rect.height < 1) continue;
|
|
198
|
+
left = Math.min(left, rect.left);
|
|
199
|
+
top = Math.min(top, rect.top);
|
|
200
|
+
right = Math.max(right, rect.right);
|
|
201
|
+
bottom = Math.max(bottom, rect.bottom);
|
|
202
|
+
}
|
|
203
|
+
if (!Number.isFinite(left)) return null;
|
|
204
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
205
|
+
}
|
|
206
|
+
|
|
183
207
|
function positionPointerOutline(outline: HTMLDivElement, target: Element) {
|
|
184
|
-
const rect = target
|
|
185
|
-
if (rect.width < 1 && rect.height < 1) {
|
|
208
|
+
const rect = visibleRect(target);
|
|
209
|
+
if (!rect || (rect.width < 1 && rect.height < 1)) {
|
|
186
210
|
outline.style.display = "none";
|
|
187
211
|
return;
|
|
188
212
|
}
|
|
@@ -242,8 +266,12 @@ function repositionHover() {
|
|
|
242
266
|
positionPointerOutline(hoverOutline, hoverTarget);
|
|
243
267
|
}
|
|
244
268
|
|
|
245
|
-
/** Outline a page element
|
|
246
|
-
export function highlightPointedElement(
|
|
269
|
+
/** Outline a page element. Pass null to clear. Chip hover scrolls; a held modifier does not. */
|
|
270
|
+
export function highlightPointedElement(
|
|
271
|
+
doc: Document,
|
|
272
|
+
target: Element | null,
|
|
273
|
+
options?: { scroll?: boolean },
|
|
274
|
+
) {
|
|
247
275
|
if (!target) {
|
|
248
276
|
hoverOutline?.remove();
|
|
249
277
|
hoverOutline = null;
|
|
@@ -264,6 +292,11 @@ export function highlightPointedElement(doc: Document, target: Element | null) {
|
|
|
264
292
|
doc.addEventListener("scroll", repositionHover, true);
|
|
265
293
|
doc.defaultView?.addEventListener("resize", repositionHover);
|
|
266
294
|
}
|
|
295
|
+
const scroll = options?.scroll !== false;
|
|
296
|
+
if (!scroll) {
|
|
297
|
+
positionPointerOutline(hoverOutline, target);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
267
300
|
target.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
268
301
|
requestAnimationFrame(() => {
|
|
269
302
|
if (hoverTarget === target && hoverOutline) positionPointerOutline(hoverOutline, target);
|
package/src/site-widget.tsx
CHANGED
|
@@ -14,7 +14,13 @@ import { DOC_CHANGED_EVENT, EditorFocusProvider, useEditorFocus } from "./chrome
|
|
|
14
14
|
import { matchAppHotkey } from "./chrome/hotkeys";
|
|
15
15
|
import { EmbedPublishPage } from "./pages/EmbedPublishPage";
|
|
16
16
|
import { SiteBar } from "./site-bar";
|
|
17
|
+
import { useUncommittedCount } from "./site-changes";
|
|
17
18
|
import { installSitePointer } from "./site-pointer";
|
|
19
|
+
import {
|
|
20
|
+
currentPreviewPointerHost,
|
|
21
|
+
subscribePreviewPointerHost,
|
|
22
|
+
type PreviewPointerHost,
|
|
23
|
+
} from "./preview-pointer-host";
|
|
18
24
|
import { lightTheme } from "./styles/themes";
|
|
19
25
|
import { configureStudio } from "./studio-config";
|
|
20
26
|
import { applyThemeClass } from "./theme/apply-theme-class";
|
|
@@ -91,7 +97,9 @@ export function SiteWidget({
|
|
|
91
97
|
|
|
92
98
|
const [chatOpen, setChatOpen] = useState(false);
|
|
93
99
|
const [publishOpen, setPublishOpen] = useState(false);
|
|
94
|
-
const
|
|
100
|
+
const { count: changeCount, setCount: setChangeCount } = useUncommittedCount(
|
|
101
|
+
doc?.workspaceId ?? null,
|
|
102
|
+
);
|
|
95
103
|
const [publishAnchor, setPublishAnchor] = useState<DOMRect | null>(null);
|
|
96
104
|
const label = doc ? `${doc.type}/${doc.slug}` : "No document";
|
|
97
105
|
const onPublishAnchor = useCallback((rect: DOMRect | null) => {
|
|
@@ -249,34 +257,69 @@ function SitePointer({ doc }: { doc: SiteWidgetDoc }) {
|
|
|
249
257
|
docRef.current = doc;
|
|
250
258
|
|
|
251
259
|
useEffect(() => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
260
|
+
let local: ReturnType<typeof installSitePointer> | null = null;
|
|
261
|
+
let host: PreviewPointerHost | null = currentPreviewPointerHost();
|
|
262
|
+
|
|
263
|
+
function remember(grab: unknown) {
|
|
264
|
+
const next = chatPointerFromGrab(docRef.current, grab);
|
|
265
|
+
if (!next) return;
|
|
266
|
+
addPointer(next);
|
|
267
|
+
requestAnimationFrame(() => {
|
|
268
|
+
window.dispatchEvent(new Event("backmatter:chat-focus"));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function takeLocal() {
|
|
273
|
+
local?.dispose();
|
|
274
|
+
local = installSitePointer(document, {
|
|
275
|
+
bar: () => document.querySelector("[data-backmatter-devbar]"),
|
|
276
|
+
ignore: (target) => {
|
|
277
|
+
if (!(target instanceof Element)) return true;
|
|
278
|
+
if (target.closest("[data-backmatter-panel]")) return true;
|
|
279
|
+
return target.closest("iframe") !== null;
|
|
280
|
+
},
|
|
281
|
+
onGrab: (grab) => remember(grab),
|
|
282
|
+
onEscape: () => {
|
|
283
|
+
window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function onDevbarGrab(event: Event) {
|
|
289
|
+
remember((event as CustomEvent).detail);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function follow(next: PreviewPointerHost | null) {
|
|
293
|
+
host = next;
|
|
294
|
+
if (next) {
|
|
295
|
+
local?.dispose();
|
|
296
|
+
local = null;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!local) takeLocal();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
follow(host);
|
|
303
|
+
const stop = subscribePreviewPointerHost(follow);
|
|
304
|
+
window.addEventListener("backmatter:devbar-grab", onDevbarGrab);
|
|
305
|
+
|
|
271
306
|
function onArm(event: Event) {
|
|
272
307
|
const on = (event as CustomEvent<{ on?: boolean }>).detail?.on;
|
|
273
|
-
|
|
274
|
-
|
|
308
|
+
const current = currentPreviewPointerHost();
|
|
309
|
+
if (current) {
|
|
310
|
+
if (on && visibleRef.current) current.armFromDevbar();
|
|
311
|
+
else current.disarmFromDevbar();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (on && visibleRef.current) local?.arm();
|
|
315
|
+
else local?.disarm();
|
|
275
316
|
}
|
|
276
317
|
window.addEventListener(POINTER_ARM_EVENT, onArm);
|
|
277
318
|
return () => {
|
|
278
319
|
window.removeEventListener(POINTER_ARM_EVENT, onArm);
|
|
279
|
-
|
|
320
|
+
window.removeEventListener("backmatter:devbar-grab", onDevbarGrab);
|
|
321
|
+
stop();
|
|
322
|
+
local?.dispose();
|
|
280
323
|
};
|
|
281
324
|
}, [addPointer]);
|
|
282
325
|
|