fix: polish PSD editor scrolling and dimension styles
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-11 00:08:29 +08:00
parent c9c78f20d4
commit 0d3d5a9507
2 changed files with 487 additions and 340 deletions

View File

@ -1,6 +1,8 @@
"use client";
import {
ChevronDown,
ChevronUp,
Eye,
EyeOff,
Expand,
@ -244,6 +246,7 @@ const MIN_IMAGE_SCALE = 0.4;
const MAX_IMAGE_SCALE = 3;
const UNASSIGNED_BRAND_LABEL = "未分类";
const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i;
const HIDDEN_LAYER_SCROLL_STEP = 180;
function buildTemplatePreviewThumbUrl(
previewUrl: string,
@ -267,6 +270,12 @@ function buildTemplatePreviewThumbUrl(
return `/api/image-proxy?${searchParams.toString()}`;
}
function getScrollAreaViewportElement(root: HTMLDivElement | null) {
return root?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLDivElement | null;
}
function isLikelyStableRenderImageUrl(url: string) {
const normalized = String(url || "").trim();
if (!normalized) {
@ -1312,8 +1321,11 @@ export function AgentPsdTemplateEditorDialog({
"editor"
);
const [stageScale, setStageScale] = useState(1);
const [canScrollHiddenLayersUp, setCanScrollHiddenLayersUp] = useState(false);
const [canScrollHiddenLayersDown, setCanScrollHiddenLayersDown] = useState(false);
const previewViewportRef = useRef<HTMLDivElement | null>(null);
const hiddenLayerScrollAreaRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const previousSelectedTemplateRef = useRef<AgentPsdTemplate | null>(null);
const imageDragStateRef = useRef<{
@ -1343,6 +1355,47 @@ export function AgentPsdTemplateEditorDialog({
}
}, [open]);
useEffect(() => {
if (!open || typeof document === "undefined") {
return;
}
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousHtmlOverscrollBehavior = html.style.overscrollBehavior;
const previousBodyOverscrollBehavior = body.style.overscrollBehavior;
const previousHtmlBackgroundColor = html.style.backgroundColor;
const previousBodyBackgroundColor = body.style.backgroundColor;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth);
const currentBodyPaddingRight = Number.parseFloat(
window.getComputedStyle(body).paddingRight || "0"
);
html.style.overflow = "hidden";
body.style.overflow = "hidden";
html.style.overscrollBehavior = "none";
body.style.overscrollBehavior = "none";
html.style.backgroundColor = "#050505";
body.style.backgroundColor = "#050505";
if (scrollbarWidth > 0) {
body.style.paddingRight = `${currentBodyPaddingRight + scrollbarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
html.style.overscrollBehavior = previousHtmlOverscrollBehavior;
body.style.overscrollBehavior = previousBodyOverscrollBehavior;
html.style.backgroundColor = previousHtmlBackgroundColor;
body.style.backgroundColor = previousBodyBackgroundColor;
body.style.paddingRight = previousBodyPaddingRight;
};
}, [open]);
const selectedTemplateSummary = useMemo(
() =>
templates.find((template) => template.id === selectedTemplateId) || null,
@ -1487,6 +1540,31 @@ export function AgentPsdTemplateEditorDialog({
new Set(hiddenLayerIds.map((layerId) => String(layerId || "").trim()).filter(Boolean)),
[hiddenLayerIds]
);
useEffect(() => {
const viewport = getScrollAreaViewportElement(hiddenLayerScrollAreaRef.current);
if (!viewport) {
setCanScrollHiddenLayersUp(false);
setCanScrollHiddenLayersDown(false);
return;
}
const updateScrollButtons = () => {
const nextCanScrollUp = viewport.scrollTop > 4;
const nextCanScrollDown =
viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 4;
setCanScrollHiddenLayersUp(nextCanScrollUp);
setCanScrollHiddenLayersDown(nextCanScrollDown);
};
updateScrollButtons();
viewport.addEventListener("scroll", updateScrollButtons, { passive: true });
const animationFrameId = window.requestAnimationFrame(updateScrollButtons);
return () => {
window.cancelAnimationFrame(animationFrameId);
viewport.removeEventListener("scroll", updateScrollButtons);
};
}, [hiddenLayerIds.length, open, orderedPreviewLayers.length, selectedTemplate?.id]);
const orderedVisiblePreviewLayers = useMemo(
() => orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)),
[hiddenLayerIdSet, orderedPreviewLayers]
@ -2258,6 +2336,18 @@ export function AgentPsdTemplateEditorDialog({
}));
}
function handleHiddenLayerListScroll(direction: "up" | "down") {
const viewport = getScrollAreaViewportElement(hiddenLayerScrollAreaRef.current);
if (!viewport) {
return;
}
viewport.scrollBy({
top: direction === "up" ? -HIDDEN_LAYER_SCROLL_STEP : HIDDEN_LAYER_SCROLL_STEP,
behavior: "smooth",
});
}
async function archiveRenderImageUrl(
rawUrl: string,
label: string
@ -2528,7 +2618,7 @@ export function AgentPsdTemplateEditorDialog({
/>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]"
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]"
hideCloseButton
onEscapeKeyDown={(event) => {
event.preventDefault();
@ -3549,11 +3639,36 @@ export function AgentPsdTemplateEditorDialog({
临时删除只影响这次套版预览和导出,不会改动 PSD 模板本身。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
已隐藏 {hiddenLayerIds.length} / {selectedTemplateLayers.length}
<div className="flex shrink-0 items-center gap-2">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] p-0.5 shadow-inner">
<button
className="flex h-7 w-7 items-center justify-center rounded-full text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={!canScrollHiddenLayersUp}
onClick={() => handleHiddenLayerListScroll("up")}
title="向上滚动图层列表"
type="button"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-full text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={!canScrollHiddenLayersDown}
onClick={() => handleHiddenLayerListScroll("down")}
title="向下滚动图层列表"
type="button"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
已隐藏 {hiddenLayerIds.length} / {selectedTemplateLayers.length}
</div>
</div>
</div>
<ScrollArea className="max-h-[220px] pr-2">
<ScrollArea
className="max-h-[220px] pr-2"
ref={hiddenLayerScrollAreaRef}
>
<div className="space-y-2">
{orderedPreviewLayers.map((layer) => {
const isHidden = hiddenLayerIdSet.has(layer.id);

View File

@ -99,6 +99,7 @@ const DIMENSION_COLOR_OPTIONS = [
"#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;
@ -110,6 +111,7 @@ 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}$/;
@ -202,13 +204,25 @@ function getAnnotationColor(annotation: DimensionAnnotation) {
function getAnnotationGlowFilter(annotation: DimensionAnnotation) {
if (!annotation.glow) {
return;
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()
@ -592,6 +606,10 @@ function drawAnnotationToCanvas(params: {
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;
@ -626,8 +644,8 @@ function drawAnnotationToCanvas(params: {
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.lineWidth = textOutlineWidth;
context.strokeStyle = textOutlineColor;
context.strokeText(label, 0, 0);
context.fillStyle = annotationColor;
context.fillText(label, 0, 0);
@ -1104,7 +1122,7 @@ export function AgentPsdTemplateExportPreview({
]);
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">
<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
@ -1154,363 +1172,377 @@ export function AgentPsdTemplateExportPreview({
</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>
<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;
<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) }}>
return (
<g key={annotation.id}>
<line
opacity={isSelected ? 1 : 0.92}
pointerEvents="none"
stroke={annotationColor}
strokeLinecap="round"
strokeWidth={strokeWidth}
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
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={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}
<g
style={{ filter: getAnnotationGlowFilter(annotation) }}
>
{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}
<line
opacity={isSelected ? 1 : 0.92}
pointerEvents="none"
stroke={annotationColor}
strokeWidth={PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH}
strokeLinecap="round"
strokeWidth={strokeWidth}
x1={geometry.start.x}
x2={geometry.end.x}
y1={geometry.start.y}
y2={geometry.end.y}
/>
<circle
cx={geometry.end.x}
cy={geometry.end.y}
fill="#ffffff"
onPointerDown={(event) =>
handleAnnotationHandlePointerDown(
annotation.id,
"move-end",
event
)
}
r={PREVIEW_DIMENSION_HANDLE_RADIUS}
<line
pointerEvents="none"
stroke={annotationColor}
strokeWidth={PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH}
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
}
/>
</>
) : 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={() =>
setAnnotations((current) =>
current.map((annotation) =>
annotation.id === selectedAnnotation.id
? {
...annotation,
color: normalizedColor,
}
: annotation
)
)
<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={{ 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
);
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,
color: nextColor,
label: normalizeDimensionInput(
event.target.value
),
}
: annotation
)
);
}}
type="color"
value={getAnnotationColor(selectedAnnotation)}
)
}
placeholder={LABEL_PLACEHOLDER}
value={normalizeDimensionInput(selectedAnnotation.label)}
/>
</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={() =>
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>
</div>
) : annotations.length > 0 ? (
<div className="text-sm text-white/55">
当前已有尺寸线,正在为你自动保留最近一条为选中状态。
如果样式面板没刷新,点一下对应尺寸线即可继续编辑。
</div>
) : (
<div className="text-sm text-white/55">
选中一条尺寸线后,可以在这里输入真实尺寸文案。
</div>
)}
</div>
) : null}
) : 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"