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
73 changes: 72 additions & 1 deletion src/components/image-editor/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ type Props = {
onBucketClick?: (point: Point) => void
/** Tolerance for the Paint Bucket flood fill (0–128). */
bucketTolerance?: number
/**
* Called by the Gradient tool when the user releases the mouse with a
* non-trivial drag. Both points are in preview-pixel space (canvas
* pixels at scale=previewScale). Layer commit happens in the parent.
*/
onCommitGradient?: (start: Point, end: Point) => void
}

type Interaction =
Expand All @@ -99,6 +105,8 @@ type Interaction =
| { kind: 'crop-drawing'; rect: { x: number; y: number; w: number; h: number } }
/** Crop drag finished, awaiting commit/cancel from caller. */
| { kind: 'crop-pending'; rect: { x: number; y: number; w: number; h: number } }
/** Gradient drag in progress — start + current end point in canvas pixels. */
| { kind: 'gradient-drawing'; start: Point; end: Point }

export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
{
Expand All @@ -117,6 +125,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
onPickColor,
onCommitCrop,
onBucketClick,
onCommitGradient,
},
ref,
) {
Expand Down Expand Up @@ -185,6 +194,10 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
if (interaction.kind === 'crop-drawing' || interaction.kind === 'crop-pending') {
drawCropOverlay(canvasRef.current, interaction.rect)
}
// Gradient preview line — start dot, end dot, dashed line between.
if (interaction.kind === 'gradient-drawing') {
drawGradientOverlay(canvasRef.current, interaction.start, interaction.end)
}
}, [image, effectiveState, interaction, selectionLayer, previewScale, imageCache])

useImperativeHandle(
Expand Down Expand Up @@ -292,6 +305,13 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
return
}

// Gradient: drag from start to end; commit on mouseup. Both endpoints
// are tracked in canvas-pixel space (= preview-pixel space).
if (tool === 'gradient') {
setInteraction({ kind: 'gradient-drawing', start: p, end: p })
return
}

// Drawing tools take priority over selection.
if (tool !== 'none') {
startDrawing(p)
Expand Down Expand Up @@ -347,6 +367,11 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
return
}

if (interaction.kind === 'gradient-drawing') {
setInteraction({ kind: 'gradient-drawing', start: interaction.start, end: p })
return
}

