From 4def6d076f7ce086ef1532011c4f3d251e28eb84 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 31 Jul 2026 22:27:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E4=BA=A4=E4=BA=92=20-=20=E4=BC=98=E5=8C=96=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E9=9D=A2=E6=9D=BF=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=BC=B9=E7=AA=97=E4=B8=8E=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-audio-settings-popover.tsx | 7 +- .../canvas/canvas-image-settings-popover.tsx | 6 +- .../canvas/canvas-node-hover-toolbar.tsx | 31 +- .../canvas/canvas-node-prompt-panel.tsx | 48 +-- web/src/components/canvas/canvas-node.tsx | 4 +- .../canvas/canvas-preset-picker.tsx | 20 +- .../canvas-resource-mention-textarea.tsx | 6 +- .../canvas/canvas-video-settings-popover.tsx | 7 +- web/src/components/canvas/infinite-canvas.tsx | 1 + web/src/components/image-settings-panel.tsx | 33 +- web/src/components/model-picker.tsx | 153 ++++++--- web/src/lib/canvas/canvas-live-viewport.ts | 2 + web/src/styles/globals.css | 290 ++++++++++++++++-- 13 files changed, 478 insertions(+), 130 deletions(-) diff --git a/web/src/components/canvas/canvas-audio-settings-popover.tsx b/web/src/components/canvas/canvas-audio-settings-popover.tsx index 14341d2c0..5e1e4f3d3 100644 --- a/web/src/components/canvas/canvas-audio-settings-popover.tsx +++ b/web/src/components/canvas/canvas-audio-settings-popover.tsx @@ -24,6 +24,7 @@ export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClass const panelRef = useRef(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(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 ( <> - {panel} diff --git a/web/src/components/canvas/canvas-image-settings-popover.tsx b/web/src/components/canvas/canvas-image-settings-popover.tsx index 818fdf7fa..fca6b43cb 100644 --- a/web/src/components/canvas/canvas-image-settings-popover.tsx +++ b/web/src/components/canvas/canvas-image-settings-popover.tsx @@ -64,7 +64,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang return ( <> - @@ -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()} > - onConfigChange(key, value)} theme={theme} showCount={showCount} className="space-y-3" /> + onConfigChange(key, value)} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" /> , document.body, ); diff --git a/web/src/components/canvas/canvas-node-hover-toolbar.tsx b/web/src/components/canvas/canvas-node-hover-toolbar.tsx index fafa75698..b4974eae3 100644 --- a/web/src/components/canvas/canvas-node-hover-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-hover-toolbar.tsx @@ -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(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()} > -
+
{dockItems.length ? : null} {hasImage && !simpleMode ? (
) : ( -
+
onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} />
-
+
{mode === "image" ? ( onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} onMissingConfig={() => navigateToSettings({ continueCreation: true })} onOpenChange={expanded ? undefined : onImageSettingsOpenChange} /> ) : mode === "video" ? ( - onConfigChange(node.id, videoConfigPatch(key, value))} /> + onConfigChange(node.id, videoConfigPatch(key, value))} /> ) : mode === "audio" ? ( - onConfigChange(node.id, audioConfigPatch(key, value))} /> + onConfigChange(node.id, audioConfigPatch(key, value))} /> ) : null} )) :
没有匹配的预设
} @@ -115,10 +115,10 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha ); return ( - - ); diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index e667b1c9a..03cecbc93 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -125,12 +125,12 @@ export const CanvasResourceMentionTextarea = forwardRef { 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"; diff --git a/web/src/components/canvas/canvas-video-settings-popover.tsx b/web/src/components/canvas/canvas-video-settings-popover.tsx index 82612ccd9..3640411c4 100644 --- a/web/src/components/canvas/canvas-video-settings-popover.tsx +++ b/web/src/components/canvas/canvas-video-settings-popover.tsx @@ -21,6 +21,7 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass const panelRef = useRef(null); const [open, setOpen] = useState(false); const [buttonRect, setButtonRect] = useState(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 ( <> - {panel} diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index d46dfc394..ffbe2d75a 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -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`, diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index a364a63b6..8764a659a 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -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 =
宽高比 -
+
{aspectOptions.map((item) => ( ))}
@@ -138,10 +138,10 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
{Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => ( onConfigChange("count", String(value))}> - {value} 张 + {value} ))} - onConfigChange("count", String(value || 1))} /> + onConfigChange("count", String(value || 1))} />
) : 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 ( -