@backmatter/studio 0.0.10 → 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 (37) hide show
  1. package/dist/chunks/{UiKitApp-BxvTwsko.js → UiKitApp-ZZXQt3zy.js} +29 -29
  2. package/dist/chunks/{WorkspaceRoutes-BKKEkAq7.js → WorkspaceRoutes-CCDPtLla.js} +22712 -22498
  3. package/dist/chunks/{dashboard-8rjkfEXl.js → dashboard-Dw0zfTXg.js} +35 -35
  4. package/dist/chunks/{index-laWAz5_p.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/api.ts +1 -1
  12. package/src/chat/ChatProvider.tsx +13 -2
  13. package/src/chat/Composer.tsx +2 -1
  14. package/src/chat/PointerChip.tsx +15 -2
  15. package/src/chat/chat-helpers.ts +6 -0
  16. package/src/chat/pointer-arm.ts +3 -0
  17. package/src/chrome/WorkspaceStatusBar.tsx +64 -25
  18. package/src/chrome/branch-head.ts +14 -0
  19. package/src/chrome/commit-error.ts +82 -0
  20. package/src/chrome/commit-failure.tsx +111 -0
  21. package/src/install-preview-pointer.ts +116 -0
  22. package/src/pages/BlocksList.tsx +17 -2
  23. package/src/pages/DocEditorPage.tsx +121 -23
  24. package/src/pages/EmbedPublishPage.tsx +27 -24
  25. package/src/pages/RenderedBody.tsx +141 -322
  26. package/src/pages/document-paint.ts +79 -0
  27. package/src/pages/editor-grab.ts +39 -0
  28. package/src/pages/html-to-markdown.ts +26 -0
  29. package/src/pages/preview-bridge.ts +8 -1
  30. package/src/pages/preview-url.ts +15 -0
  31. package/src/pages/selection-format.ts +5 -2
  32. package/src/preview-highlight.ts +16 -0
  33. package/src/preview-pointer-host.ts +30 -0
  34. package/src/shell-resets.css +13 -0
  35. package/src/site-changes.ts +46 -0
  36. package/src/site-pointer.ts +39 -6
  37. 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.10",
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": [
package/src/api.ts CHANGED
@@ -501,7 +501,7 @@ export function notifyTreeChanged(detail?: TreeChangeDetail) {
501
501
  );
502
502
  }
503
503
 
504
- /** Branch HEAD moved (teammate push, pull, commit); clean docs should reload. */
504
+ /** Branch HEAD moved (teammate push, pull, commit). Clean docs reload their fields. The preview frame stays up. */
505
505
  export function notifyHeadChanged() {
506
506
  window.dispatchEvent(new Event("backmatter:head-changed"));
507
507
  }
@@ -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";
@@ -35,9 +41,11 @@ import { replicaStore } from "../replica/instance";
35
41
  import { useReplicaSession } from "../replica/ReplicaProvider";
36
42
  import { useReplicaState } from "../replica/useReplica";
37
43
  import { useWorkspaceNav } from "./WorkspaceNavContext";
44
+ import { branchHeadMoved } from "./branch-head";
38
45
  import { commitTriggerDisabled, statusBarAction } from "./status-bar-action";
39
46
 
40
47
  const hover = "@media (hover: hover)";
48
+ /** Status bar only. The preview frame follows HEAD on its SSE stream, not on this poll. */
41
49
  const POLL_MS = 8000;
42
50
 
