mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-28 02:34:28 +08:00
feat(canvas): 优化图片编辑交互
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。
|
||||
+ [优化] 图片遮罩、切图与裁剪编辑支持缩放、平移、局部撤回和常用快捷操作,并修复高分辨率图片预览闪烁。
|
||||
|
||||
## v0.9.0 - 2026-07-17
|
||||
|
||||
|
||||
@@ -4,3 +4,5 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
---
|
||||
|
||||
# 待测试
|
||||
|
||||
- 图片编辑弹窗:遮罩、切图和裁剪支持滚轮缩放、中键或空格加左键平移;遮罩支持撤回/重做、Alt 加左/右键横拖调整笔刷;切图支持选中线后 Delete 删除及撤回/重做;裁剪支持固定当前比例、原图、1:1、4:3、16:9、9:16,需验证高分辨率图片下无重影、闪烁或误触。
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [ratioPreset, setRatioPreset] = useState("free");
|
||||
const [fixedRatio, setFixedRatio] = useState<number | null>(null);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const dragAbortRef = useRef<AbortController | null>(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 (
|
||||
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
|
||||
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
|
||||
<CropMask crop={crop} />
|
||||
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
|
||||
{handles.map((handle) => (
|
||||
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
|
||||
))}
|
||||
<div
|
||||
ref={viewport.viewportRef}
|
||||
{...viewport.panHandlers}
|
||||
className={`relative h-[min(62vh,620px)] min-h-[340px] rounded-lg bg-black/5 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
|
||||
>
|
||||
<div className="relative" style={viewport.contentStyle}>
|
||||
<div ref={boxRef} className="absolute isolate overflow-hidden rounded-lg bg-black select-none [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
|
||||
<img src={dataUrl} alt="" className="absolute inset-0 block h-full w-full object-contain opacity-90" draggable={false} />
|
||||
<CropMask crop={crop} />
|
||||
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
|
||||
{handles.map((handle) => (
|
||||
<button
|
||||
key={handle}
|
||||
type="button"
|
||||
className="absolute size-3 rounded-full border border-black bg-white"
|
||||
style={handleStyle(handle)}
|
||||
onPointerDown={(event) => startDrag("resize", event, handle)}
|
||||
aria-label="调整裁剪框"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip title="缩小">
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
|
||||
</Tooltip>
|
||||
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</button>
|
||||
<Tooltip title="放大">
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
|
||||
</Tooltip>
|
||||
<span className="ml-2 text-xs opacity-55">滚轮缩放 · 中键或空格+左键拖动画面</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
|
||||
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
|
||||
@@ -82,9 +131,20 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
|
||||
{locked ? "锁定比例" : "自由比例"}
|
||||
</Button>
|
||||
<Segmented
|
||||
size="small"
|
||||
options={ratioOptions}
|
||||
value={ratioPreset}
|
||||
onChange={(value) => {
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -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}%` };
|
||||
}
|
||||
|
||||
@@ -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<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(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<MaskStroke[]>([]);
|
||||
const redoRef = useRef<MaskStroke[]>([]);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [brushSize, setBrushSize] = useState(defaultBrushSize);
|
||||
const [mode, setMode] = useState<DrawMode>("paint");
|
||||
const [error, setError] = useState("");
|
||||
const [historySize, setHistorySize] = useState(0);
|
||||
const [redoSize, setRedoSize] = useState(0);
|
||||
const [brushPreview, setBrushPreview] = useState<BrushPreview | null>(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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>, 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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
|
||||
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
|
||||
{image ? (
|
||||
<>
|
||||
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
|
||||
onPointerDown={startDraw}
|
||||
onPointerMove={moveDraw}
|
||||
onPointerUp={stopDraw}
|
||||
onPointerCancel={stopDraw}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden transitionName="" maskTransitionName="">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]" data-canvas-no-zoom>
|
||||
<div
|
||||
ref={viewport.viewportRef}
|
||||
{...viewport.panHandlers}
|
||||
className={`relative h-[min(68vh,720px)] min-h-[360px] rounded-xl border border-black/10 bg-transparent dark:border-white/10 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
|
||||
>
|
||||
<div className="relative" style={viewport.contentStyle}>
|
||||
<div ref={viewport.stageRef} className="absolute isolate overflow-hidden rounded-lg bg-transparent select-none [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
|
||||
<img src={dataUrl} alt="" className="absolute inset-0 block h-full w-full bg-transparent object-contain [backface-visibility:hidden]" draggable={false} />
|
||||
{image ? (
|
||||
<>
|
||||
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="absolute inset-0 h-full w-full cursor-none touch-none [backface-visibility:hidden]"
|
||||
onPointerDown={startDraw}
|
||||
onPointerMove={moveDraw}
|
||||
onPointerUp={stopDraw}
|
||||
onPointerCancel={stopDraw}
|
||||
onPointerEnter={(event) => updateBrushPreview(event)}
|
||||
onPointerLeave={() => {
|
||||
if (!drawingRef.current.active && !brushAdjustRef.current?.active) setBrushPreview(null);
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
{brushPreview ? (
|
||||
<div
|
||||
className={`pointer-events-none absolute z-10 rounded-full border-2 ${brushPreview.adjusting ? "border-[#fbbf24] bg-black/10" : "border-white/90 bg-black/5"} shadow-[0_0_0_1px_rgba(0,0,0,.8)]`}
|
||||
style={{ left: brushPreview.x, top: brushPreview.y, width: brushPreview.size, height: brushPreview.size, transform: "translate(-50%, -50%)" }}
|
||||
>
|
||||
{brushPreview.adjusting ? <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded bg-black/75 px-1.5 py-0.5 text-xs font-semibold text-white">{brushSize}px</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +255,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">局部遮罩编辑</h2>
|
||||
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||
<div className="mt-2 text-xs leading-5 opacity-55">滚轮缩放 · 中键或空格+左键拖动画面 · Alt+左/右键横拖调笔刷 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -137,6 +267,26 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-black/10 px-2 py-1 dark:border-white/10">
|
||||
<Tooltip title="撤回局部涂抹 (Ctrl/Cmd+Z)">
|
||||
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回局部涂抹" onClick={undoMask} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重做局部涂抹 (Ctrl/Cmd+Shift+Z)">
|
||||
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做局部涂抹" onClick={redoMask} />
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="缩小">
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
|
||||
</Tooltip>
|
||||
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</button>
|
||||
<Tooltip title="放大">
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium opacity-75">笔刷大小</span>
|
||||
@@ -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");
|
||||
|
||||
@@ -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<ActiveLine>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const historyRef = useRef<CanvasImageSplitParams[]>([]);
|
||||
const redoRef = useRef<CanvasImageSplitParams[]>([]);
|
||||
const dragAbortRef = useRef<AbortController | null>(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 (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
|
||||
<div className="space-y-5">
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
|
||||
<div className="space-y-5" data-canvas-no-zoom>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">切分图片</h2>
|
||||
<p className="mt-1 text-sm opacity-60">生成 {total} 个图片子节点,并按原图网格排列到画布右侧</p>
|
||||
<p className="mt-2 text-xs leading-5 opacity-55">滚轮缩放 · 中键或空格+左键拖动画面 · Delete 删除选中线 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
|
||||
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
|
||||
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
|
||||
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
|
||||
<div
|
||||
ref={viewport.viewportRef}
|
||||
{...viewport.panHandlers}
|
||||
className={`relative isolate h-[340px] min-h-[300px] rounded-lg bg-black/5 ${viewport.scrollClassName} ${viewport.isPanning ? "cursor-grabbing" : viewport.spacePressed ? "cursor-grab" : ""}`}
|
||||
>
|
||||
<div className="relative" style={viewport.contentStyle}>
|
||||
<div ref={previewRef} className="absolute isolate overflow-hidden rounded-lg bg-black [backface-visibility:hidden] [contain:layout_paint] [transform:translateZ(0)]" style={viewport.stageStyle}>
|
||||
<img src={dataUrl} alt="" className="absolute inset-0 block h-full w-full object-contain [backface-visibility:hidden]" draggable={false} />
|
||||
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="opacity-60">原图</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="撤回切图调整 (Ctrl/Cmd+Z)">
|
||||
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回切图调整" onClick={undoSplit} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重做切图调整 (Ctrl/Cmd+Shift+Z)">
|
||||
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做切图调整" onClick={redoSplit} />
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小">
|
||||
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
|
||||
</Tooltip>
|
||||
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</button>
|
||||
<Tooltip title="放大">
|
||||
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,10 +192,18 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
|
||||
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
|
||||
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>横向线</Button>
|
||||
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>纵向线</Button>
|
||||
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>删除线</Button>
|
||||
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>重置线</Button>
|
||||
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
|
||||
横向线
|
||||
</Button>
|
||||
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>
|
||||
纵向线
|
||||
</Button>
|
||||
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>
|
||||
删除线
|
||||
</Button>
|
||||
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>
|
||||
重置线
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const panRef = useRef<{ pointerId: number; x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
|
||||
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
|
||||
const [viewportSize, setViewportSize] = useState<ImageSize>({ 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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user