From ccc66e8d438f797effe219b8b2e8dccbb0d265df Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Fri, 24 Apr 2026 19:42:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(image-editor):=20wire=20Blur=20tool=20?= =?UTF-8?q?=E2=80=94=20drag=20a=20rect=20to=20blur=20a=20region?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the Blur tool from stub to functional. Drag a rectangle on the canvas; the area inside is blurred at render time using the canvas \`filter: blur(Npx)\` primitive. Modeled after the Mosaic tool — same "sample the underlying composite, draw it back in place" pattern, just with a Gaussian blur instead of pixelation. ## Implementation - New \`BlurShape\` (kind: 'blur') with x/y/w/h + radius (default 8 px, preview-pixel space). Joins the \`Shape\` union; round-trips through serialize/parse via the existing image-shape forwards-compat path. - \`drawing.ts\` gets \`drawBlurRegion\`: \`ctx.filter = blur(r * scale)\`, then \`drawImage(underlying, src, dst)\` of the rect onto itself. Scale applies to the radius so blur looks consistent across preview and export. - \`render.ts\` snapshots the canvas before any mosaic OR blur layer (the underlying-canvas read is the same need for both). - \`hit.ts\` + \`transform.ts\`: blur falls into the rect-corner-handle case, so move + resize "just work" via the existing pattern. - \`Canvas.tsx\`: tool 'blur' starts a rect-style drag like mosaic; default radius bakes into the new shape. - \`OptionsBar\`: blur shares the simple-hint variant with mosaic/mask. - \`ToolsPalette\`: new entry next to Mosaic in the Annotation group (Aperture icon). - \`LayersPanel\`: \`annoLabel.blur\` for the layer list. Removes \`blur\` from \`STUB_TOOLS\`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/image-editor/Canvas.tsx | 13 ++++++-- src/components/image-editor/LayersPanel.tsx | 2 ++ src/components/image-editor/OptionsBar.tsx | 8 +++-- src/components/image-editor/ToolsPalette.tsx | 2 ++ src/components/image-editor/tool-meta.ts | 1 - src/i18n/en.json | 4 ++- src/i18n/zh-CN.json | 4 ++- src/lib/image-editor/drawing.ts | 35 ++++++++++++++++++++ src/lib/image-editor/hit.ts | 2 ++ src/lib/image-editor/render.ts | 11 +++--- src/lib/image-editor/transform.ts | 4 ++- src/lib/image-editor/types.ts | 11 ++++++ 12 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index 2064995..29148ff 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -488,6 +488,15 @@ export const Canvas = forwardRef(function Canvas( shape: { kind: 'mosaic', x: p.x, y: p.y, w: 0, h: 0, cell: 12 }, } as AnnotationLayer, }) + } else if (tool === 'blur') { + setInteraction({ + kind: 'drawing', + layer: { + ...baseLayer('Blur'), + kind: 'annotation', + shape: { kind: 'blur', x: p.x, y: p.y, w: 0, h: 0, radius: 8 }, + } as AnnotationLayer, + }) } else if (tool === 'brush' || tool === 'eraser') { setInteraction({ kind: 'drawing', @@ -533,7 +542,7 @@ export const Canvas = forwardRef(function Canvas( const drawing = interaction.layer if (drawing.kind === 'annotation') { const s = drawing.shape - if (s.kind === 'rect' || s.kind === 'mosaic' || s.kind === 'ellipse') { + if (s.kind === 'rect' || s.kind === 'mosaic' || s.kind === 'ellipse' || s.kind === 'blur') { setInteraction({ kind: 'drawing', layer: { ...drawing, shape: { ...s, w: p.x - s.x, h: p.y - s.y } } as AnnotationLayer, @@ -661,7 +670,7 @@ function shouldDiscardDrawing(layer: Layer): boolean { } if (layer.kind === 'annotation') { const s = layer.shape - if (s.kind === 'rect' || s.kind === 'mosaic' || s.kind === 'ellipse') { + if (s.kind === 'rect' || s.kind === 'mosaic' || s.kind === 'ellipse' || s.kind === 'blur') { return Math.abs(s.w) < 4 && Math.abs(s.h) < 4 } if (s.kind === 'arrow' || s.kind === 'line') { diff --git a/src/components/image-editor/LayersPanel.tsx b/src/components/image-editor/LayersPanel.tsx index da6701a..52e6c83 100644 --- a/src/components/image-editor/LayersPanel.tsx +++ b/src/components/image-editor/LayersPanel.tsx @@ -182,5 +182,7 @@ function layerLabelKey(layer: Layer): string { return 'pages.imageEditor.annoLabel.ellipse' case 'line': return 'pages.imageEditor.annoLabel.line' + case 'blur': + return 'pages.imageEditor.annoLabel.blur' } } diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index 58a7e2a..3898f8a 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -296,12 +296,16 @@ export function OptionsBar({ ) } - if (tool === 'mask' || tool === 'mosaic') { + if (tool === 'mask' || tool === 'mosaic' || tool === 'blur') { return (
- {t('pages.imageEditor.toolHint', { tool: t(`pages.imageEditor.tool.${tool}`) })} + {tool === 'blur' + ? t('pages.imageEditor.blurHint') + : t('pages.imageEditor.toolHint', { + tool: t(`pages.imageEditor.tool.${tool}`), + })}
diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index af475de..f873dda 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { + Aperture, ArrowRight, Brush, Circle, @@ -150,6 +151,7 @@ const GROUPS: ToolDef[][] = [ [ { id: 'mask', icon: , labelKey: 'pages.imageEditor.tool.mask' }, { id: 'mosaic', icon: , labelKey: 'pages.imageEditor.tool.mosaic' }, + { id: 'blur', icon: , labelKey: 'pages.imageEditor.tool.blur' }, ], // 5. Navigation [ diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index 5b85395..b46c932 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', - 'blur', 'dodge', 'pen', 'arrowPath', diff --git a/src/i18n/en.json b/src/i18n/en.json index 15693dd..cdcf33b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -526,6 +526,7 @@ "bucketEmpty": "Nothing to fill at that point — try a higher tolerance.", "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.", "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.", @@ -585,7 +586,8 @@ "ellipse": "Ellipse", "line": "Line", "bucket": "Paint fill", - "gradient": "Gradient" + "gradient": "Gradient", + "blur": "Blur region" }, "project": "Project", "projectSave": "Save project (.json)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 72f3048..2f256d2 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -526,6 +526,7 @@ "bucketEmpty": "该位置无可填充区域 —— 试试调高容差。", "errBucketRead": "无法读取画布像素(可能跨域污染)。", "gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。", + "blurHint": "模糊:在画布上拖出一个矩形 —— 渲染时该区域会被模糊。模糊半径默认 8 px,区域大小可拖角调整。", "textToolHint": "文字 (T):在画布上单击以新增文字。", "toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。", "toolStubToast": "{{tool}} 暂未实现。", @@ -585,7 +586,8 @@ "ellipse": "椭圆", "line": "直线", "bucket": "油漆桶填充", - "gradient": "渐变" + "gradient": "渐变", + "blur": "模糊区域" }, "project": "项目", "projectSave": "保存项目 (.json)", diff --git a/src/lib/image-editor/drawing.ts b/src/lib/image-editor/drawing.ts index f1f8949..d6bcd7e 100644 --- a/src/lib/image-editor/drawing.ts +++ b/src/lib/image-editor/drawing.ts @@ -1,5 +1,6 @@ import type { ArrowShape, + BlurShape, BrushShape, EllipseShape, ImageShape, @@ -51,9 +52,43 @@ export function drawShape( case 'line': drawLine(ctx, shape, scale) break + case 'blur': + drawBlurRegion(ctx, shape, scale, underlying) + break } } +/** + * Region blur — sample the underlying canvas under the rect, draw it back at + * the same place with `ctx.filter = blur(Npx)` applied. Same pattern as + * `drawMosaic` (snapshot the canvas before drawing, sample from it). Skipped + * for degenerate rects. + */ +function drawBlurRegion( + ctx: CanvasRenderingContext2D, + s: BlurShape, + scale: number, + underlying: HTMLCanvasElement, +) { + const x = s.x * scale + const y = s.y * scale + const w = s.w * scale + const h = s.h * scale + const nx = w >= 0 ? x : x + w + const ny = h >= 0 ? y : y + h + const nw = Math.abs(w) + const nh = Math.abs(h) + if (nw < 2 || nh < 2) return + const r = Math.max(0.5, s.radius * scale) + ctx.save() + ctx.filter = `blur(${r}px)` + // The blur filter samples *outside* the source rect to fill its own edge, + // so artifacts at the rect's own border are minimal as long as we draw + // back into the same rect we sampled. + ctx.drawImage(underlying, nx, ny, nw, nh, nx, ny, nw, nh) + ctx.restore() +} + function drawEllipse(ctx: CanvasRenderingContext2D, s: EllipseShape, scale: number) { // Normalise so negative w/h drag (drag from BR to TL) still draws correctly. const x = (s.w >= 0 ? s.x : s.x + s.w) * scale diff --git a/src/lib/image-editor/hit.ts b/src/lib/image-editor/hit.ts index c4e89fc..df40fa3 100644 --- a/src/lib/image-editor/hit.ts +++ b/src/lib/image-editor/hit.ts @@ -32,6 +32,7 @@ function getShapeBBox(shape: Shape): Rect { case 'mosaic': case 'image': case 'ellipse': + case 'blur': return normalizeRect({ x: shape.x, y: shape.y, w: shape.w, h: shape.h }) case 'arrow': case 'line': { @@ -108,6 +109,7 @@ export function getHandles(layer: Layer): Handle[] { case 'mosaic': case 'image': case 'ellipse': + case 'blur': return rectCornerHandles( normalizeRect({ x: layer.shape.x, diff --git a/src/lib/image-editor/render.ts b/src/lib/image-editor/render.ts index 5878c9f..2f2669f 100644 --- a/src/lib/image-editor/render.ts +++ b/src/lib/image-editor/render.ts @@ -170,9 +170,10 @@ export function renderTo(canvas: HTMLCanvasElement, input: RenderInput): void { ctx.globalAlpha = l.opacity / 100 ctx.globalCompositeOperation = blendModeToOp(l.blend) applyShadow(ctx, l.shadow, annoScale) - // Mosaic is the only shape that needs to read the underlying composite. - const underlying = - l.shape.kind === 'mosaic' ? snapshotCanvas(canvas) : canvas + // Mosaic + Blur both sample the underlying composite — snapshot first + // so they read pre-shape pixels rather than their own output. + const needsUnderlying = l.shape.kind === 'mosaic' || l.shape.kind === 'blur' + const underlying = needsUnderlying ? snapshotCanvas(canvas) : canvas drawShape(ctx, l.shape, annoScale, underlying, input.imageCache) ctx.restore() } else if (l.kind === 'mask') { @@ -188,8 +189,8 @@ export function renderTo(canvas: HTMLCanvasElement, input: RenderInput): void { ctx.globalAlpha = layer.opacity / 100 ctx.globalCompositeOperation = blendModeToOp(layer.blend) applyShadow(ctx, layer.shadow, annoScale) - const underlying = - layer.shape.kind === 'mosaic' ? snapshotCanvas(canvas) : canvas + const needsUnderlying = layer.shape.kind === 'mosaic' || layer.shape.kind === 'blur' + const underlying = needsUnderlying ? snapshotCanvas(canvas) : canvas drawShape(ctx, layer.shape, annoScale, underlying, input.imageCache) ctx.restore() } else if (layer.kind === 'mask') { diff --git a/src/lib/image-editor/transform.ts b/src/lib/image-editor/transform.ts index cf6ec1a..b493edd 100644 --- a/src/lib/image-editor/transform.ts +++ b/src/lib/image-editor/transform.ts @@ -18,6 +18,7 @@ function translateShape(shape: Shape, dx: number, dy: number): Shape { case 'mosaic': case 'image': case 'ellipse': + case 'blur': return { ...shape, x: shape.x + dx, y: shape.y + dy } case 'arrow': case 'line': @@ -65,7 +66,8 @@ export function resizeLayer( case 'rect': case 'mosaic': case 'image': - case 'ellipse': { + case 'ellipse': + case 'blur': { const next = resizeRect({ x: s.x, y: s.y, w: s.w, h: s.h }, handleId, newPoint) return { ...layer, shape: { ...s, ...next } } } diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts index 87df956..491b782 100644 --- a/src/lib/image-editor/types.ts +++ b/src/lib/image-editor/types.ts @@ -163,6 +163,16 @@ export type LineShape = { color: string strokeWidth: number } +/** Region-blur — drag a rect, area inside is blurred at render time. */ +export type BlurShape = { + kind: 'blur' + x: number + y: number + w: number + h: number + /** Blur radius in preview-canvas pixels. */ + radius: number +} export type Shape = | RectShape @@ -173,6 +183,7 @@ export type Shape = | ImageShape | EllipseShape | LineShape + | BlurShape /** Drop shadow applied to a layer at render time via canvas shadow* properties. */ export type Shadow = {