功能增加
This commit is contained in:
1 parent
b340fc7ad7
commit
f1993eb388
69 files changed
+2243
-162
No files matched your search
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { BoxSelect, Brush, Check, ImageIcon, Loader2, MapPin, Redo2, Trash2, Undo2, Upload, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { BoxSelect, Brush, Check, Hand, ImageIcon, Loader2, MapPin, Maximize2, Minimize2, Redo2, ScanLine, Trash2, Undo2, Upload, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { materialPreviewUrl } from "@/lib/client/asset-urls";
|
||||
import { canvasAnnotationPoint, constrainCanvasView, fitCanvasImage, FIT_CANVAS_VIEW, MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM, zoomCanvasAt, type CanvasSize, type CanvasView } from "@/lib/client/seedream-viewport";
|
||||
import type { PromptMaterial } from "@/lib/prompt/assembler";
|
||||
import {
|
||||
seedreamAnnotationToken,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
type SeedreamPoint
|
||||
} from "@/lib/seedream/creation";
|
||||
|
||||
type AnnotationTool = "box" | "point" | "brush";
|
||||
type AnnotationTool = "box" | "point" | "brush" | "pan";
|
||||
|
||||
type BoxDraft = {
|
||||
start: SeedreamPoint;
|
||||
@@ -56,7 +57,21 @@ export function SeedreamWorkspace({
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [redoStack, setRedoStack] = useState<SeedreamAnnotation[]>([]);
|
||||
const canvasRef = useRef<SVGSVGElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const workspaceRef = useRef<HTMLElement | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const fullscreenButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const drawingRef = useRef(false);
|
||||
const panRef = useRef<{ pointerId: number; x: number; y: number; view: CanvasView } | null>(null);
|
||||
const [view, setView] = useState<CanvasView>(FIT_CANVAS_VIEW);
|
||||
const [viewportSize, setViewportSize] = useState<CanvasSize>({ width: 0, height: 0 });
|
||||
const [imageSize, setImageSize] = useState<CanvasSize & { key: string }>({ width: 0, height: 0, key: "" });
|
||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [viewError, setViewError] = useState<string | null>(null);
|
||||
const sourceKey = materialKey(source);
|
||||
const fittedImage = fitCanvasImage(imageSize.key === sourceKey ? imageSize : { width: 0, height: 0 }, viewportSize);
|
||||
const selectedSourceKeys = useMemo(() => new Set(selectedSources.map(materialKey)), [selectedSources]);
|
||||
const sourceIndexByKey = useMemo(() => new Map(selectedSources.map((material, index) => [materialKey(material), index + 1])), [selectedSources]);
|
||||
const activeAnnotations = useMemo(
|
||||
@@ -72,8 +87,144 @@ export function SeedreamWorkspace({
|
||||
setStrokeDraft(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setRedoStack([]);
|
||||
setView(FIT_CANVAS_VIEW);
|
||||
setSpaceHeld(false);
|
||||
setPanning(false);
|
||||
panRef.current = null;
|
||||
drawingRef.current = false;
|
||||
}, [sourceKey, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
// A cached image can finish loading before React attaches onLoad during
|
||||
// hydration. Read its dimensions too, otherwise the fitted stage stays 0px.
|
||||
const image = imageRef.current;
|
||||
if (image?.complete && image.naturalWidth > 0) {
|
||||
setImageSize({ key: sourceKey, width: image.naturalWidth, height: image.naturalHeight });
|
||||
}
|
||||
}, [sourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const measure = () => setViewportSize({ width: viewport.clientWidth, height: viewport.clientHeight });
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [sourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setView((current) => constrainCanvasView(current, fittedImage, viewportSize));
|
||||
}, [fittedImage.width, fittedImage.height, viewportSize.width, viewportSize.height]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || !fittedImage.width) return;
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
if (drawingRef.current || panRef.current) return;
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
|
||||
const factor = Math.exp(-Math.max(-400, Math.min(400, event.deltaY * unit)) * 0.002);
|
||||
const anchor = { x: event.clientX - bounds.left - bounds.width / 2, y: event.clientY - bounds.top - bounds.height / 2 };
|
||||
setView((current) => zoomCanvasAt(current, current.zoom * factor, anchor, fittedImage, viewportSize));
|
||||
};
|
||||
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => viewport.removeEventListener("wheel", handleWheel);
|
||||
}, [sourceKey, fittedImage.width, fittedImage.height, viewportSize.width, viewportSize.height]);
|
||||
|
||||
useEffect(() => {
|
||||
let wasActive = false;
|
||||
const handleFullscreenChange = () => {
|
||||
const active = document.fullscreenElement === workspaceRef.current;
|
||||
if (!active && !wasActive) return;
|
||||
wasActive = active;
|
||||
setFullscreen(active);
|
||||
setSpaceHeld(false);
|
||||
cancelDraft();
|
||||
panRef.current = null;
|
||||
setPanning(false);
|
||||
if (!active) fullscreenButtonRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
async function toggleFullscreen() {
|
||||
setViewError(null);
|
||||
try {
|
||||
if (document.fullscreenElement === workspaceRef.current) await document.exitFullscreen();
|
||||
else if (workspaceRef.current?.requestFullscreen) await workspaceRef.current.requestFullscreen();
|
||||
else setViewError("当前浏览器不支持全屏编辑,可使用画布缩放和拖动。");
|
||||
} catch {
|
||||
setViewError("无法进入全屏,请重试或使用画布缩放和拖动。");
|
||||
}
|
||||
}
|
||||
|
||||
function changeZoom(factor: number) {
|
||||
if (drawingRef.current || panRef.current) return;
|
||||
setView((current) => zoomCanvasAt(current, current.zoom * factor, { x: 0, y: 0 }, fittedImage, viewportSize));
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
if (drawingRef.current || panRef.current) return;
|
||||
setView(FIT_CANVAS_VIEW);
|
||||
}
|
||||
|
||||
function handleViewportPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!source || !event.isPrimary || (event.button !== 0 && event.button !== 1)) return;
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
if (tool !== "pan" && !spaceHeld && event.button !== 1) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancelDraft();
|
||||
panRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, view };
|
||||
setPanning(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handleViewportPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const pan = panRef.current;
|
||||
if (!pan || pan.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setView(constrainCanvasView({ ...pan.view, x: pan.view.x + event.clientX - pan.x, y: pan.view.y + event.clientY - pan.y }, fittedImage, viewportSize));
|
||||
}
|
||||
|
||||
function stopPan(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (panRef.current?.pointerId !== event.pointerId) return;
|
||||
event.stopPropagation();
|
||||
panRef.current = null;
|
||||
setPanning(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handleViewportKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSpaceHeld(true);
|
||||
} else if (event.key === "+" || event.key === "=") {
|
||||
event.preventDefault();
|
||||
changeZoom(1.25);
|
||||
} else if (event.key === "-") {
|
||||
event.preventDefault();
|
||||
changeZoom(0.8);
|
||||
} else if (event.key === "0") {
|
||||
event.preventDefault();
|
||||
resetView();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAnnotation(token: string) {
|
||||
// The prompt editor lives outside the fullscreen element. Exit the same
|
||||
// canvas (no remount) before moving focus there; all annotations survive.
|
||||
if (document.fullscreenElement === workspaceRef.current) {
|
||||
try { await document.exitFullscreen(); } catch { return; }
|
||||
}
|
||||
onInsertAnnotation(token);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "layers" && tool === "point") setTool("box");
|
||||
}, [mode, tool]);
|
||||
@@ -108,7 +259,7 @@ export function SeedreamWorkspace({
|
||||
}
|
||||
|
||||
function handlePointerDown(event: ReactPointerEvent<SVGSVGElement>) {
|
||||
if (!source) return;
|
||||
if (!source || !event.isPrimary || event.button !== 0 || tool === "pan" || spaceHeld || panRef.current) return;
|
||||
const point = pointerCoordinate(event, canvasRef.current);
|
||||
if (!point) return;
|
||||
if (tool === "point" && mode === "interactive") {
|
||||
@@ -116,6 +267,7 @@ export function SeedreamWorkspace({
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drawingRef.current = true;
|
||||
setSelectedAnnotationId(null);
|
||||
if (tool === "brush") {
|
||||
setStrokeDraft({ points: [point] });
|
||||
@@ -125,6 +277,7 @@ export function SeedreamWorkspace({
|
||||
}
|
||||
|
||||
function handlePointerMove(event: ReactPointerEvent<SVGSVGElement>) {
|
||||
if (!event.isPrimary) return;
|
||||
const point = pointerCoordinate(event, canvasRef.current);
|
||||
if (!point) return;
|
||||
if (boxDraft) {
|
||||
@@ -135,30 +288,35 @@ export function SeedreamWorkspace({
|
||||
setStrokeDraft((current) => {
|
||||
if (!current) return null;
|
||||
const previous = current.points.at(-1);
|
||||
if (previous && pointDistance(previous, point) < 3) return current;
|
||||
if (previous && pointDistance(previous, point) < Math.max(1, 3 / view.zoom)) return current;
|
||||
return { points: [...current.points, point] };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(event: ReactPointerEvent<SVGSVGElement>) {
|
||||
if (!event.isPrimary) return;
|
||||
drawingRef.current = false;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
const end = pointerCoordinate(event, canvasRef.current);
|
||||
if (strokeDraft) {
|
||||
const points = strokeDraft.points.length === 1
|
||||
? [strokeDraft.points[0], { x: Math.min(1000, strokeDraft.points[0].x + 1), y: Math.min(1000, strokeDraft.points[0].y + 1) }]
|
||||
: strokeDraft.points;
|
||||
const drawn = end && pointDistance(strokeDraft.points.at(-1)!, end) >= 1 ? [...strokeDraft.points, end] : strokeDraft.points;
|
||||
const points = drawn.length === 1
|
||||
? [drawn[0], { x: Math.min(1000, drawn[0].x + 1), y: Math.min(1000, drawn[0].y + 1) }]
|
||||
: drawn;
|
||||
setStrokeDraft(null);
|
||||
addAnnotation({ id: annotationID(), index: nextIndex, kind: "stroke", points });
|
||||
return;
|
||||
}
|
||||
if (!boxDraft) return;
|
||||
const box = normalizedBox(boxDraft.start, boxDraft.current);
|
||||
const box = normalizedBox(boxDraft.start, end || boxDraft.current);
|
||||
setBoxDraft(null);
|
||||
if (box.right - box.left < 8 || box.bottom - box.top < 8) return;
|
||||
if (box.right - box.left < 2 || box.bottom - box.top < 2) return;
|
||||
addAnnotation({ id: annotationID(), index: nextIndex, kind: "box", box });
|
||||
}
|
||||
|
||||
function cancelDraft() {
|
||||
drawingRef.current = false;
|
||||
setBoxDraft(null);
|
||||
setStrokeDraft(null);
|
||||
}
|
||||
@@ -166,7 +324,7 @@ export function SeedreamWorkspace({
|
||||
const currentImageIndex = sourceIndexByKey.get(sourceKey) || 1;
|
||||
|
||||
return (
|
||||
<section className={clsx("seedream-workspace", mode === "interactive" ? "interactive" : "layers")} aria-label={mode === "interactive" ? "Seedream 交互编辑" : "Seedream 图层拆分"}>
|
||||
<section ref={workspaceRef} className={clsx("seedream-workspace", mode === "interactive" ? "interactive" : "layers")} aria-label={mode === "interactive" ? "Seedream 交互编辑" : "Seedream 图层拆分"}>
|
||||
<header className="seedream-workspace-head">
|
||||
<div>
|
||||
<strong>{mode === "interactive" ? "精准编辑画布" : "指定拆分区域"}</strong>
|
||||
@@ -193,6 +351,7 @@ export function SeedreamWorkspace({
|
||||
<div className="seedream-editor-shell">
|
||||
<div className="seedream-editor-toolbar" role="toolbar" aria-label="标注工具">
|
||||
<span className="seedream-current-image-badge">图{currentImageIndex}</span>
|
||||
<button className={clsx(tool === "pan" && "active")} type="button" aria-pressed={tool === "pan"} title="拖动画布,也可按住空格或鼠标中键拖动" onClick={() => setTool("pan")}><Hand size={16} />拖动</button>
|
||||
<button className={clsx(tool === "box" && "active")} type="button" aria-pressed={tool === "box"} title="框选区域" onClick={() => setTool("box")}>
|
||||
<BoxSelect size={16} />
|
||||
框选
|
||||
@@ -211,6 +370,14 @@ export function SeedreamWorkspace({
|
||||
<button type="button" disabled={!activeAnnotations.length} title="撤销" aria-label="撤销当前图片的最后一个标注" onClick={undo}><Undo2 size={16} /></button>
|
||||
<button type="button" disabled={!redoStack.length} title="重做" aria-label="恢复最后撤销的标注" onClick={redo}><Redo2 size={16} /></button>
|
||||
<button className="danger" type="button" disabled={!selectedAnnotation} title="删除所选标注" aria-label="删除所选标注" onClick={removeSelected}><Trash2 size={16} /></button>
|
||||
<span className="seedream-editor-toolbar-divider" />
|
||||
<div className="seedream-view-controls" role="group" aria-label="画布视图">
|
||||
<button type="button" disabled={!fittedImage.width || view.zoom <= MIN_CANVAS_ZOOM} aria-label="缩小画布" title="缩小画布(-)" onClick={() => changeZoom(0.8)}><ZoomOut size={16} /></button>
|
||||
<output className="seedream-zoom-level" title="相对适应画布的缩放比例">{Math.round(view.zoom * 100)}%</output>
|
||||
<button type="button" disabled={!fittedImage.width || view.zoom >= MAX_CANVAS_ZOOM} aria-label="放大画布" title="放大画布(+)" onClick={() => changeZoom(1.25)}><ZoomIn size={16} /></button>
|
||||
<button type="button" title="重置缩放和位置(0)" onClick={resetView}><ScanLine size={16} />适应画布</button>
|
||||
<button ref={fullscreenButtonRef} type="button" onClick={toggleFullscreen} title={fullscreen ? "退出全屏(Esc)" : "全屏编辑"}>{fullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}{fullscreen ? "退出全屏" : "全屏编辑"}</button>
|
||||
</div>
|
||||
<label className="seedream-toolbar-upload" title={mode === "interactive" ? "添加图片" : "更换图片"}>
|
||||
{uploading ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||
{mode === "interactive" ? "添加图片" : "更换图片"}
|
||||
@@ -220,9 +387,23 @@ export function SeedreamWorkspace({
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="seedream-canvas-viewport">
|
||||
<div className="seedream-canvas-stage">
|
||||
<img src={materialPreviewUrl(source)} alt={source.name || `${mode === "interactive" ? "编辑" : "拆分"}底图`} draggable={false} />
|
||||
{viewError ? <span className="seedream-view-error" role="alert">{viewError}</span> : null}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={clsx("seedream-canvas-viewport", (tool === "pan" || spaceHeld) && "pan-ready", panning && "panning")}
|
||||
tabIndex={0}
|
||||
aria-label="标注画布:滚轮缩放,空格或中键拖动,加减键缩放,0 适应画布"
|
||||
onPointerDownCapture={handleViewportPointerDown}
|
||||
onPointerMoveCapture={handleViewportPointerMove}
|
||||
onPointerUpCapture={stopPan}
|
||||
onPointerCancelCapture={stopPan}
|
||||
onLostPointerCapture={stopPan}
|
||||
onKeyDown={handleViewportKeyDown}
|
||||
onKeyUp={(event) => { if (event.key === " ") { event.preventDefault(); setSpaceHeld(false); } }}
|
||||
onBlur={() => setSpaceHeld(false)}
|
||||
>
|
||||
<div className="seedream-canvas-stage" style={{ width: fittedImage.width, height: fittedImage.height, transform: `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}>
|
||||
<img ref={imageRef} key={sourceKey} src={materialPreviewUrl(source)} alt={source.name || `${mode === "interactive" ? "编辑" : "拆分"}底图`} draggable={false} onLoad={(event) => setImageSize({ key: sourceKey, width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} />
|
||||
<svg
|
||||
ref={canvasRef}
|
||||
className={clsx("seedream-annotation-canvas", `tool-${tool}`)}
|
||||
@@ -247,6 +428,7 @@ export function SeedreamWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
<div className="seedream-canvas-note">
|
||||
<span>滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图</span>
|
||||
{mode === "interactive"
|
||||
? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。"
|
||||
: "蓝色涂鸦会合成到待拆图片;框选会转换为 <bbox> 坐标,不会修改原始素材。"}
|
||||
@@ -259,7 +441,7 @@ export function SeedreamWorkspace({
|
||||
key={annotation.id}
|
||||
onClick={() => {
|
||||
setSelectedAnnotationId(annotation.id);
|
||||
onInsertAnnotation(seedreamAnnotationToken(annotation.index, annotation.kind));
|
||||
void insertAnnotation(seedreamAnnotationToken(annotation.index, annotation.kind));
|
||||
}}
|
||||
title="点击插入提示词"
|
||||
>
|
||||
@@ -391,12 +573,7 @@ function normalizedBox(start: SeedreamPoint, end: SeedreamPoint) {
|
||||
|
||||
function pointerCoordinate(event: ReactPointerEvent<SVGSVGElement>, canvas: SVGSVGElement | null): SeedreamPoint | null {
|
||||
if (!canvas) return null;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return null;
|
||||
return {
|
||||
x: clamp((event.clientX - bounds.left) / bounds.width * 1000),
|
||||
y: clamp((event.clientY - bounds.top) / bounds.height * 1000)
|
||||
};
|
||||
return canvasAnnotationPoint({ x: event.clientX, y: event.clientY }, canvas.getBoundingClientRect());
|
||||
}
|
||||
|
||||
function pointsPath(points: SeedreamPoint[]) {
|
||||
@@ -407,10 +584,6 @@ function pointDistance(a: SeedreamPoint, b: SeedreamPoint) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
function clamp(value: number) {
|
||||
return Math.max(0, Math.min(1000, Math.round(value)));
|
||||
}
|
||||
|
||||
function materialKey(material?: PromptMaterial) {
|
||||
return material ? material.id || material.url : "";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user