feat: customize PSD dimension annotations
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 22:21:31 +08:00
parent df6694de5d
commit 03f07564e0

View File

@ -13,6 +13,7 @@ import {
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";
@ -43,6 +44,8 @@ type DimensionAnnotation = {
x2: number;
y2: number;
label: string;
color: string;
glow: boolean;
};
type Point = {
@ -85,8 +88,16 @@ 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 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 PREVIEW_DIMENSION_FONT_SIZE = 10.4;
const PREVIEW_DIMENSION_LABEL_OFFSET = 13;
@ -101,6 +112,7 @@ 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;
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
type AutoDimensionValues = {
widthCm?: number;
@ -160,6 +172,43 @@ function formatDimensionValue(value: number) {
: `${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;
}
const color = getAnnotationColor(annotation);
return `drop-shadow(0 0 4px ${colorWithAlpha(color, 0.42)}) drop-shadow(0 0 10px ${colorWithAlpha(color, 0.3)})`;
}
function normalizeDimensionInput(value: string) {
return String(value || "")
.trim()
@ -510,6 +559,7 @@ function drawAnnotationToCanvas(params: {
}) {
const { context, annotation, imageSize, annotations, autoDimensionValues } =
params;
const annotationColor = getAnnotationColor(annotation);
const label = getDimensionLabel({
annotation,
annotations,
@ -544,10 +594,14 @@ function drawAnnotationToCanvas(params: {
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
context.save();
context.strokeStyle = DIMENSION_LINE_COLOR;
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);
@ -575,7 +629,7 @@ function drawAnnotationToCanvas(params: {
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.fillStyle = annotationColor;
context.fillText(label, 0, 0);
context.restore();
}
@ -654,6 +708,25 @@ export function AgentPsdTemplateExportPreview({
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) {
@ -724,7 +797,6 @@ export function AgentPsdTemplateExportPreview({
}
if (!isDrawMode) {
setSelectedAnnotationId("");
return;
}
@ -755,6 +827,8 @@ export function AgentPsdTemplateExportPreview({
x2: startPoint.x,
y2: startPoint.y,
label: "",
color: DEFAULT_DIMENSION_COLOR,
glow: false,
},
]);
setSelectedAnnotationId(annotationId);
@ -1118,6 +1192,7 @@ export function AgentPsdTemplateExportPreview({
>
{annotations.map((annotation) => {
const isSelected = annotation.id === selectedAnnotationId;
const annotationColor = getAnnotationColor(annotation);
const geometry = getPreviewAnnotationGeometry({
annotation,
annotations,
@ -1145,53 +1220,56 @@ export function AgentPsdTemplateExportPreview({
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 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={DIMENSION_TEXT_COLOR}
fill={annotationColor}
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
fontWeight={500}
paintOrder="stroke"
@ -1221,7 +1299,7 @@ export function AgentPsdTemplateExportPreview({
)
}
r={PREVIEW_DIMENSION_HANDLE_RADIUS}
stroke={DIMENSION_LINE_COLOR}
stroke={annotationColor}
strokeWidth={PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH}
/>
<circle
@ -1236,7 +1314,7 @@ export function AgentPsdTemplateExportPreview({
)
}
r={PREVIEW_DIMENSION_HANDLE_RADIUS}
stroke={DIMENSION_LINE_COLOR}
stroke={annotationColor}
strokeWidth={PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH}
/>
</>
@ -1280,57 +1358,152 @@ export function AgentPsdTemplateExportPreview({
{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 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>
{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 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={() =>
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
);
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">
选中一条尺寸线后,可以在这里输入真实尺寸文案。