Skip to content

Commit c4cd821

Browse files
committed
feat(notes): 阶段三自由画布 OneNote 式核心墨迹(钢笔/荧光笔/橡皮擦 + SVG 平滑笔画 + 色板笔粗)
1 parent 318470e commit c4cd821

7 files changed

Lines changed: 475 additions & 1 deletion

File tree

‎client/src/features/notes/components/FreeCanvas.tsx‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
55
import { Plus, Trash2, Copy, Eraser, CheckSquare } from 'lucide-react';
66
import FreeTextBlock from './FreeTextBlock';
77
import { FreeCanvasOverlays } from './FreeCanvasOverlays';
8-
import type { FreeCanvasData, FreeCanvasBlock } from '@/types/models';
8+
import { InkToolbar } from './canvas/InkToolbar';
9+
import { InkLayer } from './canvas/InkLayer';
10+
import { useInkDrawing, type InkTool } from '../lib/canvas/useInkDrawing';
11+
import type { FreeCanvasData, FreeCanvasBlock, InkStroke, InkPoint } from '@/types/models';
912

1013
interface FreeCanvasProps {
1114
content: FreeCanvasData | null;
@@ -44,6 +47,44 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
4447
const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());
4548
const [paletteOpen, setPaletteOpen] = useState(false);
4649

50+
// 阶段三:墨迹工具状态 / ink tool state
51+
const [inkTool, setInkTool] = useState<InkTool>('select');
52+
const [inkColor, setInkColor] = useState('#1e293b');
53+
const [inkWidth, setInkWidth] = useState(4);
54+
const innerCanvasRef = useRef<HTMLDivElement>(null);
55+
56+
// 阶段三:墨迹笔画数据与绘制 / ink strokes data & drawing
57+
const strokes = data.strokes ?? [];
58+
59+
const getCanvasPoint = useCallback((clientX: number, clientY: number): InkPoint => {
60+
const rect = innerCanvasRef.current?.getBoundingClientRect();
61+
if (!rect) return { x: 0, y: 0 };
62+
return { x: clientX - rect.left, y: clientY - rect.top };
63+
}, []);
64+
65+
const handleCommitStroke = useCallback((stroke: InkStroke) => {
66+
const current = dataRef.current;
67+
emitChange({ ...current, strokes: [...(current.strokes ?? []), stroke] });
68+
}, [emitChange]);
69+
70+
const handleErase = useCallback((predicate: (s: InkStroke) => boolean) => {
71+
const current = dataRef.current;
72+
const prev = current.strokes ?? [];
73+
const remaining = prev.filter((s) => !predicate(s));
74+
if (remaining.length !== prev.length) {
75+
emitChange({ ...current, strokes: remaining });
76+
}
77+
}, [emitChange]);
78+
79+
const { currentStroke, handlePointerDown, handlePointerMove, handlePointerUp } = useInkDrawing({
80+
tool: inkTool,
81+
color: inkColor,
82+
width: inkWidth,
83+
getCanvasPoint,
84+
onCommitStroke: handleCommitStroke,
85+
onErase: handleErase,
86+
});
87+
4788
// 框选状态
4889
const [selectionBox, setSelectionBox] = useState<{x1: number; y1: number; x2: number; y2: number} | null>(null);
4990
const isSelectingRef = useRef(false);
@@ -125,6 +166,7 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
125166

126167
// 双击空白区域添加文本块
127168
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
169+
if (inkTool !== 'select') return; // 绘制工具下不新建文本块
128170
const scrollContainer = scrollContainerRef.current;
129171
if (!scrollContainer) return;
130172

@@ -489,6 +531,7 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
489531
onContextMenu={(e) => e.preventDefault()}
490532
>
491533
<div
534+
ref={innerCanvasRef}
492535
style={{
493536
position: 'relative',
494537
width: data.canvasWidth,
@@ -511,6 +554,26 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
511554
/>
512555
))}
513556

