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
18 changes: 18 additions & 0 deletions src/components/image-editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,24 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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',
Expand Down
2 changes: 2 additions & 0 deletions src/components/image-editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 10 additions & 2 deletions src/components/image-editor/OptionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="pf-options">
<div className="pf-opt-group">
Expand Down Expand Up @@ -90,6 +91,13 @@ export function OptionsBar({
/>
</div>
)}
{tool === 'dodge' && (
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.dodgeHint')}
</span>
</div>
)}
</div>
)
}
Expand Down
1 change: 0 additions & 1 deletion src/components/image-editor/ToolsPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ const GROUPS: ToolDef[][] = [
icon: <Sun className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.dodge',
shortcut: 'O',
stub: true,
},
],
// 3. Vector / type group
Expand Down
1 change: 0 additions & 1 deletion src/components/image-editor/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const STUB_TOOLS: ReadonlySet<Tool> = new Set<Tool>([
'spotHeal',
'stamp',
'historyBrush',
'dodge',
'pen',
'arrowPath',
'hand',
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@
"errBucketRead": "无法读取画布像素(可能跨域污染)。",
"gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。",
"blurHint": "模糊:在画布上拖出一个矩形 —— 渲染时该区域会被模糊。模糊半径默认 8 px,区域大小可拖角调整。",
"dodgeHint": "减淡 (O):拖动以提亮 —— 与 PS 一致,重复涂抹可叠加效果。图层不透明度控制强度。",
"textToolHint": "文字 (T):在画布上单击以新增文字。",
"toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。",
"toolStubToast": "{{tool}} 暂未实现。",
Expand Down Expand Up @@ -587,7 +588,9 @@
"line": "直线",
"bucket": "油漆桶填充",
"gradient": "渐变",
"blur": "模糊区域"
"blur": "模糊区域",
"dodge": "减淡笔触",
"burn": "加深笔触"
},
"project": "项目",
"projectSave": "保存项目 (.json)",
Expand Down
19 changes: 16 additions & 3 deletions src/lib/image-editor/drawing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/lib/image-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading