feat: customize PSD dimension annotations
This commit is contained in:
parent
df6694de5d
commit
03f07564e0
@ -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">
|
||||
选中一条尺寸线后,可以在这里输入真实尺寸文案。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user