43
51
  export const STATUS_LABEL: Record<ChangeStatus, string> = {
@@ -47,18 +55,6 @@ export const STATUS_LABEL: Record<ChangeStatus, string> = {
47
55
  deleted: "deleted",
48
56
  };
49
57
 
50
- function formatCommitError(err: unknown): string {
51
- if (!(err instanceof Error)) return "Commit failed";
52
- const apiErr = err as ApiError;
53
- if (apiErr.code === "conflict") return "Resolve conflicts before committing";
54
- if (apiErr.code === "nothing_to_commit") return "Nothing to commit";
55
- if (apiErr.code === "validation") {
56
- return err.message.replace(/^validation:\s*/, "Validation failed: ");
57
- }
58
- if (apiErr.code === "github_not_bound") return "Connect GitHub first";
59
- return err.message;
60
- }
61
-
62
58
  function changeKey(doc: GitChange) {
63
59
  return `${doc.locale}/${doc.type}/${doc.slug}`;
64
60
  }
@@ -144,10 +140,10 @@ function ConflictResolvePanel({
144
140
  );
145
141
  }
146
142
 
147
- function ChangeRow({ doc }: { doc: GitChange }) {
143
+ function ChangeRow({ doc, invalid = false }: { doc: GitChange; invalid?: boolean }) {
148
144
  return (
149
145
  <div {...stylex.props(styles.changeItem)}>
150
- <span {...stylex.props(styles.changeName)}>
146
+ <span {...stylex.props(styles.changeName, invalid && styles.conflictText)}>
151
147
  {doc.type}/{doc.slug}
152
148
  </span>
153
149
  <span
@@ -181,7 +177,9 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
181
177
  const [resolvingKey, setResolvingKey] = useState<string | null>(null);
182
178
  const [message, setMessage] = useState("");
183
179
  const [error, setError] = useState<string | null>(null);
184
- // HEAD identity from the last poll so we can announce "HEAD moved".
180
+ const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
181
+ // Commit sha from the last poll. A new sha means HEAD moved. The preview
182
+ // iframe does not reload for that; its page follows the SSE head event.
185
183
  const lastHead = useRef<string | null | undefined>(undefined);
186
184
 
187
185
  async function reloadGit() {
@@ -195,11 +193,9 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
195
193
  } else {
196
194
  setError(null);
197
195
  }
198
- const headKey = next.head ? `${next.head.sha}:${next.head.updatedAt}` : null;
199
- if (lastHead.current !== undefined && lastHead.current !== headKey) {
200
- notifyHeadChanged();
201
- }
202
- lastHead.current = headKey;
196
+ const nextSha = next.head?.sha ?? null;
197
+ if (branchHeadMoved(lastHead.current, nextSha)) notifyHeadChanged();
198
+ lastHead.current = nextSha;
203
199
  } catch (err) {
204
200
  setError(err instanceof Error ? err.message : "Git status failed");
205
201
  }
@@ -323,6 +319,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
323
319
  if (committing || conflictCount > 0 || changeCount === 0) return;
324
320
  setCommitting(true);
325
321
  setError(null);
322
+ setCommitFailure(null);
326
323
  try {
327
324
  const result = await commitWorkspace(workspaceId, message);
328
325
  setMessage("");
@@ -330,13 +327,29 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
330
327
  publishCommittedFiles(workspaceId, result.files);
331
328
  await reloadGit();
332
329
  } catch (err) {
333
- setError(formatCommitError(err));
330
+ setCommitFailure(explainCommitError(err));
334
331
  await reloadGit();
335
332
  } finally {
336
333
  setCommitting(false);
337
334
  }
338
335
  }
339
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
+
340
353
  const commitLabel = committing
341
354
  ? "Committing…"
342
355
  : conflictCount > 0
@@ -506,8 +519,11 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
506
519
  {changeCount} uncommitted
507
520
  </StatusItem>
508
521
  ) : null}
509
- {error ? <StatusItem tone="danger">{error}</StatusItem> : null}
510
- {refreshError && !error ? (
522
+ {error ? (
523
+ <StatusItem tone="danger">{error}</StatusItem>
524
+ ) : commitFailure ? (
525
+ <StatusItem tone="danger">{commitErrorSummary(commitFailure)}</StatusItem>
526
+ ) : refreshError ? (
511
527
  <StatusItem tone="danger">{refreshError}</StatusItem>
512
528
  ) : null}
513
529
  <StatusSpacer />
@@ -562,10 +578,33 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
562
578
  <p {...stylex.props(popup.groupLabel)}>
563
579
  {changeCount} {changeCount === 1 ? "file" : "files"} → {git?.currentBranch}
564
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}
565
599
  <ul {...stylex.props(styles.commitFiles)}>
566
600
  {changes.map((doc) => (
567
601
  <li key={`${doc.locale}/${doc.type}/${doc.slug}`}>
568
- <ChangeRow doc={doc} />
602
+ <ChangeRow
603
+ doc={doc}
604
+ invalid={
605
+ problemsForRef(commitFailure, `${doc.type}/${doc.slug}`).length > 0
606
+ }
607
+ />
569
608
  </li>
570
609
  ))}
571
610
  </ul>
@@ -0,0 +1,14 @@
1
+ // packages/studio/src/chrome/branch-head.ts
2
+
3
+ /**
4
+ * Whether a later git poll is a new commit.
5
+ * The first sample only records the sha. `updatedAt` is not part of the
6
+ * identity: rewriting the head row must not look like HEAD moved.
7
+ */
8
+ export function branchHeadMoved(
9
+ previousSha: string | null | undefined,
10
+ nextSha: string | null,
11
+ ): boolean {
12
+ if (previousSha === undefined) return false;
13
+ return previousSha !== nextSha;
14
+ }
@@ -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
+ }