@lemonriver/core 0.1.0 → 0.2.0

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
  3. package/dist/cli/bin.js +4 -4
  4. package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
  5. package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
  6. package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +4 -3
  9. package/dist/locale/index.d.ts +5 -2
  10. package/dist/locale/index.js +2 -2
  11. package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
  12. package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
  13. package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
  14. package/dist/vite/index.d.ts +1 -1
  15. package/dist/vite/index.js +1 -1
  16. package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
  17. package/package.json +21 -15
  18. package/skills/apply-comments/SKILL.md +10 -6
  19. package/skills/create-slide/SKILL.md +2 -0
  20. package/skills/current-slide/SKILL.md +1 -0
  21. package/skills/slide-authoring/SKILL.md +31 -1
  22. package/src/agent/prompt.ts +141 -0
  23. package/src/agent/provider.ts +123 -0
  24. package/src/agent/types.ts +151 -0
  25. package/src/app/app.tsx +2 -0
  26. package/src/app/assets/open-slide.svg +39 -0
  27. package/src/app/components/agent/agent-dock.tsx +35 -0
  28. package/src/app/components/agent/agent-markdown.tsx +45 -0
  29. package/src/app/components/agent/agent-panel.tsx +692 -0
  30. package/src/app/components/agent/agent-presence.tsx +74 -0
  31. package/src/app/components/agent/composer.tsx +120 -0
  32. package/src/app/components/agent/connect-view.tsx +184 -0
  33. package/src/app/components/agent/history-view.tsx +135 -0
  34. package/src/app/components/agent/provider-logo.tsx +34 -0
  35. package/src/app/components/agent/transcript.tsx +609 -0
  36. package/src/app/components/board/board-canvas.tsx +143 -12
  37. package/src/app/components/board/board.css +51 -0
  38. package/src/app/components/board/layout.test.ts +53 -1
  39. package/src/app/components/board/layout.ts +50 -2
  40. package/src/app/components/board/page-node.tsx +287 -13
  41. package/src/app/components/board/use-board-cards.ts +210 -0
  42. package/src/app/components/board/use-page-order.ts +17 -9
  43. package/src/app/components/command/slide-command-menu.tsx +10 -0
  44. package/src/app/components/icon-switcher-indicator.tsx +1 -1
  45. package/src/app/components/inspector/comment-layer.tsx +1011 -0
  46. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  47. package/src/app/components/inspector/inspect-overlay.tsx +2 -39
  48. package/src/app/components/inspector/inspector-panel.tsx +10 -93
  49. package/src/app/components/inspector/inspector-provider.tsx +121 -47
  50. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  51. package/src/app/components/panel/panel-shell.tsx +2 -2
  52. package/src/app/components/panel/resize-handle.tsx +129 -0
  53. package/src/app/components/player.tsx +0 -1
  54. package/src/app/components/preview-step-host.tsx +16 -0
  55. package/src/app/components/sidebar/sidebar.tsx +2 -2
  56. package/src/app/components/style-panel/style-panel.tsx +3 -2
  57. package/src/app/components/thumbnail-rail.tsx +76 -1
  58. package/src/app/favicon.ico +0 -0
  59. package/src/app/favicon.svg +39 -0
  60. package/src/app/index.html +2 -1
  61. package/src/app/lib/agent/use-agent.ts +543 -0
  62. package/src/app/lib/inspector/freeze-motion.ts +61 -0
  63. package/src/app/lib/inspector/use-comments.ts +32 -3
  64. package/src/app/lib/locale-store.ts +6 -1
  65. package/src/app/lib/print-ready.ts +9 -6
  66. package/src/app/lib/use-auto-grow.ts +15 -0
  67. package/src/app/lib/utils.ts +4 -0
  68. package/src/app/routes/board.tsx +48 -11
  69. package/src/app/routes/home.tsx +43 -7
  70. package/src/app/routes/presenter.tsx +1 -16
  71. package/src/app/routes/preview.tsx +169 -0
  72. package/src/app/routes/slide.tsx +221 -173
  73. package/src/app/styles.css +234 -80
  74. package/src/editing/placeholder-id.ts +1 -0
  75. package/src/locale/en.ts +127 -5
  76. package/src/locale/es.ts +692 -0
  77. package/src/locale/index.ts +1 -0
  78. package/src/locale/ja.ts +120 -6
  79. package/src/locale/types.ts +132 -5
  80. package/src/locale/zh-cn.ts +117 -5
  81. package/src/locale/zh-tw.ts +117 -5
  82. package/src/app/assets/open-slide.png +0 -0
  83. package/src/app/components/inspector/comment-widget.tsx +0 -119
