feat(canvas): 交互可读性 - 优化连线、字号与工作条
This commit is contained in:
@@ -166,7 +166,9 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
{
|
||||
id: "asset-tray-toggle",
|
||||
label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`,
|
||||
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
|
||||
displayLabel: "素材",
|
||||
badge: (showLibrary ? assetImages.length : 0) + canvasImages.length,
|
||||
icon: <Images />,
|
||||
active: open,
|
||||
onClick: () => {
|
||||
bringToFront();
|
||||
@@ -248,7 +250,7 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<FloatingDock items={dockItems} className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
|
||||
<FloatingDock items={dockItems} showLabels className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<CanvasNodeContentProps, "theme">) {
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const liveScaleRef = useRef(scale);
|
||||
@@ -73,6 +75,7 @@ export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoA
|
||||
{ id: "zoom-minimap", label: isMiniMapOpen ? "关闭小地图" : "打开小地图", icon: <Compass />, active: isMiniMapOpen, onClick: onToggleMiniMap },
|
||||
{ id: "zoom-fit", label: "适应画布", icon: <Focus />, onClick: onFitContent },
|
||||
...(onAutoArrange ? [{ id: "zoom-auto-arrange", label: "自动整理节点", icon: <LayoutTemplate />, onClick: onAutoArrange }] : []),
|
||||
...(onHideNodeConnectionsChange ? [{ id: "zoom-hide-node-connections", label: hideNodeConnections ? "显示全部连线" : "设置隐藏节点连线", icon: hideNodeConnections ? <Eye /> : <EyeOff />, active: hideNodeConnections, onClick: () => onHideNodeConnectionsChange(!hideNodeConnections) }] : []),
|
||||
{ kind: "separator", id: "zoom-separator" },
|
||||
{ id: "zoom-out", label: "缩小画布", icon: <Minus />, onClick: () => commitScale(liveScaleRef.current - 0.1) },
|
||||
{
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center">{command.icon}</span>
|
||||
<span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">{command.displayLabel || command.label}</span>
|
||||
{command.badge !== undefined ? <span className="aceternity-dock-command-badge inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[var(--fs-micro)] font-bold leading-4">{command.badge}</span> : null}
|
||||
</motion.button>
|
||||
</motion.span>
|
||||
);
|
||||
@@ -292,15 +295,17 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
|
||||
const [focusedId, setFocusedId] = useState<string | null>(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 (
|
||||
<span
|
||||
role="radiogroup"
|
||||
aria-label={entry.label}
|
||||
className="aceternity-dock-switch relative flex shrink-0 self-end items-center"
|
||||
className={cn("aceternity-dock-switch relative flex shrink-0 self-end items-center", labeled && "is-labeled")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
||||
event.preventDefault();
|
||||
@@ -315,25 +320,25 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="aceternity-dock-switch-thumb pointer-events-none absolute top-1/2 rounded-full"
|
||||
className={cn("aceternity-dock-switch-thumb pointer-events-none absolute top-1/2", labeled ? "rounded-[var(--dock-item-radius)]" : "rounded-full")}
|
||||
initial={false}
|
||||
animate={{ x: selectedIndex * (slot + gap), y: "-50%" }}
|
||||
transition={reducedMotion || !motionEnabled ? { duration: 0 } : aceternityMotion.spring.dock}
|
||||
style={{ width: slot, height: slot, left: padX }}
|
||||
style={{ width: slot, height: slotHeight, left: padX }}
|
||||
/>
|
||||
{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 (
|
||||
<span key={option.id} className="relative">
|
||||
<span key={option.id} className="relative inline-flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={option.label}
|
||||
title={!motionEnabled ? option.label : undefined}
|
||||
className="aceternity-dock-switch-option relative z-[1] grid place-items-center rounded-full border-0 outline-none"
|
||||
style={{ width: slot, height: slot }}
|
||||
className={cn("aceternity-dock-switch-option relative z-[1] inline-flex items-center justify-center border-0 outline-none", labeled ? "gap-1 rounded-[var(--dock-item-radius)] px-2" : "rounded-full")}
|
||||
style={{ width: slot, height: slotHeight }}
|
||||
onMouseEnter={() => setHoveredId(option.id)}
|
||||
onMouseLeave={() => setHoveredId((current) => current === option.id ? null : current)}
|
||||
onFocus={(event) => {
|
||||
@@ -345,7 +350,8 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
|
||||
if (!checked) entry.onChange(option.value);
|
||||
}}
|
||||
>
|
||||
<span className={cn("grid place-items-center", touch ? "[&_svg]:size-4" : "[&_svg]:size-[15px]")}>{option.icon}</span>
|
||||
<span className="grid size-4 shrink-0 place-items-center">{option.icon}</span>
|
||||
{labeled ? <span className="shrink-0 whitespace-nowrap text-[length:var(--fs-micro)] font-semibold leading-none">{option.displayLabel || option.label}</span> : null}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showTooltip ? (
|
||||
|
||||
50
web/src/lib/canvas/canvas-connection-visibility.ts
Normal file
50
web/src/lib/canvas/canvas-connection-visibility.ts
Normal file
@@ -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<string>;
|
||||
activeNodeId?: string | null;
|
||||
selectedConnectionId?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!options.enabled) return connections;
|
||||
|
||||
const focusedNodeIds = new Set<string>();
|
||||
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),
|
||||
);
|
||||
}
|
||||
19
web/src/lib/canvas/canvas-text-scale.ts
Normal file
19
web/src/lib/canvas/canvas-text-scale.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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 控制) */
|
||||
|
||||
@@ -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<CanvasToolMode>("box-select");
|
||||
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
web/test/canvas-connection-visibility.test.ts
Normal file
25
web/test/canvas-connection-visibility.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
23
web/test/canvas-text-scale.test.ts
Normal file
23
web/test/canvas-text-scale.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(<FloatingDock size={size} items={[modeSwitch]} />);
|
||||
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("选择</span>");
|
||||
expect(html).not.toContain("移动</span>");
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user