@open-slide/core 1.1.0 → 1.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 (31) hide show
  1. package/dist/{build-DSqSio-T.js → build-6BeQ3cxb.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-KdiYeWtK.js → config-AxZ5OE1u.js} +673 -211
  4. package/dist/{config-C7vMYzFD.d.ts → config-CtT8K4VF.d.ts} +1 -1
  5. package/dist/{dev-B_GVbr11.js → dev-C9eLmUEq.js} +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/locale/index.d.ts +1 -1
  8. package/dist/locale/index.js +96 -20
  9. package/dist/{preview-D_mxhj7w.js → preview-Cunm-f4i.js} +1 -1
  10. package/dist/{types-DYgVpIGo.d.ts → types-CRHIeoNq.d.ts} +28 -4
  11. package/dist/vite/index.d.ts +2 -2
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +1 -1
  14. package/skills/current-slide/SKILL.md +110 -0
  15. package/skills/slide-authoring/SKILL.md +48 -1
  16. package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
  17. package/src/app/components/inspector/inspector-panel.tsx +44 -13
  18. package/src/app/components/inspector/inspector-provider.tsx +60 -7
  19. package/src/app/components/notes-drawer.tsx +117 -0
  20. package/src/app/components/player.tsx +11 -7
  21. package/src/app/components/present/overview-grid.tsx +2 -2
  22. package/src/app/components/thumbnail-rail.tsx +119 -24
  23. package/src/app/components/ui/context-menu.tsx +237 -0
  24. package/src/app/lib/inspector/use-notes.ts +134 -0
  25. package/src/app/routes/home.tsx +34 -12
  26. package/src/app/routes/slide.tsx +209 -74
  27. package/src/locale/en.ts +26 -4
  28. package/src/locale/ja.ts +26 -4
  29. package/src/locale/types.ts +29 -4
  30. package/src/locale/zh-cn.ts +26 -4
  31. package/src/locale/zh-tw.ts +26 -4
