Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 175 additions & 1 deletion src/components/image-editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<CanvasHandle, Props>(function Canvas(
{
Expand All @@ -136,6 +162,8 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
onBucketClick,
onCommitGradient,
onCommitSelection,
onCommitPolygonSelection,
onWandClick,
},
ref,
) {
Expand Down Expand Up @@ -214,6 +242,13 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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(
Expand Down Expand Up @@ -261,6 +296,12 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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],
)
Expand Down Expand Up @@ -334,6 +375,46 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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)
Expand Down Expand Up @@ -403,6 +484,22 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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
Expand Down Expand Up @@ -454,6 +551,16 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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)
Expand Down Expand Up @@ -652,7 +759,14 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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
}
Expand Down Expand Up @@ -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
Expand Down
63 changes: 60 additions & 3 deletions src/components/image-editor/OptionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -40,6 +42,8 @@ export function OptionsBar({
setStrokeWidth,
bucketTolerance,
setBucketTolerance,
wandTolerance,
setWandTolerance,
isStubTool,
stubMessage,
hasActiveCrop,
Expand All @@ -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 (
<div className="pf-options">
<div className="pf-opt-group">
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.marqueeHint')}
{t(hintKey)}
</span>
</div>
{hasSelection && (
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<button
type="button"
className="pf-opt-btn"
onClick={onClearSelection}
style={{ width: 'auto', padding: '0 8px' }}
title={t('pages.imageEditor.deselect')}
>
{t('pages.imageEditor.deselect')}
</button>
</div>
)}
</div>
)
}

// Magic Wand — tolerance slider + hint + Deselect button.
if (tool === 'wand') {
return (
<div className="pf-options">
<div className="pf-opt-group">
<span className="pf-opt-label">{t('pages.imageEditor.wandTolerance')}:</span>
<input
className="pf-opt-input"
type="number"
min={0}
max={128}
value={wandTolerance}
onChange={(e) =>
setWandTolerance(Math.min(128, Math.max(0, Number(e.target.value) || 0)))
}
/>
<input
type="range"
min={0}
max={128}
value={wandTolerance}
onChange={(e) => setWandTolerance(Number(e.target.value))}
style={{ width: 120, accentColor: 'var(--pf-accent)' }}
/>
</div>
<div className="pf-opt-group">
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.wandHint')}
</span>
</div>
{hasSelection && (
Expand Down
7 changes: 5 additions & 2 deletions src/components/image-editor/ToolsPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,17 @@ const GROUPS: ToolDef[][] = [
icon: <Lasso className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.lasso',
shortcut: 'L',
stub: true,
},
{
id: 'polyLasso',
icon: <PenTool className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.polyLasso',
},
{
id: 'wand',
icon: <Wand2 className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.wand',
shortcut: 'W',
stub: true,
},
{ id: 'crop', icon: <Crop className="h-4 w-4" />, labelKey: 'pages.imageEditor.tool.crop', shortcut: 'C' },
{
Expand Down
3 changes: 0 additions & 3 deletions src/components/image-editor/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tool> = new Set<Tool>([
'lasso',
'polyLasso',
'wand',
'spotHeal',
'stamp',
'historyBrush',
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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):在画布上单击以新增文字。",
Expand Down
Loading
Loading