@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.
- package/dist/{build-DSqSio-T.js → build-6BeQ3cxb.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-KdiYeWtK.js → config-AxZ5OE1u.js} +673 -211
- package/dist/{config-C7vMYzFD.d.ts → config-CtT8K4VF.d.ts} +1 -1
- package/dist/{dev-B_GVbr11.js → dev-C9eLmUEq.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +96 -20
- package/dist/{preview-D_mxhj7w.js → preview-Cunm-f4i.js} +1 -1
- package/dist/{types-DYgVpIGo.d.ts → types-CRHIeoNq.d.ts} +28 -4
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +48 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
- package/src/app/components/inspector/inspector-panel.tsx +44 -13
- package/src/app/components/inspector/inspector-provider.tsx +60 -7
- package/src/app/components/notes-drawer.tsx +117 -0
- package/src/app/components/player.tsx +11 -7
- package/src/app/components/present/overview-grid.tsx +2 -2
- package/src/app/components/thumbnail-rail.tsx +119 -24
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/routes/home.tsx +34 -12
- package/src/app/routes/slide.tsx +209 -74
- package/src/locale/en.ts +26 -4
- package/src/locale/ja.ts +26 -4
- package/src/locale/types.ts +29 -4
- package/src/locale/zh-cn.ts +26 -4
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
+
}
|