Files
NianAIGC/components/seedream-workspace.tsx
2026-09-11 15:33:18 +08:00

594 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
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,
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,
onInsertAnnotation
}: {
mode: Exclude<SeedreamCreationMode, "basic">;
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;
onInsertAnnotation: (token: string) => void;
}) {
const [tool, setTool] = useState<AnnotationTool>("box");
const [boxDraft, setBoxDraft] = useState<BoxDraft | null>(null);
const [strokeDraft, setStrokeDraft] = useState<StrokeDraft | null>(null);
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(
() => 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<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]);
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<SVGSVGElement>) {
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<SVGSVGElement>) {
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<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 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 (
<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>
<span>{mode === "interactive" ? "可在最多 10 张图片间切换,使用坐标或蓝色涂鸦描述编辑位置" : "可留空自动全拆,也可框选或涂鸦指定希望拆出的元素"}</span>
</div>
<span className="seedream-model-pill">Seedream 5.0 Pro</span>
</header>
{!source ? (
<div className="seedream-source-empty">
<span className="seedream-source-empty-icon" aria-hidden="true"><ImageIcon size={24} /></span>
<strong>{sources.length ? "选择参与生成的图片" : "先上传一张图片"}</strong>
<span>{mode === "interactive" ? "可以选择多张图片进行跨图定位编辑" : "模板效果预览图不会自动参与拆分"}</span>
<label className="button primary">
{uploading ? <Loader2 className="spin" size={16} /> : <Upload size={16} />}
上传图片
<input type="file" multiple={mode === "interactive"} accept={mode === "layers" ? "image/png,image/jpeg" : "image/*"} disabled={uploading} onChange={(event) => {
onUpload(event.currentTarget.files);
event.currentTarget.value = "";
}} />
</label>
</div>
) : (
<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} />
框选
</button>
{mode === "interactive" ? (
<button className={clsx(tool === "point" && "active")} type="button" aria-pressed={tool === "point"} title="点选位置" onClick={() => setTool("point")}>
<MapPin size={16} />
点选
</button>
) : null}
<button className={clsx(tool === "brush" && "active")} type="button" aria-pressed={tool === "brush"} title="蓝色涂鸦标记" onClick={() => setTool("brush")}>
<Brush size={16} />
涂鸦
</button>
<span className="seedream-editor-toolbar-divider" />
<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" ? "添加图片" : "更换图片"}
<input type="file" multiple={mode === "interactive"} accept={mode === "layers" ? "image/png,image/jpeg" : "image/*"} disabled={uploading || mode === "interactive" && selectionLimitReached} onChange={(event) => {
onUpload(event.currentTarget.files);
event.currentTarget.value = "";
}} />
</label>
</div>
{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}`)}
viewBox="0 0 1000 1000"
preserveAspectRatio="none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={cancelDraft}
>
{activeAnnotations.map((annotation) => (
<AnnotationShape
key={annotation.id}
annotation={annotation}
selected={annotation.id === selectedAnnotationId}
onSelect={() => setSelectedAnnotationId(annotation.id)}
/>
))}
{boxDraft ? <DraftBox draft={boxDraft} /> : null}
{strokeDraft ? <DraftStroke draft={strokeDraft} /> : null}
</svg>
</div>
</div>
<div className="seedream-canvas-note">
<span>滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图</span>
{mode === "interactive"
? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。"
: "蓝色涂鸦会合成到待拆图片;框选会转换为 <bbox> 坐标,不会修改原始素材。"}
</div>
<div className="seedream-annotation-strip" aria-label="当前图片标注">
{activeAnnotations.length ? activeAnnotations.map((annotation) => (
<button
type="button"
className={clsx(annotation.id === selectedAnnotationId && "active")}
key={annotation.id}
onClick={() => {
setSelectedAnnotationId(annotation.id);
void insertAnnotation(seedreamAnnotationToken(annotation.index, annotation.kind));
}}
title="点击插入提示词"
>
<span>{annotationIcon(annotation.kind)}</span>
{seedreamAnnotationToken(annotation.index, annotation.kind)}
<small>插入提示词</small>
</button>
)) : <span className="seedream-annotation-empty">尚未标记。可以框选、{mode === "interactive" ? "点选或" : "或"}使用蓝色涂鸦。</span>}
</div>
</div>
)}
{mode === "interactive" && sources.length ? (
<div className="seedream-source-picker">
<div className="seedream-source-picker-head">
<span>{`输入图片 ${selectedSources.length}/${maximumInteractiveSources}`}</span>
<small>图片编号按选中顺序生成,用于“图1 / 图2”坐标提示词</small>
</div>
<div>
{sources.map((material) => {
const key = materialKey(material);
const included = selectedSourceKeys.has(key);
const imageIndex = sourceIndexByKey.get(key);
return (
<article className={clsx("seedream-source-card", key === sourceKey && "active", included && "included")} key={key}>
<button
type="button"
className="seedream-source-card-main"
title={material.name || material.label}
aria-pressed={key === sourceKey}
onClick={() => {
if (!included) {
onToggleSource(material);
return;
}
onSelectSource(material);
}}
>
<img src={materialPreviewUrl(material)} alt="" />
<span>{material.name || material.label || "图片"}</span>
</button>
<button
type="button"
className="seedream-source-toggle"
aria-label={included ? `移除${material.name || "图片"}` : `加入${material.name || "图片"}`}
title={included ? "不参与本次生成" : selectionLimitReached ? "最多选择 10 张图片" : "参与本次生成"}
disabled={!included && selectionLimitReached}
onClick={() => onToggleSource(material)}
>
{included ? <><Check size={12} />图{imageIndex}</> : <><X size={12} />未选</>}
</button>
</article>
);
})}
</div>
</div>
) : null}
</section>
);
}
function AnnotationShape({ annotation, selected, onSelect }: { annotation: SeedreamAnnotation; selected: boolean; onSelect: () => void }) {
if (annotation.kind === "point" && annotation.point) {
return (
<g className={clsx("seedream-annotation-shape", selected && "selected")} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}>
<circle className="seedream-annotation-halo" cx={annotation.point.x} cy={annotation.point.y} r="24" />
<circle className="seedream-annotation-point" cx={annotation.point.x} cy={annotation.point.y} r="9" />
<AnnotationLabel x={annotation.point.x + 18} y={annotation.point.y - 18} index={annotation.index} />
</g>
);
}
if (annotation.kind === "box" && annotation.box) {
const width = annotation.box.right - annotation.box.left;
const height = annotation.box.bottom - annotation.box.top;
return (
<g className={clsx("seedream-annotation-shape", selected && "selected")} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}>
<rect className="seedream-annotation-box" x={annotation.box.left} y={annotation.box.top} width={width} height={height} rx="4" />
<AnnotationLabel x={annotation.box.left + 10} y={annotation.box.top + 10} index={annotation.index} />
</g>
);
}
if (annotation.kind === "stroke" && annotation.points?.length) {
const first = annotation.points[0];
return (
<g className={clsx("seedream-annotation-shape stroke", selected && "selected")} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}>
<path className="seedream-annotation-stroke-hit" d={pointsPath(annotation.points)} />
<path className="seedream-annotation-stroke" d={pointsPath(annotation.points)} />
<AnnotationLabel x={first.x + 18} y={first.y - 18} index={annotation.index} />
</g>
);
}
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 (
<g className="seedream-annotation-label" transform={`translate(${safeX} ${safeY})`}>
<circle r="20" />
<text textAnchor="middle" dominantBaseline="central">{index}</text>
</g>
);
}
function DraftBox({ draft }: { draft: BoxDraft }) {
const box = normalizedBox(draft.start, draft.current);
return <rect className="seedream-annotation-box draft" x={box.left} y={box.top} width={box.right - box.left} height={box.bottom - box.top} rx="4" />;
}
function DraftStroke({ draft }: { draft: StrokeDraft }) {
return <path className="seedream-annotation-stroke draft" d={pointsPath(draft.points)} />;
}
function annotationIcon(kind: SeedreamAnnotation["kind"]) {
if (kind === "point") return <MapPin size={13} />;
if (kind === "stroke") return <Brush size={13} />;
return <BoxSelect size={13} />;
}
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<SVGSVGElement>, 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)}`;
}