@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
|
@@ -2,33 +2,44 @@ import {
|
|
|
2
2
|
applyNodeChanges,
|
|
3
3
|
Background,
|
|
4
4
|
BackgroundVariant,
|
|
5
|
+
BaseEdge,
|
|
5
6
|
type Edge,
|
|
7
|
+
EdgeLabelRenderer,
|
|
8
|
+
type EdgeProps,
|
|
9
|
+
type EdgeTypes,
|
|
10
|
+
type InternalNode,
|
|
6
11
|
MarkerType,
|
|
7
12
|
MiniMap,
|
|
8
13
|
type Node,
|
|
9
14
|
type NodeChange,
|
|
10
15
|
type NodeProps,
|
|
11
16
|
type NodeTypes,
|
|
17
|
+
type OnConnectEnd,
|
|
12
18
|
type OnNodeDrag,
|
|
13
19
|
Panel,
|
|
14
20
|
ReactFlow,
|
|
15
21
|
ReactFlowProvider,
|
|
22
|
+
useInternalNode,
|
|
16
23
|
useReactFlow,
|
|
17
24
|
} from '@xyflow/react';
|
|
18
|
-
import { Maximize2, Minus, Plus } from 'lucide-react';
|
|
25
|
+
import { Link2, Link2Off, Maximize2, Minus, Plus, X } from 'lucide-react';
|
|
19
26
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
20
27
|
import { Button } from '@/components/ui/button';
|
|
21
28
|
import { useLocale } from '@/lib/use-locale';
|
|
22
29
|
import { cn, pad2 } from '@/lib/utils';
|
|
30
|
+
import type { BoardCard, CardPatch } from '../../../files/board-cards';
|
|
23
31
|
import { type BoardColumn, columnsAfterMove } from '../../../files/board-structure';
|
|
24
32
|
import type { DesignSystem } from '../../lib/design';
|
|
25
33
|
import type { Page } from '../../lib/sdk';
|
|
26
34
|
import {
|
|
27
35
|
type BoardLayout,
|
|
36
|
+
type Box,
|
|
28
37
|
CARD_BOX_H,
|
|
29
38
|
CARD_BOX_W,
|
|
39
|
+
cardAtPoint,
|
|
30
40
|
identityOrder,
|
|
31
41
|
layoutBoard,
|
|
42
|
+
linkCurve,
|
|
32
43
|
orderAfterMove,
|
|
33
44
|
slotAtPoint,
|
|
34
45
|
} from './layout';
|
|
@@ -41,6 +52,11 @@ type Props = {
|
|
|
41
52
|
pages: Page[];
|
|
42
53
|
columns: BoardColumn[];
|
|
43
54
|
notes?: (string | undefined)[];
|
|
55
|
+
cards: (BoardCard | undefined)[];
|
|
56
|
+
cardsReady: boolean;
|
|
57
|
+
/** Linked pages, as deck positions. */
|
|
58
|
+
links: [number, number][];
|
|
59
|
+
showLinks: boolean;
|
|
44
60
|
design?: DesignSystem;
|
|
45
61
|
onOpenPage: (index: number) => void;
|
|
46
62
|
onMovePage: (from: number, to: number) => void;
|
|
@@ -49,6 +65,10 @@ type Props = {
|
|
|
49
65
|
onDuplicate: (index: number) => void;
|
|
50
66
|
onDelete: (index: number) => void;
|
|
51
67
|
onMoveToEnd: (index: number) => void;
|
|
68
|
+
onPatchCard: (index: number, patch: CardPatch) => void;
|
|
69
|
+
onLink: (a: number, b: number) => void;
|
|
70
|
+
onUnlink: (a: number, b: number) => void;
|
|
71
|
+
onToggleLinks: () => void;
|
|
52
72
|
};
|
|
53
73
|
|
|
54
74
|
type ColumnLabelData = {
|
|
@@ -129,6 +149,52 @@ function DropSlot() {
|
|
|
129
149
|
|
|
130
150
|
const nodeTypes: NodeTypes = { page: PageNode, columnLabel: ColumnLabel, dropSlot: DropSlot };
|
|
131
151
|
|
|
152
|
+
type LinkEdgeData = { a: number; b: number; onUnlink: (a: number, b: number) => void };
|
|
153
|
+
|
|
154
|
+
function boxOf(node: InternalNode | undefined): Box | null {
|
|
155
|
+
if (!node) return null;
|
|
156
|
+
const { x, y } = node.internals.positionAbsolute;
|
|
157
|
+
return {
|
|
158
|
+
x,
|
|
159
|
+
y,
|
|
160
|
+
width: node.measured.width ?? CARD_BOX_W,
|
|
161
|
+
height: node.measured.height ?? CARD_BOX_H,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A loose association between two pages. It is drawn thin and dashed so it can
|
|
167
|
+
* never be read as the deck's order, and it follows the cards as they move.
|
|
168
|
+
*/
|
|
169
|
+
function LinkEdge({ id, source, target, data }: EdgeProps) {
|
|
170
|
+
const t = useLocale();
|
|
171
|
+
const { a, b, onUnlink } = data as unknown as LinkEdgeData;
|
|
172
|
+
const from = boxOf(useInternalNode(source));
|
|
173
|
+
const to = boxOf(useInternalNode(target));
|
|
174
|
+
if (!from || !to) return null;
|
|
175
|
+
const curve = linkCurve(from, to);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
<BaseEdge id={id} path={curve.path} className="osd-board-link" interactionWidth={14} />
|
|
180
|
+
<EdgeLabelRenderer>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
aria-label={t.board.removeLink}
|
|
184
|
+
title={t.board.removeLink}
|
|
185
|
+
onClick={() => onUnlink(a, b)}
|
|
186
|
+
className="osd-board-link-remove nodrag nopan"
|
|
187
|
+
style={{ transform: `translate(-50%, -50%) translate(${curve.x}px, ${curve.y}px)` }}
|
|
188
|
+
>
|
|
189
|
+
<X className="size-3" strokeWidth={2.2} />
|
|
190
|
+
</button>
|
|
191
|
+
</EdgeLabelRenderer>
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const edgeTypes: EdgeTypes = { link: LinkEdge };
|
|
197
|
+
|
|
132
198
|
const edgeStyle = { stroke: 'var(--osd-board-line)', strokeWidth: 1.5 } as const;
|
|
133
199
|
const marker = {
|
|
134
200
|
type: MarkerType.ArrowClosed,
|
|
@@ -137,6 +203,12 @@ const marker = {
|
|
|
137
203
|
color: 'var(--osd-board-line)',
|
|
138
204
|
};
|
|
139
205
|
|
|
206
|
+
const linkDraftStyle = {
|
|
207
|
+
stroke: 'var(--osd-board-link)',
|
|
208
|
+
strokeWidth: 1.5,
|
|
209
|
+
strokeDasharray: '5 4',
|
|
210
|
+
} as const;
|
|
211
|
+
|
|
140
212
|
const pageIndexOf = (id: string) => Number(id.replace('page-', ''));
|
|
141
213
|
|
|
142
214
|
type BuildArgs = {
|
|
@@ -146,8 +218,10 @@ type BuildArgs = {
|
|
|
146
218
|
slideId: string;
|
|
147
219
|
pages: Page[];
|
|
148
220
|
notes?: (string | undefined)[];
|
|
221
|
+
cards: (BoardCard | undefined)[];
|
|
222
|
+
cardsReady: boolean;
|
|
149
223
|
design?: DesignSystem;
|
|
150
|
-
actions: Pick<PageNodeData, 'onDuplicate' | 'onDelete' | 'onMoveToEnd'>;
|
|
224
|
+
actions: Pick<PageNodeData, 'onDuplicate' | 'onDelete' | 'onMoveToEnd' | 'onPatchCard'>;
|
|
151
225
|
columnActions: {
|
|
152
226
|
onRename: (index: number, title: string) => void;
|
|
153
227
|
onAddPage: (index: number) => void;
|
|
@@ -164,6 +238,8 @@ function buildNodes({
|
|
|
164
238
|
slideId,
|
|
165
239
|
pages,
|
|
166
240
|
notes,
|
|
241
|
+
cards,
|
|
242
|
+
cardsReady,
|
|
167
243
|
design,
|
|
168
244
|
actions,
|
|
169
245
|
columnActions,
|
|
@@ -199,7 +275,7 @@ function buildNodes({
|
|
|
199
275
|
hidden: true,
|
|
200
276
|
data: {},
|
|
201
277
|
};
|
|
202
|
-
const
|
|
278
|
+
const pageNodes: Node[] = layout.slots.map((slot) => {
|
|
203
279
|
const pageIndex = order[slot.index] ?? slot.index;
|
|
204
280
|
return {
|
|
205
281
|
id: `page-${pageIndex}`,
|
|
@@ -211,6 +287,9 @@ function buildNodes({
|
|
|
211
287
|
slideId,
|
|
212
288
|
page: pages[pageIndex],
|
|
213
289
|
note: notes?.[pageIndex],
|
|
290
|
+
card: cards[pageIndex],
|
|
291
|
+
cardsReady,
|
|
292
|
+
canLink: cardsReady && pages.length > 1,
|
|
214
293
|
index: pageIndex,
|
|
215
294
|
total: pages.length,
|
|
216
295
|
design,
|
|
@@ -219,7 +298,7 @@ function buildNodes({
|
|
|
219
298
|
} satisfies PageNodeData as unknown as Record<string, unknown>,
|
|
220
299
|
};
|
|
221
300
|
});
|
|
222
|
-
return [...labels, drop, ...
|
|
301
|
+
return [...labels, drop, ...pageNodes];
|
|
223
302
|
}
|
|
224
303
|
|
|
225
304
|
function BoardFlow({
|
|
@@ -227,6 +306,10 @@ function BoardFlow({
|
|
|
227
306
|
pages,
|
|
228
307
|
columns,
|
|
229
308
|
notes,
|
|
309
|
+
cards,
|
|
310
|
+
cardsReady,
|
|
311
|
+
links,
|
|
312
|
+
showLinks,
|
|
230
313
|
design,
|
|
231
314
|
onOpenPage,
|
|
232
315
|
onMovePage,
|
|
@@ -235,15 +318,19 @@ function BoardFlow({
|
|
|
235
318
|
onDuplicate,
|
|
236
319
|
onDelete,
|
|
237
320
|
onMoveToEnd,
|
|
321
|
+
onPatchCard,
|
|
322
|
+
onLink,
|
|
323
|
+
onUnlink,
|
|
324
|
+
onToggleLinks,
|
|
238
325
|
}: Props) {
|
|
239
326
|
const t = useLocale();
|
|
240
|
-
const { fitView, zoomIn, zoomOut } = useReactFlow();
|
|
327
|
+
const { fitView, zoomIn, zoomOut, screenToFlowPosition } = useReactFlow();
|
|
241
328
|
|
|
242
329
|
const layout = useMemo(() => layoutBoard(columns), [columns]);
|
|
243
330
|
|
|
244
331
|
const actions = useMemo(
|
|
245
|
-
() => ({ onDuplicate, onDelete, onMoveToEnd }),
|
|
246
|
-
[onDuplicate, onDelete, onMoveToEnd],
|
|
332
|
+
() => ({ onDuplicate, onDelete, onMoveToEnd, onPatchCard }),
|
|
333
|
+
[onDuplicate, onDelete, onMoveToEnd, onPatchCard],
|
|
247
334
|
);
|
|
248
335
|
|
|
249
336
|
const columnActions = useMemo(
|
|
@@ -259,11 +346,13 @@ function BoardFlow({
|
|
|
259
346
|
slideId,
|
|
260
347
|
pages,
|
|
261
348
|
notes,
|
|
349
|
+
cards,
|
|
350
|
+
cardsReady,
|
|
262
351
|
design,
|
|
263
352
|
actions,
|
|
264
353
|
columnActions,
|
|
265
354
|
}),
|
|
266
|
-
[layout, slideId, pages, notes, design, actions, columnActions],
|
|
355
|
+
[layout, slideId, pages, notes, cards, cardsReady, design, actions, columnActions],
|
|
267
356
|
);
|
|
268
357
|
|
|
269
358
|
const [nodes, setNodes] = useState<Node[]>(baseNodes);
|
|
@@ -299,6 +388,8 @@ function BoardFlow({
|
|
|
299
388
|
slideId,
|
|
300
389
|
pages,
|
|
301
390
|
notes,
|
|
391
|
+
cards,
|
|
392
|
+
cardsReady,
|
|
302
393
|
design,
|
|
303
394
|
actions,
|
|
304
395
|
columnActions,
|
|
@@ -319,7 +410,7 @@ function BoardFlow({
|
|
|
319
410
|
});
|
|
320
411
|
});
|
|
321
412
|
},
|
|
322
|
-
[layout, columns, slideId, pages, notes, design, actions, columnActions],
|
|
413
|
+
[layout, columns, slideId, pages, notes, cards, cardsReady, design, actions, columnActions],
|
|
323
414
|
);
|
|
324
415
|
|
|
325
416
|
const handleDragStop = useCallback<OnNodeDrag>(
|
|
@@ -358,7 +449,7 @@ function BoardFlow({
|
|
|
358
449
|
|
|
359
450
|
const edges = useMemo<Edge[]>(() => {
|
|
360
451
|
const byIndex = new Map(layout.slots.map((slot) => [slot.index, slot]));
|
|
361
|
-
|
|
452
|
+
const mainLine: Edge[] = pages.slice(0, -1).map((_, i) => {
|
|
362
453
|
const sameColumn = byIndex.get(i)?.column === byIndex.get(i + 1)?.column;
|
|
363
454
|
return {
|
|
364
455
|
id: `line-${i}`,
|
|
@@ -373,20 +464,50 @@ function BoardFlow({
|
|
|
373
464
|
focusable: false,
|
|
374
465
|
};
|
|
375
466
|
});
|
|
376
|
-
|
|
467
|
+
if (!showLinks) return mainLine;
|
|
468
|
+
const linkLines: Edge[] = links.map(([a, b]) => ({
|
|
469
|
+
id: `link-${Math.min(a, b)}-${Math.max(a, b)}`,
|
|
470
|
+
source: `page-${a}`,
|
|
471
|
+
target: `page-${b}`,
|
|
472
|
+
type: 'link',
|
|
473
|
+
data: { a, b, onUnlink } satisfies LinkEdgeData,
|
|
474
|
+
selectable: false,
|
|
475
|
+
focusable: false,
|
|
476
|
+
}));
|
|
477
|
+
return [...mainLine, ...linkLines];
|
|
478
|
+
}, [layout, pages, links, showLinks, onUnlink]);
|
|
479
|
+
|
|
480
|
+
// A link is drawn by dragging a card's dot onto another card. The whole card is
|
|
481
|
+
// the target rather than a handle on it, because a handle is too small to hit
|
|
482
|
+
// at the zoom a long deck is read at.
|
|
483
|
+
const handleConnectEnd = useCallback<OnConnectEnd>(
|
|
484
|
+
(event, state) => {
|
|
485
|
+
const from = state.fromNode;
|
|
486
|
+
if (from?.type !== 'page') return;
|
|
487
|
+
const point = 'changedTouches' in event ? event.changedTouches[0] : event;
|
|
488
|
+
if (!point) return;
|
|
489
|
+
const at = screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
|
490
|
+
const target = cardAtPoint(layout, at.x, at.y);
|
|
491
|
+
const source = pageIndexOf(from.id);
|
|
492
|
+
if (target !== null && target !== source) onLink(source, target);
|
|
493
|
+
},
|
|
494
|
+
[layout, screenToFlowPosition, onLink],
|
|
495
|
+
);
|
|
377
496
|
|
|
378
497
|
return (
|
|
379
498
|
<ReactFlow
|
|
380
499
|
nodes={nodes}
|
|
381
500
|
edges={edges}
|
|
382
501
|
nodeTypes={nodeTypes}
|
|
502
|
+
edgeTypes={edgeTypes}
|
|
383
503
|
onNodesChange={onNodesChange}
|
|
384
504
|
onNodeDrag={handleDrag}
|
|
385
505
|
onNodeDragStop={handleDragStop}
|
|
386
506
|
onNodeDoubleClick={(_, node) => {
|
|
387
507
|
if (node.type === 'page') onOpenPage(pageIndexOf(node.id));
|
|
388
508
|
}}
|
|
389
|
-
|
|
509
|
+
onConnectEnd={handleConnectEnd}
|
|
510
|
+
connectionLineStyle={linkDraftStyle}
|
|
390
511
|
edgesFocusable={false}
|
|
391
512
|
onlyRenderVisibleElements
|
|
392
513
|
onInit={frameStart}
|
|
@@ -425,6 +546,16 @@ function BoardFlow({
|
|
|
425
546
|
<Button variant="outline" size="icon-sm" aria-label={t.board.fitView} onClick={fitAll}>
|
|
426
547
|
<Maximize2 />
|
|
427
548
|
</Button>
|
|
549
|
+
<Button
|
|
550
|
+
variant="outline"
|
|
551
|
+
size="icon-sm"
|
|
552
|
+
aria-label={showLinks ? t.board.hideLinks : t.board.showLinks}
|
|
553
|
+
aria-pressed={showLinks}
|
|
554
|
+
title={showLinks ? t.board.hideLinks : t.board.showLinks}
|
|
555
|
+
onClick={onToggleLinks}
|
|
556
|
+
>
|
|
557
|
+
{showLinks ? <Link2 /> : <Link2Off />}
|
|
558
|
+
</Button>
|
|
428
559
|
</Panel>
|
|
429
560
|
</ReactFlow>
|
|
430
561
|
);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
--osd-board-line: color-mix(in oklab, var(--color-muted-foreground) 45%, transparent);
|
|
5
5
|
--osd-board-mask: color-mix(in oklab, var(--color-background) 82%, transparent);
|
|
6
6
|
--osd-board-minimap-node: color-mix(in oklab, var(--color-foreground) 28%, transparent);
|
|
7
|
+
--osd-board-link: color-mix(in oklab, var(--color-brand) 55%, transparent);
|
|
7
8
|
background: var(--color-background);
|
|
8
9
|
}
|
|
9
10
|
|
|
@@ -73,3 +74,53 @@
|
|
|
73
74
|
.osd-board .react-flow__attribution a {
|
|
74
75
|
color: color-mix(in oklab, var(--color-muted-foreground) 60%, transparent);
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
/* Links are loose associations, so they stay thin, dashed and quiet next to the
|
|
79
|
+
solid main line that carries the deck's order. */
|
|
80
|
+
.osd-board .osd-board-link {
|
|
81
|
+
stroke: var(--osd-board-link);
|
|
82
|
+
stroke-width: 1.25;
|
|
83
|
+
stroke-dasharray: 5 4;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.osd-board-link-remove {
|
|
87
|
+
position: absolute;
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
width: 20px;
|
|
92
|
+
height: 20px;
|
|
93
|
+
border-radius: 999px;
|
|
94
|
+
border: 1px solid var(--color-border);
|
|
95
|
+
background: var(--color-card);
|
|
96
|
+
color: var(--color-muted-foreground);
|
|
97
|
+
pointer-events: all;
|
|
98
|
+
opacity: 0.7;
|
|
99
|
+
transition:
|
|
100
|
+
opacity 120ms ease,
|
|
101
|
+
color 120ms ease;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.osd-board-link-remove:hover,
|
|
105
|
+
.osd-board-link-remove:focus-visible {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
color: var(--color-destructive);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* The dot a link is dragged from. It only shows on the card under the pointer,
|
|
111
|
+
so a board at rest carries no extra chrome. */
|
|
112
|
+
.osd-board .osd-board-link-handle {
|
|
113
|
+
width: 14px;
|
|
114
|
+
height: 14px;
|
|
115
|
+
border-radius: 999px;
|
|
116
|
+
border: 2px solid var(--color-card);
|
|
117
|
+
background: var(--color-brand);
|
|
118
|
+
cursor: crosshair;
|
|
119
|
+
opacity: 0;
|
|
120
|
+
transition: opacity 120ms ease;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.osd-board .react-flow__node-page:hover .osd-board-link-handle,
|
|
124
|
+
.osd-board .osd-board-link-handle.connectingfrom {
|
|
125
|
+
opacity: 1;
|
|
126
|
+
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Page } from '../../lib/sdk';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CARD_BOX_H,
|
|
5
|
+
CARD_BOX_W,
|
|
6
|
+
CARD_H,
|
|
7
|
+
CARD_W,
|
|
8
|
+
cardAtPoint,
|
|
9
|
+
layoutBoard,
|
|
10
|
+
linkCurve,
|
|
11
|
+
orderAfterMove,
|
|
12
|
+
seedColumns,
|
|
13
|
+
slotAtPoint,
|
|
14
|
+
} from './layout';
|
|
4
15
|
|
|
5
16
|
/** Pages are identified by their component name, so the fixtures need real names. */
|
|
6
17
|
function named(name: string): Page {
|
|
@@ -107,3 +118,44 @@ describe('orderAfterMove', () => {
|
|
|
107
118
|
expect(orderAfterMove(5, 2, 2)).toEqual([0, 1, 2, 3, 4]);
|
|
108
119
|
});
|
|
109
120
|
});
|
|
121
|
+
|
|
122
|
+
describe('cardAtPoint', () => {
|
|
123
|
+
const layout = layoutBoard([
|
|
124
|
+
{ title: 'Cover', size: 2 },
|
|
125
|
+
{ title: 'Chapter One', size: 2 },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
it('finds the card under a point anywhere inside it', () => {
|
|
129
|
+
const slot = layout.slots[3];
|
|
130
|
+
expect(cardAtPoint(layout, slot.x + CARD_BOX_W - 1, slot.y + CARD_BOX_H - 1)).toBe(3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('reports empty canvas between cards as no card', () => {
|
|
134
|
+
const [a, b] = [layout.slots[0], layout.slots[1]];
|
|
135
|
+
expect(cardAtPoint(layout, a.x + 10, (a.y + CARD_BOX_H + b.y) / 2)).toBeNull();
|
|
136
|
+
expect(cardAtPoint(layout, a.x + CARD_BOX_W + 20, a.y + 10)).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('linkCurve', () => {
|
|
141
|
+
const box = (x: number, y: number) => ({ x, y, width: 100, height: 60 });
|
|
142
|
+
|
|
143
|
+
it('bows a link between cards in one column out to the right of the column', () => {
|
|
144
|
+
const curve = linkCurve(box(0, 0), box(0, 400));
|
|
145
|
+
expect(curve.x).toBeGreaterThan(100);
|
|
146
|
+
expect(curve.path.startsWith('M 100,18')).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('never ends where the main line does, at the middle of a card side', () => {
|
|
150
|
+
const curve = linkCurve(box(0, 0), box(300, 0));
|
|
151
|
+
expect(curve.path).not.toContain(',30');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('joins cards in different columns between their facing sides, whichever is first', () => {
|
|
155
|
+
const forward = linkCurve(box(0, 0), box(300, 200));
|
|
156
|
+
const backward = linkCurve(box(300, 200), box(0, 0));
|
|
157
|
+
expect(forward.path).toBe(backward.path);
|
|
158
|
+
expect(forward.path.startsWith('M 100,18')).toBe(true);
|
|
159
|
+
expect(forward.path.endsWith('300,218')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -7,9 +7,11 @@ export const CARD_W = 480;
|
|
|
7
7
|
export const CARD_H = (CARD_W * CANVAS_HEIGHT) / CANVAS_WIDTH;
|
|
8
8
|
/** Height of the card's speaker-note strip. */
|
|
9
9
|
export const NOTE_H = 52;
|
|
10
|
-
/**
|
|
10
|
+
/** Height of the card's work-memo strip. */
|
|
11
|
+
export const MEMO_H = 40;
|
|
12
|
+
/** Card box = preview plus the card's padding, title row, note strip and memo strip. */
|
|
11
13
|
export const CARD_BOX_W = CARD_W + 12;
|
|
12
|
-
export const CARD_BOX_H = CARD_H +
|
|
14
|
+
export const CARD_BOX_H = CARD_H + 60 + NOTE_H + MEMO_H;
|
|
13
15
|
/** Gap between columns, and between cards inside a column. */
|
|
14
16
|
const COL_GAP = 88;
|
|
15
17
|
const ROW_GAP = 52;
|
|
@@ -122,6 +124,52 @@ export function slotAtPoint(layout: BoardLayout, x: number, y: number, dragged:
|
|
|
122
124
|
return best;
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/** Deck position of the card under a point, or null over empty canvas. */
|
|
128
|
+
export function cardAtPoint(layout: BoardLayout, x: number, y: number): number | null {
|
|
129
|
+
for (const slot of layout.slots) {
|
|
130
|
+
if (x >= slot.x && x <= slot.x + CARD_BOX_W && y >= slot.y && y <= slot.y + CARD_BOX_H) {
|
|
131
|
+
return slot.index;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type Box = { x: number; y: number; width: number; height: number };
|
|
138
|
+
|
|
139
|
+
const LINK_ANCHOR = 0.3;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A link's curve and the point its remove button sits on. Cards in one column
|
|
143
|
+
* are joined by an arc bowing into the gap on their right, which the main line
|
|
144
|
+
* never uses; cards in different columns by a curve between their facing sides.
|
|
145
|
+
*/
|
|
146
|
+
export function linkCurve(a: Box, b: Box): { path: string; x: number; y: number } {
|
|
147
|
+
// The main line enters and leaves a card at its vertical middle; links attach
|
|
148
|
+
// higher up so the two never share an end.
|
|
149
|
+
const ay = a.y + a.height * LINK_ANCHOR;
|
|
150
|
+
const by = b.y + b.height * LINK_ANCHOR;
|
|
151
|
+
if (Math.abs(a.x - b.x) < a.width / 2) {
|
|
152
|
+
const sx = a.x + a.width;
|
|
153
|
+
const tx = b.x + b.width;
|
|
154
|
+
// Stays inside the gap between columns, so the arc never slips behind the next column.
|
|
155
|
+
const bow = 56 + Math.min(Math.abs(by - ay) / 8, 36);
|
|
156
|
+
return {
|
|
157
|
+
path: `M ${sx},${ay} C ${sx + bow},${ay} ${tx + bow},${by} ${tx},${by}`,
|
|
158
|
+
x: (sx + tx) / 2 + 0.75 * bow,
|
|
159
|
+
y: (ay + by) / 2,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const [left, right, ly, ry] = a.x < b.x ? [a, b, ay, by] : [b, a, by, ay];
|
|
163
|
+
const sx = left.x + left.width;
|
|
164
|
+
const tx = right.x;
|
|
165
|
+
const reach = Math.max((tx - sx) / 2, 40);
|
|
166
|
+
return {
|
|
167
|
+
path: `M ${sx},${ly} C ${sx + reach},${ly} ${tx - reach},${ry} ${tx},${ry}`,
|
|
168
|
+
x: (sx + tx) / 2,
|
|
169
|
+
y: (ly + ry) / 2,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
125
173
|
export const identityOrder = (total: number) => Array.from({ length: total }, (_, i) => i);
|
|
126
174
|
|
|
127
175
|
/** The deck's order once the card at `from` lands on `to`, as original page indexes. */
|