557+
{/* 阶段三:墨迹渲染层 / ink rendering layer */}
558+
<InkLayer
559+
strokes={strokes}
560+
currentStroke={currentStroke}
561+
width={data.canvasWidth}
562+
height={data.canvasHeight}
563+
/>
564+
565+
{/* 阶段三:绘制覆盖层(绘制工具激活时捕获指针事件) */}
566+
{inkTool !== 'select' && (
567+
<div
568+
className="absolute inset-0"
569+
style={{ zIndex: 8, cursor: inkTool === 'eraser' ? 'cell' : 'crosshair', touchAction: 'none' }}
570+
onPointerDown={handlePointerDown}
571+
onPointerMove={handlePointerMove}
572+
onPointerUp={handlePointerUp}
573+
onPointerLeave={handlePointerUp}
574+
/>
575+
)}
576+
514577
{/* 框选矩形 */}
515578
{selectionBox && (
516579
<div
@@ -534,6 +597,16 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
534597
actions={actions}
535598
onClosePalette={() => setPaletteOpen(false)}
536599
/>
600+
601+
{/* 阶段三:墨迹工具栏 / ink toolbar */}
602+
<InkToolbar
603+
tool={inkTool}
604+
color={inkColor}
605+
width={inkWidth}
606+
onToolChange={setInkTool}
607+
onColorChange={setInkColor}
608+
onWidthChange={setInkWidth}
609+
/>
537610
</div>
538611
);
539612
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* 墨迹渲染层(SVG)
3+
* Ink rendering layer (SVG)
4+
*
5+
* @ai-context: 阶段三。将已提交笔画 + 进行中笔画渲染为 SVG path(pointsToSvgPath
6+
* 平滑)。荧光笔半透明(strokeOpacity 0.4)。pointer-events-none,绘制事件由
7+
* FreeCanvas 的绘制覆盖层处理。绝对定位覆盖整个画布(与文本块同坐标系)。
8+
* @ai-context: Renders committed + in-progress strokes as smoothed SVG paths;
9+
* highlighter is translucent. pointer-events-none (drawing handled by overlay).
10+
*/
11+
import { pointsToSvgPath } from '../../lib/canvas/inkGeometry';
12+
import type { InkStroke } from '@/types/models';
13+
14+
interface InkLayerProps {
15+
strokes: InkStroke[];
16+
currentStroke: InkStroke | null;
17+
width: number;
18+
height: number;
19+
}
20+
21+
export function InkLayer({ strokes, currentStroke, width, height }: InkLayerProps) {
22+
const all = currentStroke ? [...strokes, currentStroke] : strokes;
23+
if (all.length === 0) return null;
24+
return (
25+
<svg
26+
width={width}
27+
height={height}
28+
className="absolute inset-0 pointer-events-none"
29+
style={{ zIndex: 6 }}
30+
>
31+
{all.map((s) => (
32+
<path
33+
key={s.id}
34+
d={pointsToSvgPath(s.points)}
35+
stroke={s.color}
36+
strokeWidth={s.width}
37+
strokeOpacity={s.tool === 'highlighter' ? 0.4 : 1}
38+
fill="none"
39+
strokeLinecap="round"
40+
strokeLinejoin="round"
41+
/>
42+
))}
43+
</svg>
44+
);
45+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* 墨迹工具栏(OneNote 式:选择/钢笔/荧光笔/橡皮 + 色板 + 笔粗)
3+
* Ink toolbar (OneNote-style: select/pen/highlighter/eraser + palette + width)
4+
*
5+
* @ai-context: 阶段三。悬浮于画布顶部居中。钢笔/荧光笔显示色板与笔粗选择;
6+
* 选择/橡皮工具隐藏颜色。当前工具高亮。工具/颜色/笔粗变更上抛父级。
7+
* @ai-context: Floating top-center toolbar. Color palette & width shown only for
8+
* pen/highlighter. Active tool highlighted; changes bubble up to parent.
9+
*/
10+
import { MousePointer2, Pen, Highlighter, Eraser } from 'lucide-react';
11+
import { cn } from '@/lib/utils';
12+
import type { InkTool } from '../../lib/canvas/useInkDrawing';
13+
14+
/** OneNote 式色板 / OneNote-style palette */
15+
const INK_COLORS = ['#1e293b', '#64748b', '#dc2626', '#ea580c', '#eab308', '#16a34a', '#2563eb', '#7c3aed'];
16+
/** 笔粗档位(px) / Width stops */
17+
const INK_WIDTHS = [2, 4, 7];
18+
19+
interface ToolDef {
20+
id: InkTool;
21+
icon: React.ComponentType<React.SVGProps<SVGSVGElement> & { strokeWidth?: number | string }>;
22+
label: string;
23+
}
24+
25+
const TOOLS: ToolDef[] = [
26+
{ id: 'select', icon: MousePointer2, label: '选择(操作文本块)' },
27+
{ id: 'pen', icon: Pen, label: '钢笔' },
28+
{ id: 'highlighter', icon: Highlighter, label: '荧光笔' },
29+
{ id: 'eraser', icon: Eraser, label: '橡皮擦' },
30+
];
31+
32+
interface InkToolbarProps {
33+
tool: InkTool;
34+
color: string;
35+
width: number;
36+
onToolChange: (t: InkTool) => void;
37+
onColorChange: (c: string) => void;
38+
onWidthChange: (w: number) => void;
39+
}
40+
41+
export function InkToolbar({ tool, color, width, onToolChange, onColorChange, onWidthChange }: InkToolbarProps) {
42+
const showInkOptions = tool === 'pen' || tool === 'highlighter';
43+
return (
44+
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 px-3 py-2 rounded-kb-lg bg-bg-elevated/95 backdrop-blur border border-border/60 shadow-kb-md">
45+
<div className="flex items-center gap-1">
46+
{TOOLS.map((t) => {
47+
const Icon = t.icon;
48+
return (
49+
<button
50+
key={t.id}
51+
onClick={() => onToolChange(t.id)}
52+
title={t.label}
53+
className={cn(
54+
'p-1.5 rounded-kb-sm transition-colors',
55+
tool === t.id ? 'bg-brand-500 text-white' : 'text-text-secondary hover:bg-bg-tertiary',
56+
)}
57+
>
58+
<Icon className="w-4 h-4" strokeWidth={1.5} />
59+
</button>
60+
);
61+
})}
62+
</div>
63+
64+
{showInkOptions && (
65+
<>
66+
<div className="w-px h-5 bg-border/60" />
67+
<div className="flex items-center gap-1">
68+
{INK_COLORS.map((c) => (
69+
<button
70+
key={c}
71+
onClick={() => onColorChange(c)}
72+
title={c}
73+
className={cn(
74+
'w-5 h-5 rounded-full border transition-transform',
75+
color === c ? 'border-brand-500 scale-110 ring-1 ring-brand-500/40' : 'border-border/60',
76+
)}
77+
style={{ backgroundColor: c }}
78+
/>
79+
))}
80+
</div>
81+
<div className="w-px h-5 bg-border/60" />
82+
<div className="flex items-center gap-1">
83+
{INK_WIDTHS.map((w) => (
84+
<button
85+
key={w}
86+
onClick={() => onWidthChange(w)}
87+
title={`${w}px`}
88+
className={cn(
89+
'w-6 h-6 rounded-kb-sm flex items-center justify-center transition-colors',
90+
width === w ? 'bg-brand-50 text-brand-600' : 'text-text-secondary hover:bg-bg-tertiary',
91+
)}
92+
>
93+
<span className="rounded-full bg-current" style={{ width: w + 1, height: w + 1 }} />
94+
</button>
95+
))}
96+
</div>
97+
</>
98+
)}
99+
</div>
100+
);
101+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* inkGeometry 单测:墨迹几何纯函数
3+
* Unit tests for ink geometry utilities
4+
*/
5+
import { describe, it, expect } from 'vitest';
6+
import { pointsToSvgPath, distanceToStroke, strokesInRegion } from './inkGeometry';
7+
import type { InkStroke } from '@/types/models';
8+
9+
function stroke(id: string, points: Array<[number, number]>): InkStroke {
10+
return { id, tool: 'pen', color: '#000', width: 2, points: points.map(([x, y]) => ({ x, y })) };
11+
}
12+
13+
describe('pointsToSvgPath', () => {
14+
it('空点返回空字符串', () => {
15+
expect(pointsToSvgPath([])).toBe('');
16+
});
17+
18+
it('单点返回极短线段', () => {
19+
const d = pointsToSvgPath([{ x: 5, y: 5 }]);
20+
expect(d).toContain('M 5 5');
21+
expect(d).toContain('L');
22+
});
23+
24+
it('多点生成 M/Q/L 平滑路径', () => {
25+
const d = pointsToSvgPath([{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 20, y: 0 }]);
26+
expect(d.startsWith('M 0 0')).toBe(true);
27+
expect(d).toContain('Q');
28+
expect(d).toContain('L 20 0');
29+
});
30+
});
31+
32+
describe('distanceToStroke', () => {
33+
it('空笔画返回 Infinity', () => {
34+
expect(distanceToStroke({ x: 0, y: 0 }, stroke('s', []))).toBe(Infinity);
35+
});
36+
37+
it('点到水平线段距离', () => {
38+
const s = stroke('s', [[0, 0], [10, 0]]);
39+
expect(distanceToStroke({ x: 5, y: 3 }, s)).toBeCloseTo(3);
40+
});
41+
42+
it('点在线段上距离为 0', () => {
43+
const s = stroke('s', [[0, 0], [10, 0]]);
44+
expect(distanceToStroke({ x: 5, y: 0 }, s)).toBeCloseTo(0);
45+
});
46+
47+
it('取各线段最小距离', () => {
48+
const s = stroke('s', [[0, 0], [10, 0], [10, 10]]);
49+
expect(distanceToStroke({ x: 10, y: 5 }, s)).toBeCloseTo(0);
50+
});
51+
});
52+
53+
describe('strokesInRegion', () => {
54+
it('选中点在区域内的笔画', () => {
55+
const strokes = [stroke('in', [[5, 5]]), stroke('out', [[50, 50]])];
56+
const ids = strokesInRegion(strokes, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
57+
expect(ids).toEqual(['in']);
58+
});
59+
60+
it('笔画任意点在区域内即选中', () => {
61+
const strokes = [stroke('cross', [[-5, 5], [5, 5]])];
62+
const ids = strokesInRegion(strokes, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
63+
expect(ids).toEqual(['cross']);
64+
});
65+
66+
it('无命中返回空', () => {
67+
const strokes = [stroke('out', [[50, 50]])];
68+
expect(strokesInRegion(strokes, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toEqual([]);
69+
});
70+
});

0 commit comments

Comments
 (0)