fix(canvas): 画布创作交互 - 优化提示词面板并修复工具弹窗与节点缩放卡顿
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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="更多图片工具"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user