diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index 4b39642..433b897 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -37,6 +37,10 @@ export type CanvasHandle = { cancelPendingCrop: () => void /** True iff there's a pending (un-committed) crop drag waiting on user. */ hasPendingCrop: () => boolean + /** True iff a Polygonal Lasso vertex chain is in progress. */ + hasPendingPolyLasso: () => boolean + /** Cancel an in-progress Polygonal Lasso chain (Esc binding). */ + cancelPendingPolyLasso: () => void } type Props = { @@ -89,6 +93,20 @@ type Props = { * shape coords are stored). */ onCommitSelection?: (rect: { x: number; y: number; w: number; h: number }) => void + /** + * Called by Lasso / Polygonal Lasso when the user closes a non-trivial + * polygon. Points are in canvas-pixel space; the parent shifts by the + * crop origin and stores both the bbox and the polygon. + */ + onCommitPolygonSelection?: (points: Point[]) => void + /** + * Called by the Magic Wand on click. Point is in canvas-pixel space; the + * parent runs the flood fill and stores the bbox of the matching region + * as the rect selection. + */ + onWandClick?: (point: Point) => void + /** Tolerance for the Magic Wand flood fill (0–128). */ + wandTolerance?: number } type Interaction = @@ -116,6 +134,14 @@ type Interaction = | { kind: 'gradient-drawing'; start: Point; end: Point } /** Marquee selection drag — rect in canvas-pixel space. */ | { kind: 'marquee-drawing'; rect: { x: number; y: number; w: number; h: number } } + /** Lasso freeform drag — accumulating points in canvas-pixel space. */ + | { kind: 'lasso-drawing'; points: Point[] } + /** + * Polygonal Lasso click-by-click. `points` are committed; `cursor` is the + * current mouse position so the live preview can show the next pending + * segment from the last committed point to the cursor. + */ + | { kind: 'polylasso-drawing'; points: Point[]; cursor: Point } export const Canvas = forwardRef(function Canvas( { @@ -136,6 +162,8 @@ export const Canvas = forwardRef(function Canvas( onBucketClick, onCommitGradient, onCommitSelection, + onCommitPolygonSelection, + onWandClick, }, ref, ) { @@ -214,6 +242,13 @@ export const Canvas = forwardRef(function Canvas( if (interaction.kind === 'marquee-drawing') { drawMarqueePreview(canvasRef.current, interaction.rect) } + if (interaction.kind === 'lasso-drawing') { + drawPolygonPreview(canvasRef.current, interaction.points, false) + } + if (interaction.kind === 'polylasso-drawing') { + // 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]) useImperativeHandle( @@ -261,6 +296,12 @@ export const Canvas = forwardRef(function Canvas( }, hasPendingCrop: () => interaction.kind === 'crop-pending' || interaction.kind === 'crop-drawing', + hasPendingPolyLasso: () => interaction.kind === 'polylasso-drawing', + cancelPendingPolyLasso: () => { + if (interaction.kind === 'polylasso-drawing') { + setInteraction({ kind: 'idle' }) + } + }, }), [image, state, previewScale, imageCache, interaction, onCommitCrop], ) @@ -334,6 +375,46 @@ export const Canvas = forwardRef(function Canvas( return } + // Lasso: drag-to-trace a freeform polygon. mousedown starts; mousemove + // appends points; mouseup closes + commits. + if (tool === 'lasso') { + setInteraction({ kind: 'lasso-drawing', points: [p] }) + return + } + + // Polygonal Lasso: each click adds a vertex; double-click closes. Esc + // cancels (handled below in the keydown path). + if (tool === 'polyLasso') { + if (interaction.kind === 'polylasso-drawing') { + // Double-click detection: if click is within ~6 px of the first point + // AND there are ≥3 vertices, close the polygon. + const first = interaction.points[0] + const closeToFirst = + interaction.points.length >= 3 && + Math.abs(p.x - first.x) < 8 && + Math.abs(p.y - first.y) < 8 + if (closeToFirst) { + onCommitPolygonSelection?.(interaction.points) + setInteraction({ kind: 'idle' }) + } else { + setInteraction({ + kind: 'polylasso-drawing', + points: [...interaction.points, p], + cursor: p, + }) + } + } else { + setInteraction({ kind: 'polylasso-drawing', points: [p], cursor: p }) + } + return + } + + // Magic Wand: click → flood fill bbox handled by parent. + if (tool === 'wand') { + onWandClick?.(p) + return + } + // Drawing tools take priority over selection. if (tool !== 'none') { startDrawing(p) @@ -403,6 +484,22 @@ export const Canvas = forwardRef(function Canvas( return } + if (interaction.kind === 'lasso-drawing') { + // Subsample: only append if at least 2 px from the last point, so the + // path doesn't bloat at slow drags. + const last = interaction.points[interaction.points.length - 1] + if (Math.abs(p.x - last.x) >= 2 || Math.abs(p.y - last.y) >= 2) { + setInteraction({ kind: 'lasso-drawing', points: [...interaction.points, p] }) + } + return + } + + if (interaction.kind === 'polylasso-drawing') { + // Just update cursor — no point committed until next click. + setInteraction({ ...interaction, cursor: p }) + return + } + if (interaction.kind === 'drawing') { updateDrawing(p) return @@ -454,6 +551,16 @@ export const Canvas = forwardRef(function Canvas( setInteraction({ kind: 'idle' }) return } + if (interaction.kind === 'lasso-drawing') { + // Need ≥3 distinct points to make a polygon. Otherwise drop. + if (interaction.points.length >= 3) { + onCommitPolygonSelection?.(interaction.points) + } + setInteraction({ kind: 'idle' }) + return + } + // PolyLasso intentionally does NOT commit on mouseup — clicks add + // vertices, double-click (handled in mousedown) closes. if (interaction.kind === 'drawing') { if (!shouldDiscardDrawing(interaction.layer)) { onCommitLayer(interaction.layer) @@ -652,7 +759,14 @@ export const Canvas = forwardRef(function Canvas( setHoverCursor('crosshair') return } - if (tool === 'bucket' || tool === 'gradient' || tool === 'marquee') { + if ( + tool === 'bucket' || + tool === 'gradient' || + tool === 'marquee' || + tool === 'lasso' || + tool === 'polyLasso' || + tool === 'wand' + ) { setHoverCursor('crosshair') return } @@ -815,6 +929,66 @@ function drawGradientOverlay( ctx.restore() } +/** + * In-progress lasso/polyLasso preview — open polyline (or open with rubber- + * band cursor segment for polyLasso). Same white-dashes-over-black look as + * the committed marching-ants. `rubberBand=true` draws the last segment in + * a slightly different style to hint that it's not yet committed. + */ +function drawPolygonPreview( + canvas: HTMLCanvasElement | null, + points: Point[], + rubberBand: boolean, +) { + if (!canvas || points.length < 2) return + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + const trace = (n: number) => { + ctx.beginPath() + ctx.moveTo(points[0].x + 0.5, points[0].y + 0.5) + for (let i = 1; i < n; i++) ctx.lineTo(points[i].x + 0.5, points[i].y + 0.5) + } + // Black halo for committed segments (all but last when rubber-banding). + const committedCount = rubberBand ? points.length - 1 : points.length + if (committedCount >= 2) { + ctx.strokeStyle = '#000000' + ctx.lineWidth = 1 + ctx.setLineDash([]) + trace(committedCount) + ctx.stroke() + ctx.strokeStyle = '#ffffff' + ctx.setLineDash([4, 3]) + trace(committedCount) + ctx.stroke() + } + // Rubber-band segment: dashed grey, less prominent. + if (rubberBand && committedCount >= 1) { + const a = points[committedCount - 1] + const b = points[committedCount] + ctx.strokeStyle = 'rgba(255,255,255,0.6)' + ctx.setLineDash([2, 3]) + ctx.beginPath() + ctx.moveTo(a.x + 0.5, a.y + 0.5) + ctx.lineTo(b.x + 0.5, b.y + 0.5) + ctx.stroke() + } + ctx.setLineDash([]) + // Vertex dots — useful for polyLasso to see where you've clicked. + for (let i = 0; i < points.length; i++) { + const p = points[i] + ctx.beginPath() + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2) + ctx.fillStyle = i === 0 ? '#ffaa00' : '#ffffff' + ctx.fill() + ctx.strokeStyle = '#000000' + ctx.lineWidth = 1 + ctx.stroke() + } + ctx.restore() +} + /** * 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/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index 60a8bb1..e45b087 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -11,6 +11,8 @@ type Props = { setStrokeWidth: (n: number) => void bucketTolerance: number setBucketTolerance: (n: number) => void + wandTolerance: number + setWandTolerance: (n: number) => void /** Show "applies to all in fly-out group" notice for stub tools. */ isStubTool: boolean /** Re-fired with the toast pattern when a stub tool was clicked. */ @@ -40,6 +42,8 @@ export function OptionsBar({ setStrokeWidth, bucketTolerance, setBucketTolerance, + wandTolerance, + setWandTolerance, isStubTool, stubMessage, hasActiveCrop, @@ -61,13 +65,66 @@ export function OptionsBar({ ) } - // Marquee — show selection-state hint + "Deselect" button when active. - if (tool === 'marquee') { + // Marquee / Lasso / Polygonal Lasso — same shell: hint + "Deselect" when active. + if (tool === 'marquee' || tool === 'lasso' || tool === 'polyLasso') { + const hintKey = + tool === 'lasso' + ? 'pages.imageEditor.lassoHint' + : tool === 'polyLasso' + ? 'pages.imageEditor.polyLassoHint' + : 'pages.imageEditor.marqueeHint' return (
- {t('pages.imageEditor.marqueeHint')} + {t(hintKey)} + +
+ {hasSelection && ( +
+ +
+ )} +
+ ) + } + + // Magic Wand — tolerance slider + hint + Deselect button. + if (tool === 'wand') { + return ( +
+
+ {t('pages.imageEditor.wandTolerance')}: + + setWandTolerance(Math.min(128, Math.max(0, Number(e.target.value) || 0))) + } + /> + setWandTolerance(Number(e.target.value))} + style={{ width: 120, accentColor: 'var(--pf-accent)' }} + /> +
+
+ + {t('pages.imageEditor.wandHint')}
{hasSelection && ( diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index 5137ceb..397b754 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -62,14 +62,17 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.lasso', shortcut: 'L', - stub: true, + }, + { + id: 'polyLasso', + icon: , + labelKey: 'pages.imageEditor.tool.polyLasso', }, { id: 'wand', icon: , labelKey: 'pages.imageEditor.tool.wand', shortcut: 'W', - stub: true, }, { id: 'crop', icon: , labelKey: 'pages.imageEditor.tool.crop', shortcut: 'C' }, { diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index 27b9c13..a0523be 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -8,9 +8,6 @@ import type { Tool } from '@/lib/image-editor/types' * duplication isn't worth a build-time dance. */ export const STUB_TOOLS: ReadonlySet = new Set([ - 'lasso', - 'polyLasso', - 'wand', 'spotHeal', 'stamp', 'historyBrush', diff --git a/src/i18n/en.json b/src/i18n/en.json index 448574a..ab02f7f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -530,6 +530,11 @@ "dodgeHint": "Dodge (O): drag to brighten — like PS, repeat strokes to build up the effect. Layer opacity controls intensity.", "burnHint": "Burn: drag to darken. Repeat strokes to build up; layer opacity controls intensity.", "marqueeHint": "Marquee (M): drag a rectangular selection. Cmd/Ctrl+A select all · Cmd/Ctrl+D deselect.", + "lassoHint": "Lasso (L): drag to trace a freeform region. Release the mouse to close and commit.", + "polyLassoHint": "Polygonal Lasso: click to add vertices, click near the first vertex (or hit Esc to cancel) to close.", + "wandHint": "Magic Wand (W): click a region — pixels within tolerance of the click colour are selected (bounding box).", + "wandTolerance": "Tolerance", + "wandEmpty": "No matching region at that point — try a higher tolerance.", "deselect": "Deselect", "rotateViewHint": "Rotate View (R): each click rotates the workspace 90°. Pixels are not modified — purely a viewing aid.", "textToolHint": "Type (T): click on the canvas to add text.", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 30ccd1c..663b126 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -530,6 +530,11 @@ "dodgeHint": "减淡 (O):拖动以提亮 —— 与 PS 一致,重复涂抹可叠加效果。图层不透明度控制强度。", "burnHint": "加深:拖动以压暗。重复涂抹叠加效果,图层不透明度控制强度。", "marqueeHint": "矩形选框 (M):拖出矩形选区。Cmd/Ctrl+A 全选 · Cmd/Ctrl+D 取消选区。", + "lassoHint": "套索 (L):拖动鼠标勾出任意形状的选区,松开鼠标自动闭合并提交。", + "polyLassoHint": "多边形套索:单击添加节点,靠近起点再单击闭合(按 Esc 取消)。", + "wandHint": "魔棒 (W):单击某点,颜色与之相近(容差范围内)的连通区域将被选中(取外接矩形)。", + "wandTolerance": "容差", + "wandEmpty": "该位置未找到匹配区域 —— 试试调高容差。", "deselect": "取消选区", "rotateViewHint": "旋转视图 (R):每次点击旋转工作区 90°,仅影响显示,不修改像素。", "textToolHint": "文字 (T):在画布上单击以新增文字。", diff --git a/src/lib/image-editor/render.ts b/src/lib/image-editor/render.ts index 750a108..9741c59 100644 --- a/src/lib/image-editor/render.ts +++ b/src/lib/image-editor/render.ts @@ -7,6 +7,7 @@ import type { EditorState, Layer, MaskLayer, + Point, Rect, Shadow, } from './types' @@ -210,12 +211,60 @@ export function renderTo(canvas: HTMLCanvasElement, input: RenderInput): void { } // Marquee selection (state.selection) — UI affordance only, gated on - // `liveCanvas` so it never bakes into an export. + // `liveCanvas` so it never bakes into an export. If `selectionPath` is set, + // draw a closed polygon outline (Lasso / Polygonal Lasso); otherwise the + // rect bbox (Marquee / Wand). if (input.liveCanvas && state.selection) { - drawMarqueeChrome(ctx, state.selection, cropOriginX, cropOriginY, annoScale) + if (state.selectionPath && state.selectionPath.length >= 3) { + drawSelectionPathChrome(ctx, state.selectionPath, cropOriginX, cropOriginY, annoScale) + } else { + drawMarqueeChrome(ctx, state.selection, cropOriginX, cropOriginY, annoScale) + } } } +/** + * Polygon variant of the marching-ants selection chrome. Same white-on-black + * dashed look as `drawMarqueeChrome`, but along an arbitrary closed path — + * used for Lasso and Polygonal Lasso selections. + */ +function drawSelectionPathChrome( + ctx: CanvasRenderingContext2D, + path: Point[], + cropOriginX: number, + cropOriginY: number, + scale: number, +) { + const tx = (p: Point) => ({ + x: (p.x - cropOriginX) * scale, + y: (p.y - cropOriginY) * scale, + }) + ctx.save() + const trace = () => { + ctx.beginPath() + const first = tx(path[0]) + ctx.moveTo(first.x + 0.5, first.y + 0.5) + for (let i = 1; i < path.length; i++) { + const p = tx(path[i]) + ctx.lineTo(p.x + 0.5, p.y + 0.5) + } + ctx.closePath() + } + // Black halo + ctx.strokeStyle = '#000000' + ctx.lineWidth = Math.max(1, scale) + ctx.setLineDash([]) + trace() + ctx.stroke() + // White dashes + ctx.strokeStyle = '#ffffff' + ctx.setLineDash([4 * scale, 3 * scale]) + trace() + ctx.stroke() + ctx.setLineDash([]) + ctx.restore() +} + /** * Draw the active marquee selection — a dashed white outline (with a thin * black halo for legibility on any background). Coords are in original-image diff --git a/src/lib/image-editor/serialize.ts b/src/lib/image-editor/serialize.ts index a204987..f914036 100644 --- a/src/lib/image-editor/serialize.ts +++ b/src/lib/image-editor/serialize.ts @@ -47,6 +47,7 @@ export function parseProject(text: string): Project { adjust: { ...fallback.adjust, ...obj.state.adjust }, cropRect: obj.state.cropRect, selection: obj.state.selection, + selectionPath: obj.state.selectionPath, }, } } diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts index bcaf465..d9c3dbc 100644 --- a/src/lib/image-editor/types.ts +++ b/src/lib/image-editor/types.ts @@ -229,13 +229,15 @@ export type EditorState = { transforms: Transforms adjust: Adjustments /** - * Active marquee selection. Stored in the same coordinate space shape coords - * use (post-rotation preview-pixel space, relative to the original image). - * Other tools (bucket / brush / fill) can later restrict their effect to - * this rect. Cleared via Cmd+D, replaced via re-marquee, persisted through - * undo/redo + .json save. + * Active selection. `selection` is the bounding rect (always set when a + * selection exists); `selectionPath`, when present, refines the visible + * outline to a polygon — used by the Lasso / Polygonal Lasso tools. + * Coords are in post-rotation preview-pixel space, relative to the original + * image. Persisted through undo/redo + .json save. Other tools can read + * `selection` (and optionally `selectionPath`) to restrict their effect. */ selection?: Rect + selectionPath?: Point[] /** * Optional crop region. Stored in the same coordinate space shape coords use * (post-rotation preview-canvas pixels), so it's applied after transforms. diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 8918b97..05e92a7 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -27,6 +27,7 @@ import type { EditorState, Layer, OutputFormat, + Point, Tool, Transforms, } from '@/lib/image-editor/types' @@ -76,6 +77,7 @@ export function ImageEditorPage() { ) const [strokeWidth, setStrokeWidth] = useState(4) const [bucketTolerance, setBucketTolerance] = useState(32) + const [wandTolerance, setWandTolerance] = useState(32) const [selectedLayerId, setSelectedLayerId] = useState('image') const [outFormat, setOutFormat] = useState('png') @@ -184,7 +186,7 @@ export function ImageEditorPage() { // when there's no image yet. if (e.key === 'd' || e.key === 'D') { e.preventDefault() - history.set({ ...state, selection: undefined }) + history.set({ ...state, selection: undefined, selectionPath: undefined }) return } if (e.key === 'a' || e.key === 'A') { @@ -195,6 +197,7 @@ export function ImageEditorPage() { history.set({ ...state, selection: { x: 0, y: 0, w: baseW * previewScale, h: baseH * previewScale }, + selectionPath: undefined, }) } return @@ -227,6 +230,11 @@ export function ImageEditorPage() { canvasRef.current.cancelPendingCrop() return } + if (e.key === 'Escape' && canvasRef.current?.hasPendingPolyLasso()) { + e.preventDefault() + canvasRef.current.cancelPendingPolyLasso() + 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 } @@ -374,11 +382,119 @@ export function ImageEditorPage() { const y0 = Math.min(rect.y, rect.y + rect.h) + cropOriginY const w = Math.abs(rect.w) const h = Math.abs(rect.h) - history.set({ ...state, selection: { x: x0, y: y0, w, h } }) + history.set({ ...state, selection: { x: x0, y: y0, w, h }, selectionPath: undefined }) }, [history, state], ) + /** + * Commit a polygon selection from Lasso / Polygonal Lasso. Points arrive in + * cropped-canvas preview-pixel space; we shift each by the crop origin so + * both `selection` (bbox) and `selectionPath` (outline) live in + * original-image preview-pixel space — same convention as marquee. + */ + const handleCommitPolygonSelection = useCallback( + (points: Point[]) => { + if (points.length < 3) return + const cropOriginX = state.cropRect + ? Math.min(state.cropRect.x, state.cropRect.x + state.cropRect.w) + : 0 + const cropOriginY = state.cropRect + ? Math.min(state.cropRect.y, state.cropRect.y + state.cropRect.h) + : 0 + const shifted = points.map((p) => ({ x: p.x + cropOriginX, y: p.y + cropOriginY })) + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const p of shifted) { + if (p.x < minX) minX = p.x + if (p.y < minY) minY = p.y + if (p.x > maxX) maxX = p.x + if (p.y > maxY) maxY = p.y + } + history.set({ + ...state, + selection: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, + selectionPath: shifted, + }) + }, + [history, state], + ) + + /** + * Magic Wand click. Renders the canvas at source resolution, runs the same + * scanline flood fill the Paint Bucket uses, and stores the bbox of the + * matching region as a rectangular selection (no polygon path — wand + * regions are already implied by their bbox + contents). + */ + const handleWandClick = useCallback( + async (point: Point) => { + if (!image) return + const { baseW, baseH } = dimsAfterRotation(image, state) + const previewScale = Math.min(1, PREVIEW_MAX / Math.max(baseW, baseH, 1)) + const srcCanvas = document.createElement('canvas') + renderTo(srcCanvas, { image, state, scale: 1, previewScale, imageCache }) + const ctx = srcCanvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return + + const cropOriginX = state.cropRect + ? Math.min(state.cropRect.x, state.cropRect.x + state.cropRect.w) + : 0 + const cropOriginY = state.cropRect + ? Math.min(state.cropRect.y, state.cropRect.y + state.cropRect.h) + : 0 + const sx = Math.round((point.x + cropOriginX) / previewScale) + const sy = Math.round((point.y + cropOriginY) / previewScale) + + let imageData: ImageData + try { + imageData = ctx.getImageData(0, 0, srcCanvas.width, srcCanvas.height) + } catch { + toast.error(t('pages.imageEditor.errBucketRead')) + return + } + + const mask = floodFillMask(imageData, sx, sy, wandTolerance) + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + let any = false + const w = srcCanvas.width + const h = srcCanvas.height + for (let y = 0; y < h; y++) { + const row = y * w + for (let x = 0; x < w; x++) { + if (mask[row + x]) { + any = true + if (x < minX) minX = x + if (y < minY) minY = y + if (x > maxX) maxX = x + if (y > maxY) maxY = y + } + } + } + if (!any) { + toast.message(t('pages.imageEditor.wandEmpty')) + return + } + // bbox is in source-pixel space; convert to original-image preview + // pixel space (where selection coords live) by multiplying by previewScale. + history.set({ + ...state, + selection: { + x: minX * previewScale, + y: minY * previewScale, + w: (maxX - minX + 1) * previewScale, + h: (maxY - minY + 1) * previewScale, + }, + selectionPath: undefined, + }) + }, + [image, state, imageCache, wandTolerance, history, t], + ) + const handleClearCrop = useCallback(() => { if (!state.cropRect) return history.set({ ...state, cropRect: undefined }) @@ -710,11 +826,15 @@ export function ImageEditorPage() { setStrokeWidth={setStrokeWidth} bucketTolerance={bucketTolerance} setBucketTolerance={setBucketTolerance} + wandTolerance={wandTolerance} + setWandTolerance={setWandTolerance} isStubTool={STUB_TOOLS.has(tool)} hasActiveCrop={!!state.cropRect} onClearCrop={handleClearCrop} hasSelection={!!state.selection} - onClearSelection={() => history.set({ ...state, selection: undefined })} + onClearSelection={() => + history.set({ ...state, selection: undefined, selectionPath: undefined }) + } />