if (interaction.kind === 'drawing') {
updateDrawing(p)
return
Expand Down Expand Up @@ -380,6 +405,16 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
}
return
}
if (interaction.kind === 'gradient-drawing') {
const dx = interaction.end.x - interaction.start.x
const dy = interaction.end.y - interaction.start.y
// Discard near-zero drags (treat as no-op click).
if (Math.abs(dx) >= 4 || Math.abs(dy) >= 4) {
onCommitGradient?.(interaction.start, interaction.end)
}
setInteraction({ kind: 'idle' })
return
}
if (interaction.kind === 'drawing') {
if (!shouldDiscardDrawing(interaction.layer)) {
onCommitLayer(interaction.layer)
Expand Down Expand Up @@ -549,7 +584,7 @@ export const Canvas = forwardRef<CanvasHandle, Props>(function Canvas(
setHoverCursor('crosshair')
return
}
if (tool === 'bucket') {
if (tool === 'bucket' || tool === 'gradient') {
setHoverCursor('crosshair')
return
}
Expand Down Expand Up @@ -676,6 +711,42 @@ function drawCropOverlay(
ctx.restore()
}

/**
* Draw a gradient preview overlay — start point, end point, dashed line. All
* coords are in canvas-pixel space; identity transform ensures pixel-perfect
* placement.
*/
function drawGradientOverlay(
canvas: HTMLCanvasElement | null,
start: { x: number; y: number },
end: { x: number; y: number },
) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 1
ctx.setLineDash([6, 4])
ctx.beginPath()
ctx.moveTo(start.x, start.y)
ctx.lineTo(end.x, end.y)
ctx.stroke()
ctx.setLineDash([])
// Endpoint dots — outlined for legibility on any background.
for (const p of [start, end]) {
ctx.beginPath()
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2)
ctx.fillStyle = '#ffffff'
ctx.fill()
ctx.strokeStyle = '#000000'
ctx.lineWidth = 1
ctx.stroke()
}
ctx.restore()
}

/**
* Read a single pixel from the canvas at the given bitmap coords and return
* its colour as #rrggbb. Out-of-bounds clicks return null.
Expand Down
62 changes: 53 additions & 9 deletions src/components/image-editor/OptionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ type Props = {
tool: Tool
fgColor: string
setFgColor: (c: string) => void
bgColor: string
setBgColor: (c: string) => void
strokeWidth: number
setStrokeWidth: (n: number) => void
bucketTolerance: number
Expand All @@ -29,6 +31,8 @@ export function OptionsBar({
tool,
fgColor,
setFgColor,
bgColor,
setBgColor,
strokeWidth,
setStrokeWidth,
bucketTolerance,
Expand Down Expand Up @@ -164,6 +168,15 @@ export function OptionsBar({
}

if (tool === 'bucket') {
const swatch: React.CSSProperties = {
width: 22,
height: 22,
padding: 0,
border: '1px solid var(--pf-line)',
background: 'transparent',
borderRadius: 3,
cursor: 'pointer',
}
return (
<div className="pf-options">
<div className="pf-opt-group">
Expand All @@ -172,15 +185,7 @@ export function OptionsBar({
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',
}}
style={swatch}
/>
</div>
<div className="pf-opt-group">
Expand Down Expand Up @@ -211,6 +216,45 @@ export function OptionsBar({
)
}

if (tool === 'gradient') {
const swatch: React.CSSProperties = {
width: 22,
height: 22,
padding: 0,
border: '1px solid var(--pf-line)',
background: 'transparent',
borderRadius: 3,
cursor: 'pointer',
}
return (
<div className="pf-options">
<div className="pf-opt-group">
<span className="pf-opt-label">{t('pages.imageEditor.fgColor')}:</span>
<input
type="color"
value={fgColor}
onChange={(e) => setFgColor(e.target.value)}
style={swatch}
/>
</div>
<div className="pf-opt-group">
<span className="pf-opt-label">{t('pages.imageEditor.bgColor')}:</span>
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
style={swatch}
/>
</div>
<div className="pf-opt-group" style={{ borderRight: 0 }}>
<span className="pf-opt-label" style={{ marginRight: 0 }}>
{t('pages.imageEditor.gradientHint')}
</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 @@ -107,7 +107,6 @@ const GROUPS: ToolDef[][] = [
icon: <ScanLine className="h-4 w-4" />,
labelKey: 'pages.imageEditor.tool.gradient',
shortcut: 'G',
stub: true,
},
{
id: 'bucket',
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',
'gradient',
'blur',
'dodge',
'pen',
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@
"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?).",
"gradientHint": "Gradient (G): drag from start to end. Foreground at the start, background at the end. Result lands as a new layer.",
"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 @@ -583,7 +584,8 @@
"image": "Image",
"ellipse": "Ellipse",
"line": "Line",
"bucket": "Paint fill"
"bucket": "Paint fill",
"gradient": "Gradient"
},
"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 @@ -525,6 +525,7 @@
"bucketHint": "油漆桶 (G):单击以填充容差内的相连像素,结果作为新图层。",
"bucketEmpty": "该位置无可填充区域 —— 试试调高容差。",
"errBucketRead": "无法读取画布像素(可能跨域污染)。",
"gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。",
"textToolHint": "文字 (T):在画布上单击以新增文字。",
"toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。",
"toolStubToast": "{{tool}} 暂未实现。",
Expand Down Expand Up @@ -583,7 +584,8 @@
"image": "图片",
"ellipse": "椭圆",
"line": "直线",
"bucket": "油漆桶填充"
"bucket": "油漆桶填充",
"gradient": "渐变"
},
"project": "项目",
"projectSave": "保存项目 (.json)",
Expand Down
65 changes: 55 additions & 10 deletions src/pages/ImageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,10 +413,6 @@ export function ImageEditorPage() {
* 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 }) => {
Expand All @@ -433,9 +429,8 @@ export function ImageEditorPage() {
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.
// is active, shape coords are post-crop preview pixels — shift back by
// the crop origin and scale to source.
const cropOriginX = state.cropRect
? Math.min(state.cropRect.x, state.cropRect.x + state.cropRect.w)
: 0
Expand All @@ -454,7 +449,6 @@ export function ImageEditorPage() {
}

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]) {
Expand All @@ -471,8 +465,6 @@ export function ImageEditorPage() {
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 = {
Expand All @@ -489,6 +481,56 @@ export function ImageEditorPage() {
[image, state, imageCache, colors.fg, bucketTolerance, ensureImage, commitLayer, t],
)

/**
* Commit a gradient drag — paints a linear gradient from FG (at `start`)
* to BG (at `end`) onto a source-resolution canvas, then commits as a new
* image-shape layer covering the full preview canvas.
*/
const handleCommitGradient = useCallback(
async (start: { x: number; y: number }, end: { 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))
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 sx0 = (start.x + cropOriginX) / previewScale
const sy0 = (start.y + cropOriginY) / previewScale
const sx1 = (end.x + cropOriginX) / previewScale
const sy1 = (end.y + cropOriginY) / previewScale

const canvas = document.createElement('canvas')
canvas.width = baseW
canvas.height = baseH
const ctx = canvas.getContext('2d')
if (!ctx) return
const grad = ctx.createLinearGradient(sx0, sy0, sx1, sy1)
grad.addColorStop(0, colors.fg)
grad.addColorStop(1, colors.bg)
ctx.fillStyle = grad
ctx.fillRect(0, 0, baseW, baseH)
const dataUrl = canvas.toDataURL('image/png')
await ensureImage(dataUrl)

const fullPreviewW = baseW * previewScale
const fullPreviewH = baseH * previewScale
const layer: AnnotationLayer = {
id: crypto.randomUUID(),
name: t('pages.imageEditor.annoLabel.gradient'),
visible: true,
opacity: 100,
blend: 'normal',
kind: 'annotation',
shape: { kind: 'image', x: 0, y: 0, w: fullPreviewW, h: fullPreviewH, dataUrl },
}
commitLayer(layer)
},
[image, state, colors.fg, colors.bg, ensureImage, commitLayer, t],
)

// ── Download / save ──────────────────────────────────────────────────────
/**
* Render and download the current canvas in the requested format. If
Expand Down Expand Up @@ -603,6 +645,8 @@ export function ImageEditorPage() {
tool={tool}
fgColor={colors.fg}
setFgColor={(c) => setColors((s) => ({ ...s, fg: c }))}
bgColor={colors.bg}
setBgColor={(c) => setColors((s) => ({ ...s, bg: c }))}
strokeWidth={strokeWidth}
setStrokeWidth={setStrokeWidth}
bucketTolerance={bucketTolerance}
Expand Down Expand Up @@ -677,6 +721,7 @@ export function ImageEditorPage() {
onCommitCrop={handleCommitCrop}
onBucketClick={handleBucketFill}
bucketTolerance={bucketTolerance}
onCommitGradient={handleCommitGradient}
/>
</Workspace>
</div>
Expand Down
Loading