diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index dbcc620..eedd8d6 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -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 = @@ -107,6 +116,7 @@ export const Canvas = forwardRef(function Canvas( onZoomAt, onPickColor, onCommitCrop, + onBucketClick, }, ref, ) { @@ -274,6 +284,14 @@ export const Canvas = forwardRef(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) @@ -531,6 +549,10 @@ export const Canvas = forwardRef(function Canvas( setHoverCursor('crosshair') return } + if (tool === 'bucket') { + setHoverCursor('crosshair') + return + } if (tool !== 'none') { setHoverCursor('crosshair') return diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index a22723d..a025f60 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -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. */ @@ -29,6 +31,8 @@ export function OptionsBar({ setFgColor, strokeWidth, setStrokeWidth, + bucketTolerance, + setBucketTolerance, isStubTool, stubMessage, hasActiveCrop, @@ -159,6 +163,54 @@ export function OptionsBar({ ) } + if (tool === 'bucket') { + return ( +
+
+ {t('pages.imageEditor.color')}: + setFgColor(e.target.value)} + style={{ + width: 22, + height: 22, + padding: 0, + border: '1px solid var(--pf-line)', + background: 'transparent', + borderRadius: 3, + cursor: 'pointer', + }} + /> +
+
+ {t('pages.imageEditor.bucketTolerance')}: + setBucketTolerance(Math.min(128, Math.max(0, Number(e.target.value) || 0)))} + /> + setBucketTolerance(Number(e.target.value))} + style={{ width: 120, accentColor: 'var(--pf-accent)' }} + /> +
+
+ + {t('pages.imageEditor.bucketHint')} + +
+
+ ) + } + if (tool === 'eyedropper') { return (
diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index 9bb91a8..1627267 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -114,7 +114,6 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.bucket', shortcut: 'G', - stub: true, }, { id: 'dodge', diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index 8d865c0..4077efe 100644 --- a/src/components/image-editor/tool-meta.ts +++ b/src/components/image-editor/tool-meta.ts @@ -16,7 +16,6 @@ export const STUB_TOOLS: ReadonlySet = new Set([ 'stamp', 'historyBrush', 'gradient', - 'bucket', 'blur', 'dodge', 'pen', diff --git a/src/i18n/en.json b/src/i18n/en.json index c1ad936..279781a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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.", @@ -578,7 +582,8 @@ "mask": "Mask", "image": "Image", "ellipse": "Ellipse", - "line": "Line" + "line": "Line", + "bucket": "Paint fill" }, "project": "Project", "projectSave": "Save project (.json)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 30ff37d..8121c31 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -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}} 暂未实现。", @@ -578,7 +582,8 @@ "mask": "蒙版", "image": "图片", "ellipse": "椭圆", - "line": "直线" + "line": "直线", + "bucket": "油漆桶填充" }, "project": "项目", "projectSave": "保存项目 (.json)", diff --git a/src/lib/image-editor/flood-fill.ts b/src/lib/image-editor/flood-fill.ts new file mode 100644 index 0000000..167f07b --- /dev/null +++ b/src/lib/image-editor/flood-fill.ts @@ -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), + ] +} diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 86e93fc..5704a14 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -12,9 +12,10 @@ import { STUB_TOOLS } from '@/components/image-editor/tool-meta' import { Workspace, type WorkspaceHandle } from '@/components/image-editor/Workspace' import '@/components/image-editor/pixelforge.css' import { initialState, PREVIEW_MAX } from '@/lib/image-editor/defaults' +import { floodFillMask, maskToDataUrl } from '@/lib/image-editor/flood-fill' import { useHistoryState } from '@/lib/image-editor/history' import { fileToDataUrl, useImageCache } from '@/lib/image-editor/image-cache' -import { dimsAfterRotation } from '@/lib/image-editor/render' +import { dimsAfterRotation, renderTo } from '@/lib/image-editor/render' import { translateLayer } from '@/lib/image-editor/transform' import { loadImageFromUrl, @@ -74,6 +75,7 @@ export function ImageEditorPage() { [], ) const [strokeWidth, setStrokeWidth] = useState(4) + const [bucketTolerance, setBucketTolerance] = useState(32) const [selectedLayerId, setSelectedLayerId] = useState('image') const [outFormat, setOutFormat] = useState('png') @@ -406,6 +408,87 @@ export function ImageEditorPage() { [], ) + /** + * Paint Bucket flood fill at `previewPoint` (preview-pixel space). Renders + * the current editor state at source resolution, runs a 4-connected + * scanline flood fill from the click point, builds an FG-coloured bitmap + * of the matching region, and commits it as a new image-shape layer. + * + * The fill bitmap is stored at SOURCE resolution so exports stay sharp; + * the layer's preview-pixel rect spans the full canvas, so drawImage + * scales it down for the preview render. + */ + const handleBucketFill = useCallback( + async (previewPoint: { x: number; y: number }) => { + if (!image) return + const { baseW, baseH } = dimsAfterRotation(image, state) + const previewScale = Math.min(1, PREVIEW_MAX / Math.max(baseW, baseH, 1)) + + // The preview canvas is the cropped region's preview-pixel size when a + // crop is active. Re-render at source resolution (scale=1) so the + // flood fill operates on the true image. + const srcCanvas = document.createElement('canvas') + renderTo(srcCanvas, { image, state, scale: 1, previewScale, imageCache }) + const ctx = srcCanvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return + + // Convert click from preview-pixel to source-pixel space. When a crop + // is active, the live canvas only spans the crop's preview region — + // shape coords are post-crop preview pixels, so we shift back by the + // crop origin (preview-pixel) and scale to source. + 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((previewPoint.x + cropOriginX) / previewScale) + const sy = Math.round((previewPoint.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, bucketTolerance) + // Empty mask → click was on a transparent edge or out of bounds. + let any = false + for (let i = 0; i < mask.length; i++) { + if (mask[i]) { + any = true + break + } + } + if (!any) { + toast.message(t('pages.imageEditor.bucketEmpty')) + return + } + + const dataUrl = maskToDataUrl(mask, srcCanvas.width, srcCanvas.height, colors.fg) + if (!dataUrl) return + await ensureImage(dataUrl) + + // Layer rect spans the un-cropped preview canvas — image-shape coords + // are in original-image preview-pixel space (same as other shapes). + const fullPreviewW = baseW * previewScale + const fullPreviewH = baseH * previewScale + const layer: AnnotationLayer = { + id: crypto.randomUUID(), + name: t('pages.imageEditor.annoLabel.bucket'), + visible: true, + opacity: 100, + blend: 'normal', + kind: 'annotation', + shape: { kind: 'image', x: 0, y: 0, w: fullPreviewW, h: fullPreviewH, dataUrl }, + } + commitLayer(layer) + }, + [image, state, imageCache, colors.fg, bucketTolerance, ensureImage, commitLayer, t], + ) + // ── Download / save ────────────────────────────────────────────────────── /** * Render and download the current canvas in the requested format. If @@ -522,6 +605,8 @@ export function ImageEditorPage() { setFgColor={(c) => setColors((s) => ({ ...s, fg: c }))} strokeWidth={strokeWidth} setStrokeWidth={setStrokeWidth} + bucketTolerance={bucketTolerance} + setBucketTolerance={setBucketTolerance} isStubTool={STUB_TOOLS.has(tool)} hasActiveCrop={!!state.cropRect} onClearCrop={handleClearCrop} @@ -590,6 +675,8 @@ export function ImageEditorPage() { onZoomAt={zoomAtPoint} onPickColor={handlePickColor} onCommitCrop={handleCommitCrop} + onBucketClick={handleBucketFill} + bucketTolerance={bucketTolerance} />