1568 lines
49 KiB
TypeScript
1568 lines
49 KiB
TypeScript
"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 { Switch } from "@/components/ui/switch";
|
||
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;
|
||
color: string;
|
||
glow: boolean;
|
||
};
|
||
|
||
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 DEFAULT_DIMENSION_COLOR = "#666b73";
|
||
const DIMENSION_COLOR_OPTIONS = [
|
||
"#666b73",
|
||
"#f8fafc",
|
||
"#fb923c",
|
||
"#f43f5e",
|
||
"#38bdf8",
|
||
"#34d399",
|
||
"#facc15",
|
||
];
|
||
const DIMENSION_TEXT_HALO_COLOR = "rgba(255,255,255,0.92)";
|
||
const DIMENSION_TEXT_OUTLINE_COLOR = "rgba(15,18,24,0.28)";
|
||
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 PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
|
||
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
|
||
const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
|
||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||
|
||
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 normalizeDimensionColor(value: string) {
|
||
const normalized = String(value || "").trim();
|
||
if (HEX_COLOR_REGEX.test(normalized)) {
|
||
return normalized.toUpperCase();
|
||
}
|
||
|
||
return DEFAULT_DIMENSION_COLOR;
|
||
}
|
||
|
||
function colorWithAlpha(color: string, alpha: number) {
|
||
const normalized = normalizeDimensionColor(color).slice(1);
|
||
const expanded =
|
||
normalized.length === 3
|
||
? normalized
|
||
.split("")
|
||
.map((item) => `${item}${item}`)
|
||
.join("")
|
||
: normalized;
|
||
const red = Number.parseInt(expanded.slice(0, 2), 16);
|
||
const green = Number.parseInt(expanded.slice(2, 4), 16);
|
||
const blue = Number.parseInt(expanded.slice(4, 6), 16);
|
||
return `rgba(${red}, ${green}, ${blue}, ${clamp(alpha, 0, 1)})`;
|
||
}
|
||
|
||
function getAnnotationColor(annotation: DimensionAnnotation) {
|
||
return normalizeDimensionColor(annotation.color);
|
||
}
|
||
|
||
function getAnnotationGlowFilter(annotation: DimensionAnnotation) {
|
||
if (!annotation.glow) {
|
||
return "none";
|
||
}
|
||
|
||
const color = getAnnotationColor(annotation);
|
||
return `drop-shadow(0 0 4px ${colorWithAlpha(color, 0.42)}) drop-shadow(0 0 10px ${colorWithAlpha(color, 0.3)})`;
|
||
}
|
||
|
||
function getAnnotationTextOutlineColor(annotation: DimensionAnnotation) {
|
||
return annotation.glow
|
||
? DIMENSION_TEXT_HALO_COLOR
|
||
: DIMENSION_TEXT_OUTLINE_COLOR;
|
||
}
|
||
|
||
function getAnnotationTextOutlineWidth(annotation: DimensionAnnotation) {
|
||
return annotation.glow
|
||
? PREVIEW_DIMENSION_TEXT_HALO_WIDTH
|
||
: PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH;
|
||
}
|
||
|
||
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 annotationColor = getAnnotationColor(annotation);
|
||
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;
|
||
const textOutlineColor = getAnnotationTextOutlineColor(annotation);
|
||
const textOutlineWidth = annotation.glow
|
||
? Math.max(1.5, fontSize * 0.18)
|
||
: Math.max(0.85, fontSize * 0.08);
|
||
|
||
context.save();
|
||
context.strokeStyle = annotationColor;
|
||
context.lineWidth = strokeWidth;
|
||
context.lineCap = "round";
|
||
context.lineJoin = "round";
|
||
context.shadowColor = annotation.glow
|
||
? colorWithAlpha(annotationColor, 0.52)
|
||
: "transparent";
|
||
context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0;
|
||
|
||
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 = textOutlineWidth;
|
||
context.strokeStyle = textOutlineColor;
|
||
context.strokeText(label, 0, 0);
|
||
context.fillStyle = annotationColor;
|
||
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 [defaultAnnotationColor, setDefaultAnnotationColor] = useState(
|
||
DEFAULT_DIMENSION_COLOR
|
||
);
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (!enableDimensionAnnotations) {
|
||
return;
|
||
}
|
||
|
||
if (annotations.length === 0) {
|
||
if (selectedAnnotationId) {
|
||
setSelectedAnnotationId("");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (
|
||
!annotations.some((annotation) => annotation.id === selectedAnnotationId)
|
||
) {
|
||
setSelectedAnnotationId(annotations.at(-1)?.id || "");
|
||
}
|
||
}, [annotations, enableDimensionAnnotations, selectedAnnotationId]);
|
||
|
||
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) {
|
||
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: "",
|
||
color: defaultAnnotationColor,
|
||
glow: false,
|
||
},
|
||
]);
|
||
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,
|
||
defaultAnnotationColor,
|
||
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 overflow-hidden 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="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||
<div
|
||
className={cn(
|
||
"relative min-h-[340px] 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 annotationColor = getAnnotationColor(annotation);
|
||
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}
|
||
/>
|
||
<g
|
||
style={{ filter: getAnnotationGlowFilter(annotation) }}
|
||
>
|
||
<line
|
||
opacity={isSelected ? 1 : 0.92}
|
||
pointerEvents="none"
|
||
stroke={annotationColor}
|
||
strokeLinecap="round"
|
||
strokeWidth={strokeWidth}
|
||
x1={geometry.start.x}
|
||
x2={geometry.end.x}
|
||
y1={geometry.start.y}
|
||
y2={geometry.end.y}
|
||
/>
|
||
<line
|
||
pointerEvents="none"
|
||
stroke={annotationColor}
|
||
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={annotationColor}
|
||
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>
|
||
<g
|
||
onPointerDown={(event) =>
|
||
handleAnnotationBodyPointerDown(annotation, event)
|
||
}
|
||
style={{ filter: getAnnotationGlowFilter(annotation) }}
|
||
>
|
||
<text
|
||
dominantBaseline="middle"
|
||
fill={annotationColor}
|
||
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
|
||
fontWeight={500}
|
||
paintOrder="stroke"
|
||
pointerEvents="none"
|
||
stroke={getAnnotationTextOutlineColor(annotation)}
|
||
strokeLinejoin="round"
|
||
strokeWidth={getAnnotationTextOutlineWidth(
|
||
annotation
|
||
)}
|
||
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={annotationColor}
|
||
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={annotationColor}
|
||
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-4">
|
||
<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="space-y-2.5 rounded-xl border border-white/10 bg-black/20 p-3">
|
||
<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">
|
||
可为当前尺寸线设置颜色,并控制是否开启外发光。
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] text-white/50">外发光</span>
|
||
<Switch
|
||
checked={Boolean(selectedAnnotation.glow)}
|
||
onCheckedChange={(checked) =>
|
||
setAnnotations((current) =>
|
||
current.map((annotation) =>
|
||
annotation.id === selectedAnnotation.id
|
||
? { ...annotation, glow: checked }
|
||
: annotation
|
||
)
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{DIMENSION_COLOR_OPTIONS.map((color) => {
|
||
const normalizedColor = normalizeDimensionColor(color);
|
||
const isActive =
|
||
getAnnotationColor(selectedAnnotation) ===
|
||
normalizedColor;
|
||
|
||
return (
|
||
<button
|
||
aria-label={`选择尺寸线颜色 ${normalizedColor}`}
|
||
className={cn(
|
||
"h-8 w-8 rounded-full border transition-all",
|
||
isActive
|
||
? "scale-105 border-white/70 shadow-[0_0_0_2px_rgba(255,255,255,0.18)]"
|
||
: "border-white/10 hover:border-white/30"
|
||
)}
|
||
key={normalizedColor}
|
||
onClick={() => {
|
||
setDefaultAnnotationColor(normalizedColor);
|
||
setAnnotations((current) =>
|
||
current.map((annotation) =>
|
||
annotation.id === selectedAnnotation.id
|
||
? {
|
||
...annotation,
|
||
color: normalizedColor,
|
||
}
|
||
: annotation
|
||
)
|
||
);
|
||
}}
|
||
style={{ backgroundColor: normalizedColor }}
|
||
type="button"
|
||
/>
|
||
);
|
||
})}
|
||
<label className="flex h-8 min-w-[84px] cursor-pointer items-center gap-2 rounded-full border border-white/10 bg-black/25 px-2.5 text-[11px] text-white/70 transition-colors hover:border-white/25 hover:text-white">
|
||
<span>自定义</span>
|
||
<input
|
||
className="h-6 w-8 cursor-pointer rounded border-0 bg-transparent p-0"
|
||
onChange={(event) => {
|
||
const nextColor = normalizeDimensionColor(
|
||
event.target.value
|
||
);
|
||
setDefaultAnnotationColor(nextColor);
|
||
setAnnotations((current) =>
|
||
current.map((annotation) =>
|
||
annotation.id === selectedAnnotation.id
|
||
? {
|
||
...annotation,
|
||
color: nextColor,
|
||
}
|
||
: annotation
|
||
)
|
||
);
|
||
}}
|
||
type="color"
|
||
value={getAnnotationColor(selectedAnnotation)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : annotations.length > 0 ? (
|
||
<div className="text-sm text-white/55">
|
||
当前已有尺寸线,正在为你自动保留最近一条为选中状态。
|
||
如果样式面板没刷新,点一下对应尺寸线即可继续编辑。
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-white/55">
|
||
选中一条尺寸线后,可以在这里输入真实尺寸文案。
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<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>
|
||
);
|
||
}
|