feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)

This commit is contained in:
vv-get-ti
2026-08-31 06:49:43 +08:00
committed by GitHub
parent cc4aa736ff
commit 151e99f8d9
22 changed files with 925 additions and 113 deletions

View File

@@ -13,6 +13,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes } from "@/lib/canvas-theme";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
@@ -26,7 +27,8 @@ type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY:
export default function SharedCanvasPage() {
const { token = "" } = useParams();
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
const dragRef = useRef<DragState | null>(null);
@@ -34,6 +36,7 @@ export default function SharedCanvasPage() {
const [title, setTitle] = useState("共享画布");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<Awaited<ReturnType<typeof getPublicCanvasShare>>["project"]["connections"]>([]);
const [appearance, setAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines");
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@@ -74,12 +77,22 @@ export default function SharedCanvasPage() {
useEffect(() => {
let active = true;
const themeBeforeShare = useThemeStore.getState().theme;
let appliedShareTheme: typeof themeBeforeShare | null = null;
let themeChangedAfterApply = false;
const unsubscribeTheme = useThemeStore.subscribe((state, previous) => {
if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true;
});
setLoading(true);
getPublicCanvasShare(token).then(({ project }) => {
if (!active) return;
setTitle(project.title || "共享画布");
setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize));
setConnections(project.connections || []);
const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare);
setAppearance(nextAppearance);
appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare);
useThemeStore.getState().setTheme(appliedShareTheme);
setBackgroundMode(project.backgroundMode || "lines");
const initial = project.viewport || { x: 0, y: 0, k: 1 };
viewportRef.current = initial;
@@ -89,7 +102,13 @@ export default function SharedCanvasPage() {
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
return () => {
active = false;
unsubscribeTheme();
if (appliedShareTheme && !themeChangedAfterApply && useThemeStore.getState().theme === appliedShareTheme) {
useThemeStore.getState().setTheme(themeBeforeShare);
}
};
}, [token]);
useEffect(() => {
@@ -222,7 +241,7 @@ export default function SharedCanvasPage() {
if (loadError) return <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
return (
<main className="relative h-screen overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main className="relative h-screen overflow-hidden" style={{ background: resolveCanvasAppearance(appearance, colorTheme).background, color: theme.node.text }}>
<header className="pointer-events-none absolute inset-x-0 top-0 z-[var(--z-panel-floating)] flex h-16 items-center justify-between px-5">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Share2 className="size-4" style={{ color: theme.node.muted }} />
@@ -232,7 +251,7 @@ export default function SharedCanvasPage() {
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
</header>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
</svg>