Canvas View

Fenetre interactive de panoramique et de zoom pour les surfaces spatiales avec prise en charge du clavier, de la molette et des superpositions. Installez Canvas View depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Fenetre interactive de panoramique et de zoom pour les surfaces spatiales avec prise en charge du clavier, de la molette et des superpositions. Fait partie de la famille utility dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Canvas View a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/canvas-view.json

Source

"use client"; import { useCallback, useEffect, useId, useImperativeHandle, useRef, useState, } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from "react"; import { cn } from "../../lib/utils"; export type CanvasViewport = { x: number; y: number; zoom: number; }; export type CanvasViewHandle = { resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; }; export type CanvasViewProps = Omit< React.ComponentPropsWithoutRef<"div">, "onScroll" > & { defaultViewport?: CanvasViewport; maxZoom?: number; minZoom?: number; onViewportChange?: (viewport: CanvasViewport) => void; overlay?: React.ReactNode; zoomStep?: number; }; type DragOrigin = { pointerX: number; pointerY: number; viewport: CanvasViewport; }; type ViewportReference = { current: CanvasViewport; }; const DEFAULT_VIEWPORT: CanvasViewport = { x: 0, y: 0, zoom: 1 }; const INTERACTIVE_ELEMENT_SELECTOR = [ "a[href]", "button", 'input:not([type="hidden"])', "select", "textarea", "summary", '[contenteditable=""]', '[contenteditable="true"]', '[role="button"]', '[role="checkbox"]', '[role="link"]', '[role="menuitem"]', '[role="option"]', '[role="radio"]', '[role="slider"]', '[role="spinbutton"]', '[role="switch"]', '[role="tab"]', '[role="textbox"]', ].join(", "); function clampZoom(value: number, minZoom: number, maxZoom: number) { return Math.min(maxZoom, Math.max(minZoom, Number(value.toFixed(2)))); } function isHtmlElement(target: EventTarget | null): target is HTMLElement { return target instanceof HTMLElement; } function isInteractiveDescendant( element: HTMLElement, container: HTMLDivElement, ) { const interactiveAncestor = element.closest(INTERACTIVE_ELEMENT_SELECTOR); return ( interactiveAncestor !== null && container.contains(interactiveAncestor) ); } function supportsScrollableOverflow(value: string) { return value === "auto" || value === "overlay" || value === "scroll"; } function hasScrollableAxis(element: HTMLElement, axis: "x" | "y") { if (axis === "x") { if (element.scrollWidth <= element.clientWidth) { return false; } return supportsScrollableOverflow( window.getComputedStyle(element).overflowX, ); } if (element.scrollHeight <= element.clientHeight) { return false; } return supportsScrollableOverflow(window.getComputedStyle(element).overflowY); } function hasScrollableAncestor( element: HTMLElement, container: HTMLDivElement, delta: { x: number; y: number }, ): boolean { if (!container.contains(element) || element === container) { return false; } if ( (delta.x !== 0 && hasScrollableAxis(element, "x")) || (delta.y !== 0 && hasScrollableAxis(element, "y")) ) { return true; } return element.parentElement === null ? false : hasScrollableAncestor(element.parentElement, container, delta); } function shouldHandleCanvasKeyboardEvent( event: ReactKeyboardEvent<HTMLDivElement>, ) { if ( isHtmlElement(event.target) && event.target !== event.currentTarget && isInteractiveDescendant(event.target, event.currentTarget) ) { return false; } return true; } function shouldHandleCanvasWheelEvent( event: WheelEvent, container: HTMLDivElement, ) { if ( isHtmlElement(event.target) && hasScrollableAncestor(event.target, container, { x: event.deltaX, y: event.deltaY, }) ) { return false; } return true; } function isPanGesture( event: ReactPointerEvent<HTMLDivElement>, isSpacePressed: boolean, ) { return event.button === 1 || (event.button === 0 && isSpacePressed); } function createViewportKeyHandler({ nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, }: { nudgeViewport: (deltaX: number, deltaY: number) => void; resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { return (event: ReactKeyboardEvent<HTMLDivElement>) => { if (event.key === "+" || event.key === "=") { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom + zoomStep, }); return; } if (event.key === "-") { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom - zoomStep, }); return; } if (event.key === "0") { event.preventDefault(); resetViewport(); return; } if (event.key === "ArrowLeft") { event.preventDefault(); nudgeViewport(40, 0); return; } if (event.key === "ArrowRight") { event.preventDefault(); nudgeViewport(-40, 0); return; } if (event.key === "ArrowUp") { event.preventDefault(); nudgeViewport(0, 40); return; } if (event.key === "ArrowDown") { event.preventDefault(); nudgeViewport(0, -40); } }; } function useViewportState({ defaultViewport, maxZoom, minZoom, onViewportChange, }: { defaultViewport: CanvasViewport; maxZoom: number; minZoom: number; onViewportChange?: (viewport: CanvasViewport) => void; }) { const defaultViewportRef = useRef(defaultViewport); const viewportRef = useRef(defaultViewport); const [viewport, setViewport] = useState(defaultViewport); useEffect(() => { defaultViewportRef.current = defaultViewport; }, [defaultViewport]); const applyViewport = useCallback( (nextViewport: CanvasViewport) => { const resolvedViewport = { x: Math.round(nextViewport.x), y: Math.round(nextViewport.y), zoom: clampZoom(nextViewport.zoom, minZoom, maxZoom), }; viewportRef.current = resolvedViewport; setViewport(resolvedViewport); onViewportChange?.(resolvedViewport); }, [maxZoom, minZoom, onViewportChange], ); const resetViewport = useCallback(() => { applyViewport(defaultViewportRef.current); }, [applyViewport]); const nudgeViewport = useCallback( (deltaX: number, deltaY: number) => { const currentViewport = viewportRef.current; applyViewport({ x: currentViewport.x + deltaX, y: currentViewport.y + deltaY, zoom: currentViewport.zoom, }); }, [applyViewport], ); return { nudgeViewport, resetViewport, setViewport: applyViewport, viewport, viewportRef, }; } function useCanvasKeyboardInteractions({ containerRef, nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, }: { containerRef: React.RefObject<HTMLDivElement | null>; nudgeViewport: (deltaX: number, deltaY: number) => void; resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { const [isSpacePressed, setIsSpacePressed] = useState(false); // React 19 marks the synthetic onWheel handler passive, which drops // preventDefault(). useCanvasWheel attaches a native non-passive listener. useCanvasWheel({ containerRef, nudgeViewport, setViewport, viewportRef, zoomStep, }); const handleKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLDivElement>) => { if (!shouldHandleCanvasKeyboardEvent(event)) { return; } if (event.key === " ") { event.preventDefault(); setIsSpacePressed(true); return; } createViewportKeyHandler({ nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, })(event); }, [nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep], ); const handleKeyUp = useCallback( (event: ReactKeyboardEvent<HTMLDivElement>) => { if (!shouldHandleCanvasKeyboardEvent(event)) { return; } if (event.key === " ") { setIsSpacePressed(false); } }, [], ); const handleBlur = useCallback(() => { setIsSpacePressed(false); }, []); return { handleBlur, handleKeyDown, handleKeyUp, isSpacePressed }; } function useCanvasWheel({ containerRef, nudgeViewport, setViewport, viewportRef, zoomStep, }: { containerRef: React.RefObject<HTMLDivElement | null>; nudgeViewport: (deltaX: number, deltaY: number) => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { useEffect(() => { const container = containerRef.current; if (!container) { return; } const handleWheel = (event: WheelEvent) => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom + (event.deltaY > 0 ? -zoomStep : zoomStep), }); return; } if (!shouldHandleCanvasWheelEvent(event, container)) { return; } event.preventDefault(); nudgeViewport(-event.deltaX, -event.deltaY); }; container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; }, [containerRef, nudgeViewport, setViewport, viewportRef, zoomStep]); } function endCanvasDrag( event: ReactPointerEvent<HTMLDivElement>, dragOriginRef: React.RefObject<DragOrigin | null>, setIsDragging: React.Dispatch<React.SetStateAction<boolean>>, ) { dragOriginRef.current = null; setIsDragging(false); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } } function useCanvasPointerInteractions({ isSpacePressed, setViewport, viewportRef, }: { isSpacePressed: boolean; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; }) { const dragOriginRef = useRef<DragOrigin | null>(null); const [isDragging, setIsDragging] = useState(false); const handlePointerDown = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { if (!isPanGesture(event, isSpacePressed)) { return; } if (event.button === 1) { event.preventDefault(); } dragOriginRef.current = { pointerX: event.clientX, pointerY: event.clientY, viewport: viewportRef.current, }; event.currentTarget.setPointerCapture(event.pointerId); setIsDragging(true); }, [isSpacePressed, viewportRef], ); const handlePointerMove = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { const dragOrigin = dragOriginRef.current; if (!dragOrigin) { return; } setViewport({ x: dragOrigin.viewport.x + (event.clientX - dragOrigin.pointerX), y: dragOrigin.viewport.y + (event.clientY - dragOrigin.pointerY), zoom: dragOrigin.viewport.zoom, }); }, [setViewport], ); const handlePointerCancel = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { endCanvasDrag(event, dragOriginRef, setIsDragging); }, [], ); const handlePointerUp = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { endCanvasDrag(event, dragOriginRef, setIsDragging); }, [], ); return { handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp, isDragging, }; } function usePreventBodySelection(disabled: boolean) { useEffect(() => { if (typeof document === "undefined") { return; } const { body } = document; const previousUserSelect = body.style.userSelect; if (disabled) { body.style.userSelect = "none"; } return () => { body.style.userSelect = previousUserSelect; }; }, [disabled]); } function useCanvasViewHandle( ref: React.Ref<CanvasViewHandle> | undefined, viewportState: { resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; }, ) { useImperativeHandle( ref, () => ({ resetViewport: viewportState.resetViewport, setViewport: viewportState.setViewport, }), [viewportState.resetViewport, viewportState.setViewport], ); } type CanvasInteractionLayerProps = { children: React.ReactNode; containerRef: React.RefObject<HTMLDivElement | null>; instructionsId: string; isDragging: boolean; isSpacePressed: boolean; onBlur: () => void; onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void; onKeyUp: (event: ReactKeyboardEvent<HTMLDivElement>) => void; onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void; viewport: CanvasViewport; }; function CanvasInteractionLayer({ children, containerRef, instructionsId, isDragging, isSpacePressed, onBlur, onKeyDown, onKeyUp, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, viewport, }: CanvasInteractionLayerProps) { return ( <div aria-describedby={instructionsId} aria-label="Canvas workspace" aria-roledescription="canvas" className={cn( "relative h-full w-full select-none touch-none outline-none", isDragging || isSpacePressed ? "cursor-grab active:cursor-grabbing" : "cursor-default", )} data-viewport={JSON.stringify(viewport)} onBlur={onBlur} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerCancel={onPointerCancel} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} ref={containerRef} role="button" tabIndex={0} > <div className="sr-only" id={instructionsId}> Hold space and drag or use the middle mouse button to pan. Use plus, minus, or control wheel to zoom. Press zero to reset the viewport. </div> {children} </div> ); } function CanvasContentLayer({ children, isDragging, overlay, viewport, }: { children: React.ReactNode; isDragging: boolean; overlay?: React.ReactNode; viewport: CanvasViewport; }) { return ( <> <div className={cn( "absolute inset-0 origin-top-left", isDragging ? "" : "transition-transform duration-150 ease-out", )} style={{ transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.zoom})`, }} > {children} </div> {overlay ? ( <div className="pointer-events-none absolute inset-0 z-20"> {overlay} </div> ) : null} </> ); } const CanvasView = ({ children, className, defaultViewport = DEFAULT_VIEWPORT, maxZoom = 2, minZoom = 0.5, onViewportChange, overlay, ref, zoomStep = 0.1, ...props }: CanvasViewProps & { ref?: React.Ref<CanvasViewHandle> }) => { const instructionsId = useId(); const interactionRef = useRef<HTMLDivElement>(null); const viewportState = useViewportState({ defaultViewport, maxZoom, minZoom, onViewportChange, }); const keyboard = useCanvasKeyboardInteractions({ containerRef: interactionRef, nudgeViewport: viewportState.nudgeViewport, resetViewport: viewportState.resetViewport, setViewport: viewportState.setViewport, viewportRef: viewportState.viewportRef, zoomStep, }); const pointer = useCanvasPointerInteractions({ isSpacePressed: keyboard.isSpacePressed, setViewport: viewportState.setViewport, viewportRef: viewportState.viewportRef, }); usePreventBodySelection(pointer.isDragging); useCanvasViewHandle(ref, viewportState); return ( <div className={cn( "relative h-full min-h-[32rem] overflow-hidden rounded-sm border border-border bg-background", className, )} {...props} > <CanvasInteractionLayer containerRef={interactionRef} instructionsId={instructionsId} isDragging={pointer.isDragging} isSpacePressed={keyboard.isSpacePressed} onBlur={keyboard.handleBlur} onKeyDown={keyboard.handleKeyDown} onKeyUp={keyboard.handleKeyUp} onPointerCancel={pointer.handlePointerCancel} onPointerDown={pointer.handlePointerDown} onPointerMove={pointer.handlePointerMove} onPointerUp={pointer.handlePointerUp} viewport={viewportState.viewport} > <CanvasContentLayer isDragging={pointer.isDragging} overlay={overlay} viewport={viewportState.viewport} > {children} </CanvasContentLayer> </CanvasInteractionLayer> </div> ); }; CanvasView.displayName = "CanvasView"; export { CanvasView };

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0