From 97bb330de12d7d3c5e248d2d63aa8642a8a4d665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=AF=E6=9C=A8=E6=B3=BD?= <195470117+kumu-ze@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:32:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E4=BC=98=E5=8C=96=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=BC=96=E8=BE=91=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/content/docs/progress/pending-test.mdx | 2 + .../canvas/canvas-node-crop-dialog.tsx | 178 ++++++++-- .../canvas/canvas-node-mask-edit-dialog.tsx | 332 +++++++++++++----- .../canvas/canvas-node-split-dialog.tsx | 161 +++++++-- .../canvas/use-image-editor-viewport.ts | 196 +++++++++++ 6 files changed, 717 insertions(+), 153 deletions(-) create mode 100644 web/src/components/canvas/use-image-editor-viewport.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index de7e54d..64d6289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased + [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。 ++ [优化] 图片遮罩、切图与裁剪编辑支持缩放、平移、局部撤回和常用快捷操作,并修复高分辨率图片预览闪烁。 ## v0.9.0 - 2026-07-17 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a781a33..54aaf02 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -4,3 +4,5 @@ description: 当前版本已实现但仍需人工验证的变更项 --- # 待测试 + +- 图片编辑弹窗:遮罩、切图和裁剪支持滚轮缩放、中键或空格加左键平移;遮罩支持撤回/重做、Alt 加左/右键横拖调整笔刷;切图支持选中线后 Delete 删除及撤回/重做;裁剪支持固定当前比例、原图、1:1、4:3、16:9、9:16,需验证高分辨率图片下无重影、闪烁或误触。 diff --git a/web/src/components/canvas/canvas-node-crop-dialog.tsx b/web/src/components/canvas/canvas-node-crop-dialog.tsx index 8f42834..5249ca9 100644 --- a/web/src/components/canvas/canvas-node-crop-dialog.tsx +++ b/web/src/components/canvas/canvas-node-crop-dialog.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { Button, Modal } from "antd"; -import { Check, Lock, LockOpen, X } from "lucide-react"; +import { Button, Modal, Segmented, Tooltip } from "antd"; +import { Check, X, ZoomIn, ZoomOut } from "lucide-react"; +import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport"; import { readImageMeta } from "@/lib/image-utils"; export type CanvasImageCropRect = { @@ -17,16 +18,32 @@ type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"; const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; const minSize = 0.06; const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 }; +const ratioOptions = [ + { label: "自由", value: "free" }, + { label: "固定", value: "fixed" }, + { label: "原图", value: "original" }, + { label: "1:1", value: "1:1" }, + { label: "4:3", value: "4:3" }, + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, +]; export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) { - const boxRef = useRef(null); const [crop, setCrop] = useState(defaultCrop); - const [locked, setLocked] = useState(false); + const [ratioPreset, setRatioPreset] = useState("free"); + const [fixedRatio, setFixedRatio] = useState(null); const [image, setImage] = useState<{ width: number; height: number } | null>(null); + const dragAbortRef = useRef(null); + const viewport = useImageEditorViewport(image, open); + const boxRef = viewport.stageRef; const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null; useEffect(() => { - if (open) setCrop(defaultCrop); + if (open) { + setCrop(defaultCrop); + setRatioPreset("free"); + setFixedRatio(null); + } }, [dataUrl, open]); useEffect(() => { @@ -34,44 +51,76 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da void readImageMeta(dataUrl).then(setImage); }, [dataUrl, open]); + useEffect(() => { + if (!open) dragAbortRef.current?.abort(); + return () => dragAbortRef.current?.abort(); + }, [open]); + const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => { const box = boxRef.current?.getBoundingClientRect(); if (!box) return; event.preventDefault(); event.stopPropagation(); + dragAbortRef.current?.abort(); + const controller = new AbortController(); + dragAbortRef.current = controller; const start = { x: event.clientX, y: event.clientY, crop }; const move = (event: PointerEvent) => { const dx = (event.clientX - start.x) / box.width; const dy = (event.clientY - start.y) / box.height; - setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box)); + setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", resolveRatio(ratioPreset, image, fixedRatio), box)); }; - const up = () => { - document.removeEventListener("pointermove", move); - document.removeEventListener("pointerup", up); - }; - document.addEventListener("pointermove", move); - document.addEventListener("pointerup", up); + const stop = () => controller.abort(); + document.addEventListener("pointermove", move, { signal: controller.signal }); + document.addEventListener("pointerup", stop, { signal: controller.signal }); + document.addEventListener("pointercancel", stop, { signal: controller.signal }); }; return ( - +
-
-
- - -
startDrag("move", event)}> -
-
-
-
- {handles.map((handle) => ( -
+
+ + + +
+
裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"} @@ -82,9 +131,20 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da ) : null}
- + { + const preset = String(value); + setRatioPreset(preset); + const currentRatio = image ? (crop.width * image.width) / Math.max(1, crop.height * image.height) : null; + const nextFixedRatio = preset === "fixed" ? currentRatio : null; + setFixedRatio(nextFixedRatio); + const ratio = resolveRatio(preset, image, nextFixedRatio); + if (ratio && image) setCrop((current) => fitCropToRatio(current, ratio, image)); + }} + />
@@ -116,7 +176,7 @@ function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImag return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) }; } -function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect { +function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, aspectRatio: number | null, box: DOMRect): CanvasImageCropRect { let next = { ...crop }; if (handle.includes("e")) next.width = crop.width + dx; if (handle.includes("s")) next.height = crop.height + dy; @@ -128,20 +188,68 @@ function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: R next.y = crop.y + dy; next.height = crop.height - dy; } - if (locked) { - const size = Math.max(next.width * box.width, next.height * box.height); - next.width = size / box.width; - next.height = size / box.height; + if (aspectRatio) { + const normalizedRatio = aspectRatio * (box.height / box.width); + const horizontalOnly = (handle.includes("e") || handle.includes("w")) && !handle.includes("n") && !handle.includes("s"); + const useWidth = horizontalOnly || (handle.length > 1 && Math.abs(dx * box.width) >= Math.abs(dy * box.height)); + if (useWidth) next.height = next.width / normalizedRatio; + else next.width = next.height * normalizedRatio; if (handle.includes("w")) next.x = crop.x + crop.width - next.width; if (handle.includes("n")) next.y = crop.y + crop.height - next.height; } - next.width = clamp(next.width, minSize, 1); - next.height = clamp(next.height, minSize, 1); + if (aspectRatio) { + const normalizedRatio = aspectRatio * (box.height / box.width); + const scaleDown = Math.min(1, 1 / Math.max(next.width, 0.001), 1 / Math.max(next.height, 0.001)); + next.width *= scaleDown; + next.height *= scaleDown; + if (next.width < minSize || next.height < minSize) { + const minimumScale = Math.max(minSize / Math.max(next.width, 0.001), minSize / Math.max(next.height, 0.001)); + next.width *= minimumScale; + next.height *= minimumScale; + } + next.width = Math.min(next.width, next.height * normalizedRatio); + next.height = next.width / normalizedRatio; + } else { + next.width = clamp(next.width, minSize, 1); + next.height = clamp(next.height, minSize, 1); + } next.x = clamp(next.x, 0, 1 - next.width); next.y = clamp(next.y, 0, 1 - next.height); return next; } +function resolveRatio(preset: string, image: { width: number; height: number } | null, fixedRatio: number | null) { + if (preset === "free" || !image) return null; + if (preset === "fixed") return fixedRatio; + if (preset === "original") return image.width / image.height; + const [width, height] = preset.split(":").map(Number); + return width > 0 && height > 0 ? width / height : null; +} + +function fitCropToRatio(crop: CanvasImageCropRect, ratio: number, image: { width: number; height: number }): CanvasImageCropRect { + const normalizedRatio = ratio * (image.height / image.width); + let width = crop.width; + let height = width / normalizedRatio; + if (height > crop.height) { + height = crop.height; + width = height * normalizedRatio; + } + if (width > 1) { + width = 1; + height = width / normalizedRatio; + } + if (height > 1) { + height = 1; + width = height * normalizedRatio; + } + return { + x: clamp(crop.x + (crop.width - width) / 2, 0, 1 - width), + y: clamp(crop.y + (crop.height - height) / 2, 0, 1 - height), + width, + height, + }; +} + function cropStyle(crop: CanvasImageCropRect) { return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` }; } diff --git a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx index 76d5151..eefbbd2 100644 --- a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx +++ b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { Button, Input, Modal, Slider } from "antd"; -import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { Button, Input, Modal, Slider, Tooltip } from "antd"; +import { Brush, Eraser, Redo2, RotateCcw, Undo2, WandSparkles, X, ZoomIn, ZoomOut } from "lucide-react"; import { readImageMeta } from "@/lib/image-utils"; +import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport"; export type CanvasImageMaskEditPayload = { prompt: string; @@ -10,20 +11,29 @@ export type CanvasImageMaskEditPayload = { }; type DrawMode = "paint" | "erase"; +type Point = { x: number; y: number }; +type MaskStroke = { mode: DrawMode; size: number; points: Point[] }; +type BrushPreview = { x: number; y: number; size: number; adjusting: boolean }; const defaultBrushSize = 100; const maskFillColor = "rgba(37, 99, 235, .38)"; -const maskBorderColor = "rgba(255, 255, 255, .72)"; export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) { const maskCanvasRef = useRef(null); const previewCanvasRef = useRef(null); - const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null }); + const drawingRef = useRef<{ active: boolean; stroke: MaskStroke | null }>({ active: false, stroke: null }); + const brushAdjustRef = useRef<{ active: boolean; pointerId: number; startX: number; startSize: number; previewX: number; previewY: number } | null>(null); + const historyRef = useRef([]); + const redoRef = useRef([]); const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [prompt, setPrompt] = useState(""); const [brushSize, setBrushSize] = useState(defaultBrushSize); const [mode, setMode] = useState("paint"); const [error, setError] = useState(""); + const [historySize, setHistorySize] = useState(0); + const [redoSize, setRedoSize] = useState(0); + const [brushPreview, setBrushPreview] = useState(null); + const viewport = useImageEditorViewport(image, open); useEffect(() => { if (!open) return; @@ -31,6 +41,13 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: setBrushSize(defaultBrushSize); setMode("paint"); setError(""); + setHistorySize(0); + setRedoSize(0); + setBrushPreview(null); + historyRef.current = []; + redoRef.current = []; + brushAdjustRef.current = null; + drawingRef.current = { active: false, stroke: null }; void readImageMeta(dataUrl).then(setImage); }, [dataUrl, open]); @@ -42,53 +59,146 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: const draw = (event: ReactPointerEvent) => { const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY); const maskCanvas = maskCanvasRef.current; - const context = maskCanvas?.getContext("2d"); - if (!maskCanvas || !context) return; - context.lineCap = "round"; - context.lineJoin = "round"; - context.lineWidth = brushSize; - context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out"; - context.strokeStyle = "#000"; - context.fillStyle = "#000"; - if (!drawingRef.current.last) { - drawMaskStroke(context, point, point, brushSize); - } else { - drawMaskStroke(context, drawingRef.current.last, point, brushSize); - } - renderMaskPreview(maskCanvas, previewCanvasRef.current); - drawingRef.current.last = point; - if (mode === "paint") { + const context = maskCanvas?.getContext("2d", { willReadFrequently: true }); + const previewContext = previewCanvasRef.current?.getContext("2d"); + const stroke = drawingRef.current.stroke; + if (!maskCanvas || !context || !previewContext || !stroke) return; + configureStrokeContext(context, stroke); + configurePreviewStrokeContext(previewContext, stroke); + const last = stroke.points.at(-1); + drawMaskStroke(context, last || point, point, stroke.size); + drawMaskStroke(previewContext, last || point, point, stroke.size); + stroke.points.push(point); + if (stroke.mode === "paint") { setError(""); } }; + const updateBrushPreview = (event: ReactPointerEvent, size = brushSize, adjusting = false) => { + const rect = event.currentTarget.getBoundingClientRect(); + setBrushPreview({ + x: event.clientX - rect.left, + y: event.clientY - rect.top, + size: Math.max(4, (size / event.currentTarget.width) * rect.width), + adjusting, + }); + }; + const startDraw = (event: ReactPointerEvent) => { + if ((event.button === 0 || event.button === 2) && event.altKey) { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const rect = event.currentTarget.getBoundingClientRect(); + brushAdjustRef.current = { + active: true, + pointerId: event.pointerId, + startX: event.clientX, + startSize: brushSize, + previewX: event.clientX - rect.left, + previewY: event.clientY - rect.top, + }; + updateBrushPreview(event, brushSize, true); + return; + } + if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - drawingRef.current = { active: true, last: null }; - if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current); + updateBrushPreview(event); + drawingRef.current = { active: true, stroke: { mode, size: brushSize, points: [] } }; draw(event); }; const moveDraw = (event: ReactPointerEvent) => { + const brushAdjust = brushAdjustRef.current; + if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) { + event.preventDefault(); + event.stopPropagation(); + const nextSize = clampBrushSize(brushAdjust.startSize + event.clientX - brushAdjust.startX); + setBrushSize(nextSize); + setBrushPreview({ + x: brushAdjust.previewX, + y: brushAdjust.previewY, + size: Math.max(4, (nextSize / event.currentTarget.width) * event.currentTarget.getBoundingClientRect().width), + adjusting: true, + }); + return; + } + updateBrushPreview(event); if (!drawingRef.current.active) return; event.preventDefault(); draw(event); }; - const stopDraw = () => { - drawingRef.current = { active: false, last: null }; - const maskCanvas = maskCanvasRef.current; - if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas)); + const stopDraw = (event: ReactPointerEvent) => { + const brushAdjust = brushAdjustRef.current; + if (brushAdjust?.active && event.pointerId === brushAdjust.pointerId) { + brushAdjustRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + updateBrushPreview(event, brushSize); + return; + } + const stroke = drawingRef.current.stroke; + drawingRef.current = { active: false, stroke: null }; + if (stroke?.points.length) { + historyRef.current.push(stroke); + setHistorySize(historyRef.current.length); + redoRef.current = []; + setRedoSize(0); + } }; + const undoMask = useCallback(() => { + if (drawingRef.current.active || !historyRef.current.length) return; + const stroke = historyRef.current.pop(); + if (stroke) redoRef.current.push(stroke); + setHistorySize(historyRef.current.length); + setRedoSize(redoRef.current.length); + replayMask(historyRef.current, maskCanvasRef.current, previewCanvasRef.current); + setError(""); + }, []); + + const redoMask = useCallback(() => { + if (drawingRef.current.active || !redoRef.current.length) return; + const stroke = redoRef.current.pop(); + if (stroke) historyRef.current.push(stroke); + setHistorySize(historyRef.current.length); + setRedoSize(redoRef.current.length); + replayMask(historyRef.current, maskCanvasRef.current, previewCanvasRef.current); + setError(""); + }, []); + const resetMask = () => { + historyRef.current = []; + redoRef.current = []; + setHistorySize(0); + setRedoSize(0); clearCanvas(maskCanvasRef.current); clearCanvas(previewCanvasRef.current); setError(""); }; + useEffect(() => { + if (!open) return; + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("input,textarea,[contenteditable='true']")) return; + const key = event.key.toLowerCase(); + const modifier = (event.metaKey || event.ctrlKey) && !event.altKey; + const isUndo = modifier && !event.shiftKey && key === "z"; + const isRedo = modifier && ((event.shiftKey && key === "z") || (!event.shiftKey && key === "y")); + if (!isUndo && !isRedo) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (isRedo) redoMask(); + else undoMask(); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [open, redoMask, undoMask]); + const submit = () => { const nextPrompt = prompt.trim(); const canvas = maskCanvasRef.current; @@ -99,26 +209,45 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: }; return ( - -
-
-
- - {image ? ( - <> - - - - ) : null} + +
+
+
+
+ + {image ? ( + <> + + updateBrushPreview(event)} + onPointerLeave={() => { + if (!drawingRef.current.active && !brushAdjustRef.current?.active) setBrushPreview(null); + }} + onContextMenu={(event) => event.preventDefault()} + /> + {brushPreview ? ( +
+ {brushPreview.adjusting ? {brushSize}px : null} +
+ ) : null} + + ) : null} +
@@ -126,6 +255,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:

局部遮罩编辑

{image ? `${image.width} x ${image.height}px` : "读取中"}
+
滚轮缩放 · 中键或空格+左键拖动画面 · Alt+左/右键横拖调笔刷 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做
@@ -137,6 +267,26 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
+
+ + + +
+
+
笔刷大小 @@ -187,8 +337,12 @@ function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: nu }; } +function clampBrushSize(value: number) { + return Math.min(160, Math.max(8, Math.round(value / 2) * 2)); +} + function clearCanvas(canvas: HTMLCanvasElement | null) { - const context = canvas?.getContext("2d"); + const context = canvas?.getContext("2d", { willReadFrequently: true }); if (!canvas || !context) return; context.clearRect(0, 0, canvas.width, canvas.height); } @@ -206,8 +360,43 @@ function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: context.stroke(); } +function configureStrokeContext(context: CanvasRenderingContext2D, stroke: MaskStroke) { + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = stroke.size; + context.globalCompositeOperation = stroke.mode === "paint" ? "source-over" : "destination-out"; + context.strokeStyle = "#000"; + context.fillStyle = "#000"; +} + +function configurePreviewStrokeContext(context: CanvasRenderingContext2D, stroke: MaskStroke) { + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = stroke.size; + context.globalCompositeOperation = stroke.mode === "paint" ? "source-over" : "destination-out"; + context.strokeStyle = maskFillColor; + context.fillStyle = maskFillColor; +} + +function replayMask(strokes: MaskStroke[], maskCanvas: HTMLCanvasElement | null, previewCanvas: HTMLCanvasElement | null) { + const context = maskCanvas?.getContext("2d", { willReadFrequently: true }); + const previewContext = previewCanvas?.getContext("2d"); + if (!maskCanvas || !context || !previewCanvas || !previewContext) return; + context.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + previewContext.clearRect(0, 0, previewCanvas.width, previewCanvas.height); + for (const stroke of strokes) { + configureStrokeContext(context, stroke); + configurePreviewStrokeContext(previewContext, stroke); + stroke.points.forEach((point, index) => { + const previous = stroke.points[index - 1] || point; + drawMaskStroke(context, previous, point, stroke.size); + drawMaskStroke(previewContext, previous, point, stroke.size); + }); + } +} + function canvasHasPaint(canvas: HTMLCanvasElement) { - const context = canvas.getContext("2d"); + const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return false; const data = context.getImageData(0, 0, canvas.width, canvas.height).data; for (let index = 3; index < data.length; index += 4) { @@ -216,54 +405,13 @@ function canvasHasPaint(canvas: HTMLCanvasElement) { return false; } -function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) { - const context = previewCanvas?.getContext("2d"); - if (!previewCanvas || !context) return; - context.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - context.fillStyle = maskFillColor; - context.fillRect(0, 0, previewCanvas.width, previewCanvas.height); - context.globalCompositeOperation = "destination-in"; - context.drawImage(maskCanvas, 0, 0); - context.globalCompositeOperation = "source-over"; - if (withBorder) drawDashedMaskBorder(context, maskCanvas); -} - -function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) { - const maskContext = maskCanvas.getContext("2d"); - if (!maskContext) return; - const { width, height } = maskCanvas; - const data = maskContext.getImageData(0, 0, width, height).data; - const step = Math.max(1, Math.round(Math.max(width, height) / 1200)); - const dash = step * 8; - const gap = step * 5; - const period = dash + gap; - - context.save(); - context.fillStyle = maskBorderColor; - context.shadowColor = "rgba(0, 0, 0, .24)"; - context.shadowBlur = step * 1.5; - for (let y = step; y < height - step; y += step) { - for (let x = step; x < width - step; x += step) { - const offset = (y * width + x) * 4 + 3; - if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue; - if ((x + y) % period > dash) continue; - context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step)); - } - } - context.restore(); -} - -function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) { - return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0; -} - function buildEditMask(selectionCanvas: HTMLCanvasElement) { const canvas = document.createElement("canvas"); canvas.width = selectionCanvas.width; canvas.height = selectionCanvas.height; - const context = canvas.getContext("2d"); + const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return selectionCanvas.toDataURL("image/png"); - const selectionContext = selectionCanvas.getContext("2d"); + const selectionContext = selectionCanvas.getContext("2d", { willReadFrequently: true }); context.fillStyle = "#fff"; context.fillRect(0, 0, canvas.width, canvas.height); if (!selectionContext) return canvas.toDataURL("image/png"); diff --git a/web/src/components/canvas/canvas-node-split-dialog.tsx b/web/src/components/canvas/canvas-node-split-dialog.tsx index 08a9261..afc6fc0 100644 --- a/web/src/components/canvas/canvas-node-split-dialog.tsx +++ b/web/src/components/canvas/canvas-node-split-dialog.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { Button, InputNumber, Modal } from "antd"; -import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { Button, InputNumber, Modal, Tooltip } from "antd"; +import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react"; import { readImageMeta } from "@/lib/image-utils"; import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data"; +import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport"; export type CanvasImageSplitParams = ImageSplitParams; @@ -15,7 +16,13 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d const [params, setParams] = useState(defaultParams); const [image, setImage] = useState<{ width: number; height: number } | null>(null); const [active, setActive] = useState(null); - const previewRef = useRef(null); + const historyRef = useRef([]); + const redoRef = useRef([]); + const dragAbortRef = useRef(null); + const [historySize, setHistorySize] = useState(0); + const [redoSize, setRedoSize] = useState(0); + const viewport = useImageEditorViewport(image, open); + const previewRef = viewport.stageRef; const horizontalLines = params.horizontalLines || []; const verticalLines = params.verticalLines || []; const rows = horizontalLines.length + 1; @@ -28,6 +35,10 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d setParams(defaultParams); setActive(null); setImage(null); + historyRef.current = []; + redoRef.current = []; + setHistorySize(0); + setRedoSize(0); }, [dataUrl, open]); useEffect(() => { @@ -35,20 +46,28 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d void readImageMeta(dataUrl).then(setImage); }, [dataUrl, open]); + useEffect(() => { + if (!open) dragAbortRef.current?.abort(); + return () => dragAbortRef.current?.abort(); + }, [open]); + const update = (key: "rows" | "columns", value: string | number | null) => { const count = clampGrid(value ?? params[key]); + pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize); setActive(null); setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) })); }; const addLine = (axis: "horizontal" | "vertical") => { - setParams((current) => { - const key = axis === "horizontal" ? "horizontalLines" : "verticalLines"; - const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b); - return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns }; - }); + pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize); + const key = axis === "horizontal" ? "horizontalLines" : "verticalLines"; + const spot = findLineSpot(params[key] || []); + const lines = [...(params[key] || []), spot].sort((a, b) => a - b); + setActive({ axis, index: lines.indexOf(spot) }); + setParams({ ...params, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : params.rows, columns: axis === "vertical" ? lines.length + 1 : params.columns }); }; const deleteLine = () => { if (!active) return; + pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize); setParams((current) => { const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines"; const lines = (current[key] || []).filter((_, index) => index !== active.index); @@ -58,16 +77,19 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d }; const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => { event.preventDefault(); + event.stopPropagation(); setActive({ axis, index }); const box = previewRef.current?.getBoundingClientRect(); if (!box) return; + pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize); + dragAbortRef.current?.abort(); + const controller = new AbortController(); + dragAbortRef.current = controller; const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width); - const up = () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); + const stop = () => controller.abort(); + window.addEventListener("pointermove", move, { signal: controller.signal }); + window.addEventListener("pointerup", stop, { signal: controller.signal }); + window.addEventListener("pointercancel", stop, { signal: controller.signal }); }; const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => { setParams((current) => { @@ -78,28 +100,91 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d }); }; const resetLines = () => { + pushHistory(historyRef, redoRef, params, setHistorySize, setRedoSize); setActive(null); setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) })); }; + const undoSplit = useCallback(() => { + const previous = historyRef.current.pop(); + if (!previous) return; + redoRef.current.push(cloneSplitParams(params)); + setParams(previous); + setActive(null); + setHistorySize(historyRef.current.length); + setRedoSize(redoRef.current.length); + }, [params]); + const redoSplit = useCallback(() => { + const next = redoRef.current.pop(); + if (!next) return; + historyRef.current.push(cloneSplitParams(params)); + setParams(next); + setActive(null); + setHistorySize(historyRef.current.length); + setRedoSize(redoRef.current.length); + }, [params]); + + useEffect(() => { + if (!open) return; + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("input,textarea,[contenteditable='true']")) return; + const key = event.key.toLowerCase(); + const isUndo = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && key === "z"; + const isRedo = (event.metaKey || event.ctrlKey) && !event.altKey && ((event.shiftKey && key === "z") || (!event.shiftKey && key === "y")); + const isDelete = event.key === "Delete" || event.key === "Backspace"; + if (!isUndo && !isRedo && !isDelete) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (isDelete) deleteLine(); + else if (isRedo) redoSplit(); + else undoSplit(); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [active, open, params, redoSplit, undoSplit]); const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns }; return ( - -
+ +

切分图片

生成 {total} 个图片子节点,并按原图网格排列到画布右侧

+

滚轮缩放 · 中键或空格+左键拖动画面 · Delete 删除选中线 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做

-
-
- - +
+
+
+ + +
- 原图 +
+ + + +
{image ? `${image.width} x ${image.height} px` : "读取中"}
@@ -107,10 +192,18 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d update("rows", value)} /> update("columns", value)} />
- - - - + + + +
@@ -184,3 +277,19 @@ function clampGrid(value: string | number) { const numberValue = Number(value); return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1))); } + +function cloneSplitParams(params: CanvasImageSplitParams) { + return { + ...params, + horizontalLines: [...(params.horizontalLines || [])], + verticalLines: [...(params.verticalLines || [])], + }; +} + +function pushHistory(historyRef: { current: CanvasImageSplitParams[] }, redoRef: { current: CanvasImageSplitParams[] }, params: CanvasImageSplitParams, setHistorySize: (size: number) => void, setRedoSize: (size: number) => void) { + historyRef.current.push(cloneSplitParams(params)); + if (historyRef.current.length > 50) historyRef.current.shift(); + redoRef.current = []; + setHistorySize(historyRef.current.length); + setRedoSize(0); +} diff --git a/web/src/components/canvas/use-image-editor-viewport.ts b/web/src/components/canvas/use-image-editor-viewport.ts new file mode 100644 index 0000000..c9a149b --- /dev/null +++ b/web/src/components/canvas/use-image-editor-viewport.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react"; + +type ImageSize = { width: number; height: number }; + +const minZoom = 1; +const maxZoom = 4; +const zoomStep = 1.2; +const viewportPadding = 16; + +export function useImageEditorViewport(image: ImageSize | null, open: boolean) { + const viewportNodeRef = useRef(null); + const stageRef = useRef(null); + const panRef = useRef<{ pointerId: number; x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null); + const [viewportElement, setViewportElement] = useState(null); + const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); + const [zoom, setZoom] = useState(minZoom); + const [isPanning, setIsPanning] = useState(false); + const [spacePressed, setSpacePressed] = useState(false); + const spacePressedRef = useRef(false); + const viewportRef = useCallback((node: HTMLDivElement | null) => { + viewportNodeRef.current = node; + setViewportElement(node); + }, []); + + useEffect(() => { + if (open) setZoom(minZoom); + }, [open, image?.width, image?.height]); + + useEffect(() => { + if (!open) return; + const releaseSpace = () => { + spacePressedRef.current = false; + setSpacePressed(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Space" || event.repeat) return; + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("input,textarea,[contenteditable='true']")) return; + event.preventDefault(); + spacePressedRef.current = true; + setSpacePressed(true); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.code === "Space") releaseSpace(); + }; + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("blur", releaseSpace); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("blur", releaseSpace); + spacePressedRef.current = false; + }; + }, [open]); + + useEffect(() => { + if (!open || !viewportElement) return; + const updateSize = () => { + const width = viewportElement.clientWidth; + const height = viewportElement.clientHeight; + setViewportSize((current) => (current.width === width && current.height === height ? current : { width, height })); + }; + updateSize(); + const observer = new ResizeObserver(updateSize); + observer.observe(viewportElement); + return () => observer.disconnect(); + }, [open, viewportElement]); + + const baseSize = fitImage(image, viewportSize); + const stageSize = { width: baseSize.width * zoom, height: baseSize.height * zoom }; + const contentSize = { + width: Math.max(viewportSize.width, stageSize.width), + height: Math.max(viewportSize.height, stageSize.height), + }; + const stageOffset = { + left: Math.max(0, Math.round((contentSize.width - stageSize.width) / 2)), + top: Math.max(0, Math.round((contentSize.height - stageSize.height) / 2)), + }; + + const setZoomAround = useCallback( + (nextZoom: number, clientX?: number, clientY?: number) => { + const viewport = viewportNodeRef.current; + const stage = stageRef.current; + if (!viewport || !stage || !baseSize.width || !baseSize.height) return; + + const boundedZoom = clamp(nextZoom, minZoom, maxZoom); + if (Math.abs(boundedZoom - zoom) < 0.001) return; + + const viewportRect = viewport.getBoundingClientRect(); + const stageRect = stage.getBoundingClientRect(); + const pointerX = clientX ?? viewportRect.left + viewportRect.width / 2; + const pointerY = clientY ?? viewportRect.top + viewportRect.height / 2; + const ratioX = clamp((pointerX - stageRect.left) / Math.max(1, stageRect.width), 0, 1); + const ratioY = clamp((pointerY - stageRect.top) / Math.max(1, stageRect.height), 0, 1); + const viewportX = pointerX - viewportRect.left; + const viewportY = pointerY - viewportRect.top; + + setZoom(boundedZoom); + requestAnimationFrame(() => { + const nextWidth = baseSize.width * boundedZoom; + const nextHeight = baseSize.height * boundedZoom; + const nextContentWidth = Math.max(viewport.clientWidth, nextWidth); + const nextContentHeight = Math.max(viewport.clientHeight, nextHeight); + const nextLeft = Math.max(0, (nextContentWidth - nextWidth) / 2); + const nextTop = Math.max(0, (nextContentHeight - nextHeight) / 2); + viewport.scrollLeft = nextLeft + ratioX * nextWidth - viewportX; + viewport.scrollTop = nextTop + ratioY * nextHeight - viewportY; + }); + }, + [baseSize.height, baseSize.width, zoom], + ); + + useEffect(() => { + if (!open || !viewportElement) return; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + setZoomAround(event.deltaY < 0 ? zoom * zoomStep : zoom / zoomStep, event.clientX, event.clientY); + }; + viewportElement.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewportElement.removeEventListener("wheel", handleWheel); + }, [open, setZoomAround, viewportElement, zoom]); + + const startPan = useCallback((event: ReactPointerEvent) => { + if (event.button !== 1 && !(event.button === 0 && spacePressedRef.current)) return; + event.preventDefault(); + event.stopPropagation(); + const viewport = event.currentTarget; + panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, scrollLeft: viewport.scrollLeft, scrollTop: viewport.scrollTop }; + viewport.setPointerCapture(event.pointerId); + setIsPanning(true); + }, []); + const movePan = useCallback((event: ReactPointerEvent) => { + const pan = panRef.current; + if (!pan || event.pointerId !== pan.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.scrollLeft = pan.scrollLeft - (event.clientX - pan.x); + event.currentTarget.scrollTop = pan.scrollTop - (event.clientY - pan.y); + }, []); + const stopPan = useCallback((event: ReactPointerEvent) => { + const pan = panRef.current; + if (!pan || event.pointerId !== pan.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + panRef.current = null; + setIsPanning(false); + }, []); + const preventAuxClick = useCallback((event: ReactMouseEvent) => { + if (event.button !== 1) return; + event.preventDefault(); + event.stopPropagation(); + }, []); + + return { + viewportRef, + stageRef, + zoom, + isPanning, + spacePressed, + scrollClassName: zoom > minZoom + 0.001 ? "overflow-scroll" : "overflow-hidden", + panHandlers: { + onPointerDownCapture: startPan, + onPointerMoveCapture: movePan, + onPointerUpCapture: stopPan, + onPointerCancelCapture: stopPan, + onAuxClick: preventAuxClick, + }, + canZoomIn: zoom < maxZoom, + canZoomOut: zoom > minZoom, + zoomIn: () => setZoomAround(zoom * zoomStep), + zoomOut: () => setZoomAround(zoom / zoomStep), + resetZoom: () => setZoomAround(minZoom), + contentStyle: { width: contentSize.width, height: contentSize.height } satisfies CSSProperties, + stageStyle: { + left: stageOffset.left, + top: stageOffset.top, + width: stageSize.width, + height: stageSize.height, + } satisfies CSSProperties, + }; +} + +function fitImage(image: ImageSize | null, viewport: ImageSize): ImageSize { + if (!image || !viewport.width || !viewport.height) return { width: 0, height: 0 }; + const availableWidth = Math.max(1, viewport.width - viewportPadding * 2); + const availableHeight = Math.max(1, viewport.height - viewportPadding * 2); + const scale = Math.min(availableWidth / image.width, availableHeight / image.height, 1); + return { width: Math.max(1, Math.floor(image.width * scale)), height: Math.max(1, Math.floor(image.height * scale)) }; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +}