fix(canvas): 画布 - 防止旧内容覆盖并支持版本历史恢复
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user