fix(canvas): 画布创作交互 - 优化提示词面板并修复工具弹窗与节点缩放卡顿

This commit is contained in:
ddCat
2026-07-31 22:27:58 +08:00
parent 55b37df04c
commit 4def6d076f
13 changed files with 478 additions and 130 deletions

View File

@@ -24,6 +24,7 @@ export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClass
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const summary = `${audioVoiceLabel(config.audioVoice)} · ${audioFormatLabel(config.audioFormat)} · ${audioSpeedLabel(config.audioSpeed)}`;
useEffect(() => {
if (!open) return;
@@ -51,10 +52,8 @@ export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClass
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate">
{audioVoiceLabel(config.audioVoice)} · {audioFormatLabel(config.audioFormat)} · {audioSpeedLabel(config.audioSpeed)}
</span>
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`音频设置:${summary}`} title={`音频设置 · ${summary}`} onClick={() => setOpen((current) => !current)}>
<span className="truncate">{summary}</span>
</Button>
</span>
{panel}

View File

@@ -64,7 +64,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => updateOpen(!open)}>
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
<span className="truncate">{summary}</span>
</Button>
</span>
@@ -90,9 +90,9 @@ function ImageSettingsPortal({
showCount: boolean;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const width = 328;
const gap = 8;
const margin = 12;
const width = Math.min(420, window.innerWidth - margin * 2);
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
@@ -121,7 +121,7 @@ function ImageSettingsPortal({
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} showCount={showCount} className="space-y-3" />
<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
</div>,
document.body,
);

View File

@@ -118,9 +118,13 @@ export function CanvasNodeHoverToolbar({
const [imageToolMenuOpen, setImageToolMenuOpen] = useState(false);
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
// Dropdown 关闭与菜单点击处于同一事件批次,ref 用来同步守住即将打开的 Modal,避免工具栏先被卸载。
const imageToolSettingsOpenRef = useRef(false);
const imageToolMenuOpenRef = useRef(false);
const { message } = App.useApp();
const copyText = useCopyText();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const themeName = useThemeStore((state) => state.theme);
const theme = canvasThemes[themeName];
const simpleMode = workspaceMode === "simple";
useEffect(() => {
@@ -139,6 +143,8 @@ export function CanvasNodeHoverToolbar({
}, []);
useEffect(() => {
imageToolSettingsOpenRef.current = false;
imageToolMenuOpenRef.current = false;
setImageToolSettingsOpen(false);
setImageToolMenuOpen(false);
}, [node?.id]);
@@ -162,8 +168,8 @@ export function CanvasNodeHoverToolbar({
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
// 外置节点标题占用节点上方一行,工具 Dock 再向上错开,避免遮挡名称编辑入口。
const top = nodeRect.top - containerRect.top - 42;
// 外置标题固定占 24px,额外保留 6px 即可兼顾层级和名称编辑入口。
const top = nodeRect.top - containerRect.top - 30;
if (toolbarRef.current) {
toolbarRef.current.style.left = `${left}px`;
toolbarRef.current.style.top = `${top}px`;
@@ -217,6 +223,8 @@ export function CanvasNodeHoverToolbar({
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
function openImageToolSettings() {
imageToolSettingsOpenRef.current = true;
imageToolMenuOpenRef.current = false;
onKeep(activeNode.id);
setImageToolMenuOpen(false);
setDraftImageToolIds(quickImageToolIds);
@@ -254,19 +262,22 @@ export function CanvasNodeHoverToolbar({
];
const closeImageToolSettings = () => {
imageToolSettingsOpenRef.current = false;
setImageToolSettingsOpen(false);
onLeave();
};
const runTemporaryImageTool = (tool: ToolbarTool) => {
imageToolMenuOpenRef.current = false;
setImageToolMenuOpen(false);
tool.onClick();
};
const handleImageToolMenuOpenChange = (open: boolean) => {
imageToolMenuOpenRef.current = open;
setImageToolMenuOpen(open);
if (open) onKeep(activeNode.id);
else if (!imageToolSettingsOpen) onLeave();
else if (!imageToolSettingsOpenRef.current) onLeave();
};
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
@@ -292,6 +303,12 @@ export function CanvasNodeHoverToolbar({
const dockShellStyle = canvasDockStyle(theme, theme.node.text);
const embeddedDockStyle = { ...dockShellStyle, background: "transparent", borderColor: "transparent", boxShadow: "none" };
const labeledDockStyle = {
...dockShellStyle,
boxShadow: themeName === "dark"
? `0 18px 52px ${theme.spatial.shadow}`
: "0 10px 30px rgba(15,23,42,.12), 0 1px 2px rgba(15,23,42,.05), inset 0 1px 0 rgba(255,255,255,.7)",
};
return (
<>
@@ -301,12 +318,12 @@ export function CanvasNodeHoverToolbar({
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: `min(calc(100% - 20px), ${showDockLabels ? 840 : 560}px)`, color: theme.node.text }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => {
if (!imageToolSettingsOpen && !imageToolMenuOpen) onLeave();
if (!imageToolSettingsOpenRef.current && !imageToolMenuOpenRef.current) onLeave();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className={`aceternity-floating-dock thin-scrollbar relative flex max-w-full overflow-x-auto rounded-[14px] border backdrop-blur-2xl ${showDockLabels ? "h-11 items-center px-2 py-1" : "h-10 items-end gap-1 px-1.5 pb-1"}`} style={showDockLabels ? { ...dockShellStyle, boxShadow: `0 18px 52px ${theme.spatial.shadow}` } : dockShellStyle}>
<div className={`aceternity-floating-dock thin-scrollbar relative flex max-w-full overflow-x-auto rounded-[14px] border backdrop-blur-2xl ${showDockLabels ? "h-11 items-center px-2 py-1" : "h-10 items-end gap-1 px-1.5 pb-1"}`} style={showDockLabels ? labeledDockStyle : dockShellStyle}>
{dockItems.length ? <FloatingDock embedded items={dockItems} size="compact" showLabels={showDockLabels} ariaLabel="节点快捷工具" className={`pointer-events-auto shrink-0 ${showDockLabels ? "" : "max-w-[min(calc(100vw-20px),400px)]"}`} style={embeddedDockStyle} /> : null}
{hasImage && !simpleMode ? (
<Dropdown
@@ -324,7 +341,7 @@ export function CanvasNodeHoverToolbar({
>
<button
type="button"
className={`aceternity-dock-command pointer-events-auto shrink-0 outline-none focus-visible:ring-2 ${showDockLabels ? "inline-flex h-8 items-center justify-center gap-1.5 rounded-[9px] px-2.5" : "grid size-8 place-items-center rounded-full"}`}
className={`aceternity-dock-command pointer-events-auto shrink-0 outline-none focus-visible:ring-2 ${showDockLabels ? "is-labeled inline-flex h-8 items-center justify-center gap-1.5 rounded-[9px] px-2.5" : "grid size-8 place-items-center rounded-full"}`}
style={{ color: theme.node.text, "--tw-ring-color": theme.accent.primary } as CSSProperties}
aria-label="更多图片工具"
title="更多图片工具"

View File

@@ -59,15 +59,19 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
.map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const darkSurface = themeName === "dark";
const monochromeAccent = theme.node.activeStroke;
const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(17,24,39,.12)";
const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(17,24,39,.10)";
const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(15,23,42,.08)";
const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(15,23,42,.07)";
const shellSurface = darkSurface
? theme.canvas.background
: `linear-gradient(180deg, rgba(255,255,255,.94), rgba(248,250,252,.86)), ${theme.spatial.elevated}`;
: "rgba(250,251,252,.97)";
const composerSurface = darkSurface
? theme.canvas.background
: `linear-gradient(180deg, rgba(255,255,255,.7), rgba(248,250,252,.72)), ${theme.spatial.dropzone}`;
const controlSurface = darkSurface ? theme.canvas.background : "rgba(17,24,39,.045)";
: "rgba(15,23,42,.025)";
const controlSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.045)";
const controlsSurface = darkSurface ? controlSurface : "transparent";
const shellShadow = darkSurface ? `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` : "0 12px 32px rgba(15,23,42,.10), 0 1px 2px rgba(15,23,42,.06)";
const composerShadow = darkSurface ? "inset 0 1px 4px rgba(0,0,0,.22)" : "inset 0 0 0 1px rgba(15,23,42,.045)";
const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)";
const referenceShelfHeight = activeReferenceCount ? 42 : 0;
const composerMinHeight = activeReferenceCount ? 82 : 58;
const composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
@@ -177,13 +181,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
);
const renderComposerControls = (expanded: boolean) => simpleMode ? (
<div className="flex min-w-0 items-center justify-between gap-2 p-1" style={{ background: controlSurface }}>
<div className="flex min-w-0 items-center justify-between gap-2 p-1" style={{ background: controlsSurface }}>
<span className="min-w-0 truncate px-2 text-[10px]" style={{ color: theme.node.muted }}>
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
</span>
<Button
type="text"
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-md !px-2.5 !text-[10px] !font-medium"
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-full !px-2.5 !text-[10px] !font-medium"
danger={isRunning}
disabled={isSubmitDisabled}
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent, color: isSubmitDisabled ? theme.node.faint : isRunning ? "#ffffff" : theme.canvas.background, boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)` }}
@@ -195,29 +199,29 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</Button>
</div>
) : (
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlSurface }}>
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlsSurface }}>
<div className={`${expanded ? "max-w-[320px]" : mode === "image" || mode === "video" ? "max-w-[240px]" : "max-w-[174px]"} min-w-[104px] flex-1`}>
<ModelPicker className="!h-7 !w-full !min-w-0 !text-[10px] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3" fullWidth config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} />
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-0.5">
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
{mode === "image" ? (
<CanvasImageSettingsPopover
config={config}
placement={expanded ? "topRight" : "topLeft"}
buttonClassName="!h-7 !w-[138px] !justify-start !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
/>
) : mode === "video" ? (
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-7 !w-[136px] !justify-start !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-7 !w-[144px] !justify-start !rounded-full !border-0 !px-2.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[138px] !justify-start !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null}
<GenerationCostBadge credits={credits} theme={theme} />
<Button
type="text"
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-md !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
danger={isRunning}
disabled={isSubmitDisabled}
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent, borderColor: isSubmitDisabled ? insetBorder : monochromeAccent, color: isSubmitDisabled ? theme.node.faint : theme.canvas.background, boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)` }}
@@ -232,8 +236,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
return (
<div
className="aceternity-floating-panel relative overflow-hidden rounded-lg border p-2"
style={{ background: shellSurface, borderColor: shellBorder, color: theme.node.text, boxShadow: `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` }}
className="aceternity-floating-panel relative overflow-hidden rounded-xl border p-2"
style={{ background: shellSurface, borderColor: shellBorder, color: theme.node.text, boxShadow: shellShadow }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
@@ -241,8 +245,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
{renderComposerHeader(false)}
<div
className="relative mt-2 flex max-h-36 flex-col overflow-hidden rounded-lg outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
style={{ height: composerHeight, background: composerSurface, boxShadow: "inset 0 1px 4px rgba(0,0,0,.22)" }}
className="relative mt-2 flex max-h-36 flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
style={{ height: composerHeight, background: composerSurface, boxShadow: composerShadow }}
>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
@@ -274,13 +278,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
width={760}
destroyOnHidden
onCancel={() => { setExpandedPresetOpen(false); setExpandedPromptOpen(false); }}
styles={{ container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }, body: { minHeight: 0, flex: 1, padding: 0 } }}
styles={{ container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow }, body: { minHeight: 0, flex: 1, padding: 0 } }}
>
<div className="flex h-full min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
<div
className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-lg outline-none ring-0 focus-within:outline-none focus-within:ring-0"
style={{ background: composerSurface, boxShadow: "inset 0 1px 4px rgba(0,0,0,.22)" }}
className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-xl outline-none ring-0 focus-within:outline-none focus-within:ring-0"
style={{ background: composerSurface, boxShadow: composerShadow }}
>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
@@ -375,8 +379,8 @@ function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference
function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) {
if (credits === null) return null;
return (
<span className="inline-flex h-6 shrink-0 items-center gap-0.5 px-1 text-[9px] font-medium tabular-nums" style={{ color: theme.node.muted }} title="本次生成消耗">
<CreditSymbol />
<span className="canvas-generation-cost-badge inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-[9px] font-semibold tabular-nums" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title={`预计消耗 ${credits.toLocaleString()} 积分`} aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}>
<CreditSymbol className="text-[11px]" />
{credits.toLocaleString()}
</span>
);

View File

@@ -1138,8 +1138,8 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
return (
<div
className="absolute left-0 z-40 flex h-6 max-w-[240px] items-center gap-1"
style={{ top: -24 * inverseScale, color: active ? theme.node.text : theme.node.label, transform: `scale(${inverseScale})`, transformOrigin: "left top" }}
className="canvas-node-external-header absolute bottom-full left-0 z-40 flex h-6 max-w-[240px] items-center gap-1"
style={{ color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>

View File

@@ -87,26 +87,26 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
}, [mode, query, skillReferences]);
const content = (
<div data-canvas-no-zoom className="w-[320px] max-w-[calc(100vw-32px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Input autoFocus allowClear size="small" prefix={<Search className="size-3.5 opacity-45" />} placeholder="搜索预设或已激活技能" value={query} onChange={(event) => setQuery(event.target.value)} />
<div className="thin-scrollbar mt-2 max-h-72 overflow-y-auto space-y-1">
<div data-canvas-no-zoom className="canvas-preset-picker-menu w-[320px] max-w-[calc(100vw-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Input className="canvas-preset-picker-search" variant="borderless" autoFocus allowClear size="small" prefix={<Search className="size-3.5" />} placeholder="搜索预设或已激活技能" value={query} onChange={(event) => setQuery(event.target.value)} />
<div className="thin-scrollbar mt-1 max-h-72 space-y-0.5 overflow-y-auto">
{presets.length ? presets.map((preset) => (
<button
key={preset.id}
type="button"
className="flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left transition hover:bg-black/5 dark:hover:bg-white/10"
className="canvas-preset-picker-option"
onClick={() => {
onSelect(preset);
setOpen(false);
}}
>
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}><WandSparkles className="size-3.5" /></span>
<span className="canvas-preset-picker-option-icon" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}><WandSparkles className="size-3.5" /></span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: theme.node.text }}>
<span className="truncate">{preset.name}</span>
<span className="shrink-0 text-[9px] font-medium" style={{ color: theme.node.faint }}>{preset.source === "skill" ? "技能" : "预设"}</span>
<span className="shrink-0 text-[9px] font-medium" style={{ color: theme.accent.primary }}>{preset.source === "skill" ? "技能" : "预设"}</span>
</span>
<span className="mt-0.5 block line-clamp-2 text-[10px] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
<span className="mt-0.5 block truncate text-[10px] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
</span>
</button>
)) : <div className="py-8 text-center text-xs" style={{ color: theme.node.muted }}>没有匹配的预设</div>}
@@ -115,10 +115,10 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
);
return (
<Popover open={actualOpen} onOpenChange={setOpen} trigger="click" placement="topLeft" content={content} styles={{ content: { padding: 8, background: theme.toolbar.panel, border: `1px solid ${theme.toolbar.border}` } }}>
<button type="button" className={`inline-flex shrink-0 items-center justify-center gap-1 rounded-md transition hover:brightness-125 ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`} style={{ background: dense ? theme.canvas.background : theme.toolbar.itemHover, color: dense ? theme.node.text : theme.node.muted }} title="预设(输入 / 也可打开)" aria-label="打开预设">
<Popover open={actualOpen} onOpenChange={setOpen} trigger="click" placement="topLeft" arrow={false} content={content} classNames={{ root: "canvas-preset-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }}>
<button type="button" className={`canvas-preset-picker-trigger inline-flex shrink-0 items-center justify-center gap-1 rounded-lg transition hover:brightness-110 focus-visible:outline-none ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`} style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title="打开提示词预设" aria-label="打开提示词预设" aria-expanded={actualOpen}>
<WandSparkles className={dense ? "size-3" : "size-3.5"} />
{compact ? null : <span className="text-[10px] font-medium">预设</span>}
{compact ? null : <span className="text-[10px] font-semibold">预设</span>}
</button>
</Popover>
);

View File

@@ -125,12 +125,12 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
const syncMention = (nextValue: string, cursor: number) => {
const prefix = nextValue.slice(0, cursor);
const match = /(^|\s)@([^\s@]*)$/.exec(prefix);
const match = /@([^\s@,.;:!?,。;:!?、)\]}】)]*)$/.exec(prefix);
if (!match || !references.some((item) => item.active)) {
closeMention();
return;
}
const nextMention = { start: cursor - match[2].length - 1, query: match[2] };
const nextMention = { start: match.index, query: match[1] };
const isSameMention = mention?.start === nextMention.start && mention.query === nextMention.query;
if (!isSameMention) {
setMention(nextMention);
@@ -353,7 +353,7 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.mentionToken = token;
chip.className = "mx-[0.06em] inline-flex h-[1.55em] translate-y-[0.18em] select-none items-center gap-[0.18em] rounded-[0.38em] bg-black/[0.06] px-[0.22em] text-[0.92em] font-medium leading-none text-current align-baseline dark:bg-white/[0.1]";
chip.className = "mx-[0.06em] inline-flex h-[1.68em] translate-y-[0.08em] select-none items-center gap-[0.18em] rounded-[0.38em] bg-black/[0.06] px-[0.22em] text-[0.92em] font-medium leading-none text-current align-baseline dark:bg-white/[0.1]";
const at = document.createElement("span");
at.className = "shrink-0 opacity-90";

View File

@@ -21,6 +21,7 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const summary = `${videoResolutionLabel(config.vquality)} · ${videoSizeLabel(config.size)} · ${videoSecondsLabel(config.videoSeconds)}`;
useEffect(() => {
if (!open) return;
@@ -48,10 +49,8 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate">
{videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
</span>
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`视频设置:${summary}`} title={`视频设置 · ${summary}`} onClick={() => setOpen((current) => !current)}>
<span className="truncate">{summary}</span>
</Button>
</span>
{panel}

View File

@@ -375,6 +375,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots"
"--canvas-live-x": `${viewport.x}px`,
"--canvas-live-y": `${viewport.y}px`,
"--canvas-live-scale": viewport.k,
"--canvas-live-inverse-scale": 1 / Math.max(viewport.k, 0.05),
"--canvas-committed-scale": viewport.k,
"--canvas-live-scale-ratio": 1,
"--canvas-grid-size": `${48 * viewport.k}px`,

View File

@@ -40,7 +40,7 @@ type ImageSettingsPanelProps = {
quickCount?: number;
};
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 10 }: ImageSettingsPanelProps) {
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) {
const [snapDimensionToStep, setSnapDimensionToStep] = useState(true);
const quality = config.quality || "auto";
const transparentBackground = config.transparentBackground === "true";
@@ -116,18 +116,18 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
</div>
<div className="space-y-2">
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
<div className="grid grid-cols-4 gap-1.5">
<div className="grid grid-cols-4 gap-1.5 min-[380px]:grid-cols-5">
{aspectOptions.map((item) => (
<button
key={item.value}
type="button"
className="flex h-[58px] cursor-pointer flex-col items-center justify-center gap-0.5 rounded-lg bg-transparent text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
className="flex h-[52px] cursor-pointer flex-col items-center justify-center gap-0.5 rounded-lg bg-transparent text-[11px] transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
style={{ background: selectedAspect?.value === item.value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => selectAspect(item.value)}
>
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
<span>{item.label}</span>
<span className="whitespace-nowrap">{item.label}</span>
</button>
))}
</div>
@@ -138,10 +138,10 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => (
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
{value} 张
{value}
</OptionPill>
))}
<CountInput value={count} max={maxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
<CountInput value={count} quickCount={quickCount} max={maxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
</div>
</div>
) : null}
@@ -214,17 +214,28 @@ function DimensionInput({ prefix, value, disabled, theme, alignToStep, onChange
);
}
function CountInput({ value, max, theme, onChange }: { value: number; max: number; theme: CanvasTheme; onChange: (value: number | null) => void }) {
function CountInput({ value, quickCount, max, theme, onChange }: { value: number; quickCount: number; max: number; theme: CanvasTheme; onChange: (value: number | null) => void }) {
const commit = (input: HTMLInputElement) => {
const next = Math.max(1, Math.min(max, Math.floor(Number(input.value) || 1)));
input.value = String(next);
onChange(next);
};
return (
<label className="col-span-2 flex h-8 overflow-hidden rounded-full text-xs" style={{ background: theme.toolbar.itemHover, color: theme.node.text }}>
<label className="flex h-8 overflow-hidden rounded-full text-xs" style={{ background: theme.toolbar.itemHover, color: theme.node.text }}>
<input
key={value > quickCount ? `custom-${value}` : "quick"}
type="number"
min={1}
max={max}
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label="自定义生成张数"
placeholder="输入"
className="min-w-0 flex-1 bg-transparent px-2 text-center outline-none placeholder:text-current placeholder:opacity-55 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
value={value || ""}
onChange={(event) => onChange(Number(event.target.value) || null)}
defaultValue={value > quickCount ? value : ""}
onBlur={(event) => commit(event.currentTarget)}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
onMouseDown={(event) => event.stopPropagation()}
/>
</label>

View File

@@ -1,9 +1,11 @@
import { useEffect, useId, useMemo, useState } from "react";
import { Coins, Cpu } from "lucide-react";
import { Select } from "antd";
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Check, ChevronDown, Coins, Cpu } from "lucide-react";
import { Popover } from "antd";
import { canvasThemes } from "@/lib/canvas-theme";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
type ModelPickerProps = {
config: AiConfig;
@@ -19,7 +21,10 @@ type ModelPickerProps = {
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true }: ModelPickerProps) {
const pickerId = useId();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const options = useMemo(
() => Array.from(new Set([...(config.channelMode === "local" && !capability ? [value] : []), ...selectableModelsByCapability(config, capability)].filter((model): model is string => Boolean(model)))),
[capability, config, value],
@@ -39,10 +44,6 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
}, [config, options]);
const current = value || "";
const currentPrice = modelMenuPrice(config, current);
const selectOptions = useMemo(() => optionGroups.map((group) => ({
label: <span className="flex min-w-0 items-center gap-1.5"><span className="truncate">{group.label}</span><span className="shrink-0 text-[10px] font-normal text-foreground/38">{group.scope}</span></span>,
options: group.models.map((model) => ({ value: model, label: modelOptionLabel(config, model) })),
})), [config, optionGroups]);
useEffect(() => {
const closeOtherPicker = (event: Event) => {
@@ -52,41 +53,117 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
return () => window.removeEventListener("model-picker-open", closeOtherPicker);
}, [pickerId]);
return (
const setPickerOpen = (nextOpen: boolean) => {
if (nextOpen && !options.length && config.channelMode === "local") onMissingConfig?.();
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
setOpen(nextOpen);
};
const focusMenuOption = (last = false) => {
window.requestAnimationFrame(() => {
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0);
target?.focus();
});
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
setPickerOpen(true);
focusMenuOption(event.key === "ArrowUp");
};
const handleMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
if (!buttons.length) return;
event.preventDefault();
const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowUp" ? Math.max(0, activeIndex - 1) : Math.min(buttons.length - 1, activeIndex + 1);
buttons[nextIndex]?.focus();
};
const content = (
<div
className={cn(fullWidth ? "w-full min-w-0" : "w-fit max-w-full", className)}
ref={menuRef}
data-canvas-no-zoom
className="canvas-model-picker-menu w-[320px] max-w-[calc(100vw-24px)]"
role="listbox"
aria-label={placeholder}
onKeyDown={handleMenuKeyDown}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Select<string>
size="small"
{optionGroups.length ? optionGroups.map((group) => (
<section key={group.key} className="canvas-model-picker-group">
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
<span className="truncate">{group.label}</span>
<span className="shrink-0" style={{ color: theme.node.muted }}>{group.scope}</span>
</div>
<div className="grid gap-1">
{group.models.map((model) => {
const selected = model === current;
return (
<button
key={model}
type="button"
role="option"
aria-selected={selected}
className="canvas-model-picker-option"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
onClick={() => {
onChange(model);
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel config={config} model={model} capability={capability} theme={theme} />
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
})}
</div>
</section>
)) : <div className="canvas-model-picker-empty" style={{ color: theme.node.muted }}>{emptyModelLabel(config, capability)}</div>}
</div>
);
return (
<div
className={cn(fullWidth ? "w-full min-w-0" : "w-fit max-w-full")}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Popover
open={open}
value={current || undefined}
options={selectOptions}
showSearch
filterOption={(input, option) => String(option?.label || "").toLocaleLowerCase().includes(input.toLocaleLowerCase())}
notFoundContent={<span className="block px-2 py-3 text-center text-xs text-foreground/48">{emptyModelLabel(config, capability)}</span>}
popupMatchSelectWidth={capability === "image" || capability === "video" ? 320 : 280}
onOpenChange={setPickerOpen}
trigger="click"
placement="bottomLeft"
className={cn("canvas-composer-model-picker", fullWidth ? "w-full" : "min-w-36 max-w-full")}
classNames={{ popup: { root: "canvas-model-picker-popup" } }}
onOpenChange={(nextOpen) => {
if (nextOpen && !options.length && config.channelMode === "local") onMissingConfig?.();
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
setOpen(nextOpen);
}}
onChange={onChange}
optionRender={(option) => <ModelLabel config={config} model={String(option.value)} capability={capability} />}
labelRender={() => (
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5 text-[11px]">
<ModelIcon model={current} />
arrow={false}
content={content}
classNames={{ root: "canvas-model-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }}
>
<button
ref={triggerRef}
type="button"
className={cn("canvas-composer-model-picker", fullWidth ? "w-full" : "min-w-36 max-w-full", className)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={placeholder}
title={current ? modelOptionLabel(config, current) : placeholder}
onKeyDown={handleTriggerKeyDown}
>
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}><ModelIcon model={current} /></span>
<span className="min-w-0 flex-1 truncate">{current ? modelOptionLabel(config, current) : placeholder}</span>
{showSelectedPrice ? <ModelPrice price={currentPrice} compact /> : null}
</span>
)}
aria-label={placeholder}
title={current ? modelOptionLabel(config, current) : placeholder}
/>
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
</button>
</Popover>
</div>
);
}
@@ -97,19 +174,19 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
}
function ModelLabel({ config, model, capability }: { config: AiConfig; model: string; capability?: ModelCapability }) {
function ModelLabel({ config, model, capability, theme }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const meta = modelMenuMeta(model, capability);
return (
<span className="flex min-w-0 items-center gap-1.5 py-0">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-black/5 dark:bg-white/10">
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block min-w-0 truncate text-[11px] font-medium leading-none">{modelDisplayName(config, model)}</span>
<span className="mt-0.5 block truncate text-[10px] opacity-55">{modelOptionName(model)} · {meta.description}</span>
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }}>{modelOptionName(model)} · {meta.description}</span>
</span>
<ModelPrice price={modelMenuPrice(config, model)} />
{meta.time ? <span className="shrink-0 rounded-full bg-black/5 px-1 py-0.5 text-[10px] tabular-nums opacity-60 dark:bg-white/10">{meta.time}</span> : null}
{meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
</span>
);
}
@@ -126,7 +203,7 @@ function ModelPrice({ price, compact = false }: { price: { value: number; unit:
if (price === undefined) return null;
if (price === null) return compact ? null : <span className="shrink-0 text-[10px] text-foreground/40">未配置</span>;
return (
<span className="inline-flex shrink-0 items-center gap-0.5 rounded border border-foreground/10 bg-foreground/[.045] px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-foreground/55" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
<span className="inline-flex shrink-0 items-center gap-0.5 text-[10px] font-medium tabular-nums text-foreground/55" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
<Coins className="size-3" />
{price.value.toLocaleString("zh-CN", { maximumFractionDigits: compact ? 3 : 6 })}/{price.unit}
</span>

View File

@@ -11,6 +11,8 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
container.style.setProperty("--canvas-live-x", `${viewport.x}px`);
container.style.setProperty("--canvas-live-y", `${viewport.y}px`);
container.style.setProperty("--canvas-live-scale", String(viewport.k));
// 外置节点标题用同一帧逆倍率抵消世界层缩放,避免等待 React 提交后再校正尺寸。
container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05)));
container.style.setProperty("--canvas-live-scale-ratio", String(viewport.k / committedScale));
container.style.setProperty("--canvas-grid-size", `${gridSize}px`);
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);

View File

@@ -1504,45 +1504,285 @@
display: none;
}
.canvas-composer-tools .canvas-composer-model-picker.ant-select {
.canvas-composer-tools .canvas-composer-model-picker {
width: 32px;
min-width: 32px;
}
.canvas-composer-tools .canvas-composer-model-picker .ant-select-content {
.canvas-composer-tools .canvas-composer-model-picker {
padding-inline: 7px !important;
}
.canvas-composer-tools .canvas-composer-model-picker .ant-select-content,
.canvas-composer-tools .canvas-model-picker-label {
min-width: 0;
}
.canvas-model-picker-popup .ant-select-item-option-content {
overflow: visible;
.canvas-composer-model-picker {
align-items: center;
appearance: none;
background: color-mix(in srgb, var(--workspace-surface-strong) 94%, var(--workspace-accent) 6%);
border: 0;
border-radius: 9px;
color: var(--foreground);
cursor: pointer;
display: flex;
font: inherit;
gap: 8px;
height: 32px;
justify-content: space-between;
line-height: 1;
max-width: 100%;
min-width: 0;
outline: none !important;
padding: 0 9px;
text-align: left;
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
}
.canvas-composer-model-picker.ant-select .ant-select-content {
background: var(--background) !important;
border-color: color-mix(in srgb, var(--foreground) 8%, transparent) !important;
box-shadow: none !important;
.canvas-composer-model-picker:hover {
background: color-mix(in srgb, var(--workspace-surface-strong) 88%, var(--workspace-accent) 12%);
}
.dark .canvas-composer-model-picker.ant-select,
.dark .canvas-composer-model-picker.ant-select .ant-select-content,
.dark .canvas-model-picker-popup.ant-select-dropdown {
background: #000 !important;
}
.canvas-composer-model-picker.ant-select-focused .ant-select-content {
border-color: color-mix(in srgb, var(--foreground) 8%, transparent) !important;
.canvas-composer-model-picker:focus,
.canvas-composer-model-picker:focus-visible,
.canvas-composer-model-picker[aria-expanded="true"] {
background: color-mix(in srgb, var(--workspace-surface-strong) 84%, var(--workspace-accent) 16%);
box-shadow: none !important;
outline: none !important;
}
.canvas-model-picker-popup .ant-select-item-option-active:not(.ant-select-item-option-disabled),
.canvas-model-picker-popup .ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
background: color-mix(in srgb, var(--foreground) 6%, transparent) !important;
.canvas-model-picker-label {
flex: 1 1 auto;
overflow: hidden;
}
.canvas-model-picker-trigger-icon {
align-items: center;
border-radius: 6px;
display: inline-flex;
flex: 0 0 22px;
height: 22px;
justify-content: center;
width: 22px;
}
.canvas-model-picker-chevron {
color: color-mix(in srgb, var(--foreground) 52%, transparent);
flex: 0 0 14px;
height: 14px;
transition: transform var(--motion-instant) ease-out;
width: 14px;
}
.canvas-model-picker-chevron.is-open {
transform: rotate(180deg);
}
.canvas-model-picker-popover,
.canvas-preset-picker-popover {
filter: none !important;
}
.canvas-composer-popover-surface {
border: 0 !important;
border-radius: 10px !important;
background: var(--workspace-surface-strong) !important;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.22) !important;
overflow: hidden;
padding: 0 !important;
backdrop-filter: blur(22px) saturate(118%);
-webkit-backdrop-filter: blur(22px) saturate(118%);
}
.dark .canvas-composer-popover-surface {
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.34) !important;
}
.canvas-composer-popover-content {
padding: 0 !important;
}
.canvas-model-picker-menu {
max-height: min(420px, calc(100vh - 32px));
overflow-y: auto;
padding: 4px;
}
.canvas-model-picker-group + .canvas-model-picker-group {
border-top: 1px solid var(--workspace-border);
margin-top: 5px;
padding-top: 5px;
}
.canvas-model-picker-group-label {
align-items: center;
display: flex;
font-size: 10px;
font-weight: 600;
gap: 6px;
letter-spacing: 0.02em;
padding: 5px 7px 4px;
}
.canvas-model-picker-option {
align-items: center;
border: 0;
border-radius: 7px;
cursor: pointer;
display: flex;
gap: 8px;
min-height: 44px;
outline: none;
padding: 6px 7px;
text-align: left;
transition: background-color var(--motion-instant) ease-out;
width: 100%;
}
.canvas-model-picker-option:hover {
background: color-mix(in srgb, var(--workspace-accent) 9%, transparent) !important;
}
.canvas-model-picker-option:focus-visible {
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent) !important;
outline: none;
}
.canvas-model-picker-option > span:first-child {
flex: 1 1 auto;
min-width: 0;
}
.canvas-model-picker-option-check {
flex: 0 0 14px;
height: 14px;
width: 14px;
}
.canvas-model-picker-empty {
font-size: 11px;
padding: 22px 12px;
text-align: center;
}
.canvas-preset-picker-trigger:focus-visible,
.canvas-preset-picker-trigger[aria-expanded="true"] {
background: color-mix(in srgb, var(--workspace-accent) 18%, transparent) !important;
outline: none;
}
.canvas-preset-picker-trigger {
cursor: pointer;
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out, transform 90ms ease-out;
}
.canvas-preset-picker-trigger:active {
transform: scale(0.97);
}
.canvas-generation-settings-trigger.ant-btn {
background: color-mix(in srgb, var(--workspace-surface-strong) 94%, var(--workspace-accent) 6%) !important;
border: 0 !important;
color: var(--foreground) !important;
cursor: pointer;
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out, transform 90ms ease-out;
}
.canvas-generation-settings-trigger.ant-btn:hover,
.canvas-generation-settings-trigger.ant-btn:focus-visible,
.canvas-generation-settings-trigger.ant-btn[aria-expanded="true"] {
background: color-mix(in srgb, var(--workspace-surface-strong) 86%, var(--workspace-accent) 14%) !important;
box-shadow: none !important;
outline: none !important;
}
.canvas-generation-settings-trigger.ant-btn .ant-btn-icon {
color: color-mix(in srgb, var(--workspace-accent) 72%, var(--foreground));
}
html:not(.dark) .canvas-composer-model-picker,
html:not(.dark) .canvas-generation-settings-trigger.ant-btn {
background: rgba(15, 23, 42, 0.045) !important;
color: #1f2937 !important;
}
html:not(.dark) .canvas-composer-model-picker:hover,
html:not(.dark) .canvas-composer-model-picker[aria-expanded="true"],
html:not(.dark) .canvas-generation-settings-trigger.ant-btn:hover,
html:not(.dark) .canvas-generation-settings-trigger.ant-btn:focus-visible,
html:not(.dark) .canvas-generation-settings-trigger.ant-btn[aria-expanded="true"] {
background: rgba(15, 23, 42, 0.075) !important;
}
html:not(.dark) .canvas-generation-cost-badge {
background: rgba(79, 110, 232, 0.09) !important;
color: #4f6ee8 !important;
}
.canvas-preset-picker-menu {
padding: 6px;
}
.canvas-preset-picker-search.ant-input-affix-wrapper {
border: 0 !important;
border-radius: 8px;
background: color-mix(in srgb, var(--workspace-accent) 7%, transparent) !important;
box-shadow: none !important;
color: color-mix(in srgb, var(--foreground) 66%, transparent);
height: 32px;
padding-inline: 9px;
}
.canvas-preset-picker-search.ant-input-affix-wrapper:focus,
.canvas-preset-picker-search.ant-input-affix-wrapper:focus-within {
background: color-mix(in srgb, var(--workspace-accent) 11%, transparent) !important;
box-shadow: none !important;
}
.canvas-preset-picker-option {
align-items: center;
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
display: flex;
gap: 9px;
min-height: 48px;
outline: none;
padding: 6px 8px;
text-align: left;
transition: background-color var(--motion-instant) ease-out;
width: 100%;
}
.canvas-preset-picker-option:hover,
.canvas-preset-picker-option:focus-visible {
background: color-mix(in srgb, var(--workspace-accent) 9%, transparent);
outline: none;
}
.canvas-preset-picker-option-icon {
align-items: center;
border-radius: 7px;
display: inline-flex;
flex: 0 0 28px;
height: 28px;
justify-content: center;
width: 28px;
}
body.app-spatial-overlays .canvas-composer-popover-surface {
border: 0 !important;
background: var(--workspace-surface-strong) !important;
}
html:not(.dark) body.app-spatial-overlays .canvas-composer-popover-surface {
background: #ffffff !important;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.13) !important;
}
.dark body.app-spatial-overlays .canvas-composer-popover-surface {
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3) !important;
}
.agent-text-model-select .ant-select-content {
@@ -1576,12 +1816,12 @@
min-width: 0;
}
.canvas-composer-tools .canvas-composer-model-picker.ant-select {
.canvas-composer-tools .canvas-composer-model-picker {
width: auto;
min-width: 9rem;
}
.canvas-composer-tools .canvas-composer-model-picker .ant-select-content {
.canvas-composer-tools .canvas-composer-model-picker {
padding-inline: 10px !important;
}
@@ -1645,15 +1885,13 @@
content: "";
}
.canvas-floating-dock,
.canvas-node-toolbar {
.canvas-floating-dock {
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.06),
0 10px 26px rgba(15, 23, 42, 0.08);
}
.dark .canvas-floating-dock,
.dark .canvas-node-toolbar {
.dark .canvas-floating-dock {
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.28),
0 12px 28px rgba(0, 0, 0, 0.3);