"use client"; import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, 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, type SeedreamAnnotation, type SeedreamCreationMode, type SeedreamPoint } from "@/lib/seedream/creation"; type AnnotationTool = "box" | "point" | "brush" | "pan"; type BoxDraft = { start: SeedreamPoint; current: SeedreamPoint; }; type StrokeDraft = { points: SeedreamPoint[]; }; const maximumInteractiveSources = 10; export function SeedreamWorkspace({ mode, sources, selectedSources, source, uploading, annotations, onAnnotationsChange, onSelectSource, onToggleSource, onUpload, onPasteImage, onInsertAnnotation }: { mode: Exclude; sources: PromptMaterial[]; selectedSources: PromptMaterial[]; source?: PromptMaterial; uploading: boolean; annotations: SeedreamAnnotation[]; onAnnotationsChange: (annotations: SeedreamAnnotation[]) => void; onSelectSource: (material: PromptMaterial) => void; onToggleSource: (material: PromptMaterial) => void; onUpload: (files: FileList | null) => void; onPasteImage?: (event: ReactClipboardEvent) => void; onInsertAnnotation: (token: string) => void; }) { const [tool, setTool] = useState("box"); const [boxDraft, setBoxDraft] = useState(null); const [strokeDraft, setStrokeDraft] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [redoStack, setRedoStack] = useState([]); const canvasRef = useRef(null); const imageRef = useRef(null); const workspaceRef = useRef(null); const viewportRef = useRef(null); const fullscreenButtonRef = useRef(null); const drawingRef = useRef(false); const panRef = useRef<{ pointerId: number; x: number; y: number; view: CanvasView } | null>(null); const [view, setView] = useState(FIT_CANVAS_VIEW); const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); const [imageSize, setImageSize] = useState({ width: 0, height: 0, key: "" }); const [spaceHeld, setSpaceHeld] = useState(false); const [panning, setPanning] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [viewError, setViewError] = useState(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( () => annotations.filter((annotation) => !annotation.sourceKey || annotation.sourceKey === sourceKey), [annotations, sourceKey] ); const selectedAnnotation = activeAnnotations.find((annotation) => annotation.id === selectedAnnotationId); const nextIndex = useMemo(() => annotations.reduce((maximum, annotation) => Math.max(maximum, annotation.index), 0) + 1, [annotations]); const selectionLimitReached = selectedSources.length >= maximumInteractiveSources; useEffect(() => { setBoxDraft(null); 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) { 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) { 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) { 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) { 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]); function addAnnotation(annotation: SeedreamAnnotation) { onAnnotationsChange([...annotations, { ...annotation, sourceKey }]); setSelectedAnnotationId(annotation.id); setRedoStack([]); } function undo() { const removed = activeAnnotations.at(-1); if (!removed) return; onAnnotationsChange(annotations.filter((annotation) => annotation.id !== removed.id)); setRedoStack((items) => [...items, removed]); if (selectedAnnotationId === removed.id) setSelectedAnnotationId(null); } function redo() { const restored = redoStack.at(-1); if (!restored) return; onAnnotationsChange([...annotations, restored]); setRedoStack((items) => items.slice(0, -1)); setSelectedAnnotationId(restored.id); } function removeSelected() { if (!selectedAnnotation) return; onAnnotationsChange(annotations.filter((annotation) => annotation.id !== selectedAnnotation.id)); setRedoStack((items) => [...items, selectedAnnotation]); setSelectedAnnotationId(null); } function handlePointerDown(event: ReactPointerEvent) { 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") { addAnnotation({ id: annotationID(), index: nextIndex, kind: "point", point }); return; } event.currentTarget.setPointerCapture(event.pointerId); drawingRef.current = true; setSelectedAnnotationId(null); if (tool === "brush") { setStrokeDraft({ points: [point] }); return; } setBoxDraft({ start: point, current: point }); } function handlePointerMove(event: ReactPointerEvent) { if (!event.isPrimary) return; const point = pointerCoordinate(event, canvasRef.current); if (!point) return; if (boxDraft) { setBoxDraft((current) => current ? { ...current, current: point } : null); return; } if (strokeDraft) { setStrokeDraft((current) => { if (!current) return null; const previous = current.points.at(-1); if (previous && pointDistance(previous, point) < Math.max(1, 3 / view.zoom)) return current; return { points: [...current.points, point] }; }); } } function handlePointerUp(event: ReactPointerEvent) { 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 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, end || boxDraft.current); setBoxDraft(null); 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); } const currentImageIndex = sourceIndexByKey.get(sourceKey) || 1; return (
{ if (event.target instanceof HTMLElement && event.target.closest("input, textarea, [contenteditable]")) return; onPasteImage?.(event); }} >
{mode === "interactive" ? "精准编辑画布" : "指定拆分区域"} {mode === "interactive" ? "可在最多 10 张图片间切换,使用坐标或蓝色涂鸦描述编辑位置" : "可留空自动全拆,也可框选或涂鸦指定希望拆出的元素"}
Seedream 5.0 Pro
{!source ? (
{ if (event.target instanceof Element && event.target.closest("label, button, input")) return; event.currentTarget.focus({ preventScroll: true }); }} > {sources.length ? "选择参与生成的图片" : "先上传一张图片"} {mode === "interactive" ? "可以选择多张图片进行跨图定位编辑" : "模板效果预览图不会自动参与拆分"} 支持 Ctrl+V / ⌘+V 粘贴图片或图片下载地址,链接确认后导入
) : (
图{currentImageIndex} {mode === "interactive" ? ( ) : null}
{Math.round(view.zoom * 100)}%
{viewError ? {viewError} : null}
{ if (event.key === " ") { event.preventDefault(); setSpaceHeld(false); } }} onBlur={() => setSpaceHeld(false)} >
{source.name setImageSize({ key: sourceKey, width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} /> {activeAnnotations.map((annotation) => ( setSelectedAnnotationId(annotation.id)} /> ))} {boxDraft ? : null} {strokeDraft ? : null}
滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图 · Ctrl+V / ⌘+V 粘贴图片或图片地址 {mode === "interactive" ? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。" : "蓝色涂鸦会合成到待拆图片;框选会转换为 坐标,不会修改原始素材。"}
{activeAnnotations.length ? activeAnnotations.map((annotation) => ( )) : 尚未标记。可以框选、{mode === "interactive" ? "点选或" : "或"}使用蓝色涂鸦。}
)} {mode === "interactive" && sources.length ? (
{`输入图片 ${selectedSources.length}/${maximumInteractiveSources}`} 图片编号按选中顺序生成,用于“图1 / 图2”坐标提示词
{sources.map((material) => { const key = materialKey(material); const included = selectedSourceKeys.has(key); const imageIndex = sourceIndexByKey.get(key); return (
); })}
) : null}
); } function AnnotationShape({ annotation, selected, onSelect }: { annotation: SeedreamAnnotation; selected: boolean; onSelect: () => void }) { if (annotation.kind === "point" && annotation.point) { return ( { event.stopPropagation(); onSelect(); }}> ); } if (annotation.kind === "box" && annotation.box) { const width = annotation.box.right - annotation.box.left; const height = annotation.box.bottom - annotation.box.top; return ( { event.stopPropagation(); onSelect(); }}> ); } if (annotation.kind === "stroke" && annotation.points?.length) { const first = annotation.points[0]; return ( { event.stopPropagation(); onSelect(); }}> ); } return null; } function AnnotationLabel({ x, y, index }: { x: number; y: number; index: number }) { const safeX = Math.min(955, Math.max(12, x)); const safeY = Math.min(955, Math.max(34, y)); return ( {index} ); } function DraftBox({ draft }: { draft: BoxDraft }) { const box = normalizedBox(draft.start, draft.current); return ; } function DraftStroke({ draft }: { draft: StrokeDraft }) { return ; } function annotationIcon(kind: SeedreamAnnotation["kind"]) { if (kind === "point") return ; if (kind === "stroke") return ; return ; } function normalizedBox(start: SeedreamPoint, end: SeedreamPoint) { return { left: Math.min(start.x, end.x), top: Math.min(start.y, end.y), right: Math.max(start.x, end.x), bottom: Math.max(start.y, end.y) }; } function pointerCoordinate(event: ReactPointerEvent, canvas: SVGSVGElement | null): SeedreamPoint | null { if (!canvas) return null; return canvasAnnotationPoint({ x: event.clientX, y: event.clientY }, canvas.getBoundingClientRect()); } function pointsPath(points: SeedreamPoint[]) { return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" "); } function pointDistance(a: SeedreamPoint, b: SeedreamPoint) { return Math.hypot(a.x - b.x, a.y - b.y); } function materialKey(material?: PromptMaterial) { return material ? material.id || material.url : ""; } function annotationID() { return `annotation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }