feat(canvas): 交互可读性 - 优化连线、字号与工作条

This commit is contained in:
ddCat
2026-09-19 22:29:41 +08:00
parent db9e18da1e
commit ec5abc967e
12 changed files with 210 additions and 57 deletions

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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) },
{