fix: polish PSD editor scrolling and dimension styles
This commit is contained in:
parent
c9c78f20d4
commit
0d3d5a9507
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user