From 53fc2eebf5eb2361e1f1c4fba4f0081a89245852 Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Fri, 24 Apr 2026 18:16:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(image-editor):=20wire=20Gradient=20(G)=20?= =?UTF-8?q?=E2=80=94=20drag=20to=20define=20linear=20gradient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the Gradient tool from stub to functional. Drag from start to end on the canvas to define a linear gradient that goes FG → BG; result lands as a new image-shape layer covering the full canvas. - New \`Interaction\` kind in \`Canvas.tsx\`: \`gradient-drawing\` (start + current end in canvas-pixel space). mousedown → start; mousemove → update end; mouseup → call \`onCommitGradient(start, end)\` if the drag is non-trivial. - Live preview overlay (white dashed line + black-outlined endpoint dots) drawn after \`renderTo\` so it sits on top, identity transform for pixel-perfect placement. Same pattern as the crop overlay; never bleeds into the export canvas. - \`ImageEditor.handleCommitGradient\` re-derives source-pixel coords (shifting by crop origin if a crop is active) and renders a linear gradient using \`ctx.createLinearGradient\` onto a baseW × baseH canvas. The bitmap is stored at SOURCE resolution so exports are sharp; layer rect spans the full preview canvas. - New \`OptionsBar\` variant: FG + BG color pickers and a hint reminding the user that the start gets FG and the end gets BG. Shape-tools variant signature gains \`bgColor\`/\`setBgColor\`. Removes \`gradient\` from \`STUB_TOOLS\`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/image-editor/Canvas.tsx | 73 +++++++++++++++++++- src/components/image-editor/OptionsBar.tsx | 62 ++++++++++++++--- src/components/image-editor/ToolsPalette.tsx | 1 - src/components/image-editor/tool-meta.ts | 1 - src/i18n/en.json | 4 +- src/i18n/zh-CN.json | 4 +- src/pages/ImageEditor.tsx | 65 ++++++++++++++--- 7 files changed, 186 insertions(+), 24 deletions(-) diff --git a/src/components/image-editor/Canvas.tsx b/src/components/image-editor/Canvas.tsx index eedd8d6..2064995 100644 --- a/src/components/image-editor/Canvas.tsx +++ b/src/components/image-editor/Canvas.tsx @@ -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 = @@ -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(function Canvas( { @@ -117,6 +125,7 @@ export const Canvas = forwardRef(function Canvas( onPickColor, onCommitCrop, onBucketClick, + onCommitGradient, }, ref, ) { @@ -185,6 +194,10 @@ export const Canvas = forwardRef(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( @@ -292,6 +305,13 @@ export const Canvas = forwardRef(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) @@ -347,6 +367,11 @@ export const Canvas = forwardRef(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 @@ -380,6 +405,16 @@ export const Canvas = forwardRef(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) @@ -549,7 +584,7 @@ export const Canvas = forwardRef(function Canvas( setHoverCursor('crosshair') return } - if (tool === 'bucket') { + if (tool === 'bucket' || tool === 'gradient') { setHoverCursor('crosshair') return } @@ -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. diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx index a025f60..58a7e2a 100644 --- a/src/components/image-editor/OptionsBar.tsx +++ b/src/components/image-editor/OptionsBar.tsx @@ -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 @@ -29,6 +31,8 @@ export function OptionsBar({ tool, fgColor, setFgColor, + bgColor, + setBgColor, strokeWidth, setStrokeWidth, bucketTolerance, @@ -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 (
@@ -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} />
@@ -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 ( +
+
+ {t('pages.imageEditor.fgColor')}: + setFgColor(e.target.value)} + style={swatch} + /> +
+
+ {t('pages.imageEditor.bgColor')}: + setBgColor(e.target.value)} + style={swatch} + /> +
+
+ + {t('pages.imageEditor.gradientHint')} + +
+
+ ) + } + if (tool === 'eyedropper') { return (
diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx index 1627267..af475de 100644 --- a/src/components/image-editor/ToolsPalette.tsx +++ b/src/components/image-editor/ToolsPalette.tsx @@ -107,7 +107,6 @@ const GROUPS: ToolDef[][] = [ icon: , labelKey: 'pages.imageEditor.tool.gradient', shortcut: 'G', - stub: true, }, { id: 'bucket', diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts index 4077efe..5b85395 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', - 'gradient', 'blur', 'dodge', 'pen', diff --git a/src/i18n/en.json b/src/i18n/en.json index 279781a..15693dd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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.", @@ -583,7 +584,8 @@ "image": "Image", "ellipse": "Ellipse", "line": "Line", - "bucket": "Paint fill" + "bucket": "Paint fill", + "gradient": "Gradient" }, "project": "Project", "projectSave": "Save project (.json)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 8121c31..72f3048 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -525,6 +525,7 @@ "bucketHint": "油漆桶 (G):单击以填充容差内的相连像素,结果作为新图层。", "bucketEmpty": "该位置无可填充区域 —— 试试调高容差。", "errBucketRead": "无法读取画布像素(可能跨域污染)。", + "gradientHint": "渐变 (G):从起点拖到终点,起点用前景色,终点用背景色,结果作为新图层。", "textToolHint": "文字 (T):在画布上单击以新增文字。", "toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。", "toolStubToast": "{{tool}} 暂未实现。", @@ -583,7 +584,8 @@ "image": "图片", "ellipse": "椭圆", "line": "直线", - "bucket": "油漆桶填充" + "bucket": "油漆桶填充", + "gradient": "渐变" }, "project": "项目", "projectSave": "保存项目 (.json)", diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx index 5704a14..3a53d48 100644 --- a/src/pages/ImageEditor.tsx +++ b/src/pages/ImageEditor.tsx @@ -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 }) => { @@ -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 @@ -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]) { @@ -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 = { @@ -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 @@ -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} @@ -677,6 +721,7 @@ export function ImageEditorPage() { onCommitCrop={handleCommitCrop} onBucketClick={handleBucketFill} bucketTolerance={bucketTolerance} + onCommitGradient={handleCommitGradient} />