diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index 4f07526..03565ad 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -25,6 +25,7 @@ import type { EditorState, Layer, MaskLayer, + PathAnchor, Point, Tool, } from '@/lib/image-editor/types' @@ -41,6 +42,12 @@ export type CanvasHandle = { hasPendingPolyLasso: () => boolean /** Cancel an in-progress Polygonal Lasso chain (Esc binding). */ cancelPendingPolyLasso: () => void + /** True iff a Pen tool path is being built. */ + hasPendingPen: () => boolean + /** Commit the current open Pen path as a layer (Enter binding). */ + commitPendingPen: () => void + /** Discard an in-progress Pen path (Esc binding). */ + cancelPendingPen: () => void } type Props = { @@ -142,6 +149,14 @@ type Interaction = * segment from the last committed point to the cursor. */ | { kind: 'polylasso-drawing'; points: Point[]; cursor: Point } + /** + * Pen tool — click adds a corner anchor; click-and-drag turns it into a + * smooth anchor with symmetric handles. `pressed` is true between mousedown + * and mouseup on the current anchor (the window during which the drag sets + * the handles). Closing happens by clicking near the first anchor (handled + * in mousedown). Esc cancels; Enter commits the current path open. + */ + | { kind: 'pen-drawing'; anchors: PathAnchor[]; pressed: boolean; cursor: Point } export const Canvas = forwardRef(function Canvas( { @@ -222,7 +237,11 @@ export const Canvas = forwardRef(function Canvas( scale: previewScale, previewScale, drawingPreview: - interaction.kind === 'drawing' ? { layer: interaction.layer } : undefined, + interaction.kind === 'drawing' + ? { layer: interaction.layer } + : interaction.kind === 'pen-drawing' && interaction.anchors.length >= 1 + ? { layer: penPreviewLayer(interaction.anchors, toolColor, toolStrokeWidth) } + : undefined, selection: selectionLayer ? { layer: selectionLayer } : undefined, imageCache, liveCanvas: true, @@ -249,7 +268,24 @@ export const Canvas = forwardRef(function Canvas( // Show committed segments + a "rubber band" line from last vertex to cursor. drawPolygonPreview(canvasRef.current, [...interaction.points, interaction.cursor], true) } - }, [image, effectiveState, interaction, selectionLayer, previewScale, imageCache]) + if (interaction.kind === 'pen-drawing') { + drawPenPreview( + canvasRef.current, + interaction.anchors, + interaction.cursor, + interaction.pressed, + ) + } + }, [ + image, + effectiveState, + interaction, + selectionLayer, + previewScale, + imageCache, + toolColor, + toolStrokeWidth, + ]) useImperativeHandle( ref, @@ -302,8 +338,47 @@ export const Canvas = forwardRef(function Canvas( setInteraction({ kind: 'idle' }) } }, + hasPendingPen: () => interaction.kind === 'pen-drawing', + commitPendingPen: () => { + if (interaction.kind !== 'pen-drawing') return + // Inlined to keep this function self-contained (no closed-over + // helper that the deps array would have to track). + if (interaction.anchors.length >= 2) { + onCommitLayer({ + id: crypto.randomUUID(), + name: 'Path', + visible: true, + opacity: 100, + blend: 'normal', + kind: 'annotation', + shape: { + kind: 'path', + anchors: interaction.anchors, + closed: false, + color: toolColor, + strokeWidth: toolStrokeWidth, + }, + } as AnnotationLayer) + } + setInteraction({ kind: 'idle' }) + }, + cancelPendingPen: () => { + if (interaction.kind === 'pen-drawing') { + setInteraction({ kind: 'idle' }) + } + }, }), - [image, state, previewScale, imageCache, interaction, onCommitCrop], + [ + image, + state, + previewScale, + imageCache, + interaction, + onCommitCrop, + onCommitLayer, + toolColor, + toolStrokeWidth, + ], ) // ── Mouse handling ────────────────────────────────────────────────────── @@ -409,6 +484,38 @@ export const Canvas = forwardRef(function Canvas( return } + // Pen tool — click adds an anchor; click+drag turns it into a smooth + // anchor (handles set on mousemove). Clicking near the first anchor with + // ≥2 anchors closes the path. Enter / Esc handled in the parent. + if (tool === 'pen') { + if (interaction.kind === 'pen-drawing') { + const first = interaction.anchors[0] + const closeToFirst = + interaction.anchors.length >= 2 && + Math.abs(p.x - first.x) < 8 && + Math.abs(p.y - first.y) < 8 + if (closeToFirst) { + commitPenPath(interaction.anchors, true) + setInteraction({ kind: 'idle' }) + return + } + setInteraction({ + kind: 'pen-drawing', + anchors: [...interaction.anchors, { x: p.x, y: p.y }], + pressed: true, + cursor: p, + }) + } else { + setInteraction({ + kind: 'pen-drawing', + anchors: [{ x: p.x, y: p.y }], + pressed: true, + cursor: p, + }) + } + return + } + // Magic Wand: click → flood fill bbox handled by parent. if (tool === 'wand') { onWandClick?.(p) @@ -519,6 +626,26 @@ export const Canvas = forwardRef(function Canvas( return } + if (interaction.kind === 'pen-drawing') { + // While the mouse is held after a click, dragging sets the last + // anchor's symmetric handles based on the drag delta from the anchor. + // Without a press, just track cursor for rubber-band preview. + if (interaction.pressed && interaction.anchors.length >= 1) { + const idx = interaction.anchors.length - 1 + const a = interaction.anchors[idx] + const dx = p.x - a.x + const dy = p.y - a.y + if (Math.abs(dx) >= 3 || Math.abs(dy) >= 3) { + const next = [...interaction.anchors] + next[idx] = { ...a, hout: { x: dx, y: dy }, hin: { x: -dx, y: -dy } } + setInteraction({ ...interaction, anchors: next, cursor: p }) + return + } + } + setInteraction({ ...interaction, cursor: p }) + return + } + if (interaction.kind === 'drawing') { updateDrawing(p) return @@ -580,6 +707,11 @@ export const Canvas = forwardRef(function Canvas( } // PolyLasso intentionally does NOT commit on mouseup — clicks add // vertices, double-click (handled in mousedown) closes. + if (interaction.kind === 'pen-drawing') { + // Just release the press — anchors stay; next mousedown adds another. + setInteraction({ ...interaction, pressed: false }) + return + } if (interaction.kind === 'drawing') { if (!shouldDiscardDrawing(interaction.layer)) { onCommitLayer(interaction.layer) @@ -599,6 +731,25 @@ export const Canvas = forwardRef(function Canvas( // ── Drawing-tool helpers ──────────────────────────────────────────────── + function commitPenPath(anchors: PathAnchor[], closed: boolean) { + if (anchors.length < 2) return + onCommitLayer({ + id: crypto.randomUUID(), + name: closed ? 'Closed Path' : 'Path', + visible: true, + opacity: 100, + blend: 'normal', + kind: 'annotation', + shape: { + kind: 'path', + anchors, + closed, + color: toolColor, + strokeWidth: toolStrokeWidth, + }, + } as AnnotationLayer) + } + function startDrawing(p: Point) { const id = crypto.randomUUID() const baseLayer = (name: string) => ({ @@ -801,7 +952,8 @@ export const Canvas = forwardRef(function Canvas( tool === 'polyLasso' || tool === 'wand' || tool === 'note' || - tool === 'frame' + tool === 'frame' || + tool === 'pen' ) { setHoverCursor('crosshair') return @@ -1033,6 +1185,112 @@ function drawPolygonPreview( ctx.restore() } +/** + * Build a temporary AnnotationLayer wrapping an in-progress pen path, so the + * standard render pipeline draws the curves (with crop translation, opacity, + * etc.) — Canvas overlays anchor markers + the rubber-band cursor segment on + * top via `drawPenPreview`. + */ +function penPreviewLayer( + anchors: PathAnchor[], + color: string, + strokeWidth: number, +): AnnotationLayer { + return { + id: '__pen_preview__', + name: 'Pen Preview', + visible: true, + opacity: 100, + blend: 'normal', + kind: 'annotation', + shape: { kind: 'path', anchors, closed: false, color, strokeWidth }, + } +} + +/** + * In-progress pen overlay — anchor squares (orange first, white rest), handle + * lines + dots for smooth anchors, and a dashed rubber-band line previewing + * the next segment from the last anchor to the cursor. + */ +function drawPenPreview( + canvas: HTMLCanvasElement | null, + anchors: PathAnchor[], + cursor: Point, + pressed: boolean, +) { + if (!canvas || anchors.length === 0) return + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + + // Rubber-band: show the next pending segment from last anchor to cursor. + // Skipped while pressed (the user is dragging handles, not aiming the next). + if (!pressed) { + const last = anchors[anchors.length - 1] + ctx.strokeStyle = 'rgba(255,255,255,0.7)' + ctx.lineWidth = 1 + ctx.setLineDash([4, 3]) + ctx.beginPath() + ctx.moveTo(last.x + 0.5, last.y + 0.5) + if (last.hout) { + ctx.quadraticCurveTo( + last.x + last.hout.x + 0.5, + last.y + last.hout.y + 0.5, + cursor.x + 0.5, + cursor.y + 0.5, + ) + } else { + ctx.lineTo(cursor.x + 0.5, cursor.y + 0.5) + } + ctx.stroke() + ctx.setLineDash([]) + } + + // Handles: lines from anchor to control points, with small dots at endpoints. + for (const a of anchors) { + if (a.hin) drawHandle(ctx, a.x, a.y, a.x + a.hin.x, a.y + a.hin.y) + if (a.hout) drawHandle(ctx, a.x, a.y, a.x + a.hout.x, a.y + a.hout.y) + } + + // Anchor squares — orange for the first (close-target hint), white otherwise. + for (let i = 0; i < anchors.length; i++) { + const a = anchors[i] + const size = 6 + ctx.fillStyle = i === 0 ? '#ffaa00' : '#ffffff' + ctx.strokeStyle = '#000' + ctx.lineWidth = 1 + ctx.fillRect(a.x - size / 2, a.y - size / 2, size, size) + ctx.strokeRect(a.x - size / 2, a.y - size / 2, size, size) + } + + ctx.restore() +} + +function drawHandle( + ctx: CanvasRenderingContext2D, + ax: number, + ay: number, + cx: number, + cy: number, +) { + // Tether line + ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)' + ctx.lineWidth = 1 + ctx.setLineDash([]) + ctx.beginPath() + ctx.moveTo(ax + 0.5, ay + 0.5) + ctx.lineTo(cx + 0.5, cy + 0.5) + ctx.stroke() + // Control-point dot + ctx.fillStyle = '#60a5fa' + ctx.beginPath() + ctx.arc(cx, cy, 3, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = '#000' + ctx.stroke() +} + /** * In-progress marquee selection preview — same look as the committed selection * (white dashes over a black halo). Coords in canvas-pixel space; identity diff --git a/src/components/image-editor/LayersPanel.tsx b/src/components/image-editor/LayersPanel.tsx index 0228040..eb6fed8 100644 --- a/src/components/image-editor/LayersPanel.tsx +++ b/src/components/image-editor/LayersPanel.tsx @@ -190,5 +190,7 @@ function layerLabelKey(layer: Layer): string { return 'pages.imageEditor.annoLabel.note' case 'frame': return 'pages.imageEditor.annoLabel.frame' + case 'path': + return 'pages.imageEditor.annoLabel.path' } } diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index 324db5f..d2aa470 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -442,6 +442,46 @@ export function OptionsBar({ ) } + if (tool === 'pen') { + return ( +
+
+ {t('pages.imageEditor.strokeWidth')}: + setStrokeWidth(Number(e.target.value) || 1)} + /> +
+
+ {t('pages.imageEditor.color')}: + setFgColor(e.target.value)} + style={{ + width: 22, + height: 22, + padding: 0, + border: '1px solid var(--pf-line)', + background: 'transparent', + borderRadius: 3, + cursor: 'pointer', + }} + /> +
+
+ + {t('pages.imageEditor.penHint')} + +
+
+ ) + } + if (tool === 'mask' || tool === 'mosaic' || tool === 'blur') { return (
diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index f9d32b3..3d78951 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -141,7 +141,6 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.pen', shortcut: 'P', - stub: true, }, { id: 'arrowPath', diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index f2db64a..974d688 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -11,6 +11,5 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'spotHeal', 'stamp', 'historyBrush', - 'pen', - // hand, arrowPath, frame, note are functional — see ImageEditor / Canvas. + // hand, arrowPath, frame, note, pen are functional — see ImageEditor / Canvas. ]) diff --git a/src/i18n/en.json b/src/i18n/en.json index 3ab5436..479d5ed 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -564,6 +564,7 @@ "notePrompt": "Note text", "frameHint": "Frame: drag to draw a rectangular placeholder frame.", "arrowPathHint": "Path Selection (A): click to select a vector layer, drag to move. Behaves like the Move tool — full vector-anchor selection arrives with the Pen tool.", + "penHint": "Pen (P): click to add corner anchors, click+drag to add smooth anchors with handles. Click the first anchor to close · Enter to commit open · Esc to cancel.", "currentTool": "Tool", "zoomFit": "Fit (Cmd/Ctrl+0)", "style": "Style", @@ -606,7 +607,8 @@ "dodge": "Dodge stroke", "burn": "Burn stroke", "note": "Note", - "frame": "Frame" + "frame": "Frame", + "path": "Path" }, "project": "Project", "projectSave": "Save project (.json)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index b31ed14..05ebf1f 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -564,6 +564,7 @@ "notePrompt": "便签文字", "frameHint": "画框:拖动绘制一个矩形占位框。", "arrowPathHint": "路径选择 (A):单击选中矢量图层,拖动移动。当前与移动工具行为一致 —— 完整的锚点选择需待钢笔工具上线。", + "penHint": "钢笔 (P):单击添加角点,单击拖动添加带手柄的平滑点。点击第一个锚点闭合 · Enter 提交开放路径 · Esc 取消。", "currentTool": "工具", "zoomFit": "适配 (Cmd/Ctrl+0)", "style": "样式", @@ -606,7 +607,8 @@ "dodge": "减淡笔触", "burn": "加深笔触", "note": "便签", - "frame": "画框" + "frame": "画框", + "path": "路径" }, "project": "项目", "projectSave": "保存项目 (.json)", diff --git a/src/lib/image-editor/drawing.ts b/src/lib/image-editor/drawing.ts index 7cbe155..caf23bd 100644 --- a/src/lib/image-editor/drawing.ts +++ b/src/lib/image-editor/drawing.ts @@ -8,6 +8,7 @@ import type { LineShape, MosaicShape, NoteShape, + PathShape, RectShape, Shape, TextShape, @@ -63,7 +64,74 @@ export function drawShape( case 'frame': drawFrame(ctx, shape, scale) break + case 'path': + drawPath(ctx, shape, scale) + break + } +} + +/** + * Vector path. Walks anchors, emitting bezierCurveTo / quadraticCurveTo / + * lineTo segments depending on which handles are present on each side. Closed + * paths add a final segment from the last anchor back to the first (using + * last.hout + first.hin). + */ +function drawPath(ctx: CanvasRenderingContext2D, s: PathShape, scale: number) { + if (s.anchors.length === 0) return + const k = (n: number) => n * scale + ctx.beginPath() + const a0 = s.anchors[0] + ctx.moveTo(k(a0.x), k(a0.y)) + for (let i = 1; i < s.anchors.length; i++) { + drawSegment(ctx, s.anchors[i - 1], s.anchors[i], scale) } + if (s.closed && s.anchors.length >= 2) { + drawSegment(ctx, s.anchors[s.anchors.length - 1], a0, scale) + ctx.closePath() + } + if (s.fill && s.closed) { + ctx.fillStyle = s.fill + ctx.fill() + } + ctx.strokeStyle = s.color + ctx.lineWidth = s.strokeWidth * scale + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.stroke() +} + +function drawSegment( + ctx: CanvasRenderingContext2D, + prev: PathAnchorLike, + curr: PathAnchorLike, + scale: number, +) { + const k = (n: number) => n * scale + const out = prev.hout + const inn = curr.hin + if (out && inn) { + ctx.bezierCurveTo( + k(prev.x + out.x), + k(prev.y + out.y), + k(curr.x + inn.x), + k(curr.y + inn.y), + k(curr.x), + k(curr.y), + ) + } else if (out) { + ctx.quadraticCurveTo(k(prev.x + out.x), k(prev.y + out.y), k(curr.x), k(curr.y)) + } else if (inn) { + ctx.quadraticCurveTo(k(curr.x + inn.x), k(curr.y + inn.y), k(curr.x), k(curr.y)) + } else { + ctx.lineTo(k(curr.x), k(curr.y)) + } +} + +type PathAnchorLike = { + x: number + y: number + hin?: { x: number; y: number } + hout?: { x: number; y: number } } /** diff --git a/src/lib/image-editor/hit.ts b/src/lib/image-editor/hit.ts index 76bc7ad..a4b5d4c 100644 --- a/src/lib/image-editor/hit.ts +++ b/src/lib/image-editor/hit.ts @@ -69,6 +69,27 @@ function getShapeBBox(shape: Shape): Rect { const pad = shape.strokeWidth / 2 + 2 return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 } } + case 'path': { + if (shape.anchors.length === 0) return { x: 0, y: 0, w: 0, h: 0 } + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const a of shape.anchors) { + const xs = [a.x, a.hin && a.x + a.hin.x, a.hout && a.x + a.hout.x] + const ys = [a.y, a.hin && a.y + a.hin.y, a.hout && a.y + a.hout.y] + for (const v of xs) if (v !== undefined) { + if (v < minX) minX = v + if (v > maxX) maxX = v + } + for (const v of ys) if (v !== undefined) { + if (v < minY) minY = v + if (v > maxY) maxY = v + } + } + const pad = shape.strokeWidth / 2 + 2 + return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 } + } } } @@ -133,7 +154,10 @@ export function getHandles(layer: Layer): Handle[] { case 'text': case 'brush': case 'note': - // Note is move-only; resizing it would just stretch a 16-px icon. + case 'path': + // Path: per-anchor handles arrive when full vector editing lands; v1 + // is move-only. Note: move-only by design (resizing a 16-px icon makes + // little sense). return [] } } diff --git a/src/lib/image-editor/transform.ts b/src/lib/image-editor/transform.ts index 23f30f4..7ebdb9c 100644 --- a/src/lib/image-editor/transform.ts +++ b/src/lib/image-editor/transform.ts @@ -38,6 +38,13 @@ function translateShape(shape: Shape, dx: number, dy: number): Shape { ...shape, points: shape.points.map((p) => ({ x: p.x + dx, y: p.y + dy })), } + case 'path': + // Anchor handles are stored RELATIVE to their anchor — only the anchor + // positions move on translation. + return { + ...shape, + anchors: shape.anchors.map((a) => ({ ...a, x: a.x + dx, y: a.y + dy })), + } } } diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts index 9264601..23d2956 100644 --- a/src/lib/image-editor/types.ts +++ b/src/lib/image-editor/types.ts @@ -205,6 +205,32 @@ export type FrameShape = { name?: string } +/** + * One anchor on a vector path. `hin` / `hout` are control-handle offsets + * RELATIVE to the anchor (i.e. the absolute control point is `anchor + hin`). + * Missing handles imply a corner anchor on that side; both missing = the + * segment to/from this anchor degrades to a straight line. + */ +export type PathAnchor = { + x: number + y: number + hin?: { x: number; y: number } + hout?: { x: number; y: number } +} +/** + * Vector path made of cubic-bezier (or straight) segments between anchors. + * Open paths render as an unfilled polyline-ish curve; closed paths can + * optionally be filled. + */ +export type PathShape = { + kind: 'path' + anchors: PathAnchor[] + closed: boolean + color: string + strokeWidth: number + fill?: string +} + export type Shape = | RectShape | ArrowShape @@ -217,6 +243,7 @@ export type Shape = | BlurShape | NoteShape | FrameShape + | PathShape /** Drop shadow applied to a layer at render time via canvas shadow* properties. */ export type Shadow = { diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 9390013..375841a 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -230,6 +230,11 @@ export function ImageEditorPage() { canvasRef.current.commitPendingCrop() return } + if (e.key === 'Enter' && canvasRef.current?.hasPendingPen()) { + e.preventDefault() + canvasRef.current.commitPendingPen() + return + } if (e.key === 'Escape' && canvasRef.current?.hasPendingCrop()) { e.preventDefault() canvasRef.current.cancelPendingCrop() @@ -240,6 +245,11 @@ export function ImageEditorPage() { canvasRef.current.cancelPendingPolyLasso() return } + if (e.key === 'Escape' && canvasRef.current?.hasPendingPen()) { + e.preventDefault() + canvasRef.current.cancelPendingPen() + return + } if (e.key === 'Escape' && focused) { e.preventDefault(); setFocused(false); return } if (e.key === 'x' || e.key === 'X') { e.preventDefault(); swapColors(); return } if (e.key === 'd' || e.key === 'D') { e.preventDefault(); resetColors(); return }