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
22 changes: 22 additions & 0 deletions src/components/image-editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ type Props = {
* coords if a crop is currently active).
*/
onCommitCrop?: (rect: { x: number; y: number; w: number; h: number }) => void
/**
* Called by the Paint Bucket tool with a click point in *preview-pixel
* space*. Paint bucket needs to render the canvas to read pixels, so the
* heavy lifting (re-rendering at source res, flood fill, layer commit)
* happens in the parent — Canvas just hands off the click coords.
*/
onBucketClick?: (point: Point) => void
/** Tolerance for the Paint Bucket flood fill (0–128). */
bucketTolerance?: number
}

type Interaction =
Expand Down Expand Up @@ -107,6 +116,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
onZoomAt,
onPickColor,
onCommitCrop,
onBucketClick,
},
ref,
) {
Expand Down Expand Up @@ -274,6 +284,14 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
return
}

// Paint Bucket: hand the click off to the parent — flood fill needs to
// re-render the canvas at source resolution, which only the parent has
// the inputs for. (p.x, p.y) is in preview-pixel space.
if (tool === 'bucket') {
onBucketClick?.(p)
return
}

// Drawing tools take priority over selection.
if (tool !== 'none') {
startDrawing(p)
Expand Down Expand Up @@ -531,6 +549,10 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
setHoverCursor('crosshair')
return
}
if (tool === 'bucket') {
setHoverCursor('crosshair')
return
}
if (tool !== 'none') {
setHoverCursor('crosshair')
return
Expand Down
52 changes: 52 additions & 0 deletions src/components/image-editor/OptionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ type Props = {
setFgColor: (c: string) => void
strokeWidth: number
setStrokeWidth: (n: number) => void
bucketTolerance: number
setBucketTolerance: (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 All @@ -29,6 +31,8 @@ export function OptionsBar({
setFgColor,
strokeWidth,
setStrokeWidth,
bucketTolerance,
setBucketTolerance,
isStubTool,
stubMessage,
hasActiveCrop,
Expand Down Expand Up @@ -159,6 +163,54 @@ export function OptionsBar({
)
}

if (tool === 'bucket') {
return (
<div className="pf-options">
<div className="pf-opt-group">
<span className="pf-opt-label">{t('pages.imageEditor.color')}:</span>
<input
type="color"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
style={{
width: 22,
height: 22,
padding: 0,
border: '1px solid var(--pf-line)',
background: 'transparent',
borderRadius: 3,
cursor: 'pointer',
}}
/>
</div>
<div className="pf-opt-group">
<span className="pf-opt-label">{t('pages.imageEditor.bucketTolerance')}:</span>
<input
className="pf-opt-input"
type="number"
min={0}
max={128}
value={bucketTolerance}
onChange={(e) => setBucketTolerance(Math.min(128, Math.max(0, Number(e.target.value) || 0)))}
/>
<input
type="range"
min={0}
max={128}
value={bucketTolerance}
onChange={(e) => setBucketTolerance(Number(e.target.value))}
style={{ width: 120, accentColor: 'var(--pf-accent)' }}
/>
</div>
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.bucketHint')}
</span>
</div>
</div>
)
}

if (tool === 'eyedropper') {
return (
<div className="pf-options">
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 @@ -114,7 +114,6 @@ const GROUPS: ToolDef[][] = [
icon: <PaintBucket className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.bucket',
shortcut: 'G',
stub: true,
},
{
id: 'dodge',
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 @@ -16,7 +16,6 @@ export const STUB_TOOLS: ReadonlySet<Tool> = new Set<Tool>([
'stamp',
'historyBrush',
'gradient',
'bucket',
'blur',
'dodge',
'pen',
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,10 @@
"moveToolHint": "Move (V): drag layers; Cmd/Ctrl+J duplicate; Delete remove.",
"zoomToolHint": "Zoom (Z): click to zoom in 2×, Alt-click to zoom out. Cmd/Ctrl+wheel zooms at cursor.",
"eyedropperHint": "Eyedropper (I): click anywhere on the canvas to set the foreground color.",
"bucketTolerance": "Tolerance",
"bucketHint": "Paint Bucket (G): click to flood-fill connected pixels within tolerance. Result lands as a new layer.",
"bucketEmpty": "Nothing to fill at that point — try a higher tolerance.",
"errBucketRead": "Couldn't read pixels for fill (canvas tainted?).",
"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 @@ -578,7 +582,8 @@
"mask": "Mask",
"image": "Image",
"ellipse": "Ellipse",
"line": "Line"
"line": "Line",
"bucket": "Paint fill"
},
"project": "Project",
"projectSave": "Save project (.json)",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,10 @@
"moveToolHint": "移动 (V):拖动图层;Cmd/Ctrl+J 复制;Delete 删除。",
"zoomToolHint": "缩放 (Z):单击放大 2×,Alt 单击缩小。Cmd/Ctrl+滚轮在光标位置缩放。",
"eyedropperHint": "吸管 (I):在画布上单击以拾取前景色。",
"bucketTolerance": "容差",
"bucketHint": "油漆桶 (G):单击以填充容差内的相连像素,结果作为新图层。",
"bucketEmpty": "该位置无可填充区域 —— 试试调高容差。",
"errBucketRead": "无法读取画布像素(可能跨域污染)。",
"textToolHint": "文字 (T):在画布上单击以新增文字。",
"toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。",
"toolStubToast": "{{tool}} 暂未实现。",
Expand Down Expand Up @@ -578,7 +582,8 @@
"mask": "蒙版",
"image": "图片",
"ellipse": "椭圆",
"line": "直线"
"line": "直线",
"bucket": "油漆桶填充"
},
"project": "项目",
"projectSave": "保存项目 (.json)",
Expand Down
114 changes: 114 additions & 0 deletions src/lib/image-editor/flood-fill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Flood-fill helpers for the Paint Bucket tool.
*
* Operates on raw ImageData. The `tolerance` is a 0–255 max channel-wise
* deviation from the seed pixel — anything within that distance on R, G, B,
* AND A is considered "matching" and gets included in the filled region.
* Returns a Uint8Array mask of size width*height (1 = filled, 0 = not).
*
* Implementation is a 4-connected scanline fill via an explicit stack — no
* recursion (canvases are big and JS stacks are small), and we avoid the
* naive 4×stack push by greedily extending each row before pushing
* neighbours. Fast enough for ~12 megapixels (which is the practical cap
* for browser canvas anyway).
*/
export function floodFillMask(
data: ImageData,
startX: number,
startY: number,
tolerance: number,
): Uint8Array {
const { data: px, width, height } = data
const mask = new Uint8Array(width * height)
if (startX < 0 || startX >= width || startY < 0 || startY >= height) return mask

const seedIdx = (startY * width + startX) * 4
const sr = px[seedIdx]
const sg = px[seedIdx + 1]
const sb = px[seedIdx + 2]
const sa = px[seedIdx + 3]

const matches = (i: number) =>
Math.abs(px[i] - sr) <= tolerance &&
Math.abs(px[i + 1] - sg) <= tolerance &&
Math.abs(px[i + 2] - sb) <= tolerance &&
Math.abs(px[i + 3] - sa) <= tolerance

// Scanline stack — each entry is a row to scan starting at (x, y).
const stack: [number, number][] = [[startX, startY]]
while (stack.length) {
const [sx, sy] = stack.pop()!
// Walk left until we leave the matching span.
let lx = sx
while (lx >= 0 && !mask[sy * width + lx] && matches((sy * width + lx) * 4)) lx--
lx++
// Walk right, marking + checking neighbours along the way.
let spanAbove = false
let spanBelow = false
while (lx < width && !mask[sy * width + lx] && matches((sy * width + lx) * 4)) {
mask[sy * width + lx] = 1
// Push start-of-run for the row above when we enter a matching span there.
if (sy > 0) {
const matchAbove = !mask[(sy - 1) * width + lx] && matches(((sy - 1) * width + lx) * 4)
if (!spanAbove && matchAbove) {
stack.push([lx, sy - 1])
spanAbove = true
} else if (spanAbove && !matchAbove) {
spanAbove = false
}
}
if (sy < height - 1) {
const matchBelow = !mask[(sy + 1) * width + lx] && matches(((sy + 1) * width + lx) * 4)
if (!spanBelow && matchBelow) {
stack.push([lx, sy + 1])
spanBelow = true
} else if (spanBelow && !matchBelow) {
spanBelow = false
}
}
lx++
}
}
return mask
}

/**
* Build a transparent bitmap of (width × height) where every pixel set in
* `mask` is painted with `color` (#rrggbb). Returns a data URL suitable for
* an image-shape layer.
*/
export function maskToDataUrl(
mask: Uint8Array,
width: number,
height: number,
color: string,
): string | null {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return null
const img = ctx.createImageData(width, height)
const [r, g, b] = hexToRgb(color)
for (let i = 0; i < mask.length; i++) {
if (mask[i]) {
const j = i * 4
img.data[j] = r
img.data[j + 1] = g
img.data[j + 2] = b
img.data[j + 3] = 255
}
}
ctx.putImageData(img, 0, 0)
return canvas.toDataURL('image/png')
}

function hexToRgb(hex: string): [number, number, number] {
const m = hex.replace('#', '')
if (m.length !== 6) return [0, 0, 0]
return [
parseInt(m.slice(0, 2), 16),
parseInt(m.slice(2, 4), 16),
parseInt(m.slice(4, 6), 16),
]
}
Loading
Loading