diff --git a/web/src/components/canvas/canvas-asset-tray.tsx b/web/src/components/canvas/canvas-asset-tray.tsx index 845d68351..6eb136398 100644 --- a/web/src/components/canvas/canvas-asset-tray.tsx +++ b/web/src/components/canvas/canvas-asset-tray.tsx @@ -166,7 +166,9 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true, { id: "asset-tray-toggle", label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`, - icon: {(showLibrary ? assetImages.length : 0) + canvasImages.length}, + displayLabel: "素材", + badge: (showLibrary ? assetImages.length : 0) + canvasImages.length, + icon: , active: open, onClick: () => { bringToFront(); @@ -248,7 +250,7 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true, ) : null} - + ); } diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 0d0cce431..55bd5190b 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -6,6 +6,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display"; import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; +import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale"; import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage"; import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview"; import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview"; @@ -15,7 +16,7 @@ import type { CanvasTheme } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import type { GenerationTask } from "@/services/api/task-center"; -import { cacheResourceObjectUrl, getCachedResourceObjectUrl, peekCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache"; +import { scheduleResourceBlobCache } from "@/services/resource-blob-cache"; import { resolveMediaUrl } from "@/services/file-storage"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; @@ -306,7 +307,7 @@ function UnknownNodeContent({ theme }: Pick) { } function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) { - const fontSize = node.metadata?.fontSize || 14; + const fontSize = canvasTextFontSize(node.width, node.height, node.metadata?.fontSize); const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties; const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]); @@ -650,49 +651,27 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { || (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content); const resourceId = resourceIdFromStorageKey(storageKey); const isRemoteResource = Boolean(resourceId); - // 图片内容随资源 ID 不可变且后端允许磁盘强缓存:视口内的远程图片首帧直接上直链, - // 走浏览器原生解码与磁盘缓存;Blob 缓存就绪后再平滑替换,避免刷新后满屏转圈。 - const synchronousUrl = eager && isRemoteResource && node.type === CanvasNodeType.Image ? peekCachedResourceObjectUrl(storageKey) || resourceFileUrl(resourceId) : ""; + // 远程资源展示统一走稳定的云端资源文件地址。Blob 缓存只服务于导出、抽帧和 + // 供应商输入等字节处理,不再把缓存生成的 blob: URL 写入媒体展示链路。 + const remoteUrl = isRemoteResource ? resourceFileUrl(resourceId) : ""; // Inline data URLs are already local, but decoding thousands of them is // still expensive. Images must wait for the same viewport gate as remote // resources; otherwise DOM virtualization does not reduce image work. const isLazyVisual = node.type === CanvasNodeType.Image; const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:")); - const initialUrl = synchronousUrl || (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback)); + const initialUrl = eager && isRemoteResource ? remoteUrl : (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback)); const [url, setUrl] = useState(() => initialUrl); const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager); useEffect(() => { - let cancelled = false; if (!isRemoteResource) { setUrl(isLazyVisual && !eager ? "" : fallback); setLoading(false); return; } - const cachedSync = peekCachedResourceObjectUrl(storageKey); - if (cachedSync) { - setUrl(cachedSync); - setLoading(false); - return; - } - if (!url && eager && isHttpUrl) { - setUrl(fallback); - setLoading(false); - } else if (!url) { - setLoading(eager); - } - // 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。 - const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); - void resolve.then((cached) => { - if (!cancelled && cached) setUrl(cached); - else if (!cancelled && eager && fallback) setUrl(fallback); - }).catch(() => { - if (!cancelled && eager) setUrl(synchronousUrl || fallback); - }).finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, [eager, fallback, isHttpUrl, isLazyVisual, isRemoteResource, storageKey]); + setUrl(eager ? remoteUrl : ""); + setLoading(false); + }, [eager, fallback, isLazyVisual, isRemoteResource, remoteUrl]); return { url, loading }; } diff --git a/web/src/components/canvas/canvas-zoom-controls.tsx b/web/src/components/canvas/canvas-zoom-controls.tsx index 8713c3f30..530b468fc 100644 --- a/web/src/components/canvas/canvas-zoom-controls.tsx +++ b/web/src/components/canvas/canvas-zoom-controls.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef, useState, type RefObject } from "react"; -import { Compass, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react"; +import { Compass, Eye, EyeOff, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react"; import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock"; import { aceternityMotion } from "@/lib/aceternity-motion"; @@ -14,6 +14,8 @@ type CanvasZoomControlsProps = { onScaleChange: (scale: number) => void; onFitContent: () => void; onAutoArrange?: () => void; + hideNodeConnections?: boolean; + onHideNodeConnectionsChange?: (value: boolean) => void; isMiniMapOpen: boolean; onToggleMiniMap: () => void; onOpenShortcuts: () => void; @@ -22,7 +24,7 @@ type CanvasZoomControlsProps = { const QUICK_ZOOM_LEVELS = [0.25, 0.5, 1, 2] as const; -export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) { +export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, hideNodeConnections = false, onHideNodeConnectionsChange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) { const theme = canvasThemes[useActiveTheme()]; const rootRef = useRef(null); const liveScaleRef = useRef(scale); @@ -73,6 +75,7 @@ export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoA { id: "zoom-minimap", label: isMiniMapOpen ? "关闭小地图" : "打开小地图", icon: , active: isMiniMapOpen, onClick: onToggleMiniMap }, { id: "zoom-fit", label: "适应画布", icon: , onClick: onFitContent }, ...(onAutoArrange ? [{ id: "zoom-auto-arrange", label: "自动整理节点", icon: , onClick: onAutoArrange }] : []), + ...(onHideNodeConnectionsChange ? [{ id: "zoom-hide-node-connections", label: hideNodeConnections ? "显示全部连线" : "设置隐藏节点连线", icon: hideNodeConnections ? : , active: hideNodeConnections, onClick: () => onHideNodeConnectionsChange(!hideNodeConnections) }] : []), { kind: "separator", id: "zoom-separator" }, { id: "zoom-out", label: "缩小画布", icon: , onClick: () => commitScale(liveScaleRef.current - 0.1) }, { diff --git a/web/src/components/ui/aceternity/floating-dock.tsx b/web/src/components/ui/aceternity/floating-dock.tsx index acf4e8130..6f02f0f4a 100644 --- a/web/src/components/ui/aceternity/floating-dock.tsx +++ b/web/src/components/ui/aceternity/floating-dock.tsx @@ -9,6 +9,7 @@ export type FloatingDockCommand = { id: string; label: string; displayLabel?: string; + badge?: ReactNode; icon: ReactNode; wide?: boolean; quiet?: boolean; @@ -25,6 +26,7 @@ export type FloatingDockSwitchOption = { label: string; icon: ReactNode; value: string; + displayLabel?: string; }; export type FloatingDockSwitch = { @@ -237,6 +239,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s > {command.icon} {command.displayLabel || command.label} + {command.badge !== undefined ? {command.badge} : null} ); @@ -292,15 +295,17 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent const [focusedId, setFocusedId] = useState(null); const selectedIndex = Math.max(0, entry.options.findIndex((option) => option.value === entry.value)); const touch = metrics.base >= 40; - const slot = touch ? 32 : compact ? 24 : 26; - const gap = touch ? 10 : compact ? 8 : 10; - const padX = touch ? 7 : compact ? 6 : 7; + const labeled = showLabel || entry.options.some((option) => option.displayLabel); + const slot = labeled ? (touch ? 68 : compact ? 58 : 64) : touch ? 32 : compact ? 24 : 26; + const slotHeight = labeled ? metrics.base : slot; + const gap = labeled ? (touch ? 4 : 3) : touch ? 10 : compact ? 8 : 10; + const padX = labeled ? (touch ? 5 : 4) : touch ? 7 : compact ? 6 : 7; return ( { if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; event.preventDefault(); @@ -315,25 +320,25 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent > {entry.options.map((option) => { const checked = option.value === entry.value; - const showTooltip = !showLabel && (hoveredId === option.id || focusedId === option.id); + const showTooltip = !labeled && (hoveredId === option.id || focusedId === option.id); return ( - + {showTooltip ? ( diff --git a/web/src/lib/canvas/canvas-connection-visibility.ts b/web/src/lib/canvas/canvas-connection-visibility.ts new file mode 100644 index 000000000..4e2e9e865 --- /dev/null +++ b/web/src/lib/canvas/canvas-connection-visibility.ts @@ -0,0 +1,50 @@ +import type { CanvasDisplayConnection } from "@/types/canvas"; + +export const CANVAS_HIDE_NODE_CONNECTIONS_STORAGE_KEY = "canvas-hide-node-connections"; + +export function readCanvasHideNodeConnections() { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(CANVAS_HIDE_NODE_CONNECTIONS_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +export function persistCanvasHideNodeConnections(value: boolean) { + try { + window.localStorage.setItem(CANVAS_HIDE_NODE_CONNECTIONS_STORAGE_KEY, String(value)); + } catch { + // 浏览器禁用本地存储时保留当前会话内的选择。 + } +} + +/** + * 隐藏模式只保留当前交互焦点的直接连线。 + * 该函数返回同一份结果给 Leafer 视觉层和 SVG 命中层,避免“看不见但仍能点到”的幽灵连线。 + */ +export function filterCanvasDisplayConnections( + connections: CanvasDisplayConnection[], + options: { + enabled: boolean; + hoveredNodeId?: string | null; + selectedNodeIds?: ReadonlySet; + activeNodeId?: string | null; + selectedConnectionId?: string | null; + }, +) { + if (!options.enabled) return connections; + + const focusedNodeIds = new Set(); + if (options.hoveredNodeId) focusedNodeIds.add(options.hoveredNodeId); + if (options.activeNodeId) focusedNodeIds.add(options.activeNodeId); + options.selectedNodeIds?.forEach((nodeId) => focusedNodeIds.add(nodeId)); + + return connections.filter(({ connection, from, to }) => + connection.id === options.selectedConnectionId + || focusedNodeIds.has(connection.fromNodeId) + || focusedNodeIds.has(connection.toNodeId) + || focusedNodeIds.has(from.id) + || focusedNodeIds.has(to.id), + ); +} diff --git a/web/src/lib/canvas/canvas-text-scale.ts b/web/src/lib/canvas/canvas-text-scale.ts new file mode 100644 index 000000000..fca3dc6d6 --- /dev/null +++ b/web/src/lib/canvas/canvas-text-scale.ts @@ -0,0 +1,19 @@ +const DEFAULT_TEXT_NODE_WIDTH = 340; +const DEFAULT_TEXT_NODE_HEIGHT = 240; +const DEFAULT_TEXT_FONT_SIZE = 14; +const MIN_TEXT_FONT_SIZE = 8; +const MAX_TEXT_FONT_SIZE = 72; + +/** + * Keep text readable while a text node is resized. + * + * The geometric mean makes the scale respond to both dimensions without making + * a very wide, short node grow as aggressively as a purely width-based rule. + */ +export function canvasTextFontSize(width: number, height: number, baseFontSize = DEFAULT_TEXT_FONT_SIZE) { + const safeWidth = Number.isFinite(width) && width > 0 ? width : DEFAULT_TEXT_NODE_WIDTH; + const safeHeight = Number.isFinite(height) && height > 0 ? height : DEFAULT_TEXT_NODE_HEIGHT; + const safeBaseFontSize = Number.isFinite(baseFontSize) && baseFontSize > 0 ? baseFontSize : DEFAULT_TEXT_FONT_SIZE; + const scale = Math.sqrt((safeWidth / DEFAULT_TEXT_NODE_WIDTH) * (safeHeight / DEFAULT_TEXT_NODE_HEIGHT)); + return Math.min(MAX_TEXT_FONT_SIZE, Math.max(MIN_TEXT_FONT_SIZE, safeBaseFontSize * scale)); +} diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts index 8acdead69..4cf890bdf 100644 --- a/web/src/lib/canvas/tool-registry/tool-definition.ts +++ b/web/src/lib/canvas/tool-registry/tool-definition.ts @@ -186,7 +186,7 @@ export type ToolDefinition = { /** 互斥开关:在 dock 中渲染为分段切换,而不是两个独立按钮 */ switchGroup?: { value: (ctx: ToolContext) => string; - options: Array<{ id: string; label: string; icon: ReactNode; value: string }>; + options: Array<{ id: string; label: string; displayLabel?: string; icon: ReactNode; value: string }>; onChange: (ctx: ToolContext, value: string) => void; }; /** 上下文可见性谓词——返回 false 时工具不渲染(不受 prefs 控制) */ diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 056630207..7f64bc822 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -19,6 +19,7 @@ import { nanoid } from "nanoid"; import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode"; +import { filterCanvasDisplayConnections, persistCanvasHideNodeConnections, readCanvasHideNodeConnections } from "@/lib/canvas/canvas-connection-visibility"; import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference"; import { useAssetStore } from "@/stores/use-asset-store"; @@ -304,6 +305,7 @@ function InfiniteCanvasPage() { const [showImageInfo, setShowImageInfo] = useState(false); const [canvasTool, setCanvasTool] = useState("box-select"); const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode); + const [hideNodeConnections, setHideNodeConnections] = useState(readCanvasHideNodeConnections); const [projectLoaded, setProjectLoaded] = useState(false); const workspaceMode: CanvasWorkspaceMode = "professional"; const [clearConfirmOpen, setClearConfirmOpen] = useState(false); @@ -348,6 +350,10 @@ function InfiniteCanvasPage() { persistCanvasMediaPerformanceMode(mediaPerformanceMode); }, [mediaPerformanceMode]); + useEffect(() => { + persistCanvasHideNodeConnections(hideNodeConnections); + }, [hideNodeConnections]); + useEffect(() => { didInitialCenterRef.current = false; setNodeStackOrder([]); @@ -1434,6 +1440,17 @@ function InfiniteCanvasPage() { scriptEditorNodeId, dialogNodeId, }); + + const visibleDisplayConnections = useMemo( + () => filterCanvasDisplayConnections(displayConnections, { + enabled: hideNodeConnections, + hoveredNodeId, + selectedNodeIds, + activeNodeId, + selectedConnectionId, + }), + [activeNodeId, displayConnections, hideNodeConnections, hoveredNodeId, selectedConnectionId, selectedNodeIds], + ); useEffect(() => { setNodes((current) => { let changed = false; @@ -2513,7 +2530,7 @@ function InfiniteCanvasPage() { containerRef={containerRef} viewport={viewport} theme={theme} - displayConnections={displayConnections} + displayConnections={visibleDisplayConnections} selectedConnectionId={selectedConnectionId} relatedConnectionIds={relatedHighlight.connectionIds} scriptScrollTopById={scriptScrollTopById} @@ -2547,7 +2564,7 @@ function InfiniteCanvasPage() { projectId={projectId} viewportScale={viewport.k} connectionLayerBounds={connectionLayerBounds} - displayConnections={displayConnections} + displayConnections={visibleDisplayConnections} selectedConnectionId={selectedConnectionId} relatedConnectionIds={relatedHighlight.connectionIds} scriptScrollTopById={scriptScrollTopById} @@ -2890,6 +2907,8 @@ function InfiniteCanvasPage() { onScaleChange={setZoomScale} onFitContent={fitCanvasContent} onAutoArrange={autoArrangeCanvasNodes} + hideNodeConnections={hideNodeConnections} + onHideNodeConnectionsChange={setHideNodeConnections} isMiniMapOpen={isMiniMapOpen} onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)} onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 398fba882..9d6d12e52 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7011,6 +7011,11 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7 box-shadow: none; } + .aceternity-dock-command-badge { + background: var(--dock-command-active); + color: var(--dock-command-active-text); + } + .aceternity-dock-separator { background: color-mix(in srgb, currentColor 13%, transparent); } @@ -7025,21 +7030,25 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7 } .aceternity-dock-switch-option { - color: #ffffff; + color: color-mix(in srgb, var(--dock-switch-thumb) 78%, var(--dock-switch-track)); background: transparent; - opacity: 0.62; - transition: - color var(--motion-instant) ease-out, - opacity var(--motion-instant) ease-out; + transition: color var(--motion-instant) ease-out; } .aceternity-dock-switch-option:hover:not([aria-checked="true"]) { - opacity: 0.88; + color: var(--dock-switch-thumb); } .aceternity-dock-switch-option[aria-checked="true"] { color: var(--dock-switch-thumb-text, #000000); - opacity: 1; + } + + .aceternity-dock-switch.is-labeled .aceternity-dock-switch-option { + min-width: 0; + } + + .aceternity-dock-switch.is-labeled .aceternity-dock-switch-option[aria-checked="true"] { + color: var(--dock-switch-thumb-text, #000000); } .aceternity-dock-switch-option > span svg { @@ -7053,7 +7062,7 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7 .aceternity-dock-switch-option[aria-checked="true"] > span svg { color: var(--dock-switch-thumb-text, #000000); stroke: currentColor; - stroke-width: 2.6px; + stroke-width: 2px; } .aceternity-dock-switch-option:focus-visible { diff --git a/web/test/canvas-connection-visibility.test.ts b/web/test/canvas-connection-visibility.test.ts new file mode 100644 index 000000000..6de7307d9 --- /dev/null +++ b/web/test/canvas-connection-visibility.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; + +import { filterCanvasDisplayConnections } from "@/lib/canvas/canvas-connection-visibility"; +import { CanvasNodeType, type CanvasDisplayConnection } from "@/types/canvas"; + +function connection(id: string, fromNodeId: string, toNodeId: string): CanvasDisplayConnection { + const node = (nodeId: string) => ({ id: nodeId, type: CanvasNodeType.Text, title: nodeId, position: { x: 0, y: 0 }, width: 100, height: 80, metadata: {} }); + return { connection: { id, fromNodeId, toNodeId }, from: node(fromNodeId), to: node(toNodeId) }; +} + +describe("canvas connection visibility", () => { + const connections = [connection("a", "one", "two"), connection("b", "two", "three"), connection("c", "four", "five")]; + + test("keeps all connections when disabled", () => { + expect(filterCanvasDisplayConnections(connections, { enabled: false })).toEqual(connections); + }); + + test("shows only direct connections for hovered and selected nodes", () => { + expect(filterCanvasDisplayConnections(connections, { enabled: true, hoveredNodeId: "three", selectedNodeIds: new Set(["one"]) }).map((item) => item.connection.id)).toEqual(["a", "b"]); + }); + + test("keeps an explicitly selected connection visible", () => { + expect(filterCanvasDisplayConnections(connections, { enabled: true, selectedConnectionId: "c" }).map((item) => item.connection.id)).toEqual(["c"]); + }); +}); diff --git a/web/test/canvas-text-scale.test.ts b/web/test/canvas-text-scale.test.ts new file mode 100644 index 000000000..b90fedf43 --- /dev/null +++ b/web/test/canvas-text-scale.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; + +import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale"; + +describe("canvas text font scaling", () => { + test("keeps the configured base size at the default node size", () => { + expect(canvasTextFontSize(340, 240, 14)).toBe(14); + }); + + test("scales proportionally when the node grows in both directions", () => { + expect(canvasTextFontSize(680, 480, 14)).toBeCloseTo(28); + }); + + test("responds when only one dimension changes", () => { + expect(canvasTextFontSize(680, 240, 14)).toBeCloseTo(14 * Math.sqrt(2)); + }); + + test("uses metadata font size as the base and clamps extremes", () => { + expect(canvasTextFontSize(340, 240, 20)).toBe(20); + expect(canvasTextFontSize(1, 1, 2)).toBe(8); + expect(canvasTextFontSize(10_000, 10_000, 20)).toBe(72); + }); +}); diff --git a/web/test/canvas-toolbar-mode-switch.test.tsx b/web/test/canvas-toolbar-mode-switch.test.tsx index 4df271ffb..5f120e9d6 100644 --- a/web/test/canvas-toolbar-mode-switch.test.tsx +++ b/web/test/canvas-toolbar-mode-switch.test.tsx @@ -27,6 +27,24 @@ function createMainContext(canvasTool: "move" | "box-select" = "box-select"): To } describe("canvas toolbar mode switch", () => { + test.each(["default", "compact"] as const)("renders %s canvas modes as an icon-only sliding switch", (size) => { + const entries = resolveToolbarEntries("main", createMainContext(), null); + const modeSwitch = entries.find((entry) => entry.kind === "switch"); + if (!modeSwitch) throw new Error("Missing canvas mode switch"); + const html = renderToStaticMarkup(); + const diameter = size === "compact" ? 24 : 26; + + expect(html).toContain(`width:${diameter}px;height:${diameter}px`); + expect(html.match(/aceternity-dock-switch-thumb/g)).toHaveLength(1); + expect(html).toContain("absolute top-1/2 rounded-full"); + expect(html).toContain("grid size-4 shrink-0 place-items-center"); + expect(html).not.toContain("is-labeled"); + expect(html).not.toContain("选择"); + expect(html).not.toContain("移动"); + expect(html).toContain('aria-label="区域选择"'); + expect(html).toContain('aria-label="抓手工具"'); + }); + test("renders grab and box-select as one dock switch", () => { const entries = resolveToolbarEntries("main", createMainContext("box-select"), null); const modeSwitch = entries.find((entry) => entry.kind === "switch" && entry.id === CANVAS_MODE_TOOL_ID);