@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
@@ -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}
@@ -49,9 +49,10 @@ import {
49
49
  import {
50
50
  editorPreviewPath,
51
51
  modelForType,
52
- previewSiteUrl,
53
- primarySiteUrl,
54
52
  onSitePreviewUrl,
53
+ previewEnableUrl,
54
+ previewFrameIdentity,
55
+ primarySiteUrl,
55
56
  studioIsOnSite,
56
57
  productionDocUrl,
57
58
  } from "./preview-url";
@@ -61,7 +62,16 @@ import { blocksFromFrontmatter } from "./blocks-list";
61
62
  import type { WorkspaceOutletContext } from "../workspace/types";
62
63
  import { contentDocPath, defaultLocaleOf, settingsPath } from "../workspace-path";
63
64
  import { bindPreviewEditor, previewFrameTransport } from "./preview-bridge";
64
- import { createWindowPreviewTransport, type PreviewChannelRef } from "@backmatter/core/preview";
65
+ import { editorGrabAction } from "./editor-grab";
66
+ import { useChatUI } from "../chat/ChatProvider";
67
+ import { POINTER_ARM_EVENT } from "../chat/pointer-arm";
68
+ import { registerPreviewHighlight } from "../preview-highlight";
69
+ import {
70
+ createWindowPreviewTransport,
71
+ samePreviewRef,
72
+ type PreviewChannelRef,
73
+ type PreviewGrabMessage,
74
+ } from "@backmatter/core/preview";
65
75
 
66
76
  const AUTOSAVE_MS = 400;
67
77
  const hover = "@media (hover: hover)";
@@ -214,6 +224,9 @@ export function DocEditorPage() {
214
224
  const splitRef = useRef<HTMLDivElement>(null);
215
225
  const previewFrameRef = useRef<HTMLIFrameElement>(null);
216
226
  const previewSession = useRef<ReturnType<typeof bindPreviewEditor> | null>(null);
227
+ const { addPointer, setPreviewPointer, setPointerHighlight } = useChatUI();
228
+ const [blockFocus, setBlockFocus] = useState<{ id: string; nonce: number } | null>(null);
229
+ const blockFocusNonce = useRef(0);
217
230
  const navigate = useNavigate();
218
231
  const wide = useWideLayout();
219
232
  const { setStatus: setEditorStatus, clear: clearEditorStatus } =
@@ -253,33 +266,39 @@ export function DocEditorPage() {
253
266
  const detailNames = fieldNames.filter((name) => fieldKinds[name] !== "blocks");
254
267
  const blocksField =
255
268
  fieldNames.find((name) => fieldKinds[name] === "blocks") ?? "blocks";
269
+ const blocksFieldRef = useRef(blocksField);
270
+ blocksFieldRef.current = blocksField;
256
271
  const siteUrl = primarySiteUrl(workspace.siteUrls ?? []);
257
272
  const liveUrl = productionDocUrl(siteUrl, modelForType(models, type), locale, slug);
273
+ const previewPath = editorPreviewPath(
274
+ modelForType(models, type),
275
+ slug,
276
+ locale,
277
+ branch,
278
+ tree,
279
+ );
280
+ // Strings only. A replica refresh builds new models and siteUrls arrays
281
+ // with the same contents; that must not mint a ticket and reload the frame.
282
+ const frameIdentity = previewFrameIdentity({
283
+ siteUrl,
284
+ path: showPreview ? previewPath : null,
285
+ branch,
286
+ tree,
287
+ });
258
288
 
259
289
  useEffect(() => {
260
- if (!showPreview) {
261
- setPreviewUrl(null);
262
- return;
263
- }
264
- const redirect = editorPreviewPath(
265
- modelForType(models, type),
266
- slug,
267
- locale,
268
- branch,
269
- tree,
270
- );
271
- if (!redirect || !workspaceId || !siteUrl) {
290
+ if (!frameIdentity || !previewPath || !workspaceId || !siteUrl) {
272
291
  setPreviewUrl(null);
273
292
  return;
274
293
  }
275
294
  if (studioIsOnSite(siteUrl, window.location.origin)) {
276
- setPreviewUrl(onSitePreviewUrl(siteUrl, redirect));
295
+ setPreviewUrl(onSitePreviewUrl(siteUrl, previewPath));
277
296
  return;
278
297
  }
279
298
  let cancelled = false;
280
- void createPreviewTicket(workspaceId, { redirect, branch, tree })
299
+ void createPreviewTicket(workspaceId, { redirect: previewPath, branch, tree })
281
300
  .then((res) => {
282
- if (!cancelled) setPreviewUrl(previewSiteUrl(workspace.siteUrls ?? [], res.ticket));
301
+ if (!cancelled) setPreviewUrl(previewEnableUrl(siteUrl, res.ticket));
283
302
  })
284
303
  .catch(() => {
285
304
  if (!cancelled) setPreviewUrl(null);
@@ -287,7 +306,7 @@ export function DocEditorPage() {
287
306
  return () => {
288
307
  cancelled = true;
289
308
  };
290
- }, [showPreview, workspaceId, workspace.siteUrls, siteUrl, models, type, slug, locale, branch, tree]);
309
+ }, [frameIdentity, previewPath, workspaceId, siteUrl, branch, tree]);
291
310
 
292
311
  useEffect(() => {
293
312
  setPreviewLoading(true);
@@ -302,6 +321,57 @@ export function DocEditorPage() {
302
321
  setPreviewLive(null);
303
322
  }, [previewUrl]);
304
323
 
324
+ function onPreviewHello(hello: PreviewChannelRef, capabilities?: string[]) {
325
+ const canPoint =
326
+ samePreviewRef(hello, channelRef.current) && (capabilities?.includes("point") ?? false);
327
+ setPreviewPointer(canPoint);
328
+ window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
329
+ }
330
+
331
+ function onPreviewGrab(grab: PreviewGrabMessage) {
332
+ const ids = blocksFromFrontmatter(latest.current.frontmatter[blocksFieldRef.current]).map(
333
+ (block) => block.id,
334
+ );
335
+ const decision = editorGrabAction({
336
+ grabRef: grab.ref,
337
+ openRef: channelRef.current,
338
+ blockIds: ids,
339
+ grab,
340
+ });
341
+ if (decision.action === "add") {
342
+ addPointer({
343
+ locale: channelRef.current.locale,
344
+ type: channelRef.current.type,
345
+ slug: channelRef.current.slug,
346
+ blockId: decision.blockId,
347
+ blockType: decision.blockType,
348
+ blockIndex: decision.blockIndex,
349
+ quote: decision.quote,
350
+ });
351
+ }
352
+ if (decision.action === "open") {
353
+ blockFocusNonce.current += 1;
354
+ setBlockFocus({ id: decision.blockId, nonce: blockFocusNonce.current });
355
+ }
356
+ }
357
+
358
+ function onPreviewPoint(on: boolean) {
359
+ if (!on) {
360
+ window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on: false } }));
361
+ }
362
+ }
363
+
364
+ const previewHandlers = useRef({
365
+ hello: onPreviewHello,
366
+ grab: onPreviewGrab,
367
+ point: onPreviewPoint,
368
+ });
369
+ previewHandlers.current = {
370
+ hello: onPreviewHello,
371
+ grab: onPreviewGrab,
372
+ point: onPreviewPoint,
373
+ };
374
+
305
375
  useEffect(() => {
306
376
  const frame = previewFrameRef.current;
307
377
  if (!frame || !siteUrl) return;
@@ -318,6 +388,9 @@ export function DocEditorPage() {
318
388
  ref: () => channelRef.current,
319
389
  onOffer: setPreviewOffer,
320
390
  onStatus: setPreviewLive,
391
+ onHello: (hello, capabilities) => previewHandlers.current.hello(hello, capabilities),
392
+ onGrab: (grab) => previewHandlers.current.grab(grab),
393
+ onPoint: (on) => previewHandlers.current.point(on),
321
394
  });
322
395
  previewSession.current = session;
323
396
  if (editEpochRef.current > 0) session.setDoc(latest.current);
@@ -347,6 +420,9 @@ export function DocEditorPage() {
347
420
  ref: () => channelRef.current,
348
421
  onOffer: setPreviewOffer,
349
422
  onStatus: setPreviewLive,
423
+ onHello: (hello, capabilities) => previewHandlers.current.hello(hello, capabilities),
424
+ onGrab: (grab) => previewHandlers.current.grab(grab),
425
+ onPoint: (on) => previewHandlers.current.point(on),
350
426
  });
351
427
  previewSession.current = session;
352
428
  if (editEpochRef.current > 0) session.setDoc(latest.current);
@@ -366,6 +442,28 @@ export function DocEditorPage() {
366
442
  };
367
443
  }, [embed]);
