@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.
- package/LICENSE +21 -0
- package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
- package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
- package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/locale/index.d.ts +5 -2
- package/dist/locale/index.js +2 -2
- package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
- package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
- package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
- package/package.json +21 -15
- package/skills/apply-comments/SKILL.md +10 -6
- package/skills/create-slide/SKILL.md +2 -0
- package/skills/current-slide/SKILL.md +1 -0
- package/skills/slide-authoring/SKILL.md +31 -1
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/app.tsx +2 -0
- package/src/app/assets/open-slide.svg +39 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/board/board-canvas.tsx +143 -12
- package/src/app/components/board/board.css +51 -0
- package/src/app/components/board/layout.test.ts +53 -1
- package/src/app/components/board/layout.ts +50 -2
- package/src/app/components/board/page-node.tsx +287 -13
- package/src/app/components/board/use-board-cards.ts +210 -0
- package/src/app/components/board/use-page-order.ts +17 -9
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/icon-switcher-indicator.tsx +1 -1
- package/src/app/components/inspector/comment-layer.tsx +1011 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspect-overlay.tsx +2 -39
- package/src/app/components/inspector/inspector-panel.tsx +10 -93
- package/src/app/components/inspector/inspector-provider.tsx +121 -47
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +0 -1
- package/src/app/components/preview-step-host.tsx +16 -0
- package/src/app/components/sidebar/sidebar.tsx +2 -2
- package/src/app/components/style-panel/style-panel.tsx +3 -2
- package/src/app/components/thumbnail-rail.tsx +76 -1
- package/src/app/favicon.ico +0 -0
- package/src/app/favicon.svg +39 -0
- package/src/app/index.html +2 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/inspector/freeze-motion.ts +61 -0
- package/src/app/lib/inspector/use-comments.ts +32 -3
- package/src/app/lib/locale-store.ts +6 -1
- package/src/app/lib/print-ready.ts +9 -6
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/board.tsx +48 -11
- package/src/app/routes/home.tsx +43 -7
- package/src/app/routes/presenter.tsx +1 -16
- package/src/app/routes/preview.tsx +169 -0
- package/src/app/routes/slide.tsx +221 -173
- package/src/app/styles.css +234 -80
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +127 -5
- package/src/locale/es.ts +692 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/ja.ts +120 -6
- package/src/locale/types.ts +132 -5
- package/src/locale/zh-cn.ts +117 -5
- package/src/locale/zh-tw.ts +117 -5
- package/src/app/assets/open-slide.png +0 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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-
|
|
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={
|
|
152
|
+
render={view}
|
|
91
153
|
/>
|
|
92
|
-
<ContextMenuContent
|
|
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],
|