From d8cb8748633e0ea50ad55c18f3b49e0ad0dc2937 Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Fri, 24 Apr 2026 20:42:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(image-editor):=20wire=20Dodge=20tool=20?= =?UTF-8?q?=E2=80=94=20paint=20a=20stroke=20that=20brightens=20pixels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the Dodge (O) tool from stub to functional. Drag on the canvas to paint a stroke that brightens the pixels underneath, same idea as PS's Dodge: each layer represents one stroke, layer opacity controls intensity, and repeated strokes build up. ## Implementation - \`BrushShape\` gains an optional \`mode: 'dodge' | 'burn'\` field. When set, \`drawBrush\` overrides the stroke color (white for dodge, black for burn) and the composite operation ('lighter' / 'multiply'). The per-layer save/restore around \`drawShape\` already isolates these changes from siblings, so no extra cleanup is needed. - \`Canvas.tsx\` adds a tool-'dodge' branch that creates a brush layer with \`mode: 'dodge'\`, default opacity 30 so a single stroke gives a soft brighten — repeat to build up. - \`OptionsBar\` shares the brush variant (stroke width slider) and adds a dodge-specific hint. - \`LayersPanel\` labels dodge/burn brushes distinctly so the layer list reads cleanly. - The 'burn' mode is wired in the data model + drawing path + label table but no UI tool selects it yet — folded in so a future Burn-tool PR is a one-line palette change. Removes \`dodge\` from \`STUB_TOOLS\`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/image-editor/Canvas.tsx | 18 ++++++++++++++++++ src/components/image-editor/LayersPanel.tsx | 2 ++ src/components/image-editor/OptionsBar.tsx | 12 ++++++++++-- src/components/image-editor/ToolsPalette.tsx | 1 - src/components/image-editor/tool-meta.ts | 1 - src/i18n/en.json | 5 ++++- src/i18n/zh-CN.json | 5 ++++- src/lib/image-editor/drawing.ts | 19 ++++++++++++++++--- src/lib/image-editor/types.ts | 6 ++++++ 9 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index 29148ff..11b190d 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -512,6 +512,24 @@ export const Canvas = forwardRef(function Canvas( }, } as AnnotationLayer, }) + } else if (tool === 'dodge') { + // Dodge layer defaults to 30% opacity so a single stroke gives a soft + // brightening — repeat strokes to build it up, just like PS. + setInteraction({ + kind: 'drawing', + layer: { + ...baseLayer('Dodge'), + opacity: 30 as const, + kind: 'annotation', + shape: { + kind: 'brush', + points: [p], + color: '#ffffff', + strokeWidth: toolStrokeWidth, + mode: 'dodge', + }, + } as AnnotationLayer, + }) } else if (tool === 'mask') { setInteraction({ kind: 'drawing', diff --git a/src/components/image-editor/LayersPanel.tsx b/src/components/image-editor/LayersPanel.tsx index 52e6c83..8f2b8d7 100644 --- a/src/components/image-editor/LayersPanel.tsx +++ b/src/components/image-editor/LayersPanel.tsx @@ -173,6 +173,8 @@ function layerLabelKey(layer: Layer): string { case 'mosaic': return 'pages.imageEditor.annoLabel.mosaic' case 'brush': + if (layer.shape.mode === 'dodge') return 'pages.imageEditor.annoLabel.dodge' + if (layer.shape.mode === 'burn') return 'pages.imageEditor.annoLabel.burn' return layer.shape.eraser ? 'pages.imageEditor.annoLabel.eraser' : 'pages.imageEditor.annoLabel.brush' diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index 3898f8a..c90c5a5 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -56,8 +56,9 @@ export function OptionsBar({ ) } - // Brush / pencil / eraser — stroke width + color. - if (tool === 'brush' || tool === 'eraser') { + // Brush / pencil / eraser / dodge — stroke width (+ color for brush only; + // dodge always paints white via 'lighter' composite, eraser cuts alpha). + if (tool === 'brush' || tool === 'eraser' || tool === 'dodge') { return (
@@ -90,6 +91,13 @@ export function OptionsBar({ />
)} + {tool === 'dodge' && ( +
+ + {t('pages.imageEditor.dodgeHint')} + +
+ )}
) } diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index f873dda..69bc26a 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -120,7 +120,6 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.dodge', shortcut: 'O', - stub: true, }, ], // 3. Vector / type group diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index b46c932..9300f53 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -15,7 +15,6 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'spotHeal', 'stamp', 'historyBrush', - 'dodge', 'pen', 'arrowPath', 'hand', diff --git a/src/i18n/en.json b/src/i18n/en.json index cdcf33b..d0f17bc 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -527,6 +527,7 @@ "errBucketRead": "Couldn't read pixels for fill (canvas tainted?).", "gradientHint": "Gradient (G): drag from start to end. Foreground at the start, background at the end. Result lands as a new layer.", "blurHint": "Blur: drag a rect on the canvas — the area inside gets blurred at render time. Adjustable via the layer's bounds; default radius is 8 px.", + "dodgeHint": "Dodge (O): drag to brighten — like PS, repeat strokes to build up the effect. Layer opacity controls intensity.", "textToolHint": "Type (T): click on the canvas to add text.", "toolStubHint": "{{tool}} is a placeholder — the palette button is here for parity with Photoshop, but the tool isn't implemented yet.", "toolStubToast": "{{tool}} is not yet implemented.", @@ -587,7 +588,9 @@ "line": "Line", "bucket": "Paint fill", "gradient": "Gradient", - "blur": "Blur region" + "blur": "Blur region", + "dodge": "Dodge stroke", + "burn": "Burn stroke" }, "project": "Project", "projectSave": "Save project (.json)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 2f256d2..f3c1ced 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -527,6 +527,7 @@ "errBucketRead": "无法读取画布像素(可能跨域污染)。", "gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。", "blurHint": "模糊:在画布上拖出一个矩形 —— 渲染时该区域会被模糊。模糊半径默认 8 px,区域大小可拖角调整。", + "dodgeHint": "减淡 (O):拖动以提亮 —— 与 PS 一致,重复涂抹可叠加效果。图层不透明度控制强度。", "textToolHint": "文字 (T):在画布上单击以新增文字。", "toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。", "toolStubToast": "{{tool}} 暂未实现。", @@ -587,7 +588,9 @@ "line": "直线", "bucket": "油漆桶填充", "gradient": "渐变", - "blur": "模糊区域" + "blur": "模糊区域", + "dodge": "减淡笔触", + "burn": "加深笔触" }, "project": "项目", "projectSave": "保存项目 (.json)", diff --git a/src/lib/image-editor/drawing.ts b/src/lib/image-editor/drawing.ts index d6bcd7e..fdeefdb 100644 --- a/src/lib/image-editor/drawing.ts +++ b/src/lib/image-editor/drawing.ts @@ -174,11 +174,23 @@ function drawImageShape( function drawBrush(ctx: CanvasRenderingContext2D, s: BrushShape, scale: number) { if (s.points.length === 0) return - ctx.strokeStyle = s.color + // Mode dispatch — dodge/burn override color + composite op; eraser cuts + // alpha; default is straight FG-coloured stroke. + if (s.mode === 'dodge') { + ctx.strokeStyle = '#ffffff' + ctx.globalCompositeOperation = 'lighter' + } else if (s.mode === 'burn') { + ctx.strokeStyle = '#000000' + ctx.globalCompositeOperation = 'multiply' + } else if (s.eraser) { + ctx.strokeStyle = s.color + ctx.globalCompositeOperation = 'destination-out' + } else { + ctx.strokeStyle = s.color + } ctx.lineWidth = s.strokeWidth * scale ctx.lineCap = 'round' ctx.lineJoin = 'round' - if (s.eraser) ctx.globalCompositeOperation = 'destination-out' ctx.beginPath() ctx.moveTo(s.points[0].x * scale, s.points[0].y * scale) if (s.points.length === 1) { @@ -190,7 +202,8 @@ function drawBrush(ctx: CanvasRenderingContext2D, s: BrushShape, scale: number) } } ctx.stroke() - if (s.eraser) ctx.globalCompositeOperation = 'source-over' + // Render path uses ctx.save()/restore() per layer, so no need to reset + // composite/strokeStyle here. } function drawMosaic( diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts index 491b782..fb00504 100644 --- a/src/lib/image-editor/types.ts +++ b/src/lib/image-editor/types.ts @@ -130,6 +130,12 @@ export type BrushShape = { color: string strokeWidth: number eraser?: boolean + /** + * Stroke mode. When set, the renderer ignores `color`/`eraser` and uses + * additive ('lighter') or multiplicative ('multiply') blending to brighten + * or darken the underlying pixels — same effect as PS's Dodge / Burn tools. + */ + mode?: 'dodge' | 'burn' } export type ImageShape = { kind: 'image'