@@ -2,6 +2,7 @@ import {
2
2
  closestCenter,
3
3
  DndContext,
4
4
  type DragEndEvent,
5
+ type DragStartEvent,
5
6
  KeyboardSensor,
6
7
  PointerSensor,
7
8
  useSensor,
@@ -14,7 +15,15 @@ import {
14
15
  verticalListSortingStrategy,
15
16
  } from '@dnd-kit/sortable';
16
17
  import { CSS } from '@dnd-kit/utilities';
17
- import { useEffect, useRef } from 'react';
18
+ import { Copy, Trash2 } from 'lucide-react';
19
+ import { Fragment, useEffect, useRef } from 'react';
20
+ import {
21
+ ContextMenu,
22
+ ContextMenuContent,
23
+ ContextMenuItem,
24
+ ContextMenuSeparator,
25
+ ContextMenuTrigger,
26
+ } from '@/components/ui/context-menu';
18
27
  import { ScrollArea } from '@/components/ui/scroll-area';
19
28
  import { format, useLocale } from '@/lib/use-locale';
20
29
  import { cn } from '@/lib/utils';
@@ -25,12 +34,18 @@ import { SlideCanvas } from './slide-canvas';
25
34
 
26
35
  type Orientation = 'vertical' | 'horizontal';
27
36
 
37
+ export type ThumbnailActions = {
38
+ onDuplicate: (index: number) => void;
39
+ onDelete: (index: number) => void;
40
+ };
41
+
28
42
  type Props = {
29
43
  pages: Page[];
30
44
  design?: DesignSystem;
31
45
  current: number;
32
46
  onSelect: (index: number) => void;
33
47
  onReorder?: (from: number, to: number) => void;
48
+ actions?: ThumbnailActions;
34
49
  orientation?: Orientation;
35
50
  };
36
51
 
@@ -43,6 +58,7 @@ export function ThumbnailRail({
43
58
  current,
44
59
  onSelect,
45
60
  onReorder,
61
+ actions,
46
62
  orientation = 'vertical',
47
63
  }: Props) {
48
64
  const activeRef = useRef<HTMLButtonElement | null>(null);
@@ -68,7 +84,7 @@ export function ThumbnailRail({
68
84
  <div className="flex items-center gap-2 px-3 py-2.5">
69
85
  {pages.map((PageComp, i) => {
70
86
  const active = i === current;
71
- return (
87
+ const button = (
72
88
  <button
73
89
  // biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
74
90
  key={i}
@@ -102,6 +118,19 @@ export function ThumbnailRail({
102
118
  </div>
103
119
  </button>
104
120
  );
121
+ if (!actions) return button;
122
+ return (
123
+ <ThumbContextMenu
124
+ // biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
125
+ key={i}
126
+ index={i}
127
+ actions={actions}
128
+ pageCount={pages.length}
129
+ ariaLabel={format(t.thumbnailRail.pageActionsAria, { n: i + 1 })}
130
+ >
131
+ {button}
132
+ </ThumbContextMenu>
133
+ );
105
134
  })}
106
135
  </div>
107
136
  </div>
@@ -125,24 +154,18 @@ export function ThumbnailRail({
125
154
  />
126
155
  );
127
156
 
128
- if (onReorder) {
129
- return (
130
- <SortableThumb
131
- key={i}
132
- index={i}
133
- active={active}
134
- activeRef={active ? activeRef : undefined}
135
- onSelect={() => onSelect(i)}
136
- ariaLabel={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
137
- >
138
- {inner}
139
- </SortableThumb>
140
- );
141
- }
142
-
143
- return (
157
+ const node = onReorder ? (
158
+ <SortableThumb
159
+ index={i}
160
+ active={active}
161
+ activeRef={active ? activeRef : undefined}
162
+ onSelect={() => onSelect(i)}
163
+ ariaLabel={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
164
+ >
165
+ {inner}
166
+ </SortableThumb>
167
+ ) : (
144
168
  <button
145
- key={i}
146
169
  type="button"
147
170
  ref={active ? activeRef : undefined}
148
171
  onClick={() => onSelect(i)}
@@ -153,6 +176,21 @@ export function ThumbnailRail({
153
176
  {inner}
154
177
  </button>
155
178
  );
179
+
180
+ if (!actions) {
181
+ return <Fragment key={i}>{node}</Fragment>;
182
+ }
183
+ return (
184
+ <ThumbContextMenu
185
+ key={i}
186
+ index={i}
187
+ actions={actions}
188
+ pageCount={pages.length}
189
+ ariaLabel={format(t.thumbnailRail.pageActionsAria, { n: i + 1 })}
190
+ >
191
+ {node}
192
+ </ThumbContextMenu>
193
+ );
156
194
  };
157
195
 
158
196
  const list = (
@@ -171,7 +209,7 @@ export function ThumbnailRail({
171
209
 
172
210
  return (
173
211
  <ScrollArea className="h-full border-r border-hairline bg-sidebar">
174
- <SortableRail pages={pages} onReorder={onReorder}>
212
+ <SortableRail pages={pages} onReorder={onReorder} onSelect={onSelect}>
175
213
  {list}
176
214
  </SortableRail>
177
215
  </ScrollArea>
@@ -234,13 +272,56 @@ function ThumbContents({
234
272
  );
235
273
  }
236
274
 
275
+ function ThumbContextMenu({
276
+ index,
277
+ actions,
278
+ pageCount,
279
+ ariaLabel,
280
+ children,
281
+ }: {
282
+ index: number;
283
+ actions: ThumbnailActions;
284
+ pageCount: number;
285
+ ariaLabel: string;
286
+ children: React.ReactNode;
287
+ }) {
288
+ const t = useLocale();
289
+ const canDelete = pageCount > 1;
290
+ return (
291
+ <ContextMenu>
292
+ <ContextMenuTrigger asChild aria-label={ariaLabel}>
293
+ {children}
294
+ </ContextMenuTrigger>
295
+ <ContextMenuContent className="min-w-[180px]">
296
+ <ContextMenuItem onSelect={() => actions.onDuplicate(index)}>
297
+ <Copy />
298
+ {t.thumbnailRail.duplicatePage}
299
+ </ContextMenuItem>
300
+ <ContextMenuSeparator />
301
+ <ContextMenuItem
302
+ variant="destructive"
303
+ disabled={!canDelete}
304
+ onSelect={() => {
305
+ if (canDelete) actions.onDelete(index);
306
+ }}
307
+ >
308
+ <Trash2 />
309
+ {t.thumbnailRail.deletePage}
310
+ </ContextMenuItem>
311
+ </ContextMenuContent>
312
+ </ContextMenu>
313
+ );
314
+ }
315
+
237
316
  function SortableRail({
238
317
  pages,
239
318
  onReorder,
319
+ onSelect,
240
320
  children,
241
321
  }: {
242
322
  pages: Page[];
243
323
  onReorder: (from: number, to: number) => void;
324
+ onSelect: (index: number) => void;
244
325
  children: React.ReactNode;
245
326
  }) {
246
327
  const sensors = useSensors(
@@ -250,6 +331,11 @@ function SortableRail({
250
331
 
251
332
  const items = pages.map((_, i) => i + 1);
252
333
 
334
+ const handleDragStart = (event: DragStartEvent) => {
335
+ const i = (event.active.id as number) - 1;
336
+ if (i >= 0) onSelect(i);
337
+ };
338
+
253
339
  const handleDragEnd = (event: DragEndEvent) => {
254
340
  const { active, over } = event;
255
341
  if (!over || active.id === over.id) return;
@@ -260,7 +346,12 @@ function SortableRail({
260
346
  };
261
347
 
262
348
  return (
263
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
349
+ <DndContext
350
+ sensors={sensors}
351
+ collisionDetection={closestCenter}
352
+ onDragStart={handleDragStart}
353
+ onDragEnd={handleDragEnd}
354
+ >
264
355
  <SortableContext items={items} strategy={verticalListSortingStrategy}>
265
356
  {children}
266
357
  </SortableContext>
@@ -275,6 +366,7 @@ function SortableThumb({
275
366
  onSelect,
276
367
  ariaLabel,
277
368
  children,
369
+ ...rest
278
370
  }: {
279
371
  index: number;
280
372
  active: boolean;
@@ -282,7 +374,10 @@ function SortableThumb({
282
374
  onSelect: () => void;
283
375
  ariaLabel: string;
284
376
  children: React.ReactNode;
285
- }) {
377
+ } & Omit<
378
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
379
+ 'onClick' | 'aria-label' | 'aria-current' | 'type' | 'style' | 'className' | 'ref' | 'children'
380
+ >) {
286
381
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
287
382
  id: index + 1,
288
383
  });
@@ -296,6 +391,7 @@ function SortableThumb({
296
391
 
297
392
  return (
298
393
  <button
394
+ {...rest}
299
395
  ref={setRef}
300
396
  type="button"
301
397
  onClick={onSelect}
@@ -308,8 +404,7 @@ function SortableThumb({
308
404
  }}
309
405
  className={cn(
310
406
  thumbButtonClass(active),
311
- 'cursor-grab active:cursor-grabbing',
312
- isDragging && 'z-10 opacity-60 shadow-edge ring-1 ring-brand',
407
+ isDragging && 'z-10 cursor-grabbing opacity-60 shadow-edge ring-1 ring-brand',
313
408
  )}
314
409
  {...attributes}
315
410
  {...listeners}
@@ -0,0 +1,237 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
5
+ import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
6
+
7
+ import { cn } from '@/lib/utils';
8
+
9
+ function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
10
+ return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
11
+ }
12
+
13
+ function ContextMenuTrigger({
14
+ ...props
15
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
16
+ return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
17
+ }
18
+
19
+ function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
20
+ return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
21
+ }
22
+
23
+ function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
24
+ return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
25
+ }
26
+
27
+ function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
28
+ return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
29
+ }
30
+
31
+ function ContextMenuRadioGroup({
32
+ ...props
33
+ }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
34
+ return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
35
+ }
36
+
37
+ function ContextMenuSubTrigger({
38
+ className,
39
+ inset,
40
+ children,
41
+ ...props
42
+ }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
43
+ inset?: boolean;
44
+ }) {
45
+ return (
46
+ <ContextMenuPrimitive.SubTrigger
47
+ data-slot="context-menu-sub-trigger"
48
+ data-inset={inset}
49
+ className={cn(
50
+ 'flex cursor-default items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] outline-hidden select-none focus:bg-foreground focus:text-background data-[inset]:pl-8 data-[state=open]:bg-muted',
51
+ className,
52
+ )}
53
+ {...props}
54
+ >
55
+ {children}
56
+ <ChevronRightIcon className="ml-auto size-3.5 opacity-60" />
57
+ </ContextMenuPrimitive.SubTrigger>
58
+ );
59
+ }
60
+
61
+ function ContextMenuSubContent({
62
+ className,
63
+ ...props
64
+ }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
65
+ return (
66
+ <ContextMenuPrimitive.SubContent
67
+ data-slot="context-menu-sub-content"
68
+ className={cn(
69
+ 'z-50 min-w-[9rem] overflow-hidden rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
70
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
71
+ 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
72
+ className,
73
+ )}
74
+ {...props}
75
+ />
76
+ );
77
+ }
78
+
79
+ function ContextMenuContent({
80
+ className,
81
+ ...props
82
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
83
+ return (
84
+ <ContextMenuPrimitive.Portal>
85
+ <ContextMenuPrimitive.Content
86
+ data-slot="context-menu-content"
87
+ className={cn(
88
+ 'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[9rem] origin-(--radix-context-menu-content-transform-origin)',
89
+ 'overflow-x-hidden overflow-y-auto rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
90
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
91
+ 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
92
+ className,
93
+ )}
94
+ {...props}
95
+ />
96
+ </ContextMenuPrimitive.Portal>
97
+ );
98
+ }
99
+
100
+ function ContextMenuItem({
101
+ className,
102
+ inset,
103
+ variant = 'default',
104
+ ...props
105
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
106
+ inset?: boolean;
107
+ variant?: 'default' | 'destructive';
108
+ }) {
109
+ return (
110
+ <ContextMenuPrimitive.Item
111
+ data-slot="context-menu-item"
112
+ data-inset={inset}
113
+ data-variant={variant}
114
+ className={cn(
115
+ 'relative flex cursor-default items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] outline-hidden select-none transition-colors',
116
+ 'focus:bg-foreground focus:text-background',
117
+ 'data-[active=true]:bg-muted data-[active=true]:text-foreground',
118
+ 'data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[inset]:pl-8',
119
+ 'data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:text-white',
120
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-current [&_svg]:opacity-80",
121
+ className,
122
+ )}
123
+ {...props}
124
+ />
125
+ );
126
+ }
127
+
128
+ function ContextMenuCheckboxItem({
129
+ className,
130
+ children,
131
+ checked,
132
+ ...props
133
+ }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
134
+ return (
135
+ <ContextMenuPrimitive.CheckboxItem
136
+ data-slot="context-menu-checkbox-item"
137
+ className={cn(
138
+ 'relative flex cursor-default items-center gap-2 rounded-[5px] py-1.5 pr-2 pl-8 text-[12.5px] outline-hidden select-none focus:bg-foreground focus:text-background data-[disabled]:pointer-events-none data-[disabled]:opacity-45',
139
+ className,
140
+ )}
141
+ checked={checked}
142
+ {...props}
143
+ >
144
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
145
+ <ContextMenuPrimitive.ItemIndicator>
146
+ <CheckIcon className="size-3.5" />
147
+ </ContextMenuPrimitive.ItemIndicator>
148
+ </span>
149
+ {children}
150
+ </ContextMenuPrimitive.CheckboxItem>
151
+ );
152
+ }
153
+
154
+ function ContextMenuRadioItem({
155
+ className,
156
+ children,
157
+ ...props
158
+ }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
159
+ return (
160
+ <ContextMenuPrimitive.RadioItem
161
+ data-slot="context-menu-radio-item"
162
+ className={cn(
163
+ 'relative flex cursor-default items-center gap-2 rounded-[5px] py-1.5 pr-2 pl-8 text-[12.5px] outline-hidden select-none focus:bg-foreground focus:text-background data-[disabled]:pointer-events-none data-[disabled]:opacity-45',
164
+ className,
165
+ )}
166
+ {...props}
167
+ >
168
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
169
+ <ContextMenuPrimitive.ItemIndicator>
170
+ <CircleIcon className="size-2 fill-current" />
171
+ </ContextMenuPrimitive.ItemIndicator>
172
+ </span>
173
+ {children}
174
+ </ContextMenuPrimitive.RadioItem>
175
+ );
176
+ }
177
+
178
+ function ContextMenuLabel({
179
+ className,
180
+ inset,
181
+ ...props
182
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
183
+ inset?: boolean;
184
+ }) {
185
+ return (
186
+ <ContextMenuPrimitive.Label
187
+ data-slot="context-menu-label"
188
+ data-inset={inset}
189
+ className={cn('eyebrow px-2 py-1.5 data-[inset]:pl-8', className)}
190
+ {...props}
191
+ />
192
+ );
193
+ }
194
+
195
+ function ContextMenuSeparator({
196
+ className,
197
+ ...props
198
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
199
+ return (
200
+ <ContextMenuPrimitive.Separator
201
+ data-slot="context-menu-separator"
202
+ className={cn('-mx-1 my-1 h-px bg-hairline', className)}
203
+ {...props}
204
+ />
205
+ );
206
+ }
207
+
208
+ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
209
+ return (
210
+ <span
211
+ data-slot="context-menu-shortcut"
212
+ className={cn(
213
+ 'ml-auto font-mono text-[10.5px] tracking-[0.06em] text-muted-foreground/80',
214
+ className,
215
+ )}
216
+ {...props}
217
+ />
218
+ );
219
+ }
220
+
221
+ export {
222
+ ContextMenu,
223
+ ContextMenuTrigger,
224
+ ContextMenuContent,
225
+ ContextMenuItem,
226
+ ContextMenuCheckboxItem,
227
+ ContextMenuRadioItem,
228
+ ContextMenuLabel,
229
+ ContextMenuSeparator,
230
+ ContextMenuShortcut,
231
+ ContextMenuGroup,
232
+ ContextMenuPortal,
233
+ ContextMenuSub,
234
+ ContextMenuSubContent,
235
+ ContextMenuSubTrigger,
236
+ ContextMenuRadioGroup,
237
+ };
@@ -0,0 +1,134 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export type NoteSaveStatus =
4
+ | { kind: 'idle' }
5
+ | { kind: 'saving' }
6
+ | { kind: 'saved' }
7
+ | { kind: 'error'; message: string };
8
+
9
+ const DEBOUNCE_MS = 600;
10
+
11
+ type Target = { slideId: string; index: number };
12
+
13
+ // HMR is suppressed for our writes, so the cached slide module's `notes`
14
+ // stays stale across navigation. Cache last-saved text per target so
15
+ // switching slides and back doesn't surface the old value.
16
+ const sessionCache = new Map<string, string>();
17
+ const cacheKey = (slideId: string, index: number) => `${slideId}:${index}`;
18
+
19
+ // Remap the per-target cache after a reorder. `order[i]` is the original
20
+ // page index that lands at new position `i`, matching the contract used by
21
+ // the `/__slides/:id/reorder` endpoint.
22
+ export function remapNotesSessionCacheAfterReorder(slideId: string, order: number[]): void {
23
+ const prev = new Map<number, string>();
24
+ for (let i = 0; i < order.length; i++) {
25
+ const cached = sessionCache.get(cacheKey(slideId, i));
26
+ if (cached !== undefined) prev.set(i, cached);
27
+ sessionCache.delete(cacheKey(slideId, i));
28
+ }
29
+ for (let newIdx = 0; newIdx < order.length; newIdx++) {
30
+ const oldIdx = order[newIdx];
31
+ const text = prev.get(oldIdx);
32
+ if (text !== undefined) sessionCache.set(cacheKey(slideId, newIdx), text);
33
+ }
34
+ }
35
+
36
+ export function useNotes(slideId: string, index: number, initial: string | undefined) {
37
+ const initialText = sessionCache.get(cacheKey(slideId, index)) ?? initial ?? '';
38
+ const [value, setValueState] = useState(initialText);
39
+ const [status, setStatus] = useState<NoteSaveStatus>({ kind: 'idle' });
40
+
41
+ const lastSavedRef = useRef(initialText);
42
+ const dirtyRef = useRef(false);
43
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
44
+ const inflightRef = useRef<AbortController | null>(null);
45
+ const targetRef = useRef<Target>({ slideId, index });
46
+ const valueRef = useRef(value);
47
+ valueRef.current = value;
48
+
49
+ const cancelTimer = useCallback(() => {
50
+ if (timerRef.current != null) {
51
+ clearTimeout(timerRef.current);
52
+ timerRef.current = null;
53
+ }
54
+ }, []);
55
+
56
+ const persist = useCallback(async (target: Target, text: string) => {
57
+ inflightRef.current?.abort();
58
+ const ctl = new AbortController();
59
+ inflightRef.current = ctl;
60
+ setStatus({ kind: 'saving' });
61
+ try {
62
+ const res = await fetch('/__notes', {
63
+ method: 'PUT',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({ slideId: target.slideId, index: target.index, text }),
66
+ signal: ctl.signal,
67
+ });
68
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
69
+ if (!res.ok) throw new Error(body.error ?? `PUT /__notes → ${res.status}`);
70
+ sessionCache.set(cacheKey(target.slideId, target.index), text);
71
+ if (inflightRef.current !== ctl) return;
72
+ lastSavedRef.current = text;
73
+ dirtyRef.current = false;
74
+ setStatus({ kind: 'saved' });
75
+ } catch (err) {
76
+ if ((err as { name?: string }).name === 'AbortError') return;
77
+ setStatus({ kind: 'error', message: String((err as Error).message ?? err) });
78
+ } finally {
79
+ if (inflightRef.current === ctl) inflightRef.current = null;
80
+ }
81
+ }, []);
82
+
83
+ const flush = useCallback(async () => {
84
+ cancelTimer();
85
+ if (!dirtyRef.current) return;
86
+ const target = targetRef.current;
87
+ await persist(target, valueRef.current);
88
+ }, [cancelTimer, persist]);
89
+
90
+ // When the (slideId, index) target changes, flush pending edits for the
91
+ // previous target before adopting the new initial text.
92
+ useEffect(() => {
93
+ const prev = targetRef.current;
94
+ const targetChanged = prev.slideId !== slideId || prev.index !== index;
95
+ if (targetChanged && dirtyRef.current) {
96
+ cancelTimer();
97
+ const pending = valueRef.current;
98
+ if (lastSavedRef.current !== pending) void persist(prev, pending);
99
+ }
100
+ targetRef.current = { slideId, index };
101
+ cancelTimer();
102
+ setValueState(initialText);
103
+ lastSavedRef.current = initialText;
104
+ dirtyRef.current = false;
105
+ setStatus({ kind: 'idle' });
106
+ }, [slideId, index, initialText, persist, cancelTimer]);
107
+
108
+ useEffect(() => {
109
+ return () => {
110
+ cancelTimer();
111
+ inflightRef.current?.abort();
112
+ };
113
+ }, [cancelTimer]);
114
+
115
+ const setValue = useCallback(
116
+ (next: string) => {
117
+ setValueState(next);
118
+ dirtyRef.current = next !== lastSavedRef.current;
119
+ cancelTimer();
120
+ if (!dirtyRef.current) {
121
+ setStatus({ kind: 'idle' });
122
+ return;
123
+ }
124
+ const target = targetRef.current;
125
+ timerRef.current = setTimeout(() => {
126
+ timerRef.current = null;
127
+ void persist(target, next);
128
+ }, DEBOUNCE_MS);
129
+ },
130
+ [persist, cancelTimer],
131
+ );
132
+
133
+ return { value, setValue, status, flush };
134
+ }