@@ -1,21 +1,33 @@
1
1
  import { Handle, type NodeProps, Position } from '@xyflow/react';
2
2
  import { Copy, MoveRight, Trash2 } from 'lucide-react';
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { memo, useEffect, useRef, useState } from 'react';
4
4
  import {
5
5
  ContextMenu,
6
6
  ContextMenuContent,
7
7
  ContextMenuItem,
8
+ ContextMenuRadioGroup,
9
+ ContextMenuRadioItem,
8
10
  ContextMenuSeparator,
11
+ ContextMenuSub,
12
+ ContextMenuSubContent,
13
+ ContextMenuSubTrigger,
9
14
  ContextMenuTrigger,
10
15
  } from '@/components/ui/context-menu';
11
16
  import { format, useLocale } from '@/lib/use-locale';
12
17
  import { cn, pad2 } from '@/lib/utils';
18
+ import {
19
+ type BoardCard,
20
+ CARD_STATUSES,
21
+ type CardPatch,
22
+ type CardStatus,
23
+ isCardStatus,
24
+ } from '../../../files/board-cards';
13
25
  import type { DesignSystem } from '../../lib/design';
14
26
  import { useNotes } from '../../lib/inspector/use-notes';
15
27
  import { SlidePageProvider } from '../../lib/page-context';
16
28
  import { CANVAS_WIDTH, type Page } from '../../lib/sdk';
17
29
  import { SlideCanvas } from '../slide-canvas';
18
- import { CARD_H, CARD_W, NOTE_H } from './layout';
30
+ import { CARD_H, CARD_W, MEMO_H, NOTE_H } from './layout';
19
31
 
