feat: refine ecommerce smart cutout editor
This commit is contained in:
@@ -3,7 +3,9 @@
|
|||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
FrownOutlined,
|
FrownOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
@@ -11,8 +13,9 @@
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
SkinOutlined,
|
SkinOutlined,
|
||||||
|
TableOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||||
import "../../styles/pages/ecommerce.css";
|
import "../../styles/pages/ecommerce.css";
|
||||||
import "../../styles/pages/local-theme-parity.css";
|
import "../../styles/pages/local-theme-parity.css";
|
||||||
import { ossAssets } from "../../data/ossAssets";
|
import { ossAssets } from "../../data/ossAssets";
|
||||||
@@ -35,6 +38,136 @@ import pinduoduoLogo from "../../assets/platform-logos/pinduoduo.webp";
|
|||||||
import shopeeLogo from "../../assets/platform-logos/shopee.webp";
|
import shopeeLogo from "../../assets/platform-logos/shopee.webp";
|
||||||
import taobaoLogo from "../../assets/platform-logos/taobao.webp";
|
import taobaoLogo from "../../assets/platform-logos/taobao.webp";
|
||||||
import tiktokShopLogo from "../../assets/platform-logos/tiktok-shop.webp";
|
import tiktokShopLogo from "../../assets/platform-logos/tiktok-shop.webp";
|
||||||
|
|
||||||
|
const smartCutoutColorPresets = [
|
||||||
|
"#ffffff",
|
||||||
|
"#111111",
|
||||||
|
"#ff3131",
|
||||||
|
"#ff7a1a",
|
||||||
|
"#f7c600",
|
||||||
|
"#29b34a",
|
||||||
|
"#25a9e0",
|
||||||
|
"#438df5",
|
||||||
|
"#9029d9",
|
||||||
|
"#8aa3ad",
|
||||||
|
"#6b7b86",
|
||||||
|
"#f46f7b",
|
||||||
|
"#ff9451",
|
||||||
|
"#f7d34f",
|
||||||
|
"#55c66f",
|
||||||
|
"#73c7f3",
|
||||||
|
"#6dabf5",
|
||||||
|
"#b45adb",
|
||||||
|
"#bcc8ce",
|
||||||
|
"#aeb7bd",
|
||||||
|
"#ffbec4",
|
||||||
|
"#ffd1ac",
|
||||||
|
"#f8e69d",
|
||||||
|
"#91de9e",
|
||||||
|
"#b7e5fb",
|
||||||
|
"#b9d9fb",
|
||||||
|
"#d7abe8",
|
||||||
|
"#dfe5e8",
|
||||||
|
"#d7dde0",
|
||||||
|
"#ffe2e4",
|
||||||
|
"#ffe5d1",
|
||||||
|
"#f8efcf",
|
||||||
|
"#c9efcf",
|
||||||
|
"#d8f0fb",
|
||||||
|
"#d8eafa",
|
||||||
|
"#ead2f1",
|
||||||
|
];
|
||||||
|
|
||||||
|
const smartCutoutSizeOptions = [
|
||||||
|
{ key: "original", label: "原尺寸", icon: "image", frameWidth: "min(520px, 78%)", frameAspect: "auto", imageMaxWidth: "78%", imageMaxHeight: "310px" },
|
||||||
|
{ key: "trim", label: "裁剪到边缘", icon: "crop", frameWidth: "min(420px, 70%)", frameAspect: "auto", imageMaxWidth: "92%", imageMaxHeight: "360px" },
|
||||||
|
{ key: "taobao-1-1", label: "淘宝1:1主图", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "taobao-3-4", label: "淘宝3:4主图", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "pdd-main", label: "拼多多主图", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "xiaohongshu-cover", label: "小红书封面", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "one-inch", label: "一寸头像", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "25 / 35", imageMaxWidth: "86%", imageMaxHeight: "86%" },
|
||||||
|
{ key: "two-inch", label: "二寸头像", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "35 / 49", imageMaxWidth: "86%", imageMaxHeight: "86%" },
|
||||||
|
{ key: "ratio-1-1", label: "1:1", icon: "square", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "ratio-3-2", label: "3:2", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "3 / 2", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "ratio-2-3", label: "2:3", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "2 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "ratio-4-3", label: "4:3", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "4 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "ratio-3-4", label: "3:4", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "ratio-16-9", label: "16:9", icon: "wide", frameWidth: "min(560px, 82%)", frameAspect: "16 / 9", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
{ key: "ratio-9-16", label: "9:16", icon: "phone", frameWidth: "min(260px, 46%)", frameAspect: "9 / 16", imageMaxWidth: "82%", imageMaxHeight: "82%" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"];
|
||||||
|
|
||||||
|
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
const normalizeHexColor = (value: string) => {
|
||||||
|
const clean = value.trim().replace(/^#/, "");
|
||||||
|
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
|
||||||
|
return `#${clean.toLowerCase()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToRgb = (value: string) => {
|
||||||
|
const normalized = normalizeHexColor(value);
|
||||||
|
if (!normalized) return null;
|
||||||
|
const numeric = Number.parseInt(normalized.slice(1), 16);
|
||||||
|
return {
|
||||||
|
r: (numeric >> 16) & 255,
|
||||||
|
g: (numeric >> 8) & 255,
|
||||||
|
b: numeric & 255,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgbToHex = (r: number, g: number, b: number) =>
|
||||||
|
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
|
||||||
|
const hsvToRgb = (h: number, s: number, v: number) => {
|
||||||
|
const hue = ((h % 360) + 360) % 360;
|
||||||
|
const saturation = clampNumber(s, 0, 100) / 100;
|
||||||
|
const value = clampNumber(v, 0, 100) / 100;
|
||||||
|
const chroma = value * saturation;
|
||||||
|
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||||
|
const match = value - chroma;
|
||||||
|
const [red, green, blue] =
|
||||||
|
hue < 60
|
||||||
|
? [chroma, x, 0]
|
||||||
|
: hue < 120
|
||||||
|
? [x, chroma, 0]
|
||||||
|
: hue < 180
|
||||||
|
? [0, chroma, x]
|
||||||
|
: hue < 240
|
||||||
|
? [0, x, chroma]
|
||||||
|
: hue < 300
|
||||||
|
? [x, 0, chroma]
|
||||||
|
: [chroma, 0, x];
|
||||||
|
return {
|
||||||
|
r: (red + match) * 255,
|
||||||
|
g: (green + match) * 255,
|
||||||
|
b: (blue + match) * 255,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToHsv = (value: string) => {
|
||||||
|
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
|
||||||
|
const red = rgb.r / 255;
|
||||||
|
const green = rgb.g / 255;
|
||||||
|
const blue = rgb.b / 255;
|
||||||
|
const max = Math.max(red, green, blue);
|
||||||
|
const min = Math.min(red, green, blue);
|
||||||
|
const delta = max - min;
|
||||||
|
const hue =
|
||||||
|
delta === 0
|
||||||
|
? 0
|
||||||
|
: max === red
|
||||||
|
? 60 * (((green - blue) / delta) % 6)
|
||||||
|
: max === green
|
||||||
|
? 60 * ((blue - red) / delta + 2)
|
||||||
|
: 60 * ((red - green) / delta + 4);
|
||||||
|
return {
|
||||||
|
h: Math.round((hue + 360) % 360),
|
||||||
|
s: max === 0 ? 0 : Math.round((delta / max) * 100),
|
||||||
|
v: Math.round(max * 100),
|
||||||
|
};
|
||||||
|
};
|
||||||
import tmallLogo from "../../assets/platform-logos/tmall.webp";
|
import tmallLogo from "../../assets/platform-logos/tmall.webp";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { ServerRequestError } from "../../api/serverConnection";
|
import { ServerRequestError } from "../../api/serverConnection";
|
||||||
@@ -991,6 +1124,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const setInputRef = useRef<HTMLInputElement>(null);
|
const setInputRef = useRef<HTMLInputElement>(null);
|
||||||
const productInputRef = useRef<HTMLInputElement>(null);
|
const productInputRef = useRef<HTMLInputElement>(null);
|
||||||
const cloneReferenceInputRef = useRef<HTMLInputElement>(null);
|
const cloneReferenceInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const smartCutoutInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const smartCutoutTransitionTimeoutRef = useRef<number | null>(null);
|
||||||
|
const smartCutoutPendingUrlsRef = useRef<string[]>([]);
|
||||||
|
const smartCutoutPaletteRef = useRef<HTMLDivElement>(null);
|
||||||
|
const smartCutoutToolsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerMenuCloseTimeoutRef = useRef<number | null>(null);
|
||||||
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
|
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
|
||||||
const garmentInputRef = useRef<HTMLInputElement>(null);
|
const garmentInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -1025,12 +1164,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
|
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
|
||||||
const [showHostingModal, setShowHostingModal] = useState(false);
|
const [showHostingModal, setShowHostingModal] = useState(false);
|
||||||
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
||||||
|
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | null>(null);
|
||||||
|
const [smartCutoutImage, setSmartCutoutImage] = useState<{ src: string; name: string } | null>(null);
|
||||||
|
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<{ src: string; name: string }[]>([]);
|
||||||
|
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
|
||||||
|
const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100);
|
||||||
|
const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff");
|
||||||
|
const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false);
|
||||||
|
const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState<SmartCutoutSizeKey>("original");
|
||||||
|
const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false);
|
||||||
|
const [isSmartCutoutTransitioning, setIsSmartCutoutTransitioning] = useState(false);
|
||||||
|
const [smartCutoutTransitionMessage, setSmartCutoutTransitionMessage] = useState({
|
||||||
|
title: "正在切换页面",
|
||||||
|
subtitle: "请稍候",
|
||||||
|
});
|
||||||
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
||||||
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput);
|
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput);
|
||||||
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
|
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
|
||||||
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
|
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
|
||||||
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||||
const [composerMenu, setComposerMenu] = useState<ComposerMenuKey | null>(null);
|
const [composerMenu, setComposerMenu] = useState<ComposerMenuKey | null>(null);
|
||||||
|
const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null);
|
||||||
|
const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false);
|
||||||
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
|
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
|
||||||
const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(false);
|
const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(false);
|
||||||
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
||||||
@@ -1414,6 +1569,219 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
if (files.length) void addSetImages(files);
|
if (files.length) void addSetImages(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const revokeSmartCutoutItems = (items: { src: string }[]) => {
|
||||||
|
items.forEach((item) => URL.revokeObjectURL(item.src));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSmartCutoutTransition = () => {
|
||||||
|
if (smartCutoutTransitionTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(smartCutoutTransitionTimeoutRef.current);
|
||||||
|
smartCutoutTransitionTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (smartCutoutPendingUrlsRef.current.length) {
|
||||||
|
smartCutoutPendingUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
smartCutoutPendingUrlsRef.current = [];
|
||||||
|
}
|
||||||
|
setIsSmartCutoutTransitioning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSmartCutoutPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 460) => {
|
||||||
|
clearSmartCutoutTransition();
|
||||||
|
setSmartCutoutTransitionMessage(message);
|
||||||
|
setIsSmartCutoutTransitioning(true);
|
||||||
|
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
smartCutoutTransitionTimeoutRef.current = null;
|
||||||
|
action();
|
||||||
|
setIsSmartCutoutTransitioning(false);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSmartCutoutUpload = () => {
|
||||||
|
clearSmartCutoutTransition();
|
||||||
|
setSmartCutoutTransitionMessage({
|
||||||
|
title: "正在进入智能抠图",
|
||||||
|
subtitle: "为你打开图片处理工具",
|
||||||
|
});
|
||||||
|
setActiveQuickTool("cutout");
|
||||||
|
setSmartCutoutBatchImages((current) => {
|
||||||
|
revokeSmartCutoutItems(current);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
setSmartCutoutImage((current) => {
|
||||||
|
if (current?.src) URL.revokeObjectURL(current.src);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setComposerMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSmartCutoutTool = () => {
|
||||||
|
runSmartCutoutPageTransition(
|
||||||
|
{
|
||||||
|
title: "正在返回首页",
|
||||||
|
subtitle: "回到电商智能体",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setSmartCutoutBatchImages((current) => {
|
||||||
|
revokeSmartCutoutItems(current);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
setSmartCutoutImage((current) => {
|
||||||
|
if (current?.src) URL.revokeObjectURL(current.src);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setActiveQuickTool(null);
|
||||||
|
setComposerMenu(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goSmartCutoutPrevious = () => {
|
||||||
|
if (!smartCutoutImage) {
|
||||||
|
closeSmartCutoutTool();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runSmartCutoutPageTransition(
|
||||||
|
{
|
||||||
|
title: "正在返回上一页",
|
||||||
|
subtitle: "回到图片上传页",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setSmartCutoutBatchImages((current) => {
|
||||||
|
revokeSmartCutoutItems(current);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
setSmartCutoutImage((current) => {
|
||||||
|
if (current?.src) URL.revokeObjectURL(current.src);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSmartCutoutImage = (files: File[]) => {
|
||||||
|
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
|
||||||
|
if (!imageFiles.length) {
|
||||||
|
toast.error("请上传图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSmartCutoutTransition();
|
||||||
|
setSmartCutoutBatchImages((current) => {
|
||||||
|
revokeSmartCutoutItems(current);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
setSmartCutoutImage((current) => {
|
||||||
|
if (current?.src) URL.revokeObjectURL(current.src);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const nextImages = imageFiles.map((file) => ({ src: URL.createObjectURL(file), name: file.name }));
|
||||||
|
smartCutoutPendingUrlsRef.current = nextImages.map((item) => item.src);
|
||||||
|
setActiveQuickTool("cutout");
|
||||||
|
setSmartCutoutSizeKey("original");
|
||||||
|
setSmartCutoutTransitionMessage({
|
||||||
|
title: imageFiles.length > 1 ? "正在批量抠图" : "正在智能抠图",
|
||||||
|
subtitle: imageFiles.length > 1 ? `正在处理 ${imageFiles.length} 张图片` : "即将进入图片编辑室",
|
||||||
|
});
|
||||||
|
setIsSmartCutoutTransitioning(true);
|
||||||
|
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
smartCutoutTransitionTimeoutRef.current = null;
|
||||||
|
smartCutoutPendingUrlsRef.current = [];
|
||||||
|
setSmartCutoutBatchImages(nextImages);
|
||||||
|
setSmartCutoutImage(nextImages[0]);
|
||||||
|
setIsSmartCutoutTransitioning(false);
|
||||||
|
}, 620);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartCutoutUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
addSmartCutoutImage(Array.from(files));
|
||||||
|
event.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartCutoutDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSmartCutoutDragging(false);
|
||||||
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
if (files.length) addSmartCutoutImage(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const smartCutoutBackgroundValue = useMemo(() => {
|
||||||
|
const rgb = hexToRgb(smartCutoutBackgroundColor) ?? { r: 255, g: 255, b: 255 };
|
||||||
|
if (smartCutoutBackgroundAlpha >= 100) return smartCutoutBackgroundColor;
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round(smartCutoutBackgroundAlpha) / 100})`;
|
||||||
|
}, [smartCutoutBackgroundAlpha, smartCutoutBackgroundColor]);
|
||||||
|
|
||||||
|
const smartCutoutColorHsv = useMemo(() => hexToHsv(smartCutoutBackgroundColor), [smartCutoutBackgroundColor]);
|
||||||
|
|
||||||
|
const selectedSmartCutoutSize = useMemo(
|
||||||
|
() => smartCutoutSizeOptions.find((option) => option.key === smartCutoutSizeKey) ?? smartCutoutSizeOptions[0],
|
||||||
|
[smartCutoutSizeKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const smartCutoutFrameStyle = useMemo<CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
"--smart-cutout-bg": smartCutoutBackgroundValue,
|
||||||
|
"--smart-cutout-frame-width": selectedSmartCutoutSize.frameWidth,
|
||||||
|
"--smart-cutout-frame-aspect": selectedSmartCutoutSize.frameAspect,
|
||||||
|
"--smart-cutout-image-max-width": selectedSmartCutoutSize.imageMaxWidth,
|
||||||
|
"--smart-cutout-image-max-height": selectedSmartCutoutSize.imageMaxHeight,
|
||||||
|
} as CSSProperties),
|
||||||
|
[selectedSmartCutoutSize, smartCutoutBackgroundValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applySmartCutoutHsv = (h: number, s: number, v: number) => {
|
||||||
|
const rgb = hsvToRgb(h, s, v);
|
||||||
|
setSmartCutoutBackgroundColor(rgbToHex(rgb.r, rgb.g, rgb.b));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSmartCutoutColorFromPoint = (element: HTMLElement, clientX: number, clientY: number) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const saturation = clampNumber(((clientX - rect.left) / rect.width) * 100, 0, 100);
|
||||||
|
const value = clampNumber(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100);
|
||||||
|
applySmartCutoutHsv(smartCutoutColorHsv.h, saturation, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartCutoutColorPlanePointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.buttons !== 1) return;
|
||||||
|
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartCutoutHexChange = (value: string) => {
|
||||||
|
const nextValue = value.startsWith("#") ? value : `#${value}`;
|
||||||
|
if (!/^#[0-9a-fA-F]{0,6}$/.test(nextValue)) return;
|
||||||
|
setSmartCutoutHexDraft(nextValue);
|
||||||
|
const normalized = normalizeHexColor(nextValue);
|
||||||
|
if (normalized) setSmartCutoutBackgroundColor(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollSmartCutoutTools = (direction: -1 | 1) => {
|
||||||
|
smartCutoutToolsRef.current?.scrollBy({
|
||||||
|
left: direction * 340,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSmartCutoutHexDraft(smartCutoutBackgroundColor);
|
||||||
|
}, [smartCutoutBackgroundColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSmartCutoutPaletteOpen) return undefined;
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!smartCutoutPaletteRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsSmartCutoutPaletteOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
}, [isSmartCutoutPaletteOpen]);
|
||||||
|
|
||||||
const removeSetImage = (imageId: string) => {
|
const removeSetImage = (imageId: string) => {
|
||||||
setSetImages((current) => {
|
setSetImages((current) => {
|
||||||
const next = current.filter((item) => item.id !== imageId);
|
const next = current.filter((item) => item.id !== imageId);
|
||||||
@@ -1869,6 +2237,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
}, [composerMenu, isCommandComposerCompact, status]);
|
}, [composerMenu, isCommandComposerCompact, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (composerMenuCloseTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(composerMenuCloseTimeoutRef.current);
|
||||||
|
composerMenuCloseTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (composerMenu) {
|
||||||
|
setVisibleComposerMenu(composerMenu);
|
||||||
|
setIsComposerMenuClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!visibleComposerMenu) return;
|
||||||
|
setIsComposerMenuClosing(true);
|
||||||
|
composerMenuCloseTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
composerMenuCloseTimeoutRef.current = null;
|
||||||
|
setVisibleComposerMenu(null);
|
||||||
|
setIsComposerMenuClosing(false);
|
||||||
|
}, 220);
|
||||||
|
}, [composerMenu, visibleComposerMenu]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (composerMenuCloseTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(composerMenuCloseTimeoutRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openCloneModelSelect) return undefined;
|
if (!openCloneModelSelect) return undefined;
|
||||||
|
|
||||||
@@ -2462,6 +2858,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const isDetail = activeTool === "detail";
|
const isDetail = activeTool === "detail";
|
||||||
const isTryOn = activeTool === "wear";
|
const isTryOn = activeTool === "wear";
|
||||||
const isCloneTool = activeTool === "clone";
|
const isCloneTool = activeTool === "clone";
|
||||||
|
const isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout";
|
||||||
const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿";
|
const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿";
|
||||||
const setPrimaryLabel =
|
const setPrimaryLabel =
|
||||||
setImages.length === 0
|
setImages.length === 0
|
||||||
@@ -2948,10 +3345,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
|
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
|
||||||
}));
|
}));
|
||||||
const composerPopoverStyle: CSSProperties = { left: composerPopoverLeft };
|
const composerPopoverStyle: CSSProperties = { left: composerPopoverLeft };
|
||||||
if (!composerMenu) return null;
|
const menuToRender = composerMenu ?? visibleComposerMenu;
|
||||||
if (composerMenu === "mode") {
|
if (!menuToRender) return null;
|
||||||
|
const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : "";
|
||||||
|
const composerPopoverKey = `${menuToRender}-${cloneOutput}-${popoverClosingClass ? "closing" : "open"}`;
|
||||||
|
if (menuToRender === "mode") {
|
||||||
return (
|
return (
|
||||||
<div className="ecom-command-popover ecom-command-popover--grid" style={composerPopoverStyle}>
|
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--grid${popoverClosingClass}`} style={composerPopoverStyle}>
|
||||||
{visibleCloneOutputOptions.map((option) => (
|
{visibleCloneOutputOptions.map((option) => (
|
||||||
<button key={option.key} type="button" className={cloneOutput === option.key ? "is-active" : ""} onClick={() => { handleCloneOutputChange(option.key); setComposerMenu(null); }}>
|
<button key={option.key} type="button" className={cloneOutput === option.key ? "is-active" : ""} onClick={() => { handleCloneOutputChange(option.key); setComposerMenu(null); }}>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -2960,9 +3360,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (composerMenu === "platform") {
|
if (menuToRender === "platform") {
|
||||||
return (
|
return (
|
||||||
<div className="ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio ecom-command-popover--platform" style={composerPopoverStyle}>
|
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio ecom-command-popover--platform${popoverClosingClass}`} style={composerPopoverStyle}>
|
||||||
{platformOptions.map((option) => (
|
{platformOptions.map((option) => (
|
||||||
<button key={option} type="button" className={platform === option ? "is-active" : ""} onClick={() => { handleClonePlatformChange(option); setComposerMenu(null); }}>
|
<button key={option} type="button" className={platform === option ? "is-active" : ""} onClick={() => { handleClonePlatformChange(option); setComposerMenu(null); }}>
|
||||||
{renderPlatformLogo(option)}
|
{renderPlatformLogo(option)}
|
||||||
@@ -2972,9 +3372,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (composerMenu === "language") {
|
if (menuToRender === "language") {
|
||||||
return (
|
return (
|
||||||
<div className="ecom-command-popover ecom-command-popover--languages" style={composerPopoverStyle}>
|
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--languages${popoverClosingClass}`} style={composerPopoverStyle}>
|
||||||
{composerLanguageOptions.map((option) => (
|
{composerLanguageOptions.map((option) => (
|
||||||
<button key={option.language} type="button" className={language === option.language ? "is-active" : ""} onClick={() => { setLanguage(option.language); setComposerMenu(null); }}>
|
<button key={option.language} type="button" className={language === option.language ? "is-active" : ""} onClick={() => { setLanguage(option.language); setComposerMenu(null); }}>
|
||||||
<strong>{option.language}</strong>
|
<strong>{option.language}</strong>
|
||||||
@@ -2984,9 +3384,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (composerMenu === "ratio") {
|
if (menuToRender === "ratio") {
|
||||||
return (
|
return (
|
||||||
<div className="ecom-command-popover ecom-command-popover--list" style={composerPopoverStyle}>
|
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list${popoverClosingClass}`} style={composerPopoverStyle}>
|
||||||
{cloneRatioOptions.map((option) => (
|
{cloneRatioOptions.map((option) => (
|
||||||
<button key={option} type="button" className={ratio === option ? "is-active" : ""} onClick={() => { setRatio(option); setComposerMenu(null); }}>
|
<button key={option} type="button" className={ratio === option ? "is-active" : ""} onClick={() => { setRatio(option); setComposerMenu(null); }}>
|
||||||
<span className="ecom-command-button-text">{formatRatioDisplayValue(option)}</span>
|
<span className="ecom-command-button-text">{formatRatioDisplayValue(option)}</span>
|
||||||
@@ -2996,7 +3396,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}`} style={composerPopoverStyle}>
|
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}${popoverClosingClass}`} style={composerPopoverStyle}>
|
||||||
{cloneOutput === "set" ? (
|
{cloneOutput === "set" ? (
|
||||||
<>
|
<>
|
||||||
<header><strong>套图分类设置</strong><span>总数 1-16 张</span></header>
|
<header><strong>套图分类设置</strong><span>总数 1-16 张</span></header>
|
||||||
@@ -3299,7 +3699,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : status === "idle" || status === "ready" ? null : (
|
||||||
<section className="clone-ai-empty-state" aria-live="polite">
|
<section className="clone-ai-empty-state" aria-live="polite">
|
||||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
||||||
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
|
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
|
||||||
@@ -3346,6 +3746,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
className="ecom-command-hidden-file"
|
className="ecom-command-hidden-file"
|
||||||
onChange={handleComposerAssetUpload}
|
onChange={handleComposerAssetUpload}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={smartCutoutInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="ecom-command-hidden-file"
|
||||||
|
onChange={handleSmartCutoutUpload}
|
||||||
|
/>
|
||||||
<div className="clone-ai-input-wrapper ecom-command-composer">
|
<div className="clone-ai-input-wrapper ecom-command-composer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3432,18 +3840,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
<strong>添加</strong>
|
<strong>添加</strong>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={composerMenu === "mode" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("mode", event)}>
|
<button type="button" className={composerMenu === "mode" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("mode", event)}>
|
||||||
|
<span className="ecom-command-option-icon" aria-hidden="true"><AppstoreOutlined /></span>
|
||||||
{selectedCloneOutput.label}<span>模式</span>
|
{selectedCloneOutput.label}<span>模式</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={composerMenu === "platform" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("platform", event)}>
|
<button type="button" className={composerMenu === "platform" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("platform", event)}>
|
||||||
|
<span className="ecom-command-option-icon" aria-hidden="true"><GlobalOutlined /></span>
|
||||||
<span>平台</span>{platform}
|
<span>平台</span>{platform}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={composerMenu === "language" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("language", event)}>
|
<button type="button" className={composerMenu === "language" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("language", event)}>
|
||||||
|
<span className="ecom-command-option-icon" aria-hidden="true"><FileImageOutlined /></span>
|
||||||
<span>语种</span>{language}
|
<span>语种</span>{language}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={composerMenu === "ratio" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("ratio", event)}>
|
<button type="button" className={composerMenu === "ratio" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("ratio", event)}>
|
||||||
|
<span className="ecom-command-option-icon" aria-hidden="true"><TableOutlined /></span>
|
||||||
<span>比例</span>{formatRatioDisplayValue(ratio)}
|
<span>比例</span>{formatRatioDisplayValue(ratio)}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={composerMenu === "settings" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("settings", event)}>
|
<button type="button" className={composerMenu === "settings" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("settings", event)}>
|
||||||
|
<span className="ecom-command-option-icon" aria-hidden="true"><SettingOutlined /></span>
|
||||||
<span>设置</span>{composerSettingLabel}
|
<span>设置</span>{composerSettingLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3455,11 +3868,292 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
{renderComposerMenu()}
|
{renderComposerMenu()}
|
||||||
</div>
|
</div>
|
||||||
|
{status !== "done" ? (
|
||||||
|
<section className="ecom-command-quick-board" aria-label="快捷功能">
|
||||||
|
{[
|
||||||
|
{ label: "智能抠图", icon: <FileImageOutlined />, onClick: openSmartCutoutUpload },
|
||||||
|
{ label: "商品套图", icon: <AppstoreOutlined /> },
|
||||||
|
{ label: "图片修改", icon: <SettingOutlined /> },
|
||||||
|
{ label: "增/去水印", icon: <FileImageOutlined /> },
|
||||||
|
{ label: "图片批处理", icon: <FolderOpenOutlined /> },
|
||||||
|
{ label: "一键翻译", icon: <FileImageOutlined /> },
|
||||||
|
{ label: "A+/详情页", icon: <FileImageOutlined /> },
|
||||||
|
{ label: "变清晰", icon: <FileImageOutlined /> },
|
||||||
|
{ label: "AI消除", icon: <SettingOutlined /> },
|
||||||
|
{ label: "证件照", icon: <SkinOutlined /> },
|
||||||
|
{ label: "爆款视频", icon: <CloudUploadOutlined /> },
|
||||||
|
{ label: "拼图", icon: <TableOutlined /> },
|
||||||
|
].map((item) => (
|
||||||
|
<button key={item.label} type="button" onClick={item.onClick}>
|
||||||
|
<span aria-hidden="true">{item.icon}</span>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
<span className="clone-ai-char-count">{requirement.length}/500</span>
|
<span className="clone-ai-char-count">{requirement.length}/500</span>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const smartCutoutPreview = (
|
||||||
|
<main className={`ecom-smart-cutout-page${smartCutoutImage ? " is-editor" : " is-upload"}${isSmartCutoutTransitioning ? " is-transitioning" : ""}`} aria-label="智能抠图">
|
||||||
|
<input
|
||||||
|
ref={smartCutoutInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="ecom-command-hidden-file"
|
||||||
|
onChange={handleSmartCutoutUpload}
|
||||||
|
/>
|
||||||
|
<nav className="ecom-smart-cutout-nav" aria-label="智能抠图导航">
|
||||||
|
<button type="button" onClick={closeSmartCutoutTool}>
|
||||||
|
首页
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={goSmartCutoutPrevious}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{isSmartCutoutTransitioning ? (
|
||||||
|
<div className="ecom-smart-cutout-transition" role="status" aria-live="polite">
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<strong>{smartCutoutTransitionMessage.title}</strong>
|
||||||
|
<em>{smartCutoutTransitionMessage.subtitle}</em>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!smartCutoutImage ? (
|
||||||
|
<section className="ecom-smart-cutout-upload">
|
||||||
|
<div className="ecom-smart-cutout-head">
|
||||||
|
<strong>智能抠图</strong>
|
||||||
|
<span>3s 一键抠图,快速去除背景</span>
|
||||||
|
</div>
|
||||||
|
<div className="ecom-smart-cutout-upload__body">
|
||||||
|
<div className="ecom-smart-cutout-demo" aria-hidden="true">
|
||||||
|
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--flower" />
|
||||||
|
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--product" />
|
||||||
|
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--poster" />
|
||||||
|
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--object" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`ecom-smart-cutout-upload-box${isSmartCutoutDragging ? " is-dragging" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => smartCutoutInputRef.current?.click()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
smartCutoutInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSmartCutoutDragging(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={() => setIsSmartCutoutDragging(false)}
|
||||||
|
onDrop={handleSmartCutoutDrop}
|
||||||
|
>
|
||||||
|
<button type="button" className="ecom-smart-cutout-upload__primary">
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
上传图片
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ecom-smart-cutout-upload__secondary">
|
||||||
|
<FolderOpenOutlined />
|
||||||
|
批量上传
|
||||||
|
</button>
|
||||||
|
<span>上传文件夹 ></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="ecom-smart-editor">
|
||||||
|
<div className="ecom-smart-editor__workspace">
|
||||||
|
<div className="ecom-smart-editor__canvas">
|
||||||
|
<div className={`ecom-smart-editor__checker is-size-${smartCutoutSizeKey}`} style={smartCutoutFrameStyle}>
|
||||||
|
<div className="ecom-smart-editor__background-layer" aria-hidden="true" />
|
||||||
|
<img src={smartCutoutImage.src} alt={smartCutoutImage.name} />
|
||||||
|
</div>
|
||||||
|
<div className="ecom-smart-editor__canvas-actions">
|
||||||
|
<button type="button">对比</button>
|
||||||
|
<button type="button">编辑图片</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ecom-smart-editor__tools-shell">
|
||||||
|
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(-1)} aria-label="查看上一组尺寸">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div className="ecom-smart-editor__tools" ref={smartCutoutToolsRef}>
|
||||||
|
{smartCutoutSizeOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className={smartCutoutSizeKey === item.key ? "is-active" : ""}
|
||||||
|
onClick={() => setSmartCutoutSizeKey(item.key)}
|
||||||
|
aria-pressed={smartCutoutSizeKey === item.key}
|
||||||
|
>
|
||||||
|
<span className={`ecom-smart-editor__tool-icon ecom-smart-editor__tool-icon--${item.icon}`} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(1)} aria-label="查看更多尺寸">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{smartCutoutBatchImages.length > 1 ? (
|
||||||
|
<section className="ecom-smart-editor__batch" aria-label="批量图片">
|
||||||
|
<header>
|
||||||
|
<strong>批量图片</strong>
|
||||||
|
<span>{smartCutoutBatchImages.length} 张</span>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
{smartCutoutBatchImages.map((image, index) => (
|
||||||
|
<button
|
||||||
|
key={`${image.src}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={smartCutoutImage.src === image.src ? "is-active" : ""}
|
||||||
|
onClick={() => setSmartCutoutImage(image)}
|
||||||
|
>
|
||||||
|
<img src={image.src} alt={image.name || `上传图片 ${index + 1}`} />
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="ecom-smart-editor__gallery">
|
||||||
|
<header><strong>纯色</strong><button type="button">查看全部 ></button></header>
|
||||||
|
<div className="ecom-smart-editor__swatches">
|
||||||
|
{["#ffffff", "#eeeae3", "#f2e3cf", "#000000", "#a89682", "#c9c9c9"].map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
|
||||||
|
style={{ "--smart-cutout-swatch-bg": color } as CSSProperties}
|
||||||
|
onClick={() => {
|
||||||
|
setSmartCutoutBackgroundColor(color);
|
||||||
|
setSmartCutoutBackgroundAlpha(100);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="ecom-smart-editor__swatch-bg" aria-hidden="true" />
|
||||||
|
<img src={smartCutoutImage.src} alt="" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<header><strong>AI换背景</strong><button type="button">查看全部 ></button></header>
|
||||||
|
<div className="ecom-smart-editor__scenes">
|
||||||
|
<button type="button" className="ecom-smart-editor__generate"><SettingOutlined />立即生成</button>
|
||||||
|
{["客厅陈列", "桌面日光", "香氛产品", "绿植窗边", "居家空间"].map((item) => (
|
||||||
|
<button key={item} type="button"><span>{item}</span></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<aside className="ecom-smart-editor__side">
|
||||||
|
<strong>背景颜色</strong>
|
||||||
|
<div className="ecom-smart-editor__color-row">
|
||||||
|
<div className="ecom-smart-editor__color-wrap" ref={smartCutoutPaletteRef} style={{ "--smart-cutout-bg": smartCutoutBackgroundValue } as CSSProperties}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ecom-smart-editor__custom-color${isSmartCutoutPaletteOpen ? " is-active" : ""}`}
|
||||||
|
style={{ background: smartCutoutBackgroundColor }}
|
||||||
|
onClick={() => setIsSmartCutoutPaletteOpen((open) => !open)}
|
||||||
|
aria-label="打开背景调色盘"
|
||||||
|
>
|
||||||
|
<span>色</span>
|
||||||
|
</button>
|
||||||
|
{isSmartCutoutPaletteOpen ? (
|
||||||
|
<div className="ecom-smart-color-picker" role="dialog" aria-label="背景调色盘">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecom-smart-color-picker__plane"
|
||||||
|
style={{ background: `linear-gradient(to top, #000000, transparent), linear-gradient(to right, #ffffff, hsl(${smartCutoutColorHsv.h} 100% 50%))` }}
|
||||||
|
onPointerDown={handleSmartCutoutColorPlanePointer}
|
||||||
|
onPointerMove={handleSmartCutoutColorPlaneMove}
|
||||||
|
aria-label="选择颜色明度和饱和度"
|
||||||
|
>
|
||||||
|
<span style={{ left: `${smartCutoutColorHsv.s}%`, top: `${100 - smartCutoutColorHsv.v}%` }} />
|
||||||
|
</button>
|
||||||
|
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--hue">
|
||||||
|
<span style={{ background: `hsl(${smartCutoutColorHsv.h} 100% 50%)` }} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={smartCutoutColorHsv.h}
|
||||||
|
onChange={(event) => applySmartCutoutHsv(Number(event.target.value), smartCutoutColorHsv.s, smartCutoutColorHsv.v)}
|
||||||
|
aria-label="色相"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--alpha">
|
||||||
|
<span style={{ background: smartCutoutBackgroundValue }} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={smartCutoutBackgroundAlpha}
|
||||||
|
onChange={(event) => setSmartCutoutBackgroundAlpha(Number(event.target.value))}
|
||||||
|
aria-label="透明度"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ecom-smart-color-picker__fields">
|
||||||
|
<span aria-hidden="true">↙</span>
|
||||||
|
<input
|
||||||
|
value={smartCutoutHexDraft}
|
||||||
|
onChange={(event) => handleSmartCutoutHexChange(event.target.value)}
|
||||||
|
onBlur={() => setSmartCutoutHexDraft(smartCutoutBackgroundColor)}
|
||||||
|
aria-label="HEX 色值"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={Math.round(smartCutoutBackgroundAlpha)}
|
||||||
|
onChange={(event) => setSmartCutoutBackgroundAlpha(clampNumber(Number(event.target.value) || 0, 0, 100))}
|
||||||
|
aria-label="透明度百分比"
|
||||||
|
/>
|
||||||
|
<strong>%</strong>
|
||||||
|
</div>
|
||||||
|
<p>纯色预设</p>
|
||||||
|
<div className="ecom-smart-color-picker__presets">
|
||||||
|
{smartCutoutColorPresets.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
|
||||||
|
style={{ background: color }}
|
||||||
|
onClick={() => {
|
||||||
|
setSmartCutoutBackgroundColor(color);
|
||||||
|
setSmartCutoutBackgroundAlpha(100);
|
||||||
|
}}
|
||||||
|
aria-label={`选择颜色 ${color}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{["#ffffff", "#f8f9fa", "#000000", "#bdbdbd"].map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
|
||||||
|
style={{ background: color }}
|
||||||
|
onClick={() => {
|
||||||
|
setSmartCutoutBackgroundColor(color);
|
||||||
|
setSmartCutoutBackgroundAlpha(100);
|
||||||
|
}}
|
||||||
|
aria-label={`背景颜色 ${color}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ecom-smart-editor__side-actions">
|
||||||
|
<button type="button" className="ecom-smart-editor__download">下载图片</button>
|
||||||
|
<button type="button" onClick={() => smartCutoutInputRef.current?.click()}>再次上传</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
const detailPreview = (
|
const detailPreview = (
|
||||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
|
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
|
||||||
<div className="product-clone-preview__headline">
|
<div className="product-clone-preview__headline">
|
||||||
@@ -3557,7 +4251,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}`}
|
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}`}
|
||||||
data-tool={activeTool}
|
data-tool={activeTool}
|
||||||
aria-label={pageLabel}
|
aria-label={pageLabel}
|
||||||
>
|
>
|
||||||
@@ -3581,7 +4275,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
|
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{isCloneTool ? (
|
{isCloneTool && !isSmartCutoutTool ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="clone-ai-settings-toggle"
|
className="clone-ai-settings-toggle"
|
||||||
@@ -3595,7 +4289,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (false ? (
|
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (isSmartCutoutTool ? smartCutoutPreview : false ? (
|
||||||
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
|
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
|
||||||
<EcommerceVideoWorkspace
|
<EcommerceVideoWorkspace
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user