@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/studio",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
/**
|
|
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,
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -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
|
|
27
|
+
export { POINTER_ARM_EVENT } from "./pointer-arm";
|
|
27
28
|
|
|
28
29
|
function postPointer(on: boolean) {
|
|
29
30
|
if (typeof window === "undefined") return;
|
package/src/chat/PointerChip.tsx
CHANGED
|
@@ -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 (
|
|
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
|
}
|
package/src/chat/chat-helpers.ts
CHANGED
|
@@ -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":
|
|
@@ -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
|
-
|
|
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 ?
|
|
511
|
-
|
|
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
|
|
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
|
+
}
|
package/src/pages/BlocksList.tsx
CHANGED
|
@@ -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}
|