@backmatter/studio 0.0.11 → 0.0.13

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.
Files changed (34) hide show
  1. package/dist/chunks/{UiKitApp-Cu0CS1NU.js → UiKitApp-ZZXQt3zy.js} +29 -29
  2. package/dist/chunks/{WorkspaceRoutes-Dzpaj8ov.js → WorkspaceRoutes-CCDPtLla.js} +22396 -22185
  3. package/dist/chunks/{dashboard-kWmpeuAP.js → dashboard-Dw0zfTXg.js} +35 -35
  4. package/dist/chunks/{index-Bryt_VJQ.js → index-DhZGCAru.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +199 -193
  7. package/dist/site-widget.css +9 -1
  8. package/dist/site-widget.js +6060 -5936
  9. package/dist/studio.css +5 -25
  10. package/package.json +6 -2
  11. package/src/chat/ChatProvider.tsx +13 -2
  12. package/src/chat/Composer.tsx +2 -1
  13. package/src/chat/PointerChip.tsx +15 -2
  14. package/src/chat/chat-helpers.ts +6 -0
  15. package/src/chat/pointer-arm.ts +3 -0
  16. package/src/chrome/WorkspaceStatusBar.tsx +57 -19
  17. package/src/chrome/commit-error.ts +82 -0
  18. package/src/chrome/commit-failure.tsx +111 -0
  19. package/src/install-preview-pointer.ts +116 -0
  20. package/src/pages/BlocksList.tsx +17 -2
  21. package/src/pages/DocEditorPage.tsx +95 -1
  22. package/src/pages/EmbedPublishPage.tsx +27 -24
  23. package/src/pages/RenderedBody.tsx +141 -322
  24. package/src/pages/document-paint.ts +79 -0
  25. package/src/pages/editor-grab.ts +39 -0
  26. package/src/pages/html-to-markdown.ts +26 -0
  27. package/src/pages/preview-bridge.ts +8 -1
  28. package/src/pages/selection-format.ts +5 -2
  29. package/src/preview-highlight.ts +16 -0
  30. package/src/preview-pointer-host.ts +30 -0
  31. package/src/shell-resets.css +13 -0
  32. package/src/site-changes.ts +46 -0
  33. package/src/site-pointer.ts +39 -6
  34. 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): void {
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
- host.replaceChildren(createBlock(format, html));
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
+ }
@@ -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
+ }
@@ -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.getBoundingClientRect();
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 while a pointer chip is hovered. Pass null to clear. */
246
- export function highlightPointedElement(doc: Document, target: Element | null) {
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);
@@ -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 [changeCount, setChangeCount] = useState(0);
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
- const pointer = installSitePointer(document, {
253
- bar: () => document.querySelector("[data-backmatter-devbar]"),
254
- ignore: (target) => {
255
- if (!(target instanceof Element)) return true;
256
- if (target.closest("[data-backmatter-panel]")) return true;
257
- return target.closest("iframe") !== null;
258
- },
259
- onGrab: (grab) => {
260
- const next = chatPointerFromGrab(docRef.current, grab);
261
- if (!next) return;
262
- addPointer(next);
263
- requestAnimationFrame(() => {
264
- window.dispatchEvent(new Event("backmatter:chat-focus"));
265
- });
266
- },
267
- onEscape: () => {
268
- window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
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
- if (on && visibleRef.current) pointer.arm();
274
- else pointer.disarm();
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
- pointer.dispose();
320
+ window.removeEventListener("backmatter:devbar-grab", onDevbarGrab);
321
+ stop();
322
+ local?.dispose();
280
323
  };
281
324
  }, [addPointer]);
282
325