20
32
  export type PageNodeData = {
21
33
  slideId: string;
@@ -23,15 +35,38 @@ export type PageNodeData = {
23
35
  index: number;
24
36
  total: number;
25
37
  note?: string;
38
+ card?: BoardCard;
39
+ /** False until the board's own data has loaded, so nothing can be written over it. */
40
+ cardsReady: boolean;
41
+ /** Whether a link can be drawn from this card right now. */
42
+ canLink: boolean;
26
43
  design?: DesignSystem;
27
44
  canDelete: boolean;
28
45
  onDuplicate: (index: number) => void;
29
46
  onDelete: (index: number) => void;
30
47
  onMoveToEnd: (index: number) => void;
48
+ onPatchCard: (index: number, patch: CardPatch) => void;
31
49
  };
32
50
 
33
51
  const hiddenHandle = { opacity: 0, width: 1, height: 1, minWidth: 1, minHeight: 1 } as const;
34
52
 
53
+ const STATUS_STYLE: Record<CardStatus, { dot: string; ring: string }> = {
54
+ todo: { dot: 'bg-muted-foreground/50', ring: 'ring-muted-foreground/35' },
55
+ doing: { dot: 'bg-blue-500', ring: 'ring-blue-500/70' },
56
+ review: { dot: 'bg-amber-500', ring: 'ring-amber-500/75' },
57
+ done: { dot: 'bg-emerald-500', ring: 'ring-emerald-500/70' },
58
+ };
59
+
60
+ export function useStatusLabels(): Record<CardStatus, string> {
61
+ const t = useLocale();
62
+ return {
63
+ todo: t.board.statusTodo,
64
+ doing: t.board.statusDoing,
65
+ review: t.board.statusReview,
66
+ done: t.board.statusDone,
67
+ };
68
+ }
69
+
35
70
  export function PageNode({ data, selected, dragging }: NodeProps) {
36
71
  const {
37
72
  slideId,
@@ -39,18 +74,31 @@ export function PageNode({ data, selected, dragging }: NodeProps) {
39
74
  index,
40
75
  total,
41
76
  note,
77
+ card,
78
+ cardsReady,
79
+ canLink,
42
80
  design,
43
81
  canDelete,
44
82
  ...actions
45
83
  } = data as unknown as PageNodeData;
46
84
  const t = useLocale();
85
+ const labels = useStatusLabels();
86
+ const status = card?.status;
87
+ const [renaming, setRenaming] = useState(false);
88
+ // A menu hands focus back to the card as it closes, which would pull the caret
89
+ // straight out of the name field "Rename" just opened.
90
+ const renameFromMenu = useRef(false);
47
91
 
48
- const card = (
92
+ const view = (
49
93
  <div
50
94
  className={cn(
51
- 'osd-board-card group/card flex flex-col gap-2 rounded-[8px] bg-card p-1.5 ring-1',
95
+ 'osd-board-card group/card flex flex-col gap-2 rounded-[8px] bg-card p-1.5',
52
96
  'motion-safe:transition-[box-shadow,ring-color,scale]',
53
- selected ? 'ring-2 ring-brand' : 'ring-hairline hover:ring-muted-foreground/30',
97
+ selected
98
+ ? 'ring-2 ring-brand'
99
+ : status
100
+ ? cn('ring-2', STATUS_STYLE[status].ring)
101
+ : 'ring-1 ring-hairline hover:ring-muted-foreground/30',
54
102
  dragging ? 'cursor-grabbing' : 'cursor-grab',
55
103
  )}
56
104
  style={{ width: CARD_W + 12 }}
@@ -59,18 +107,32 @@ export function PageNode({ data, selected, dragging }: NodeProps) {
59
107
  className="relative overflow-hidden rounded-[4px] bg-white ring-1 ring-hairline"
60
108
  style={{ width: CARD_W, height: CARD_H }}
61
109
  >
62
- <SlideCanvas scale={CARD_W / CANVAS_WIDTH} center={false} flat freezeMotion design={design}>
63
- <SlidePageProvider index={index} total={total}>
64
- <PageComp />
65
- </SlidePageProvider>
66
- </SlideCanvas>
110
+ <PagePreview page={PageComp} index={index} total={total} design={design} />
67
111
  </div>
68
- <div className="flex h-4 items-center justify-between px-0.5">
112
+ <div className="flex h-6 items-center gap-2 px-0.5">
69
113
  <span className="font-mono text-[10.5px] tracking-[0.08em] tabular-nums text-muted-foreground/70">
70
114
  {pad2(index + 1)}
71
115
  </span>
116
+ <CardTitle
117
+ title={card?.title}
118
+ editing={renaming}
119
+ disabled={!cardsReady}
120
+ onEditingChange={setRenaming}
121
+ onCommit={(title) => actions.onPatchCard(index, { title })}
122
+ />
123
+ {status && (
124
+ <span className="ml-auto flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
125
+ <span className={cn('size-2 rounded-full', STATUS_STYLE[status].dot)} />
126
+ {labels[status]}
127
+ </span>
128
+ )}
72
129
  </div>
73
130
  <NoteStrip slideId={slideId} index={index} initial={note} />
131
+ <MemoStrip
132
+ memo={card?.memo}
133
+ disabled={!cardsReady}
134
+ onCommit={(memo) => actions.onPatchCard(index, { memo })}
135
+ />
74
136
  </div>
75
137
  );
76
138
 
@@ -87,9 +149,47 @@ export function PageNode({ data, selected, dragging }: NodeProps) {
87
149
  <ContextMenu>
88
150
  <ContextMenuTrigger
89
151
  aria-label={format(t.thumbnailRail.pageActionsAria, { n: index + 1 })}
90
- render={card}
152
+ render={view}
91
153
  />
92
- <ContextMenuContent className="min-w-[180px]">
154
+ <ContextMenuContent
155
+ className="min-w-[180px]"
156
+ finalFocus={() => {
157
+ const keep = !renameFromMenu.current;
158
+ renameFromMenu.current = false;
159
+ return keep;
160
+ }}
161
+ >
162
+ <ContextMenuItem
163
+ disabled={!cardsReady}
164
+ onClick={() => {
165
+ renameFromMenu.current = true;
166
+ setRenaming(true);
167
+ }}
168
+ >
169
+ {t.board.renameCard}
170
+ </ContextMenuItem>
171
+ <ContextMenuSub>
172
+ <ContextMenuSubTrigger disabled={!cardsReady}>{t.board.status}</ContextMenuSubTrigger>
173
+ <ContextMenuSubContent className="min-w-[150px]">
174
+ <ContextMenuRadioGroup
175
+ value={status ?? ''}
176
+ onValueChange={(value: unknown) =>
177
+ actions.onPatchCard(index, { status: isCardStatus(value) ? value : null })
178
+ }
179
+ >
180
+ <ContextMenuRadioItem value="" closeOnClick>
181
+ {t.board.statusNone}
182
+ </ContextMenuRadioItem>
183
+ {CARD_STATUSES.map((value) => (
184
+ <ContextMenuRadioItem key={value} value={value} closeOnClick>
185
+ <span className={cn('size-2 rounded-full', STATUS_STYLE[value].dot)} />
186
+ {labels[value]}
187
+ </ContextMenuRadioItem>
188
+ ))}
189
+ </ContextMenuRadioGroup>
190
+ </ContextMenuSubContent>
191
+ </ContextMenuSub>
192
+ <ContextMenuSeparator />
93
193
  <ContextMenuItem onClick={() => actions.onDuplicate(index)}>
94
194
  <Copy />
95
195
  {t.thumbnailRail.duplicatePage}
@@ -122,10 +222,112 @@ export function PageNode({ data, selected, dragging }: NodeProps) {
122
222
  style={hiddenHandle}
123
223
  isConnectable={false}
124
224
  />
225
+ <Handle
226
+ type="source"
227
+ id="link"
228
+ position={Position.Right}
229
+ title={t.board.linkHandle}
230
+ className={cn('osd-board-link-handle', !canLink && 'hidden')}
231
+ isConnectableStart={canLink}
232
+ isConnectableEnd={false}
233
+ />
125
234
  </>
126
235
  );
127
236
  }
128
237
 
238
+ /**
239
+ * The live page, kept apart so editing a card's title, status or memo does not
240
+ * re-render a page that can carry thousands of elements.
241
+ */
242
+ const PagePreview = memo(function PagePreview({
243
+ page: PageComp,
244
+ index,
245
+ total,
246
+ design,
247
+ }: {
248
+ page: Page;
249
+ index: number;
250
+ total: number;
251
+ design?: DesignSystem;
252
+ }) {
253
+ return (
254
+ <SlideCanvas scale={CARD_W / CANVAS_WIDTH} center={false} flat freezeMotion design={design}>
255
+ <SlidePageProvider index={index} total={total}>
256
+ <PageComp />
257
+ </SlidePageProvider>
258
+ </SlideCanvas>
259
+ );
260
+ });
261
+
262
+ /** The card's own name, which the deck never sees. Empty until someone names it. */
263
+ function CardTitle({
264
+ title,
265
+ editing,
266
+ disabled,
267
+ onEditingChange,
268
+ onCommit,
269
+ }: {
270
+ title?: string;
271
+ editing: boolean;
272
+ disabled: boolean;
273
+ onEditingChange: (editing: boolean) => void;
274
+ onCommit: (title: string) => void;
275
+ }) {
276
+ const t = useLocale();
277
+ const ref = useRef<HTMLInputElement | null>(null);
278
+
279
+ // Opened from the context menu, the field mounts while the menu is still
280
+ // handing focus back; waiting a frame keeps the caret here.
281
+ useEffect(() => {
282
+ if (!editing) return;
283
+ const frame = requestAnimationFrame(() => ref.current?.focus());
284
+ return () => cancelAnimationFrame(frame);
285
+ }, [editing]);
286
+
287
+ if (editing) {
288
+ return (
289
+ <input
290
+ ref={ref}
291
+ defaultValue={title ?? ''}
292
+ aria-label={t.board.renameCard}
293
+ placeholder={t.board.cardTitlePlaceholder}
294
+ onKeyDown={(e) => {
295
+ e.stopPropagation();
296
+ if (e.key === 'Enter') e.currentTarget.blur();
297
+ if (e.key === 'Escape') {
298
+ e.currentTarget.value = title ?? '';
299
+ e.currentTarget.blur();
300
+ }
301
+ }}
302
+ onBlur={(e) => {
303
+ onEditingChange(false);
304
+ const next = e.currentTarget.value.trim();
305
+ if (next !== (title ?? '')) onCommit(next);
306
+ }}
307
+ className="nodrag nopan min-w-0 flex-1 rounded-[4px] bg-muted/60 px-1.5 py-0.5 text-[12.5px] font-medium text-foreground outline-none ring-1 ring-brand/40"
308
+ />
309
+ );
310
+ }
311
+
312
+ return (
313
+ <button
314
+ type="button"
315
+ disabled={disabled}
316
+ onClick={() => onEditingChange(true)}
317
+ aria-label={t.board.renameCard}
318
+ className={cn(
319
+ 'nodrag nopan min-w-0 truncate rounded-[4px] px-1.5 py-0.5 text-left text-[12.5px]',
320
+ 'motion-safe:transition-[background-color,opacity] hover:bg-muted/60 disabled:pointer-events-none',
321
+ title
322
+ ? 'font-medium text-foreground'
323
+ : 'text-muted-foreground/45 opacity-0 group-hover/card:opacity-100',
324
+ )}
325
+ >
326
+ {title || t.board.cardTitlePlaceholder}
327
+ </button>
328
+ );
329
+ }
330
+
129
331
  /**
130
332
  * The deck's own speaker notes, edited on the card. Writes go through the same
131
333
  * debounced route the notes drawer uses, so the board and the editor never hold
@@ -202,3 +404,75 @@ function NoteStrip({
202
404
  </div>
203
405
  );
204
406
  }
407
+
408
+ /**
409
+ * A memo for whoever is building the deck — "Q3 numbers still missing". It is
410
+ * board data, so it never reaches the deck, the presenter view or an export,
411
+ * and it is tinted so it cannot be mistaken for the script above it.
412
+ */
413
+ function MemoStrip({
414
+ memo,
415
+ disabled,
416
+ onCommit,
417
+ }: {
418
+ memo?: string;
419
+ disabled: boolean;
420
+ onCommit: (memo: string) => void;
421
+ }) {
422
+ const t = useLocale();
423
+ const [draft, setDraft] = useState<string | null>(null);
424
+ const ref = useRef<HTMLTextAreaElement | null>(null);
425
+ const editing = draft !== null;
426
+
427
+ useEffect(() => {
428
+ if (editing) ref.current?.focus();
429
+ }, [editing]);
430
+
431
+ const shared = 'w-full rounded-[4px] px-1.5 py-1 text-left text-[11px] leading-[1.45]';
432
+
433
+ return (
434
+ <div className="nodrag nopan" style={{ height: MEMO_H }}>
435
+ {editing ? (
436
+ <textarea
437
+ ref={ref}
438
+ value={draft}
439
+ aria-label={t.board.memoPlaceholder}
440
+ onChange={(e) => setDraft(e.target.value)}
441
+ onBlur={() => {
442
+ const next = draft.trim();
443
+ setDraft(null);
444
+ if (next !== (memo ?? '')) onCommit(next);
445
+ }}
446
+ onKeyDown={(e) => {
447
+ e.stopPropagation();
448
+ if (e.key === 'Escape') e.currentTarget.blur();
449
+ }}
450
+ placeholder={t.board.memoPlaceholder}
451
+ className={cn(
452
+ shared,
453
+ 'nowheel h-full resize-none bg-amber-500/10 text-foreground outline-none',
454
+ 'ring-1 ring-amber-500/50 placeholder:text-muted-foreground/50',
455
+ )}
456
+ />
457
+ ) : (
458
+ <button
459
+ type="button"
460
+ disabled={disabled}
461
+ onClick={() => setDraft(memo ?? '')}
462
+ className={cn(
463
+ shared,
464
+ 'h-full overflow-hidden disabled:pointer-events-none',
465
+ 'motion-safe:transition-[background-color,opacity]',
466
+ memo
467
+ ? 'bg-amber-500/10 text-amber-900 hover:bg-amber-500/15 dark:text-amber-200'
468
+ : 'text-muted-foreground/45 opacity-0 hover:bg-amber-500/10 group-hover/card:opacity-100',
469
+ )}
470
+ >
471
+ <span className="line-clamp-2 whitespace-pre-wrap">
472
+ {memo || t.board.memoPlaceholder}
473
+ </span>
474
+ </button>
475
+ )}
476
+ </div>
477
+ );
478
+ }
@@ -0,0 +1,210 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { useLocale } from '@/lib/use-locale';
4
+ import {
5
+ adoptEntries,
6
+ type BoardCard,
7
+ type BoardCards,
8
+ type BoardLink,
9
+ type CardPatch,
10
+ carryCards,
11
+ entriesAfter,
12
+ isSettledKey,
13
+ linkCards,
14
+ linkedPairs,
15
+ originAfter,
16
+ type PageOp,
17
+ pageKeys,
18
+ patchCard,
19
+ sanitizeCards,
20
+ sanitizeLinks,
21
+ unlinkCards,
22
+ } from '../../../files/board-cards';
23
+ import type { Page } from '../../lib/sdk';
24
+
25
+ type BoardState = {
26
+ cards: BoardCards;
27
+ links: BoardLink[];
28
+ /** `undefined` until read, `null` when the deck is keyed by position. */
29
+ entries: string[] | null | undefined;
30
+ loaded: boolean;
31
+ };
32
+
33
+ const INITIAL: BoardState = { cards: {}, links: [], entries: undefined, loaded: false };
34
+
35
+ const countAfter = (op: PageOp, count: number) =>
36
+ op.kind === 'move' ? count : op.kind === 'delete' ? count - 1 : count + 1;
37
+
38
+ /**
39
+ * Titles, statuses, work memos and links: board-only data that never reaches
40
+ * the deck. Each card is keyed by its page's entry in the deck source, so the
41
+ * data stays on its page however and wherever the deck is reordered.
42
+ */
43
+ export function useBoardCards(slideId: string, modulePages: Page[], count: number) {
44
+ const t = useLocale();
45
+ const [board, setBoard] = useState<BoardState>(INITIAL);
46
+ const boardRef = useRef(board);
47
+ const countRef = useRef(count);
48
+ countRef.current = count;
49
+ const saveQueue = useRef<Promise<void>>(Promise.resolve());
50
+ const opsInFlight = useRef(0);
51
+ const entrySeq = useRef(0);
52
+
53
+ const save = useCallback(
54
+ (next: BoardState) => {
55
+ const body = JSON.stringify({ slideId, cards: next.cards, links: next.links });
56
+ // One write at a time, so a slow earlier save never lands after a later one.
57
+ saveQueue.current = saveQueue.current.then(async () => {
58
+ try {
59
+ const res = await fetch('/__board/cards', {
60
+ method: 'PUT',
61
+ headers: { 'content-type': 'application/json' },
62
+ body,
63
+ });
64
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
65
+ } catch (err) {
66
+ toast.error(`${t.board.saveFailed}: ${String((err as Error).message ?? err)}`);
67
+ }
68
+ });
69
+ },
70
+ [slideId, t.board],
71
+ );
72
+
73
+ const commit = useCallback(
74
+ (next: BoardState, persist: boolean) => {
75
+ boardRef.current = next;
76
+ setBoard(next);
77
+ if (persist && next.loaded) save(next);
78
+ },
79
+ [save],
80
+ );
81
+
82
+ useEffect(() => {
83
+ let cancelled = false;
84
+ commit(INITIAL, false);
85
+ (async () => {
86
+ try {
87
+ const res = await fetch(`/__board/cards?slideId=${encodeURIComponent(slideId)}`);
88
+ if (!res.ok || cancelled) return;
89
+ const body = (await res.json()) as { cards?: unknown; links?: unknown };
90
+ if (cancelled) return;
91
+ commit(
92
+ {
93
+ ...boardRef.current,
94
+ cards: sanitizeCards(body.cards),
95
+ links: sanitizeLinks(body.links),
96
+ loaded: true,
97
+ },
98
+ false,
99
+ );
100
+ } catch {
101
+ // Left unloaded, so nothing written from here can overwrite cards that
102
+ // were never read.
103
+ }
104
+ })();
105
+ return () => {
106
+ cancelled = true;
107
+ };
108
+ }, [slideId, commit]);
109
+
110
+ // An answer read while a page operation is still being written describes a
111
+ // deck that is about to change again, so it is dropped; the operation reads
112
+ // the entries afresh once it settles.
113
+ const refreshEntries = useCallback(async () => {
114
+ if (opsInFlight.current > 0) return;
115
+ const seq = ++entrySeq.current;
116
+ let fetched: string[] | null;
117
+ try {
118
+ const res = await fetch(`/__board/entries?slideId=${encodeURIComponent(slideId)}`);
119
+ if (!res.ok) return;
120
+ fetched = ((await res.json()) as { entries: string[] | null }).entries;
121
+ } catch {
122
+ return;
123
+ }
124
+ if (seq !== entrySeq.current || opsInFlight.current > 0) return;
125
+ const current = boardRef.current;
126
+ const adopted = adoptEntries(current, current.entries, fetched);
127
+ commit({ ...current, ...adopted, entries: fetched }, adopted !== current);
128
+ }, [slideId, commit]);
129
+
130
+ // The deck module reloads after every write to its source, ours or anyone else's.
131
+ useEffect(() => {
132
+ if (modulePages.length > 0) void refreshEntries();
133
+ }, [modulePages, refreshEntries]);
134
+
135
+ /**
136
+ * Moves the board's data along with a page operation, before the deck itself
137
+ * changes. Call the returned function once the deck write settles; a refused
138
+ * write puts the cards back as they were.
139
+ */
140
+ const beginOp = useCallback(
141
+ (op: PageOp) => {
142
+ const snapshot = boardRef.current;
143
+ const count = countRef.current;
144
+ opsInFlight.current += 1;
145
+ entrySeq.current += 1;
146
+
147
+ const before = pageKeys(snapshot.entries, count);
148
+ const entries = entriesAfter(op, snapshot.entries, count);
149
+ if (before.every(isSettledKey)) {
150
+ const after = pageKeys(entries, countAfter(op, count));
151
+ const carried = carryCards(snapshot, before, after, originAfter(op, count));
152
+ commit({ ...snapshot, ...carried, entries }, true);
153
+ } else {
154
+ commit({ ...snapshot, entries }, false);
155
+ }
156
+
157
+ return (ok: boolean) => {
158
+ opsInFlight.current -= 1;
159
+ if (!ok) {
160
+ commit({ ...boardRef.current, cards: snapshot.cards, links: snapshot.links }, true);
161
+ }
162
+ void refreshEntries();
163
+ };
164
+ },
165
+ [commit, refreshEntries],
166
+ );
167
+
168
+ const keyAt = useCallback((index: number): string | null => {
169
+ const current = boardRef.current;
170
+ const key = pageKeys(current.entries, countRef.current)[index];
171
+ return current.loaded && key && isSettledKey(key) ? key : null;
172
+ }, []);
173
+
174
+ const patch = useCallback(
175
+ (index: number, change: CardPatch) => {
176
+ const key = keyAt(index);
177
+ if (!key) return;
178
+ const current = boardRef.current;
179
+ commit({ ...current, cards: patchCard(current.cards, key, change) }, true);
180
+ },
181
+ [keyAt, commit],
182
+ );
183
+
184
+ const setLink = useCallback(
185
+ (a: number, b: number, linked: boolean) => {
186
+ const from = keyAt(a);
187
+ const to = keyAt(b);
188
+ if (!from || !to) return;
189
+ const current = boardRef.current;
190
+ const links = linked
191
+ ? linkCards(current.links, from, to)
192
+ : unlinkCards(current.links, from, to);
193
+ if (links !== current.links) commit({ ...current, links }, true);
194
+ },
195
+ [keyAt, commit],
196
+ );
197
+
198
+ const link = useCallback((a: number, b: number) => setLink(a, b, true), [setLink]);
199
+ const unlink = useCallback((a: number, b: number) => setLink(a, b, false), [setLink]);
200
+
201
+ const keys = useMemo(() => pageKeys(board.entries, count), [board.entries, count]);
202
+ const ready = board.loaded && keys.every(isSettledKey);
203
+ const cards = useMemo<(BoardCard | undefined)[]>(
204
+ () => keys.map((key) => (isSettledKey(key) ? board.cards[key] : undefined)),
205
+ [keys, board.cards],
206
+ );
207
+ const pairs = useMemo(() => linkedPairs(keys, board.links), [keys, board.links]);
208
+
209
+ return { ready, cards, pairs, patch, link, unlink, beginOp };
210
+ }
@@ -25,10 +25,10 @@ export function usePageOrder(slideId: string, modulePages: Page[]) {
25
25
  }, []);
26
26
 
27
27
  const reorder = useCallback(
28
- async (from: number, to: number) => {
29
- if (from === to || from < 0 || to < 0) return;
28
+ async (from: number, to: number): Promise<boolean> => {
29
+ if (from === to || from < 0 || to < 0) return false;
30
30
  const before = pages;
31
- if (from >= before.length || to >= before.length) return;
31
+ if (from >= before.length || to >= before.length) return false;
32
32
 
33
33
  const next = [...before];
34
34
  const [moved] = next.splice(from, 1);
@@ -45,18 +45,20 @@ export function usePageOrder(slideId: string, modulePages: Page[]) {
45
45
  headers: { 'content-type': 'application/json' },
46
46
  body: JSON.stringify({ order }),
47
47
  });
48
+ return true;
48
49
  } catch (err) {
49
50
  setPages(before);
50
51
  toast.error(`${t.board.reorderFailed}: ${String((err as Error).message ?? err)}`);
52
+ return false;
51
53
  }
52
54
  },
53
55
  [pages, slideId, send, t.board],
54
56
  );
55
57
 
56
58
  const duplicate = useCallback(
57
- async (i: number) => {
59
+ async (i: number): Promise<boolean> => {
58
60
  const before = pages;
59
- if (i < 0 || i >= before.length) return;
61
+ if (i < 0 || i >= before.length) return false;
60
62
  const next = [...before];
61
63
  next.splice(i + 1, 0, before[i]);
62
64
  setPages(next);
@@ -65,20 +67,22 @@ export function usePageOrder(slideId: string, modulePages: Page[]) {
65
67
  method: 'POST',
66
68
  });
67
69
  toast.success(format(t.thumbnailRail.toastDuplicated, { n: i + 1 }));
70
+ return true;
68
71
  } catch (err) {
69
72
  setPages(before);
70
73
  toast.error(
71
74
  `${t.thumbnailRail.toastDuplicateFailed}: ${String((err as Error).message ?? err)}`,
72
75
  );
76
+ return false;
73
77
  }
74
78
  },
75
79
  [pages, slideId, send, t.thumbnailRail],
76
80
  );
77
81
 
78
82
  const insert = useCallback(
79
- async (i: number) => {
83
+ async (i: number): Promise<boolean> => {
80
84
  const before = pages;
81
- if (i < 0 || i > before.length) return;
85
+ if (i < 0 || i > before.length) return false;
82
86
  // The blank page has no component here yet; the server writes one and the
83
87
  // deck reloads through HMR, so the local list only has to hold its slot.
84
88
  const next = [...before];
@@ -89,27 +93,31 @@ export function usePageOrder(slideId: string, modulePages: Page[]) {
89
93
  method: 'POST',
90
94
  });
91
95
  toast.success(format(t.board.toastInserted, { n: i + 1 }));
96
+ return true;
92
97
  } catch (err) {
93
98
  setPages(before);
94
99
  toast.error(`${t.board.insertFailed}: ${String((err as Error).message ?? err)}`);
100
+ return false;
95
101
  }
96
102
  },
97
103
  [pages, slideId, send, t.board],
98
104
  );
99
105
 
100
106
  const remove = useCallback(
101
- async (i: number) => {
107
+ async (i: number): Promise<boolean> => {
102
108
  const before = pages;
103
- if (i < 0 || i >= before.length || before.length <= 1) return;
109
+ if (i < 0 || i >= before.length || before.length <= 1) return false;
104
110
  setPages(before.slice(0, i).concat(before.slice(i + 1)));
105
111
  try {
106
112
  await send(`/__slides/${encodeURIComponent(slideId)}/pages/${i}`, { method: 'DELETE' });
107
113
  toast.success(format(t.thumbnailRail.toastDeleted, { n: i + 1 }));
114
+ return true;
108
115
  } catch (err) {
109
116
  setPages(before);
110
117
  toast.error(
111
118
  `${t.thumbnailRail.toastDeleteFailed}: ${String((err as Error).message ?? err)}`,
112
119
  );
120
+ return false;
113
121
  }
114
122
  },
115
123
  [pages, slideId, send, t.thumbnailRail],