fix(canvas): 画布 - 防止旧内容覆盖并支持版本历史恢复

This commit is contained in:
kinoko
2026-09-17 18:40:42 +08:00
parent b6bae4f957
commit 35ebed3097
69 changed files with 3575 additions and 294 deletions

View File

@@ -1,6 +1,7 @@
import { Tooltip } from "@/components/ui/base/tooltip";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
import { History, PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -8,6 +9,9 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
type CanvasFocusModeBarProps = {
versionsOpen: boolean;
onToggleVersions: () => void;
syncStatus?: ReactNode;
dockRevealed: boolean;
zoomPercent: number;
onToggleDock: () => void;
@@ -17,7 +21,7 @@ type CanvasFocusModeBarProps = {
onFit: () => void;
};
export function CanvasFocusModeBar({ dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
export function CanvasFocusModeBar({ versionsOpen, onToggleVersions, syncStatus, dockRevealed, zoomPercent, onToggleDock, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
const theme = canvasThemes[useActiveTheme()];
const reducedMotion = useReducedMotion();
@@ -42,6 +46,12 @@ export function CanvasFocusModeBar({ dockRevealed, zoomPercent, onToggleDock, on
<X className="size-4" />
</button>
</Tooltip>
{syncStatus}
<Tooltip title="版本记录与本地草稿">
<button type="button" onClick={onToggleVersions} className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text, background: versionsOpen ? theme.toolbar.itemHover : undefined }} aria-label="版本记录" aria-pressed={versionsOpen}>
<History className="size-4" />
</button>
</Tooltip>
<span className="mx-0.5 h-4 w-px" style={{ background: theme.toolbar.border }} />
<Tooltip title={dockRevealed ? "收起工具" : "工具"}>
<button

View File

@@ -8,6 +8,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { ViewportTransform } from "@/types/canvas";
type InfiniteCanvasProps = {
interactive?: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
appearance?: CanvasAppearance;
@@ -43,7 +44,7 @@ type PinchState = {
initialScale: number;
};
export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
export function InfiniteCanvas({ interactive = true, containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
const colorTheme = useActiveTheme();
const resolvedAppearance = resolveCanvasAppearance(appearance, colorTheme);
const panState = useRef({
@@ -69,6 +70,27 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isPanning, setIsPanning] = useState(false);
useLayoutEffect(() => {
if (interactive) return;
const container = containerRef.current;
for (const id of [panState.current.pointerId, ...touchPointsRef.current.keys()]) {
if (container?.hasPointerCapture(id)) container.releasePointerCapture(id);
}
panState.current.isPanning = false;
pinchStateRef.current.active = false;
touchPointsRef.current.clear();
interactingRef.current = false;
if (frameRef.current) cancelAnimationFrame(frameRef.current);
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
frameRef.current = null;
syncTimerRef.current = null;
nextViewportRef.current = null;
delete container?.dataset.canvasViewportInteracting;
setIsPanning(false);
setIsSpacePressed(false);
document.body.style.cursor = "default";
}, [interactive, containerRef]);
useLayoutEffect(() => {
if (interactingRef.current) return;
viewportRef.current = viewport;
@@ -95,7 +117,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
[containerRef],
);
const syncViewport = useCallback(() => onViewportChange(viewportRef.current), [onViewportChange]);
const syncViewport = useCallback(() => { if (interactive) onViewportChange(viewportRef.current); }, [interactive, onViewportChange]);
const scheduleViewportChange = useCallback(
(next: ViewportTransform, commitAfterIdle = false) => {
@@ -127,6 +149,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
);
useEffect(() => {
if (!interactive) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
if (event.target instanceof Element && event.target.closest("input,textarea,select,button,[contenteditable='true']")) return;
@@ -154,7 +177,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
}, [interactive]);
const handleWheel = useCallback(
(event: WheelEvent) => {
@@ -208,6 +231,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!interactive) return;
const target = event.target instanceof Element ? event.target : null;
// AntD 浮层通过 Portal 渲染到节点 DOM 之外;若不统一排除,会被误判为画布空白并捕获指针。
if (target?.closest(CANVAS_POINTER_IGNORE_SELECTOR)) return;
@@ -297,6 +321,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
};
useEffect(() => {
if (!interactive) return;
const handlePointerMove = (event: PointerEvent) => {
if (event.pointerType === "touch" && touchPointsRef.current.has(event.pointerId)) {
touchPointsRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -374,11 +399,11 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerEnd);
};
}, [containerRef, onCanvasDeselect, scheduleViewportChange, syncViewport]);
}, [interactive, containerRef, onCanvasDeselect, scheduleViewportChange, syncViewport]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (!container || !interactive) return;
const updateRect = () => {
containerRectRef.current = container.getBoundingClientRect();
};
@@ -392,7 +417,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
window.removeEventListener("resize", updateRect);
container.removeEventListener("wheel", handleWheel, { capture: true });
};
}, [containerRef, handleWheel]);
}, [interactive, containerRef, handleWheel]);
return (
<div