@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.
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 +5 -1
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -36,6 +36,10 @@
36
36
  "./site-widget.css": "./dist/site-widget.css",
37
37
  "./prose-ui-node-shim": "./prose-ui-node-shim.ts",
38
38
  "./package.json": "./package.json",
39
+ "./src/chat/pointer-arm": "./src/chat/pointer-arm.ts",
40
+ "./src/install-preview-pointer": "./src/install-preview-pointer.ts",
41
+ "./src/preview-pointer-host": "./src/preview-pointer-host.ts",
42
+ "./src/site-pointer": "./src/site-pointer.ts",
39
43
  "./src/*": "./src/*"
40
44
  },
41
45
  "files": [
@@ -88,8 +88,12 @@ export type ChatUIValue = {
88
88
  mentions: DocRef[];
89
89
  addMention: (ref: DocRef) => void;
90
90
  removeMention: (ref: DocRef) => void;
91
- /** Elements pointed at on the host site. Only the dev bar chat sets `pointer`. */
91
+ /** Point button. The dev bar sets this, and the document editor turns it on when the preview can be pointed at. */
92
92
  pointer: boolean;
93
+ setPreviewPointer: (on: boolean) => void;
94
+ /** Local outline on this page, or a highlight message on the preview channel. */
95
+ pointerHighlight: "local" | "channel";
96
+ setPointerHighlight: (mode: "local" | "channel") => void;
93
97
  pointers: ChatPointer[];
94
98
  addPointer: (pointer: ChatPointer) => void;
95
99
  removePointer: (pointer: ChatPointer) => void;
@@ -202,6 +206,8 @@ export function ChatProvider({
202
206
  const [pendingText, setPendingText] = useState<string | null>(null);
203
207
  const [mentions, setMentions] = useState<DocRef[]>([]);
204
208
  const [pointers, setPointers] = useState<ChatPointer[]>([]);
209
+ const [previewPointer, setPreviewPointer] = useState(false);
210
+ const [pointerHighlight, setPointerHighlight] = useState<"local" | "channel">("local");
205
211
  const [includeFocus, setIncludeFocus] = useState(true);
206
212
  const [threadsVersion, setThreadsVersion] = useState(0);
207
213
  const { focus } = useEditorFocus();
@@ -318,7 +324,10 @@ export function ChatProvider({
318
324
  mentions,
319
325
  addMention,
320
326
  removeMention,
321
- pointer,
327
+ pointer: pointer || previewPointer,
328
+ setPreviewPointer,
329
+ pointerHighlight,
330
+ setPointerHighlight,
322
331
  pointers,
323
332
  addPointer,
324
333
  removePointer,
@@ -344,6 +353,8 @@ export function ChatProvider({
344
353
  addMention,
345
354
  removeMention,
346
355
  pointer,
356
+ previewPointer,
357
+ pointerHighlight,
347
358
  pointers,
348
359
  addPointer,
349
360
  removePointer,
@@ -10,6 +10,7 @@ import { ModelPicker } from "./ModelPicker";
10
10
  import { AtIcon, CloseIcon, FileIcon, FocusIcon, PointerIcon, SendIcon, StopIcon } from "./icons";
11
11
  import { mentionQueryAt } from "./chat-helpers";
12
12
  import { PointerChip, pointerKey } from "./PointerChip";
13
+ import { POINTER_ARM_EVENT } from "./pointer-arm";
13
14
 
14
15
  const hover = "@media (hover: hover)";
15
16
 
@@ -23,7 +24,7 @@ function isMentionKey(key: string): key is MentionKey {
23
24
  return MENTION_KEYS.has(key);
24
25
  }
25
26
 
26
- export const POINTER_ARM_EVENT = "backmatter:pointer-arm";
27
+ export { POINTER_ARM_EVENT } from "./pointer-arm";
27
28
 
28
29
  function postPointer(on: boolean) {
29
30
  if (typeof window === "undefined") return;
@@ -2,8 +2,10 @@
2
2
  import { useEffect } from "react";
3
3
  import * as stylex from "@stylexjs/stylex";
4
4
  import { colors, space, type } from "../styles/tokens.stylex";
5
+ import { emitPreviewHighlight } from "../preview-highlight";
5
6
  import { findPointedElement, highlightPointedElement } from "../site-pointer";
6
7
  import type { ChatPointer } from "./chat-context";
8
+ import { useChatUI } from "./ChatProvider";
7
9
  import { CloseIcon, PointerIcon } from "./icons";
8
10
 
9
11
  const hover = "@media (hover: hover)";
@@ -39,19 +41,30 @@ export function PointerChip({
39
41
  }) {
40
42
  const label = pointerChipLabel(pointer);
41
43
  const detail = pointerLabel(pointer);
44
+ const { pointerHighlight } = useChatUI();
45
+ const channel = pointerHighlight === "channel";
42
46
 
43
47
  useEffect(() => {
44
48
  return () => {
45
- if (canHighlightHere()) highlightPointedElement(document, null);
49
+ if (channel) emitPreviewHighlight(null);
50
+ else if (canHighlightHere()) highlightPointedElement(document, null);
46
51
  };
47
- }, []);
52
+ }, [channel]);
48
53
 
49
54
  function show() {
55
+ if (channel) {
56
+ emitPreviewHighlight(pointer.blockId);
57
+ return;
58
+ }
50
59
  if (!canHighlightHere()) return;
51
60
  highlightPointedElement(document, findPointedElement(document, pointer));
52
61
  }
53
62
 
54
63
  function hide() {
64
+ if (channel) {
65
+ emitPreviewHighlight(null);
66
+ return;
67
+ }
55
68
  if (!canHighlightHere()) return;
56
69
  highlightPointedElement(document, null);
57
70
  }
@@ -72,6 +72,12 @@ export function toolLabel(name: string, input: unknown, output: unknown, state:
72
72
  return running ? `Reading ${doc}…` : `Read ${doc}`;
73
73
  case "searchDocs":
74
74
  return running ? `Searching “${q ?? ""}”…` : `Searched “${q ?? ""}”`;
75
+ case "searchWeb":
76
+ return running ? `Searching “${q ?? ""}”…` : `Searched “${q ?? ""}”`;
77
+ case "readUrl": {
78
+ const url = (input as { url?: string } | undefined)?.url ?? "";
79
+ return running ? `Reading ${url}…` : `Read ${url}`;
80
+ }
75
81
  case "writeDoc":
76
82
  return running ? `Editing ${doc}…` : `Edited ${doc}`;
77
83
  case "createDoc":
@@ -0,0 +1,3 @@
1
+ // packages/studio/src/chat/pointer-arm.ts
2
+
3
+ export const POINTER_ARM_EVENT = "backmatter:pointer-arm";
@@ -17,11 +17,17 @@ import {
17
17
  notifyWorkspaceChanged,
18
18
  pullFromGithub,
19
19
  resolveDoc,
20
- type ApiError,
21
20
  type ChangeStatus,
22
21
  type GitChange,
23
22
  } from "../api";
24
23
  import { contentDocPath } from "../workspace-path";
24
+ import {
25
+ commitErrorSummary,
26
+ explainCommitError,
27
+ problemsForRef,
28
+ type CommitErrorView,
29
+ } from "./commit-error";
30
+ import { CommitFailureNotice, commitFailureLink } from "./commit-failure";
25
31
  import { CheckIcon } from "../chat/icons";
26
32
  import { WorkspaceSwitcher } from "../components/WorkspaceSwitcher";
27
33
  import { colors, radius, space, type } from "../styles/tokens.stylex";
@@ -49,18 +55,6 @@ export const STATUS_LABEL: Record<ChangeStatus, string> = {
49
55
  deleted: "deleted",
50
56
  };
51
57
 
52
- function formatCommitError(err: unknown): string {
53
- if (!(err instanceof Error)) return "Commit failed";
54
- const apiErr = err as ApiError;
55
- if (apiErr.code === "conflict") return "Resolve conflicts before committing";
56
- if (apiErr.code === "nothing_to_commit") return "Nothing to commit";
57
- if (apiErr.code === "validation") {
58
- return err.message.replace(/^validation:\s*/, "Validation failed: ");
59
- }
60
- if (apiErr.code === "github_not_bound") return "Connect GitHub first";
61
- return err.message;
62
- }
63
-
64
58
  function changeKey(doc: GitChange) {
65
59
  return `${doc.locale}/${doc.type}/${doc.slug}`;
66
60
  }
@@ -146,10 +140,10 @@ function ConflictResolvePanel({
146
140
  );
147
141
  }
148
142
 
149
- function ChangeRow({ doc }: { doc: GitChange }) {
143
+ function ChangeRow({ doc, invalid = false }: { doc: GitChange; invalid?: boolean }) {
150
144
  return (
151
145
  <div {...stylex.props(styles.changeItem)}>
152
- <span {...stylex.props(styles.changeName)}>
146
+ <span {...stylex.props(styles.changeName, invalid && styles.conflictText)}>
153
147
  {doc.type}/{doc.slug}
154
148
  </span>
155
149
  <span
@@ -183,6 +177,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
183
177
  const [resolvingKey, setResolvingKey] = useState<string | null>(null);
184
178
  const [message, setMessage] = useState("");
185
179
  const [error, setError] = useState<string | null>(null);
180
+ const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
186
181
  // Commit sha from the last poll. A new sha means HEAD moved. The preview
187
182
  // iframe does not reload for that; its page follows the SSE head event.
188
183
  const lastHead = useRef<string | null | undefined>(undefined);
@@ -324,6 +319,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
324
319
  if (committing || conflictCount > 0 || changeCount === 0) return;
325
320
  setCommitting(true);
326
321
  setError(null);
322
+ setCommitFailure(null);
327
323
  try {
328
324
  const result = await commitWorkspace(workspaceId, message);
329
325
  setMessage("");
@@ -331,13 +327,29 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
331
327
  publishCommittedFiles(workspaceId, result.files);
332
328
  await reloadGit();
333
329
  } catch (err) {
334
- setError(formatCommitError(err));
330
+ setCommitFailure(explainCommitError(err));
335
331
  await reloadGit();
336
332
  } finally {
337
333
  setCommitting(false);
338
334
  }
339
335
  }
340
336
 
337
+ function hrefForChange(ref: string): string | null {
338
+ const slash = ref.indexOf("/");
339
+ if (slash <= 0 || !workspaceNav) return null;
340
+ const type = ref.slice(0, slash);
341
+ const slug = ref.slice(slash + 1);
342
+ const doc = changes.find((change) => change.type === type && change.slug === slug);
343
+ if (!doc) return null;
344
+ return contentDocPath(
345
+ workspaceNav.workspaceSlug,
346
+ doc.type,
347
+ doc.slug,
348
+ doc.locale,
349
+ workspaceNav.defaultLocale,
350
+ );
351
+ }
352
+
341
353
  const commitLabel = committing
342
354
  ? "Committing…"
343
355
  : conflictCount > 0
@@ -507,8 +519,11 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
507
519
  {changeCount} uncommitted
508
520
  </StatusItem>
509
521
  ) : null}
510
- {error ? <StatusItem tone="danger">{error}</StatusItem> : null}
511
- {refreshError && !error ? (
522
+ {error ? (
523
+ <StatusItem tone="danger">{error}</StatusItem>
524
+ ) : commitFailure ? (
525
+ <StatusItem tone="danger">{commitErrorSummary(commitFailure)}</StatusItem>
526
+ ) : refreshError ? (
512
527
  <StatusItem tone="danger">{refreshError}</StatusItem>
513
528
  ) : null}
514
529
  <StatusSpacer />
@@ -563,10 +578,33 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
563
578
  <p {...stylex.props(popup.groupLabel)}>
564
579
  {changeCount} {changeCount === 1 ? "file" : "files"} → {git?.currentBranch}
565
580
  </p>
581
+ {commitFailure ? (
582
+ <CommitFailureNotice
583
+ failure={commitFailure}
584
+ renderRef={(ref) => {
585
+ const href = hrefForChange(ref);
586
+ if (!href) return ref;
587
+ return (
588
+ <Link
589
+ to={href}
590
+ onClick={() => setCommitOpen(false)}
591
+ {...stylex.props(commitFailureLink.link)}
592
+ >
593
+ {ref}
594
+ </Link>
595
+ );
596
+ }}
597
+ />
598
+ ) : null}
566
599
  <ul {...stylex.props(styles.commitFiles)}>
567
600
  {changes.map((doc) => (
568
601
  <li key={`${doc.locale}/${doc.type}/${doc.slug}`}>
569
- <ChangeRow doc={doc} />
602
+ <ChangeRow
603
+ doc={doc}
604
+ invalid={
605
+ problemsForRef(commitFailure, `${doc.type}/${doc.slug}`).length > 0
606
+ }
607
+ />
570
608
  </li>
571
609
  ))}
572
610
  </ul>
@@ -0,0 +1,82 @@
1
+ // packages/studio/src/chrome/commit-error.ts
2
+ import type { ApiError } from "../api";
3
+
4
+ export type CommitFileProblem = {
5
+ /** `type/slug`, the same label the commit list uses. */
6
+ ref: string;
7
+ problems: string[];
8
+ };
9
+
10
+ export type CommitErrorView =
11
+ | { kind: "validation"; files: CommitFileProblem[] }
12
+ | { kind: "message"; text: string };
13
+
14
+ /**
15
+ * Turn a failed commit into what the deploy window should say.
16
+ * Validation lists every file and field. Other failures stay one sentence.
17
+ */
18
+ export function explainCommitError(err: unknown): CommitErrorView {
19
+ if (!(err instanceof Error)) return { kind: "message", text: "Commit failed" };
20
+ const apiErr = err as ApiError;
21
+ if (apiErr.code === "conflict") {
22
+ return { kind: "message", text: "Resolve conflicts before committing" };
23
+ }
24
+ if (apiErr.code === "nothing_to_commit") {
25
+ return { kind: "message", text: "Nothing to commit" };
26
+ }
27
+ if (apiErr.code === "github_not_bound") {
28
+ return { kind: "message", text: "Connect GitHub first" };
29
+ }
30
+ if (apiErr.code === "validation") {
31
+ return { kind: "validation", files: groupValidation(validationLines(apiErr)) };
32
+ }
33
+ return { kind: "message", text: err.message };
34
+ }
35
+
36
+ /** One line for the status bar. The window lists every problem. */
37
+ export function commitErrorSummary(view: CommitErrorView): string {
38
+ if (view.kind === "message") return view.text;
39
+ const first = view.files[0];
40
+ if (!first || first.problems.length === 0) return "Can't commit";
41
+ const extra =
42
+ view.files.reduce((n, file) => n + file.problems.length, 0) - 1;
43
+ const lead = `${first.ref} — ${first.problems[0]}`;
44
+ return extra > 0 ? `${lead} (+${extra} more)` : lead;
45
+ }
46
+
47
+ export function problemsForRef(
48
+ view: CommitErrorView | null,
49
+ ref: string,
50
+ ): string[] {
51
+ if (!view || view.kind !== "validation") return [];
52
+ return view.files.find((file) => file.ref === ref)?.problems ?? [];
53
+ }
54
+
55
+ function validationLines(err: ApiError): string[] {
56
+ if (err.details && err.details.length > 0) return err.details;
57
+ const body = err.message.replace(/^validation:\s*/, "");
58
+ if (!body || body === err.message) return [];
59
+ return body.split(", ").filter((line) => line.length > 0);
60
+ }
61
+
62
+ function groupValidation(lines: string[]): CommitFileProblem[] {
63
+ const order: string[] = [];
64
+ const byRef = new Map<string, string[]>();
65
+ for (const line of lines) {
66
+ const split = line.indexOf(": ");
67
+ const ref = split === -1 ? line : line.slice(0, split);
68
+ const problem = sentence(split === -1 ? line : line.slice(split + 2));
69
+ if (!byRef.has(ref)) {
70
+ byRef.set(ref, []);
71
+ order.push(ref);
72
+ }
73
+ byRef.get(ref)!.push(problem);
74
+ }
75
+ return order.map((ref) => ({ ref, problems: byRef.get(ref)! }));
76
+ }
77
+
78
+ function sentence(problem: string): string {
79
+ const trimmed = problem.trim();
80
+ if (!trimmed) return trimmed;
81
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
82
+ }
@@ -0,0 +1,111 @@
1
+ // packages/studio/src/chrome/commit-failure.tsx
2
+ import type { ReactNode } from "react";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import type { CommitErrorView } from "./commit-error";
5
+ import { colors, space, type } from "../styles/tokens.stylex";
6
+
7
+ const hover = "@media (hover: hover)";
8
+
9
+ /** The explanation inside the deploy window after a commit is refused. */
10
+ export function CommitFailureNotice({
11
+ failure,
12
+ renderRef,
13
+ }: {
14
+ failure: CommitErrorView;
15
+ renderRef?: (ref: string) => ReactNode;
16
+ }) {
17
+ if (failure.kind === "message") {
18
+ return (
19
+ <p role="alert" {...stylex.props(styles.line)}>
20
+ {failure.text}
21
+ </p>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <div role="alert" {...stylex.props(styles.box)}>
27
+ <p {...stylex.props(styles.title)}>Can't commit yet</p>
28
+ <ul {...stylex.props(styles.list)}>
29
+ {failure.files.map((file) => (
30
+ <li key={file.ref} {...stylex.props(styles.item)}>
31
+ <span {...stylex.props(styles.ref)}>
32
+ {renderRef ? renderRef(file.ref) : file.ref}
33
+ </span>
34
+ {file.problems.map((problem, index) => (
35
+ <span key={`${problem}-${index}`} {...stylex.props(styles.problem)}>
36
+ {problem}
37
+ </span>
38
+ ))}
39
+ </li>
40
+ ))}
41
+ </ul>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ const styles = stylex.create({
47
+ box: {
48
+ display: "flex",
49
+ flexDirection: "column",
50
+ gap: space.xs,
51
+ paddingInline: space.lg,
52
+ paddingBottom: space.sm,
53
+ },
54
+ title: {
55
+ margin: 0,
56
+ fontSize: type.xs,
57
+ fontWeight: 500,
58
+ color: colors.danger,
59
+ },
60
+ list: {
61
+ margin: 0,
62
+ padding: 0,
63
+ listStyle: "none",
64
+ display: "flex",
65
+ flexDirection: "column",
66
+ gap: space.sm,
67
+ maxHeight: "140px",
68
+ overflowY: "auto",
69
+ },
70
+ item: {
71
+ display: "flex",
72
+ flexDirection: "column",
73
+ gap: "2px",
74
+ minWidth: 0,
75
+ },
76
+ ref: {
77
+ display: "block",
78
+ minWidth: 0,
79
+ overflow: "hidden",
80
+ textOverflow: "ellipsis",
81
+ whiteSpace: "nowrap",
82
+ fontFamily: type.mono,
83
+ fontSize: type.xs,
84
+ color: colors.text,
85
+ },
86
+ problem: {
87
+ fontSize: type.xs,
88
+ lineHeight: 1.4,
89
+ color: colors.danger,
90
+ },
91
+ line: {
92
+ margin: 0,
93
+ paddingInline: space.lg,
94
+ paddingBottom: space.sm,
95
+ fontSize: type.xs,
96
+ lineHeight: 1.4,
97
+ color: colors.danger,
98
+ },
99
+ });
100
+
101
+ export const commitFailureLink = stylex.create({
102
+ link: {
103
+ color: "inherit",
104
+ textDecoration: "none",
105
+ [hover]: {
106
+ ":hover": {
107
+ textDecoration: "underline",
108
+ },
109
+ },
110
+ },
111
+ });
@@ -0,0 +1,116 @@
1
+ // packages/studio/src/install-preview-pointer.ts
2
+ import { routePointerHit, type PointerArmSource, type RoutedGrab } from "@backmatter/core/preview";
3
+ import {
4
+ grabAt,
5
+ highlightPointedElement,
6
+ installSitePointer,
7
+ isOpenModifier,
8
+ type PointerGrab,
9
+ } from "./site-pointer";
10
+
11
+ export function installPreviewPointer(
12
+ doc: Document,
13
+ options: {
14
+ bar: () => Element | null;
15
+ ignore?: (target: EventTarget | null) => boolean;
16
+ onEditorGrab: (grab: RoutedGrab) => void;
17
+ onDevbarGrab: (grab: PointerGrab) => void;
18
+ onEscape?: (source: PointerArmSource) => void;
19
+ },
20
+ ): {
21
+ arm: (source: PointerArmSource) => void;
22
+ disarm: () => void;
23
+ source: () => PointerArmSource | null;
24
+ dispose: () => void;
25
+ } {
26
+ let source: PointerArmSource | null = null;
27
+
28
+ function deliver(grab: PointerGrab, shift: boolean) {
29
+ const route = routePointerHit(source, { ...grab, shift });
30
+ if (route.to === "editor") options.onEditorGrab(route.grab);
31
+ else if (route.to === "devbar") options.onDevbarGrab(route.grab);
32
+ }
33
+
34
+ const pointer = installSitePointer(doc, {
35
+ bar: options.bar,
36
+ ignore: options.ignore,
37
+ onGrab: (grab, shift) => deliver(grab, shift),
38
+ onEscape: () => {
39
+ const armed = source;
40
+ source = null;
41
+ if (armed) options.onEscape?.(armed);
42
+ },
43
+ });
44
+
45
+ let under: Element | null = null;
46
+
47
+ function elementFrom(raw: EventTarget | null): Element | null {
48
+ if (!raw || typeof raw !== "object" || !("nodeType" in raw) || raw.nodeType !== 1) return null;
49
+ return raw as Element;
50
+ }
51
+
52
+ function markedBlock(target: Element | null): Element | null {
53
+ if (!target || options.ignore?.(target)) return null;
54
+ const marker = target.closest("[data-backmatter-block]");
55
+ if (!marker?.getAttribute("data-backmatter-block")) return null;
56
+ return marker;
57
+ }
58
+
59
+ function paint(held: boolean) {
60
+ highlightPointedElement(doc, held ? markedBlock(under) : null, { scroll: false });
61
+ }
62
+
63
+ function onMove(event: PointerEvent) {
64
+ under = elementFrom(event.target);
65
+ paint(isOpenModifier(event));
66
+ }
67
+
68
+ function onModifier(event: KeyboardEvent) {
69
+ if (event.key !== "Meta" && event.key !== "Control") return;
70
+ paint(event.type === "keydown" || isOpenModifier(event));
71
+ }
72
+
73
+ function onOpen(event: MouseEvent) {
74
+ if (!isOpenModifier(event) || source !== null) return;
75
+ const target = elementFrom(event.target);
76
+ const grab = grabAt(target, options.bar());
77
+ if (!grab?.blockId) return;
78
+ event.preventDefault();
79
+ event.stopPropagation();
80
+ deliver(grab, true);
81
+ }
82
+
83
+ function onBlur() {
84
+ paint(false);
85
+ }
86
+
87
+ const view = doc.defaultView;
88
+ doc.addEventListener("pointermove", onMove, true);
89
+ doc.addEventListener("keydown", onModifier, true);
90
+ doc.addEventListener("keyup", onModifier, true);
91
+ doc.addEventListener("click", onOpen, true);
92
+ view?.addEventListener("blur", onBlur);
93
+
94
+ return {
95
+ arm(next) {
96
+ source = next;
97
+ pointer.arm();
98
+ },
99
+ disarm() {
100
+ source = null;
101
+ pointer.disarm();
102
+ },
103
+ source: () => source,
104
+ dispose() {
105
+ doc.removeEventListener("pointermove", onMove, true);
106
+ doc.removeEventListener("keydown", onModifier, true);
107
+ doc.removeEventListener("keyup", onModifier, true);
108
+ doc.removeEventListener("click", onOpen, true);
109
+ view?.removeEventListener("blur", onBlur);
110
+ highlightPointedElement(doc, null);
111
+ source = null;
112
+ under = null;
113
+ pointer.dispose();
114
+ },
115
+ };
116
+ }
@@ -1,5 +1,5 @@
1
1
  // packages/studio/src/pages/BlocksList.tsx
2
- import { useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
4
4
  import { DragDropProvider } from "@dnd-kit/react";
5
5
  import { isSortable, useSortable } from "@dnd-kit/react/sortable";
@@ -92,6 +92,7 @@ function BlockItem({
92
92
  <div
93
93
  ref={ref}
94
94
  data-block-item=""
95
+ data-block-id={block.id}
95
96
  {...stylex.props(styles.item, isDragging && styles.itemDragging)}
96
97
  >
97
98
  <div {...stylex.props(styles.itemBar)}>
@@ -185,14 +186,28 @@ export function BlocksList({
185
186
  blocks,
186
187
  catalog,
187
188
  readOnly,
189
+ focusBlock,
188
190
  onChange,
189
191
  }: {
190
192
  blocks: BlockInstance[];
191
193
  catalog: BlockSnapshot[];
192
194
  readOnly?: boolean;
195
+ focusBlock?: { id: string; nonce: number } | null;
193
196
  onChange: (next: BlockInstance[]) => void;
194
197
  }) {
195
198
  const [openId, setOpenId] = useState<string | null>(null);
199
+ const listRef = useRef<HTMLDivElement>(null);
200
+
201
+ useEffect(() => {
202
+ if (!focusBlock) return;
203
+ setOpenId(focusBlock.id);
204
+ const id = focusBlock.id;
205
+ const frame = requestAnimationFrame(() => {
206
+ const row = listRef.current?.querySelector(`[data-block-id="${CSS.escape(id)}"]`);
207
+ row?.scrollIntoView({ block: "nearest", inline: "nearest" });
208
+ });
209
+ return () => cancelAnimationFrame(frame);
210
+ }, [focusBlock]);
196
211
 
197
212
  function add(type: string) {
198
213
  const id = crypto.randomUUID();
@@ -219,7 +234,7 @@ export function BlocksList({
219
234
  onChange(reorderBlock(blocks, String(source.id), source.index));
220
235
  }}
221
236
  >
222
- <div {...stylex.props(styles.list)}>
237
+ <div {...stylex.props(styles.list)} ref={listRef}>
223
238
  {blocks.map((block, index) => (
224
239
  <BlockItem
225
240
  key={block.id}