ai-chatbot/components/agents/agent-psd-template-export-preview.tsx
Your Name be5b9aef12
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
chore: sync latest workspace changes
2026-04-05 02:08:26 +08:00

1357 lines
40 KiB
TypeScript
Raw 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 { Download, Loader2, Ruler, Trash2 } from "lucide-react";
import Image from "next/image";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
type AgentPsdTemplateExportPreviewProps = {
imageUrl: string;
imageAlt?: string;
enableDimensionAnnotations?: boolean;
downloadName?: string;
sizeInfoText?: string | null;
};
type ImageSize = {
width: number;
height: number;
};
type PreviewRect = {
left: number;
top: number;
width: number;
height: number;
};
type DimensionAnnotation = {
id: string;
x1: number;
y1: number;
x2: number;
y2: number;
label: string;
};
type Point = {
x: number;
y: number;
};
type CoordinateSpace = {
containerLeft: number;
containerTop: number;
previewRect: PreviewRect;
imageSize: ImageSize;
};
type DragState =
| {
kind: "drawing";
pointerId: number;
annotationId: string;
coordinateSpace: CoordinateSpace;
}
| {
kind: "move-start" | "move-end";
pointerId: number;
annotationId: string;
coordinateSpace: CoordinateSpace;
}
| {
kind: "move-line";
pointerId: number;
annotationId: string;
coordinateSpace: CoordinateSpace;
startClientX: number;
startClientY: number;
startAnnotation: DimensionAnnotation;
};
const PREVIEW_MIN_HEIGHT = 340;
const DIMENSION_UNIT = "CM";
const LABEL_PLACEHOLDER = "例如:120";
const IMAGE_EXTENSION_REGEX = /\.(png|jpg|jpeg|webp)$/i;
const DIMENSION_SUFFIX_REGEX = /-尺寸标注(?=\.)/;
const DIMENSION_LINE_COLOR = "#666b73";
const DIMENSION_TEXT_COLOR = "#2a2f35";
const DIMENSION_TEXT_HALO_COLOR = "rgba(255,255,255,0.92)";
const PREVIEW_DIMENSION_FONT_SIZE = 10.4;
const PREVIEW_DIMENSION_LABEL_OFFSET = 13;
const PREVIEW_DIMENSION_STROKE_WIDTH = 1.1;
const PREVIEW_DIMENSION_STROKE_WIDTH_ACTIVE = 1.5;
const PREVIEW_DIMENSION_CAP_STROKE_WIDTH = 1.55;
const PREVIEW_DIMENSION_CAP_STROKE_WIDTH_ACTIVE = 1.9;
const PREVIEW_DIMENSION_TICK_LENGTH = 5.2;
const PREVIEW_DIMENSION_TICK_LENGTH_ACTIVE = 6.5;
const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
type AutoDimensionValues = {
widthCm?: number;
depthCm?: number;
heightCm?: number;
rawValues: number[];
};
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function createAnnotationId() {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `psd-dimension-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function getContainPreviewRect(
container: ImageSize,
image: ImageSize
): PreviewRect {
const safeContainerWidth = Math.max(1, container.width);
const safeContainerHeight = Math.max(1, container.height);
const safeImageWidth = Math.max(1, image.width);
const safeImageHeight = Math.max(1, image.height);
const scale = Math.min(
safeContainerWidth / safeImageWidth,
safeContainerHeight / safeImageHeight
);
const width = safeImageWidth * scale;
const height = safeImageHeight * scale;
return {
left: (safeContainerWidth - width) / 2,
top: (safeContainerHeight - height) / 2,
width,
height,
};
}
function getDimensionLength(annotation: DimensionAnnotation) {
return Math.hypot(
annotation.x2 - annotation.x1,
annotation.y2 - annotation.y1
);
}
function formatDimensionValue(value: number) {
return Number.isInteger(value)
? `${value}${DIMENSION_UNIT}`
: `${value.toFixed(1)}${DIMENSION_UNIT}`;
}
function normalizeDimensionInput(value: string) {
return String(value || "")
.trim()
.replace(/厘米|公分/gi, "")
.replace(/cm/gi, "")
.replace(/\s+/g, "")
.replace(/[^0-9.]/g, "");
}
function extractAutoDimensionValues(
sizeInfoText: string | null | undefined
): AutoDimensionValues | null {
const normalizedText = String(sizeInfoText || "").trim();
if (!normalizedText) {
return null;
}
const matchedLine =
normalizedText.match(SIZE_INFO_LINE_REGEX)?.[1] || normalizedText;
const values = Array.from(matchedLine.matchAll(NUMBER_REGEX))
.map((item) => Number(item[0]))
.filter((item) => Number.isFinite(item) && item > 0)
.slice(0, 3);
if (values.length === 0) {
return null;
}
if (values.length === 1) {
return {
heightCm: values[0],
rawValues: values,
widthCm: values[0],
};
}
if (values.length === 2) {
const [larger, smaller] = [...values].sort((left, right) => right - left);
return {
heightCm: larger,
rawValues: values,
widthCm: smaller,
};
}
const sortedValues = [...values].sort((left, right) => right - left);
const [heightCm, widthCm, depthCm] = sortedValues;
return {
depthCm,
heightCm,
rawValues: values,
widthCm,
};
}
function getAnnotationOrientation(annotation: DimensionAnnotation) {
const deltaX = Math.abs(annotation.x2 - annotation.x1);
const deltaY = Math.abs(annotation.y2 - annotation.y1);
if (deltaY > deltaX * 1.25) {
return "vertical";
}
if (deltaX > deltaY * 1.25) {
return "horizontal";
}
return "diagonal";
}
function getAutoDimensionLabel(params: {
annotation: DimensionAnnotation;
annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null;
}) {
const { annotation, annotations, autoDimensionValues } = params;
if (!autoDimensionValues) {
return "";
}
if (autoDimensionValues.rawValues.length === 1) {
return formatDimensionValue(autoDimensionValues.rawValues[0]);
}
const orientation = getAnnotationOrientation(annotation);
if (orientation === "vertical" && autoDimensionValues.heightCm) {
return formatDimensionValue(autoDimensionValues.heightCm);
}
if (orientation === "diagonal" && autoDimensionValues.depthCm) {
return formatDimensionValue(autoDimensionValues.depthCm);
}
if (orientation === "horizontal") {
const horizontalAnnotations = annotations
.filter((item) => getAnnotationOrientation(item) === "horizontal")
.sort(
(left, right) => getDimensionLength(right) - getDimensionLength(left)
);
const currentIndex = horizontalAnnotations.findIndex(
(item) => item.id === annotation.id
);
if (currentIndex <= 0 && autoDimensionValues.widthCm) {
return formatDimensionValue(autoDimensionValues.widthCm);
}
if (autoDimensionValues.depthCm) {
return formatDimensionValue(autoDimensionValues.depthCm);
}
if (autoDimensionValues.widthCm) {
return formatDimensionValue(autoDimensionValues.widthCm);
}
}
if (autoDimensionValues.widthCm) {
return formatDimensionValue(autoDimensionValues.widthCm);
}
if (autoDimensionValues.heightCm) {
return formatDimensionValue(autoDimensionValues.heightCm);
}
return "";
}
function getDimensionLabel(params: {
annotation: DimensionAnnotation;
annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null;
}) {
const { annotation, annotations, autoDimensionValues } = params;
const customLabel = normalizeDimensionInput(annotation.label);
if (customLabel) {
return `${customLabel}${DIMENSION_UNIT}`;
}
return getAutoDimensionLabel({
annotation,
annotations,
autoDimensionValues,
});
}
function mapImagePointToPreviewPoint(
point: Point,
previewRect: PreviewRect,
imageSize: ImageSize
) {
return {
x:
previewRect.left +
(point.x / Math.max(1, imageSize.width)) * previewRect.width,
y:
previewRect.top +
(point.y / Math.max(1, imageSize.height)) * previewRect.height,
};
}
function mapClientPointToImagePoint(
clientX: number,
clientY: number,
coordinateSpace: CoordinateSpace,
options?: { requireInside?: boolean }
) {
const localX = clientX - coordinateSpace.containerLeft;
const localY = clientY - coordinateSpace.containerTop;
const { left, top, width, height } = coordinateSpace.previewRect;
const isInside =
localX >= left &&
localX <= left + width &&
localY >= top &&
localY <= top + height;
if (options?.requireInside && !isInside) {
return null;
}
return {
x: clamp(
((localX - left) / Math.max(1, width)) * coordinateSpace.imageSize.width,
0,
coordinateSpace.imageSize.width
),
y: clamp(
((localY - top) / Math.max(1, height)) * coordinateSpace.imageSize.height,
0,
coordinateSpace.imageSize.height
),
};
}
function getAnnotationVector(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.max(1, Math.hypot(dx, dy));
return {
dx,
dy,
length,
ux: dx / length,
uy: dy / length,
nx: -dy / length,
ny: dx / length,
};
}
function getPreviewAnnotationGeometry(params: {
annotation: DimensionAnnotation;
annotations: DimensionAnnotation[];
previewRect: PreviewRect;
imageSize: ImageSize;
autoDimensionValues?: AutoDimensionValues | null;
}) {
const {
annotation,
annotations,
previewRect,
imageSize,
autoDimensionValues,
} = params;
const start = mapImagePointToPreviewPoint(
{ x: annotation.x1, y: annotation.y1 },
previewRect,
imageSize
);
const end = mapImagePointToPreviewPoint(
{ x: annotation.x2, y: annotation.y2 },
previewRect,
imageSize
);
const vector = getAnnotationVector(start.x, start.y, end.x, end.y);
const label = getDimensionLabel({
annotation,
annotations,
autoDimensionValues,
});
const labelOffset = PREVIEW_DIMENSION_LABEL_OFFSET;
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
const angleRadians =
rawAngleRadians > Math.PI / 2
? rawAngleRadians - Math.PI
: rawAngleRadians < -Math.PI / 2
? rawAngleRadians + Math.PI
: rawAngleRadians;
return {
start,
end,
vector,
label,
angleDegrees: (angleRadians * 180) / Math.PI,
labelCenterX: (start.x + end.x) / 2 + vector.nx * labelOffset,
labelCenterY: (start.y + end.y) / 2 + vector.ny * labelOffset,
};
}
function buildDownloadName(downloadName: string | undefined, imageUrl: string) {
const explicitName = String(downloadName || "").trim();
if (explicitName) {
return explicitName;
}
try {
const pathname = new URL(imageUrl).pathname;
const rawName = pathname.split("/").pop() || "";
const decoded = decodeURIComponent(rawName).trim();
if (!decoded) {
return `psd-template-${Date.now()}.png`;
}
if (IMAGE_EXTENSION_REGEX.test(decoded)) {
return decoded.replace(IMAGE_EXTENSION_REGEX, "-尺寸标注.png");
}
return `${decoded}-尺寸标注.png`;
} catch {
return `psd-template-${Date.now()}.png`;
}
}
async function loadImageFromCandidates(candidates: string[]) {
const uniqueCandidates = Array.from(
new Set(candidates.map((item) => String(item || "").trim()).filter(Boolean))
);
for (const candidate of uniqueCandidates) {
try {
const response = await fetch(candidate);
if (!response.ok) {
continue;
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const nextImage = new window.Image();
nextImage.onload = () => resolve(nextImage);
nextImage.onerror = () => reject(new Error("图片加载失败"));
nextImage.src = objectUrl;
});
return {
image,
dispose: () => URL.revokeObjectURL(objectUrl),
};
} catch {
// continue to next candidate
}
}
throw new Error("结果图加载失败");
}
function downloadBlob(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(objectUrl);
}
function drawDimensionCap(params: {
context: CanvasRenderingContext2D;
point: Point;
vector: ReturnType<typeof getAnnotationVector>;
size: number;
}) {
const { context, point, vector, size } = params;
context.beginPath();
context.moveTo(point.x + vector.nx * size, point.y + vector.ny * size);
context.lineTo(point.x - vector.nx * size, point.y - vector.ny * size);
context.stroke();
}
function drawAnnotationToCanvas(params: {
context: CanvasRenderingContext2D;
annotation: DimensionAnnotation;
imageSize: ImageSize;
annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null;
}) {
const { context, annotation, imageSize, annotations, autoDimensionValues } =
params;
const label = getDimensionLabel({
annotation,
annotations,
autoDimensionValues,
});
const vector = getAnnotationVector(
annotation.x1,
annotation.y1,
annotation.x2,
annotation.y2
);
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
const angleRadians =
rawAngleRadians > Math.PI / 2
? rawAngleRadians - Math.PI
: rawAngleRadians < -Math.PI / 2
? rawAngleRadians + Math.PI
: rawAngleRadians;
const strokeWidth = clamp(
Math.min(imageSize.width, imageSize.height) * 0.001_35,
1,
2.4
);
const capLength = strokeWidth * 5.3;
const fontSize = clamp(
Math.round(Math.min(imageSize.width, imageSize.height) * 0.0176),
11,
27
);
const labelOffset = fontSize * 0.95;
const centerX = (annotation.x1 + annotation.x2) / 2 + vector.nx * labelOffset;
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
context.save();
context.strokeStyle = DIMENSION_LINE_COLOR;
context.lineWidth = strokeWidth;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.moveTo(annotation.x1, annotation.y1);
context.lineTo(annotation.x2, annotation.y2);
context.stroke();
drawDimensionCap({
context,
point: { x: annotation.x1, y: annotation.y1 },
size: capLength,
vector,
});
drawDimensionCap({
context,
point: { x: annotation.x2, y: annotation.y2 },
size: capLength,
vector,
});
context.translate(centerX, centerY);
context.rotate(angleRadians);
context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
context.lineWidth = Math.max(1.5, fontSize * 0.18);
context.strokeStyle = DIMENSION_TEXT_HALO_COLOR;
context.strokeText(label, 0, 0);
context.fillStyle = DIMENSION_TEXT_COLOR;
context.fillText(label, 0, 0);
context.restore();
}
export function AgentPsdTemplateExportPreview({
imageUrl,
imageAlt = "PSD 模板导出结果",
enableDimensionAnnotations = false,
downloadName,
sizeInfoText,
}: AgentPsdTemplateExportPreviewProps) {
const previewViewportRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const [viewportSize, setViewportSize] = useState<ImageSize>({
width: 0,
height: 0,
});
const [imageSize, setImageSize] = useState<ImageSize | null>(null);
const [annotations, setAnnotations] = useState<DimensionAnnotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string>("");
const [isDrawMode, setIsDrawMode] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const previewImageUrl = useMemo(
() => buildImageProxyUrl(imageUrl),
[imageUrl]
);
const autoDimensionValues = useMemo(
() => extractAutoDimensionValues(sizeInfoText),
[sizeInfoText]
);
const selectedAnnotation = useMemo(
() =>
annotations.find(
(annotation) => annotation.id === selectedAnnotationId
) || null,
[annotations, selectedAnnotationId]
);
const previewRect = useMemo(() => {
if (!imageSize || viewportSize.width <= 0 || viewportSize.height <= 0) {
return null;
}
return getContainPreviewRect(viewportSize, imageSize);
}, [imageSize, viewportSize]);
const clearPointerSession = useCallback(() => {
cleanupRef.current?.();
cleanupRef.current = null;
dragStateRef.current = null;
}, []);
useEffect(() => {
const element = previewViewportRef.current;
if (!element) {
return;
}
const updateViewportSize = () => {
setViewportSize({
width: element.clientWidth,
height: element.clientHeight,
});
};
updateViewportSize();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(updateViewportSize);
observer.observe(element);
return () => observer.disconnect();
}
window.addEventListener("resize", updateViewportSize);
return () => window.removeEventListener("resize", updateViewportSize);
}, []);
useEffect(() => clearPointerSession, [clearPointerSession]);
const getCoordinateSpace = useCallback(() => {
const element = previewViewportRef.current;
if (!element || !previewRect || !imageSize) {
return null;
}
const bounds = element.getBoundingClientRect();
return {
containerLeft: bounds.left,
containerTop: bounds.top,
previewRect,
imageSize,
} satisfies CoordinateSpace;
}, [imageSize, previewRect]);
const attachPointerSession = useCallback(
(
state: DragState,
event: ReactPointerEvent<SVGElement>,
onMove: (dragState: DragState, moveEvent: PointerEvent) => void,
onEnd?: (dragState: DragState) => void
) => {
clearPointerSession();
dragStateRef.current = state;
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// ignore capture failures in unsupported environments
}
const handlePointerMove = (moveEvent: PointerEvent) => {
const activeState = dragStateRef.current;
if (!activeState || activeState.pointerId !== moveEvent.pointerId) {
return;
}
onMove(activeState, moveEvent);
};
const handlePointerUp = (upEvent: PointerEvent) => {
const activeState = dragStateRef.current;
if (!activeState || activeState.pointerId !== upEvent.pointerId) {
return;
}
onEnd?.(activeState);
clearPointerSession();
};
cleanupRef.current = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[clearPointerSession]
);
const handleOverlayPointerDown = useCallback(
(event: ReactPointerEvent<SVGSVGElement>) => {
if (!enableDimensionAnnotations) {
return;
}
if (!isDrawMode) {
setSelectedAnnotationId("");
return;
}
const coordinateSpace = getCoordinateSpace();
if (!coordinateSpace) {
return;
}
const startPoint = mapClientPointToImagePoint(
event.clientX,
event.clientY,
coordinateSpace,
{ requireInside: true }
);
if (!startPoint) {
return;
}
event.preventDefault();
event.stopPropagation();
const annotationId = createAnnotationId();
setAnnotations((current) => [
...current,
{
id: annotationId,
x1: startPoint.x,
y1: startPoint.y,
x2: startPoint.x,
y2: startPoint.y,
label: "",
},
]);
setSelectedAnnotationId(annotationId);
attachPointerSession(
{
kind: "drawing",
pointerId: event.pointerId,
annotationId,
coordinateSpace,
},
event,
(dragState, moveEvent) => {
const nextPoint = mapClientPointToImagePoint(
moveEvent.clientX,
moveEvent.clientY,
dragState.coordinateSpace
);
if (!nextPoint) {
return;
}
setAnnotations((current) =>
current.map((annotation) =>
annotation.id === dragState.annotationId
? {
...annotation,
x2: nextPoint.x,
y2: nextPoint.y,
}
: annotation
)
);
},
(dragState) => {
setAnnotations((current) =>
current.filter((annotation) => {
if (annotation.id !== dragState.annotationId) {
return true;
}
return getDimensionLength(annotation) >= 12;
})
);
setIsDrawMode(false);
}
);
},
[
attachPointerSession,
enableDimensionAnnotations,
getCoordinateSpace,
isDrawMode,
]
);
const handleAnnotationHandlePointerDown = useCallback(
(
annotationId: string,
handle: "move-start" | "move-end",
event: ReactPointerEvent<SVGCircleElement>
) => {
const coordinateSpace = getCoordinateSpace();
if (!coordinateSpace) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedAnnotationId(annotationId);
attachPointerSession(
{
kind: handle,
pointerId: event.pointerId,
annotationId,
coordinateSpace,
},
event,
(dragState, moveEvent) => {
const nextPoint = mapClientPointToImagePoint(
moveEvent.clientX,
moveEvent.clientY,
dragState.coordinateSpace
);
if (!nextPoint) {
return;
}
setAnnotations((current) =>
current.map((annotation) => {
if (annotation.id !== dragState.annotationId) {
return annotation;
}
return dragState.kind === "move-start"
? { ...annotation, x1: nextPoint.x, y1: nextPoint.y }
: { ...annotation, x2: nextPoint.x, y2: nextPoint.y };
})
);
}
);
},
[attachPointerSession, getCoordinateSpace]
);
const handleAnnotationBodyPointerDown = useCallback(
(
annotation: DimensionAnnotation,
event: ReactPointerEvent<SVGLineElement | SVGGElement>
) => {
const coordinateSpace = getCoordinateSpace();
if (!coordinateSpace) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedAnnotationId(annotation.id);
attachPointerSession(
{
kind: "move-line",
pointerId: event.pointerId,
annotationId: annotation.id,
coordinateSpace,
startClientX: event.clientX,
startClientY: event.clientY,
startAnnotation: annotation,
},
event,
(dragState, moveEvent) => {
if (dragState.kind !== "move-line") {
return;
}
const deltaImageX =
((moveEvent.clientX - dragState.startClientX) /
Math.max(1, dragState.coordinateSpace.previewRect.width)) *
dragState.coordinateSpace.imageSize.width;
const deltaImageY =
((moveEvent.clientY - dragState.startClientY) /
Math.max(1, dragState.coordinateSpace.previewRect.height)) *
dragState.coordinateSpace.imageSize.height;
const minDeltaX = -Math.min(
dragState.startAnnotation.x1,
dragState.startAnnotation.x2
);
const maxDeltaX =
dragState.coordinateSpace.imageSize.width -
Math.max(
dragState.startAnnotation.x1,
dragState.startAnnotation.x2
);
const minDeltaY = -Math.min(
dragState.startAnnotation.y1,
dragState.startAnnotation.y2
);
const maxDeltaY =
dragState.coordinateSpace.imageSize.height -
Math.max(
dragState.startAnnotation.y1,
dragState.startAnnotation.y2
);
const safeDeltaX = clamp(deltaImageX, minDeltaX, maxDeltaX);
const safeDeltaY = clamp(deltaImageY, minDeltaY, maxDeltaY);
setAnnotations((current) =>
current.map((item) =>
item.id === dragState.annotationId
? {
...item,
x1: dragState.startAnnotation.x1 + safeDeltaX,
y1: dragState.startAnnotation.y1 + safeDeltaY,
x2: dragState.startAnnotation.x2 + safeDeltaX,
y2: dragState.startAnnotation.y2 + safeDeltaY,
}
: item
)
);
}
);
},
[attachPointerSession, getCoordinateSpace]
);
const handleDeleteSelected = useCallback(() => {
if (!selectedAnnotation) {
return;
}
setAnnotations((current) =>
current.filter((annotation) => annotation.id !== selectedAnnotation.id)
);
setSelectedAnnotationId("");
}, [selectedAnnotation]);
const handleDownload = useCallback(async () => {
const fileName = buildDownloadName(downloadName, imageUrl);
const shouldCompose = enableDimensionAnnotations && annotations.length > 0;
try {
setIsDownloading(true);
if (!shouldCompose) {
const response = await fetch(buildImageProxyUrl(imageUrl));
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
downloadBlob(blob, fileName.replace(DIMENSION_SUFFIX_REGEX, ""));
return;
}
const { image, dispose } = await loadImageFromCandidates([
buildImageProxyUrl(imageUrl),
imageUrl,
]);
try {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, image.naturalWidth || image.width);
canvas.height = Math.max(1, image.naturalHeight || image.height);
const context = canvas.getContext("2d");
if (!context) {
throw new Error("无法创建导出画布");
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
for (const annotation of annotations) {
drawAnnotationToCanvas({
annotation,
annotations,
autoDimensionValues,
context,
imageSize: {
width: canvas.width,
height: canvas.height,
},
});
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(nextBlob) => {
if (nextBlob) {
resolve(nextBlob);
return;
}
reject(new Error("导出结果生成失败"));
},
"image/png",
1
);
});
downloadBlob(blob, fileName);
} finally {
dispose();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载结果图失败");
} finally {
setIsDownloading(false);
}
}, [
annotations,
autoDimensionValues,
downloadName,
enableDimensionAnnotations,
imageUrl,
]);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{enableDimensionAnnotations ? (
<div className="flex flex-wrap items-center gap-2">
<Button
className={cn(
"h-9 px-3",
isDrawMode
? "bg-orange-500 text-white hover:bg-orange-400"
: "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08]"
)}
onClick={() => {
setIsDrawMode((current) => !current);
setSelectedAnnotationId("");
}}
type="button"
variant={isDrawMode ? "default" : "outline"}
>
<Ruler className="mr-1.5 h-4 w-4" />
{isDrawMode ? "取消拉线" : "新增尺寸线"}
</Button>
<Button
className="h-9 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08]"
disabled={!selectedAnnotation}
onClick={handleDeleteSelected}
type="button"
variant="outline"
>
<Trash2 className="mr-1.5 h-4 w-4" />
删除当前
</Button>
<Button
className="h-9 px-3 text-white/72 hover:text-white"
disabled={annotations.length === 0}
onClick={() => {
setAnnotations([]);
setSelectedAnnotationId("");
}}
type="button"
variant="ghost"
>
清空标注
</Button>
<div className="text-white/45 text-xs">
{isDrawMode
? "在预览图上按住拖动即可拉出一条尺寸线"
: "支持拖动整条线、拖动两端端点,并可手动改尺寸文字"}
</div>
</div>
) : null}
<div
className={cn(
"relative flex-1 overflow-hidden rounded-2xl border border-white/10 bg-[#111114]",
enableDimensionAnnotations && isDrawMode
? "cursor-crosshair"
: "cursor-default"
)}
ref={previewViewportRef}
style={{ minHeight: PREVIEW_MIN_HEIGHT }}
>
{previewRect && imageSize ? (
<>
<div
className="absolute select-none overflow-hidden"
style={{
left: previewRect.left,
top: previewRect.top,
width: previewRect.width,
height: previewRect.height,
}}
>
<Image
alt={imageAlt}
draggable={false}
fill
sizes={`${Math.max(320, Math.round(previewRect.width))}px`}
src={previewImageUrl}
style={{ objectFit: "fill" }}
unoptimized
/>
</div>
<svg
className="absolute inset-0 h-full w-full"
onPointerDown={handleOverlayPointerDown}
>
{annotations.map((annotation) => {
const isSelected = annotation.id === selectedAnnotationId;
const geometry = getPreviewAnnotationGeometry({
annotation,
annotations,
autoDimensionValues,
imageSize,
previewRect,
});
const strokeWidth = isSelected
? PREVIEW_DIMENSION_STROKE_WIDTH_ACTIVE
: PREVIEW_DIMENSION_STROKE_WIDTH;
const tickLength = isSelected
? PREVIEW_DIMENSION_TICK_LENGTH_ACTIVE
: PREVIEW_DIMENSION_TICK_LENGTH;
return (
<g key={annotation.id}>
<line
onPointerDown={(event) =>
handleAnnotationBodyPointerDown(annotation, event)
}
stroke="transparent"
strokeWidth={24}
x1={geometry.start.x}
x2={geometry.end.x}
y1={geometry.start.y}
y2={geometry.end.y}
/>
<line
opacity={isSelected ? 1 : 0.92}
pointerEvents="none"
stroke={DIMENSION_LINE_COLOR}
strokeLinecap="round"
strokeWidth={strokeWidth}
x1={geometry.start.x}
x2={geometry.end.x}
y1={geometry.start.y}
y2={geometry.end.y}
/>
<line
pointerEvents="none"
stroke={DIMENSION_LINE_COLOR}
strokeLinecap="round"
strokeWidth={
isSelected
? PREVIEW_DIMENSION_CAP_STROKE_WIDTH_ACTIVE
: PREVIEW_DIMENSION_CAP_STROKE_WIDTH
}
x1={geometry.start.x + geometry.vector.nx * tickLength}
x2={geometry.start.x - geometry.vector.nx * tickLength}
y1={geometry.start.y + geometry.vector.ny * tickLength}
y2={geometry.start.y - geometry.vector.ny * tickLength}
/>
<line
pointerEvents="none"
stroke={DIMENSION_LINE_COLOR}
strokeLinecap="round"
strokeWidth={
isSelected
? PREVIEW_DIMENSION_CAP_STROKE_WIDTH_ACTIVE
: PREVIEW_DIMENSION_CAP_STROKE_WIDTH
}
x1={geometry.end.x + geometry.vector.nx * tickLength}
x2={geometry.end.x - geometry.vector.nx * tickLength}
y1={geometry.end.y + geometry.vector.ny * tickLength}
y2={geometry.end.y - geometry.vector.ny * tickLength}
/>
<g
onPointerDown={(event) =>
handleAnnotationBodyPointerDown(annotation, event)
}
>
<text
dominantBaseline="middle"
fill={DIMENSION_TEXT_COLOR}
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
fontWeight={500}
paintOrder="stroke"
pointerEvents="none"
stroke={DIMENSION_TEXT_HALO_COLOR}
strokeLinejoin="round"
strokeWidth={PREVIEW_DIMENSION_TEXT_HALO_WIDTH}
textAnchor="middle"
transform={`rotate(${geometry.angleDegrees} ${geometry.labelCenterX} ${geometry.labelCenterY})`}
x={geometry.labelCenterX}
y={geometry.labelCenterY + 0.5}
>
{geometry.label}
</text>
</g>
{isSelected ? (
<>
<circle
cx={geometry.start.x}
cy={geometry.start.y}
fill="#ffffff"
onPointerDown={(event) =>
handleAnnotationHandlePointerDown(
annotation.id,
"move-start",
event
)
}
r={PREVIEW_DIMENSION_HANDLE_RADIUS}
stroke={DIMENSION_LINE_COLOR}
strokeWidth={PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH}
/>
<circle
cx={geometry.end.x}
cy={geometry.end.y}
fill="#ffffff"
onPointerDown={(event) =>
handleAnnotationHandlePointerDown(
annotation.id,
"move-end",
event
)
}
r={PREVIEW_DIMENSION_HANDLE_RADIUS}
stroke={DIMENSION_LINE_COLOR}
strokeWidth={PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH}
/>
</>
) : null}
</g>
);
})}
</svg>
{enableDimensionAnnotations && isDrawMode ? (
<div className="-translate-x-1/2 pointer-events-none absolute top-3 left-1/2 rounded-full border border-orange-300/25 bg-black/70 px-3 py-1.5 text-orange-100 text-xs shadow-lg backdrop-blur">
按住并拖动,拉出尺寸线
</div>
) : null}
</>
) : (
<Image
alt={imageAlt}
fill
onLoad={(event) => {
const nextWidth = Math.max(
1,
event.currentTarget.naturalWidth || 0
);
const nextHeight = Math.max(
1,
event.currentTarget.naturalHeight || 0
);
if (nextWidth > 0 && nextHeight > 0) {
setImageSize({ width: nextWidth, height: nextHeight });
}
}}
sizes="100vw"
src={previewImageUrl}
style={{ objectFit: "contain" }}
unoptimized
/>
)}
</div>
{enableDimensionAnnotations ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
{selectedAnnotation ? (
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm text-white">尺寸文字</div>
<div className="text-white/45 text-xs">
{autoDimensionValues
? "留空时会优先使用商品尺寸信息自动显示 CM;手动时只输入数值即可。"
: "当前没有拿到商品尺寸信息,请直接输入数值,系统会自动补 CM。"}
</div>
</div>
{getDimensionLabel({
annotation: selectedAnnotation,
annotations,
autoDimensionValues,
}) ? (
<div className="rounded-full border border-white/10 bg-black/40 px-2.5 py-1 text-[11px] text-white/65">
建议值{" "}
{getDimensionLabel({
annotation: selectedAnnotation,
annotations,
autoDimensionValues,
})}
</div>
) : null}
</div>
<div className="relative">
<Input
className="border-white/10 bg-black/35 pr-14 text-white placeholder:text-white/28"
inputMode="decimal"
onChange={(event) =>
setAnnotations((current) =>
current.map((annotation) =>
annotation.id === selectedAnnotation.id
? {
...annotation,
label: normalizeDimensionInput(
event.target.value
),
}
: annotation
)
)
}
placeholder={LABEL_PLACEHOLDER}
value={normalizeDimensionInput(selectedAnnotation.label)}
/>
<span className="-translate-y-1/2 pointer-events-none absolute top-1/2 right-3 text-[11px] text-white/42">
{DIMENSION_UNIT}
</span>
</div>
</div>
) : (
<div className="text-sm text-white/55">
选中一条尺寸线后,可以在这里输入真实尺寸文案。
</div>
)}
</div>
) : null}
<Button
className="w-full"
disabled={isDownloading}
onClick={handleDownload}
>
{isDownloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
下载结果图
</Button>
</div>
);
}