fix: improve canvas mode settings
This commit is contained in:
parent
e92e0b24f6
commit
edc3ae59b1
@ -727,9 +727,11 @@ type StorageConfig = {
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
realtimeLabel: string;
|
||||
enhanceLabel: string;
|
||||
};
|
||||
|
||||
type RealtimeCanvasModeIconKey = keyof RealtimeCanvasModeIconConfig;
|
||||
type RealtimeCanvasModeIconKey = "realtimeIconUrl" | "enhanceIconUrl";
|
||||
|
||||
type SiteSettings = {
|
||||
siteName: string;
|
||||
@ -849,6 +851,8 @@ const defaultContactConfig: ContactConfig = {
|
||||
const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: "",
|
||||
enhanceIconUrl: "",
|
||||
realtimeLabel: "实时画布模式",
|
||||
enhanceLabel: "图像增强模式",
|
||||
};
|
||||
|
||||
function normalizeRealtimeCanvasModeIconConfig(
|
||||
@ -858,10 +862,16 @@ function normalizeRealtimeCanvasModeIconConfig(
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const normalizeLabel = (input: unknown, fallback: string) => {
|
||||
const label = String(input || "").trim().slice(0, 32);
|
||||
return label || fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
realtimeIconUrl: String(raw.realtimeIconUrl || "").trim(),
|
||||
enhanceIconUrl: String(raw.enhanceIconUrl || "").trim(),
|
||||
realtimeLabel: normalizeLabel(raw.realtimeLabel, "实时画布模式"),
|
||||
enhanceLabel: normalizeLabel(raw.enhanceLabel, "图像增强模式"),
|
||||
};
|
||||
}
|
||||
|
||||
@ -10308,10 +10318,11 @@ export function AdminSettingsClient() {
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
实时画布模式图标
|
||||
实时画布模式名称与图标
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
上传 SVG 后会替换前端“实时画布模式”和“图像增强模式”的按钮图标;前端会按选中状态自动切换黑白色。
|
||||
名称会同步到前端模式切换按钮;上传 SVG
|
||||
后会替换前端按钮图标,并按选中状态自动切换黑白色。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -10320,6 +10331,7 @@ export function AdminSettingsClient() {
|
||||
[
|
||||
{
|
||||
field: "realtimeIconUrl",
|
||||
labelField: "realtimeLabel",
|
||||
title: "实时画布模式",
|
||||
description: "左侧模式切换按钮的画笔图标",
|
||||
uploadId: "realtime-mode-icon-upload",
|
||||
@ -10327,6 +10339,7 @@ export function AdminSettingsClient() {
|
||||
},
|
||||
{
|
||||
field: "enhanceIconUrl",
|
||||
labelField: "enhanceLabel",
|
||||
title: "图像增强模式",
|
||||
description: "右侧模式切换按钮的魔法棒图标",
|
||||
uploadId: "enhance-mode-icon-upload",
|
||||
@ -10338,6 +10351,11 @@ export function AdminSettingsClient() {
|
||||
siteSettings.realtimeCanvasModeIconConfig[
|
||||
item.field
|
||||
];
|
||||
const labelValue =
|
||||
siteSettings.realtimeCanvasModeIconConfig[
|
||||
item.labelField
|
||||
];
|
||||
const displayName = labelValue || item.title;
|
||||
const FallbackIcon = item.fallback;
|
||||
const isUploading =
|
||||
realtimeModeIconUploading === item.field;
|
||||
@ -10350,7 +10368,7 @@ export function AdminSettingsClient() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{item.title}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
@ -10415,11 +10433,35 @@ export function AdminSettingsClient() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="text-xs text-muted-foreground"
|
||||
htmlFor={`${item.uploadId}-label`}
|
||||
>
|
||||
前端显示名称
|
||||
</Label>
|
||||
<Input
|
||||
id={`${item.uploadId}-label`}
|
||||
maxLength={32}
|
||||
onChange={(event) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
realtimeCanvasModeIconConfig: {
|
||||
...prev.realtimeCanvasModeIconConfig,
|
||||
[item.labelField]:
|
||||
event.target.value.slice(0, 32),
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder={item.title}
|
||||
value={labelValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] bg-[#111111] px-3 text-xs font-medium text-white">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt={`${item.title}选中预览`}
|
||||
alt={`${displayName}选中预览`}
|
||||
className="h-4 w-4 object-contain brightness-0 invert"
|
||||
src={iconUrl}
|
||||
/>
|
||||
@ -10431,7 +10473,7 @@ export function AdminSettingsClient() {
|
||||
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] border bg-white px-3 text-xs font-medium text-[#666666]">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt={`${item.title}未选中预览`}
|
||||
alt={`${displayName}未选中预览`}
|
||||
className="h-4 w-4 object-contain brightness-0 opacity-70"
|
||||
src={iconUrl}
|
||||
/>
|
||||
|
||||
@ -138,11 +138,18 @@ type AgentWorkflowSettingsField = keyof typeof AGENT_WORKFLOW_SETTINGS_FIELD_KEY
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
realtimeLabel: string;
|
||||
enhanceLabel: string;
|
||||
};
|
||||
|
||||
const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式";
|
||||
const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式";
|
||||
|
||||
const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: "",
|
||||
enhanceIconUrl: "",
|
||||
realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL,
|
||||
enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL,
|
||||
};
|
||||
|
||||
function normalizeRealtimeCanvasModeIconConfig(
|
||||
@ -154,10 +161,22 @@ function normalizeRealtimeCanvasModeIconConfig(
|
||||
: {};
|
||||
const normalizeUrl = (input: unknown) =>
|
||||
String(input || "").trim().slice(0, 2000);
|
||||
const normalizeLabel = (input: unknown, fallback: string) => {
|
||||
const label = String(input || "").trim().slice(0, 32);
|
||||
return label || fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl),
|
||||
enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl),
|
||||
realtimeLabel: normalizeLabel(
|
||||
raw.realtimeLabel,
|
||||
DEFAULT_REALTIME_CANVAS_MODE_LABEL
|
||||
),
|
||||
enhanceLabel: normalizeLabel(
|
||||
raw.enhanceLabel,
|
||||
DEFAULT_ENHANCE_CANVAS_MODE_LABEL
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1192,6 +1211,8 @@ const updateSchema = z.object({
|
||||
.object({
|
||||
realtimeIconUrl: z.string().trim().max(2000).optional(),
|
||||
enhanceIconUrl: z.string().trim().max(2000).optional(),
|
||||
realtimeLabel: z.string().trim().max(32).optional(),
|
||||
enhanceLabel: z.string().trim().max(32).optional(),
|
||||
})
|
||||
.optional(),
|
||||
aiDetailGenerationEnabled: z.boolean().optional(),
|
||||
|
||||
@ -473,6 +473,69 @@ function getVideoWorkflowQuickConnectIcon(type: VideoWorkflowTemplateType) {
|
||||
return VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP[type] || Plus;
|
||||
}
|
||||
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
realtimeLabel: string;
|
||||
enhanceLabel: string;
|
||||
};
|
||||
|
||||
const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式";
|
||||
const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式";
|
||||
|
||||
function normalizeRealtimeCanvasModeDisplayConfig(
|
||||
value: unknown
|
||||
): RealtimeCanvasModeIconConfig {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const normalizeUrl = (input: unknown) => String(input || "").trim();
|
||||
const normalizeLabel = (input: unknown, fallback: string) => {
|
||||
const label = String(input || "").trim().slice(0, 32);
|
||||
return label || fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl),
|
||||
enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl),
|
||||
realtimeLabel: normalizeLabel(
|
||||
raw.realtimeLabel,
|
||||
DEFAULT_REALTIME_CANVAS_MODE_LABEL
|
||||
),
|
||||
enhanceLabel: normalizeLabel(
|
||||
raw.enhanceLabel,
|
||||
DEFAULT_ENHANCE_CANVAS_MODE_LABEL
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function RealtimeCanvasToolbarIcon({
|
||||
iconUrl,
|
||||
fallback: FallbackIcon,
|
||||
className,
|
||||
}: {
|
||||
iconUrl?: string;
|
||||
fallback: LucideIcon;
|
||||
className?: string;
|
||||
}) {
|
||||
if (iconUrl) {
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"object-contain brightness-0 opacity-80 transition dark:invert",
|
||||
className
|
||||
)}
|
||||
src={iconUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <FallbackIcon className={className} />;
|
||||
}
|
||||
|
||||
const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [
|
||||
{
|
||||
id: "input",
|
||||
@ -1549,6 +1612,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
layerSplitEnabled?: boolean;
|
||||
realtimeCanvasEnabled?: boolean;
|
||||
realtimeCanvasServiceEnabled?: boolean;
|
||||
realtimeCanvasModeIconConfig?: Partial<RealtimeCanvasModeIconConfig>;
|
||||
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
|
||||
agentWorkflowBrandModelConfig?: unknown;
|
||||
agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex;
|
||||
@ -1600,6 +1664,13 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
siteSettingsData?.canvasAgentModeEnabled !== false;
|
||||
const isVideoModuleSidebarEnabled =
|
||||
siteSettingsData?.videoCanvasSidebarEnabled !== false;
|
||||
const realtimeCanvasModeDisplayConfig = useMemo(
|
||||
() =>
|
||||
normalizeRealtimeCanvasModeDisplayConfig(
|
||||
siteSettingsData?.realtimeCanvasModeIconConfig
|
||||
),
|
||||
[siteSettingsData?.realtimeCanvasModeIconConfig]
|
||||
);
|
||||
const isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode;
|
||||
const hasLoadedCurrentAgentProject =
|
||||
!requestedAgentMode ||
|
||||
@ -9801,10 +9872,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<PenTool className="h-4 w-4" />
|
||||
<RealtimeCanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={PenTool}
|
||||
iconUrl={realtimeCanvasModeDisplayConfig.realtimeIconUrl}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">实时画布</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{realtimeCanvasModeDisplayConfig.realtimeLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
@ -9818,17 +9895,23 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
onClick={() => router.push(`/canvas/${id}/realtime?mode=enhance-pro`)}
|
||||
onClick={() =>
|
||||
router.push(`/canvas/${id}/realtime?mode=enhance-pro`)
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<RealtimeCanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
iconUrl={realtimeCanvasModeDisplayConfig.enhanceIconUrl}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{selectedImageNode
|
||||
? "图像增强 Pro"
|
||||
: "进入图像增强 Pro 后上传图片"}
|
||||
? realtimeCanvasModeDisplayConfig.enhanceLabel
|
||||
: `进入${realtimeCanvasModeDisplayConfig.enhanceLabel}后上传图片`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
@ -850,38 +850,6 @@ export default function CanvasModel3DPage() {
|
||||
: isHighVip
|
||||
? "VIP 用户本次不扣积分"
|
||||
: "按当前生成质量扣除";
|
||||
const getModelModeCostLabel = useCallback(
|
||||
(targetMode: GenerationMode) => {
|
||||
const unitCost = get3DTaskCreditCost(pointsSystemConfig, {
|
||||
taskType: modelModeToTaskType(targetMode),
|
||||
pbr,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
});
|
||||
if (targetMode === "batch") {
|
||||
return `每张 ${formatFeatureCostLabel({
|
||||
cost: unitCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
})}`;
|
||||
}
|
||||
return formatFeatureCostLabel({
|
||||
cost: unitCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
});
|
||||
},
|
||||
[isHighVip, pbr, pointsEnabled, pointsSystemConfig, quad, smartLowPoly]
|
||||
);
|
||||
const getWorldModeCostLabel = useCallback(
|
||||
() =>
|
||||
formatFeatureCostLabel({
|
||||
cost: worldFeatureCost,
|
||||
isHighVip,
|
||||
pointsEnabled,
|
||||
}),
|
||||
[isHighVip, pointsEnabled, worldFeatureCost]
|
||||
);
|
||||
const getWorldModelCostLabel = useCallback(
|
||||
(targetWorldModel: string) =>
|
||||
formatFeatureCostLabel({
|
||||
@ -2788,7 +2756,7 @@ export default function CanvasModel3DPage() {
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-950 p-1">
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-[50px] flex-col items-center justify-center gap-1 rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||||
workspaceMode === "model"
|
||||
? "bg-zinc-800 text-white shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
@ -2796,15 +2764,11 @@ export default function CanvasModel3DPage() {
|
||||
onClick={() => setWorkspaceMode("model")}
|
||||
type="button"
|
||||
>
|
||||
<span>3D模型模式</span>
|
||||
<CostPill
|
||||
active={workspaceMode === "model"}
|
||||
label={modelFeatureCostLabel}
|
||||
/>
|
||||
3D模型模式
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-[50px] flex-col items-center justify-center gap-1 rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||||
workspaceMode === "world"
|
||||
? "bg-zinc-800 text-white shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
@ -2812,11 +2776,7 @@ export default function CanvasModel3DPage() {
|
||||
onClick={() => setWorkspaceMode("world")}
|
||||
type="button"
|
||||
>
|
||||
<span>3D世界模式</span>
|
||||
<CostPill
|
||||
active={workspaceMode === "world"}
|
||||
label={worldFeatureCostLabel}
|
||||
/>
|
||||
3D世界模式
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="font-semibold text-sm text-white">
|
||||
@ -2833,7 +2793,7 @@ export default function CanvasModel3DPage() {
|
||||
{activeTab === "create" && (
|
||||
workspaceMode === "model" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
{[
|
||||
{ id: "text", label: "文生", icon: PenLine },
|
||||
{ id: "image", label: "图生", icon: ImagePlus },
|
||||
@ -2842,7 +2802,7 @@ export default function CanvasModel3DPage() {
|
||||
].map((m) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-[58px] flex-col items-center justify-center gap-1 rounded-md px-1 py-2 text-[10px] transition-all",
|
||||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||||
mode === m.id
|
||||
? "bg-zinc-800 text-white shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
@ -2852,12 +2812,7 @@ export default function CanvasModel3DPage() {
|
||||
type="button"
|
||||
>
|
||||
<m.icon className="h-4 w-4" />
|
||||
<span>{m.label}</span>
|
||||
<CostPill
|
||||
active={mode === m.id}
|
||||
className="max-w-full px-1.5"
|
||||
label={getModelModeCostLabel(m.id as GenerationMode)}
|
||||
/>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -3058,7 +3013,7 @@ export default function CanvasModel3DPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||||
{[
|
||||
{ id: "text", label: "文生", icon: PenLine },
|
||||
{ id: "image", label: "图生", icon: ImagePlus },
|
||||
@ -3067,7 +3022,7 @@ export default function CanvasModel3DPage() {
|
||||
].map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-[58px] flex-col items-center justify-center gap-1 rounded-md px-1 py-2 text-[10px] transition-all",
|
||||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||||
worldMode === item.id
|
||||
? "bg-zinc-800 text-white shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
@ -3077,12 +3032,7 @@ export default function CanvasModel3DPage() {
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
<CostPill
|
||||
active={worldMode === item.id}
|
||||
className="max-w-full px-1.5"
|
||||
label={getWorldModeCostLabel()}
|
||||
/>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -711,9 +711,20 @@ export function EnhanceProWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const maxOffsetX = Math.max(48, event.currentTarget.clientWidth * 0.18);
|
||||
const maxOffsetY = Math.max(48, event.currentTarget.clientHeight * 0.18);
|
||||
|
||||
setPreviewOffset({
|
||||
x: dragState.originX + event.clientX - dragState.startX,
|
||||
y: dragState.originY + event.clientY - dragState.startY,
|
||||
x: clampNumber(
|
||||
dragState.originX + event.clientX - dragState.startX,
|
||||
-maxOffsetX,
|
||||
maxOffsetX
|
||||
),
|
||||
y: clampNumber(
|
||||
dragState.originY + event.clientY - dragState.startY,
|
||||
-maxOffsetY,
|
||||
maxOffsetY
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@ -742,7 +753,7 @@ export function EnhanceProWorkspace({
|
||||
{activePreviewUrl ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center overflow-hidden touch-none select-none",
|
||||
"relative flex h-full w-full min-w-0 touch-none select-none items-center justify-center overflow-hidden",
|
||||
isPreviewDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
)}
|
||||
onDoubleClick={() => setPreviewOffset({ x: 0, y: 0 })}
|
||||
@ -751,40 +762,38 @@ export function EnhanceProWorkspace({
|
||||
onPointerMove={handlePreviewPointerMove}
|
||||
onPointerUp={finishPreviewDrag}
|
||||
>
|
||||
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||||
<div className="relative overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={activePreviewLabel}
|
||||
className="pointer-events-none block max-h-[calc(100vh-14rem)] max-w-[min(1200px,calc(100vw-31rem))] object-contain transition-transform duration-75"
|
||||
src={getDisplayImageUrl(activePreviewUrl, 1800)}
|
||||
/>
|
||||
{showComparisonPreview && sourceImageUrl ? (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="原图对比"
|
||||
className="h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(sourceImageUrl, 1800)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.32),0_0_18px_rgba(0,0,0,0.35)]"
|
||||
style={{ left: `${comparePosition}%` }}
|
||||
<div className="relative flex h-full w-full min-w-0 items-center justify-center overflow-hidden px-4 py-4">
|
||||
<div
|
||||
className="relative h-full w-full max-w-[min(1320px,calc(100vw-30rem))] overflow-hidden"
|
||||
style={{
|
||||
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={activePreviewLabel}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-contain transition-transform duration-75"
|
||||
src={getDisplayImageUrl(activePreviewUrl, 1800)}
|
||||
/>
|
||||
{showComparisonPreview && sourceImageUrl ? (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="原图对比"
|
||||
className="h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(sourceImageUrl, 1800)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.32),0_0_18px_rgba(0,0,0,0.35)]"
|
||||
style={{ left: `${comparePosition}%` }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{showComparisonPreview ? (
|
||||
<>
|
||||
<div className="pointer-events-none absolute top-3 left-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
|
||||
|
||||
@ -86,7 +86,13 @@ type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
realtimeLabel: string;
|
||||
enhanceLabel: string;
|
||||
};
|
||||
|
||||
const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式";
|
||||
const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式";
|
||||
|
||||
type RealtimeBrushMode =
|
||||
| "select"
|
||||
| "brush"
|
||||
@ -858,6 +864,8 @@ export function RealtimeCanvasDialog({
|
||||
useState<RealtimeCanvasModeIconConfig>({
|
||||
realtimeIconUrl: "",
|
||||
enhanceIconUrl: "",
|
||||
realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL,
|
||||
enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL,
|
||||
});
|
||||
|
||||
const statusBadge = useMemo(() => {
|
||||
@ -923,6 +931,10 @@ export function RealtimeCanvasDialog({
|
||||
typeof data.realtimeCanvasModeIconConfig === "object"
|
||||
? data.realtimeCanvasModeIconConfig
|
||||
: {};
|
||||
const normalizeModeLabel = (value: unknown, fallback: string) => {
|
||||
const label = String(value || "").trim().slice(0, 32);
|
||||
return label || fallback;
|
||||
};
|
||||
setModeIconConfig({
|
||||
realtimeIconUrl:
|
||||
typeof iconConfig.realtimeIconUrl === "string"
|
||||
@ -932,6 +944,14 @@ export function RealtimeCanvasDialog({
|
||||
typeof iconConfig.enhanceIconUrl === "string"
|
||||
? iconConfig.enhanceIconUrl.trim()
|
||||
: "",
|
||||
realtimeLabel: normalizeModeLabel(
|
||||
iconConfig.realtimeLabel,
|
||||
DEFAULT_REALTIME_CANVAS_MODE_LABEL
|
||||
),
|
||||
enhanceLabel: normalizeModeLabel(
|
||||
iconConfig.enhanceLabel,
|
||||
DEFAULT_ENHANCE_CANVAS_MODE_LABEL
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -3168,7 +3188,7 @@ export function RealtimeCanvasDialog({
|
||||
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-[#ebebeb] bg-[#fafafa] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "realtime"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111]"
|
||||
@ -3191,11 +3211,13 @@ export function RealtimeCanvasDialog({
|
||||
) : (
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
)}
|
||||
实时画布模式
|
||||
<span className="max-w-[9rem] truncate">
|
||||
{modeIconConfig.realtimeLabel}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "enhance-pro"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111]"
|
||||
@ -3218,7 +3240,9 @@ export function RealtimeCanvasDialog({
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
图像增强模式
|
||||
<span className="max-w-[9rem] truncate">
|
||||
{modeIconConfig.enhanceLabel}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -660,6 +660,18 @@ export function ImageNode({
|
||||
height: activeBoxCutoutRect.height * boxCutoutMetrics.renderedHeight,
|
||||
};
|
||||
}, [activeBoxCutoutRect, boxCutoutMetrics]);
|
||||
const currentPickerClickStyle = useMemo(() => {
|
||||
if (!boxCutoutMetrics || !currentClick) return null;
|
||||
|
||||
return {
|
||||
left:
|
||||
boxCutoutMetrics.offsetX +
|
||||
currentClick.x * boxCutoutMetrics.renderedWidth,
|
||||
top:
|
||||
boxCutoutMetrics.offsetY +
|
||||
currentClick.y * boxCutoutMetrics.renderedHeight,
|
||||
};
|
||||
}, [boxCutoutMetrics, currentClick]);
|
||||
|
||||
const resetBoxCutoutState = useCallback(() => {
|
||||
setBoxCutoutMode(false);
|
||||
@ -759,8 +771,18 @@ export function ImageNode({
|
||||
if (!currentMetrics) return null;
|
||||
|
||||
const rect = imageElement.getBoundingClientRect();
|
||||
const rawX = clientX - rect.left - currentMetrics.offsetX;
|
||||
const rawY = clientY - rect.top - currentMetrics.offsetY;
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
|
||||
// The image node content may be counter-scaled with CSS transforms.
|
||||
// getBoundingClientRect() returns the transformed size, while
|
||||
// object-contain offsets are calculated in the element's local box.
|
||||
// Convert the pointer back into that local coordinate space first.
|
||||
const localX =
|
||||
((clientX - rect.left) / rect.width) * imageElement.clientWidth;
|
||||
const localY =
|
||||
((clientY - rect.top) / rect.height) * imageElement.clientHeight;
|
||||
const rawX = localX - currentMetrics.offsetX;
|
||||
const rawY = localY - currentMetrics.offsetY;
|
||||
const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth);
|
||||
const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight);
|
||||
|
||||
@ -1085,12 +1107,14 @@ export function ImageNode({
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const img = e.currentTarget;
|
||||
const rect = img.getBoundingClientRect();
|
||||
const pointerResult = getBoxCutoutPointerPoint(e.clientX, e.clientY);
|
||||
if (!pointerResult?.insideImage) {
|
||||
toast.info("请点击图片内容区域");
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算相对坐标 (0-1)
|
||||
const clickX = (e.clientX - rect.left) / rect.width;
|
||||
const clickY = (e.clientY - rect.top) / rect.height;
|
||||
const clickX = pointerResult.point.x;
|
||||
const clickY = pointerResult.point.y;
|
||||
|
||||
setCurrentClick({ x: clickX, y: clickY });
|
||||
setPickerLoading(true);
|
||||
@ -1151,6 +1175,7 @@ export function ImageNode({
|
||||
isThisNodePicking,
|
||||
isPickerLoading,
|
||||
data.src,
|
||||
getBoxCutoutPointerPoint,
|
||||
node.id,
|
||||
setPickerLoading,
|
||||
addPendingPickedObject,
|
||||
@ -3100,12 +3125,12 @@ export function ImageNode({
|
||||
)}
|
||||
|
||||
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
|
||||
{isThisNodePicking && currentClick && (
|
||||
{isThisNodePicking && currentPickerClickStyle && (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${currentClick.x * 100}%`,
|
||||
top: `${currentClick.y * 100}%`,
|
||||
left: currentPickerClickStyle.left,
|
||||
top: currentPickerClickStyle.top,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user