fix: improve canvas mode settings
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-21 00:55:35 +08:00
parent e92e0b24f6
commit edc3ae59b1
7 changed files with 276 additions and 122 deletions

View File

@ -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}
/>

View File

@ -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(),

View File

@ -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}

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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%)",
}}
>