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
13 changes: 11 additions & 2 deletions src/components/image-editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,15 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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',
Expand Down Expand Up @@ -533,7 +542,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(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,
Expand Down Expand Up @@ -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') {
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 @@ -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'
}
}
8 changes: 6 additions & 2 deletions src/components/image-editor/OptionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,16 @@ export function OptionsBar({
)
}

if (tool === 'mask' || tool === 'mosaic') {
if (tool === 'mask' || tool === 'mosaic' || tool === 'blur') {
return (
<div className="pf-options">
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{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}`),
})}
</span>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/image-editor/ToolsPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
Aperture,
ArrowRight,
Brush,
Circle,
Expand Down Expand Up @@ -150,6 +151,7 @@ const GROUPS: ToolDef[][] = [
[
{ id: 'mask', icon: <Frame className="h-4 w-4" />, labelKey: 'pages.imageEditor.tool.mask' },
{ id: 'mosaic', icon: <Squircle className="h-4 w-4" />, labelKey: 'pages.imageEditor.tool.mosaic' },
{ id: 'blur', icon: <Aperture className="h-4 w-4" />, labelKey: 'pages.imageEditor.tool.blur' },
],
// 5. Navigation
[
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',
'blur',
'dodge',
'pen',
'arrowPath',
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -585,7 +586,8 @@
"ellipse": "Ellipse",
"line": "Line",
"bucket": "Paint fill",
"gradient": "Gradient"
"gradient": "Gradient",
"blur": "Blur region"
},
"project": "Project",
"projectSave": "Save project (.json)",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@
"bucketEmpty": "该位置无可填充区域 —— 试试调高容差。",
"errBucketRead": "无法读取画布像素(可能跨域污染)。",
"gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。",
"blurHint": "模糊:在画布上拖出一个矩形 —— 渲染时该区域会被模糊。模糊半径默认 8 px,区域大小可拖角调整。",
"textToolHint": "文字 (T):在画布上单击以新增文字。",
"toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。",
"toolStubToast": "{{tool}} 暂未实现。",
Expand Down Expand Up @@ -585,7 +586,8 @@
"ellipse": "椭圆",
"line": "直线",
"bucket": "油漆桶填充",
"gradient": "渐变"
"gradient": "渐变",
"blur": "模糊区域"
},
"project": "项目",
"projectSave": "保存项目 (.json)",
Expand Down
35 changes: 35 additions & 0 deletions src/lib/image-editor/drawing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ArrowShape,
BlurShape,
BrushShape,
EllipseShape,
ImageShape,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/lib/image-editor/hit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions src/lib/image-editor/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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') {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/image-editor/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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 } }
}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/image-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down
Loading