368
444
 
445
+ useEffect(() => {
446
+ function onArm(event: Event) {
447
+ const on = (event as CustomEvent<{ on?: boolean }>).detail?.on === true;
448
+ previewSession.current?.point(on);
449
+ }
450
+ window.addEventListener(POINTER_ARM_EVENT, onArm);
451
+ return () => window.removeEventListener(POINTER_ARM_EVENT, onArm);
452
+ }, []);
453
+
454
+ useEffect(() => {
455
+ setPointerHighlight("channel");
456
+ const stop = registerPreviewHighlight((blockId) => {
457
+ previewSession.current?.highlight(blockId);
458
+ });
459
+ return () => {
460
+ stop();
461
+ setPointerHighlight("local");
462
+ setPreviewPointer(false);
463
+ previewSession.current?.point(false);
464
+ };
465
+ }, [setPointerHighlight, setPreviewPointer]);
466
+
369
467
  useEffect(() => {
370
468
  if (editEpoch === 0) return;
371
469
  previewSession.current?.setDoc(latest.current);
@@ -539,13 +637,12 @@ export function DocEditorPage() {
539
637
  setDocStatus(detail.status);
540
638
  setSource(detail.status === "clean" ? "head" : "tree");
541
639
  }
542
- // Branch HEAD moved (teammate push / pull / commit). A clean doc reloads to
543
- // show the new HEAD; a doc I am editing keeps my text and just refreshes its
544
- // status, which the server reports as `conflict` when HEAD touched this file.
640
+ // Branch HEAD moved (teammate push / pull / commit). A clean doc reloads its
641
+ // fields. The preview frame stays mounted: the page follows the same move
642
+ // on its SSE stream and refreshes in place.
545
643
  function onHeadChanged() {
546
644
  if (docStatusRef.current === "clean") {
547
645
  onChanged();
548
- remountPreview();
549
646
  return;
550
647
  }
551
648
  void fetchDoc(workspaceId, locale, type, slug)
@@ -1062,6 +1159,7 @@ export function DocEditorPage() {
1062
1159
  blocks={blocksFromFrontmatter(frontmatter[blocksField])}
1063
1160
  catalog={blocks}
1064
1161
  readOnly={aiEditing || parseFailed}
1162
+ focusBlock={blockFocus}
1065
1163
  onChange={(next) => {
1066
1164
  markEdited();
1067
1165
  setFrontmatter((prev) => ({ ...prev, [blocksField]: next }));
@@ -12,12 +12,17 @@ import {
12
12
  notifyTreeChanged,
13
13
  publishCommittedFiles,
14
14
  resolveDoc,
15
- type ApiError,
16
15
  type ChangeStatus,
17
16
  type GitChange,
18
17
  type GitState,
19
18
  } from "../api";
20
19
  import { statusBarAction } from "../chrome/status-bar-action";
20
+ import {
21
+ explainCommitError,
22
+ problemsForRef,
23
+ type CommitErrorView,
24
+ } from "../chrome/commit-error";
25
+ import { CommitFailureNotice } from "../chrome/commit-failure";
21
26
  import { colors, radius, space, type } from "../styles/tokens.stylex";
22
27
  import { popup } from "../ui/popup";
23
28
  import { canPublish } from "../workspace/permissions";
@@ -33,18 +38,6 @@ function parentOrigin(): string {
33
38
  }
34
39
  }
35
40
 
36
- function formatCommitError(err: unknown): string {
37
- if (!(err instanceof Error)) return "Commit failed";
38
- const apiErr = err as ApiError;
39
- if (apiErr.code === "conflict") return "Resolve conflicts before committing";
40
- if (apiErr.code === "nothing_to_commit") return "Nothing to commit";
41
- if (apiErr.code === "validation") {
42
- return err.message.replace(/^validation:\s*/, "Validation failed: ");
43
- }
44
- if (apiErr.code === "github_not_bound") return "Connect GitHub first";
45
- return err.message;
46
- }
47
-
48
41
  function changeKey(doc: GitChange) {
49
42
  return `${doc.locale}/${doc.type}/${doc.slug}`;
50
43
  }
@@ -72,6 +65,7 @@ export function EmbedPublishPage({
72
65
  const [role, setRole] = useState("viewer");
73
66
  const [message, setMessage] = useState("");
74
67
  const [error, setError] = useState<string | null>(null);
68
+ const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
75
69
  const [committing, setCommitting] = useState(false);
76
70
  const [resolvingKey, setResolvingKey] = useState<string | null>(null);
77
71
 
@@ -157,6 +151,7 @@ export function EmbedPublishPage({
157
151
  if (committing || action !== "commit" || changes.length === 0) return;
158
152
  setCommitting(true);
159
153
  setError(null);
154
+ setCommitFailure(null);
160
155
  try {
161
156
  const result = await commitWorkspace(workspaceId, message);
162
157
  setMessage("");
@@ -168,7 +163,7 @@ export function EmbedPublishPage({
168
163
  }
169
164
  setGit(await fetchGit(workspaceId));
170
165
  } catch (err) {
171
- setError(formatCommitError(err));
166
+ setCommitFailure(explainCommitError(err));
172
167
  } finally {
173
168
  setCommitting(false);
174
169
  }
@@ -225,18 +220,23 @@ export function EmbedPublishPage({
225
220
  ? "Nothing to publish"
226
221
  : `${changes.length} ${changes.length === 1 ? "file" : "files"} → ${branch}`}
227
222
  </p>
223
+ {commitFailure ? <CommitFailureNotice failure={commitFailure} /> : null}
228
224
  {changes.length > 0 ? (
229
225
  <ul {...stylex.props(styles.files)}>
230
- {changes.map((doc) => (
231
- <li key={changeKey(doc)}>
232
- <div {...stylex.props(styles.file)}>
233
- <span {...stylex.props(styles.fileName)}>
234
- {doc.type}/{doc.slug}
235
- </span>
236
- <span {...stylex.props(popup.itemMeta)}>{STATUS_LABEL[doc.status]}</span>
237
- </div>
238
- </li>
239
- ))}
226
+ {changes.map((doc) => {
227
+ const invalid =
228
+ problemsForRef(commitFailure, `${doc.type}/${doc.slug}`).length > 0;
229
+ return (
230
+ <li key={changeKey(doc)}>
231
+ <div {...stylex.props(styles.file)}>
232
+ <span {...stylex.props(styles.fileName, invalid && styles.invalidName)}>
233
+ {doc.type}/{doc.slug}
234
+ </span>
235
+ <span {...stylex.props(popup.itemMeta)}>{STATUS_LABEL[doc.status]}</span>
236
+ </div>
237
+ </li>
238
+ );
239
+ })}
240
240
  </ul>
241
241
  ) : null}
242
242
  {changes.length > 0 ? <div {...stylex.props(popup.separator)} /> : null}
@@ -311,6 +311,9 @@ const styles = stylex.create({
311
311
  textOverflow: "ellipsis",
312
312
  whiteSpace: "nowrap",
313
313
  },
314
+ invalidName: {
315
+ color: colors.danger,
316
+ },
314
317
  footer: {
315
318
  display: "flex",
316
319
  flexDirection: "column",