import { useRef, useState, useEffect } from 'react'; import { useCanvasStore } from '@/lib/store/canvas'; import { useSceneSelector } from '@/lib/contexts/scene-context'; import { useKeyboardStore } from '@/lib/store/keyboard'; import { useViewportSize } from './hooks/useViewportSize'; import { useSelectElement } from './hooks/useSelectElement'; import { useDragElement } from './hooks/useDragElement'; import { useRotateElement } from './hooks/useRotateElement'; import { useMouseSelection } from './hooks/useMouseSelection'; import { useScaleElement } from './hooks/useScaleElement'; import { useDragLineElement } from './hooks/useDragLineElement'; import { useMoveShapeKeypoint } from './hooks/useMoveShapeKeypoint'; import { useInsertFromCreateSelection } from './hooks/useInsertFromCreateSelection'; import { useDrop } from './hooks/useDrop'; import { AlignmentLine } from './AlignmentLine'; import { MouseSelection } from './MouseSelection'; import { ViewportBackground } from './ViewportBackground'; import { EditableElement } from './EditableElement'; import { Operate } from './Operate'; import { MultiSelectOperate } from './Operate/MultiSelectOperate'; import { ElementCreateSelection } from './ElementCreateSelection'; import { ShapeCreateCanvas } from './ShapeCreateCanvas'; import { Ruler } from './Ruler'; import { GridLines } from './GridLines'; import type { PPTElement } from '@/lib/types/slides'; import type { AlignmentLineProps } from '@/lib/types/edit'; import type { ContextmenuItem } from './EditableElement'; import type { SlideContent } from '@/lib/types/stage'; import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations'; import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent, ContextMenuShortcut, ContextMenuItem, } from '@/components/ui/context-menu'; export interface CanvasProps { editable?: boolean; } /** * Canvas component * * Architecture: * - Slide data (elements, background) → Scene Context (from stageStore) * - Local element list → useRef + useState (for drag/scale/rotate operations) * - Canvas UI state (selection, toolbar) → Canvas Store * - Keyboard state → Keyboard Store * * Usage: * * * */ export function Canvas(_props: CanvasProps) { const canvasRef = useRef(null); const viewportRef = useRef(null); // Subscribe to specific parts for performance optimization const elements = useSceneSelector( (content) => content.canvas.elements, ); // Canvas UI state const canvasScale = useCanvasStore.use.canvasScale(); const activeElementIdList = useCanvasStore.use.activeElementIdList(); const activeGroupElementId = useCanvasStore.use.activeGroupElementId(); const handleElementId = useCanvasStore.use.handleElementId(); const hiddenElementIdList = useCanvasStore.use.hiddenElementIdList(); const creatingElement = useCanvasStore.use.creatingElement(); const creatingCustomShape = useCanvasStore.use.creatingCustomShape(); const showRuler = useCanvasStore.use.showRuler(); const gridLineSize = useCanvasStore.use.gridLineSize(); const setActiveElementIdList = useCanvasStore.use.setActiveElementIdList(); const setGridLineSize = useCanvasStore.use.setGridLineSize(); const setRulerState = useCanvasStore.use.setRulerState(); // Keyboard state const spaceKeyState = useKeyboardStore((state) => state.spaceKeyState); const [alignmentLines, setAlignmentLines] = useState([]); const [linkDialogVisible, setLinkDialogVisible] = useState(false); // Local element list for drag/scale/rotate operations const elementListRef = useRef(elements || []); const [elementList, setElementList] = useState(elements || []); // Sync store elements to local state useEffect(() => { const newElements = elements ? JSON.parse(JSON.stringify(elements)) : []; elementListRef.current = newElements; // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync store elements to local state setElementList(newElements); }, [elements]); // Viewport size and positioning const { viewportStyles, dragViewport } = useViewportSize(canvasRef); // Initialize drop handler useDrop(canvasRef); // Element drag (with alignment snapping) const { dragElement } = useDragElement(elementListRef, setElementList, setAlignmentLines); // Element selection const { selectElement } = useSelectElement(elementListRef, dragElement); // Mouse selection const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } = useMouseSelection(elementListRef, viewportRef); // Element operations const { scaleElement, scaleMultiElement } = useScaleElement( elementListRef, setElementList, setAlignmentLines, ); const { rotateElement } = useRotateElement( elementListRef, setElementList, viewportRef, canvasScale, ); const { dragLineElement } = useDragLineElement(elementListRef, setElementList); const { moveShapeKeypoint } = useMoveShapeKeypoint(elementListRef, setElementList, canvasScale); // Create element from selection const { insertElementFromCreateSelection } = useInsertFromCreateSelection(viewportRef); // Click on blank canvas area: clear active elements const handleClickBlankArea = (e: React.MouseEvent) => { // Check if the click target is a context menu element (menu content in Portal) const target = e.target as HTMLElement; if ( target.closest('[data-slot="context-menu-content"]') || target.closest('[data-slot="context-menu-sub-content"]') || target.closest('[data-slot="context-menu-item"]') || target.closest('[data-slot="context-menu-sub-trigger"]') ) { return; // Skip blank area handling if clicking on context menu } if (activeElementIdList.length) { setActiveElementIdList([]); } if (!spaceKeyState) { updateMouseSelection(e); } else { dragViewport(e); } }; // Double-click blank area to insert text const handleDblClick = (_e: React.MouseEvent) => { if (activeElementIdList.length || creatingElement || creatingCustomShape) return; if (!viewportRef.current) return; const _viewportRect = viewportRef.current.getBoundingClientRect(); // TODO: implement createTextElement (use _viewportRect + e.pageX/Y + canvasScale) }; const openLinkDialog = () => { setLinkDialogVisible(true); }; const { pasteElement, selectAllElements, deleteAllElements } = useCanvasOperations(); const contextmenus = (): ContextmenuItem[] => { return [ { text: '粘贴', subText: 'Ctrl + V', handler: pasteElement, }, { text: '全选', subText: 'Ctrl + A', handler: selectAllElements, }, { text: '标尺', subText: showRuler ? '√' : '', handler: () => setRulerState(!showRuler), }, { text: '网格线', handler: () => setGridLineSize(gridLineSize ? 0 : 50), children: [ { text: '无', subText: gridLineSize === 0 ? '√' : '', handler: () => setGridLineSize(0), }, { text: '小', subText: gridLineSize === 25 ? '√' : '', handler: () => setGridLineSize(25), }, { text: '中', subText: gridLineSize === 50 ? '√' : '', handler: () => setGridLineSize(50), }, { text: '大', subText: gridLineSize === 100 ? '√' : '', handler: () => setGridLineSize(100), }, ], }, { text: '重置当前页', handler: deleteAllElements, }, ]; }; return (
{/* Element creation selection */} {creatingElement && ( )} {/* Custom shape creation canvas */} {creatingCustomShape && ( { // TODO: implement insertCustomShape }} /> )} {/* Viewport wrapper */}
{/* Operations layer - alignment lines and selection handles */}
{/* Alignment lines */} {alignmentLines.map((line, index) => ( ))} {/* Multi-select operations */} {activeElementIdList.length > 1 && ( )} {/* Single element operations */} {elementList.map( (element: PPTElement) => !hiddenElementIdList.includes(element.id) && ( 1} rotateElement={rotateElement} scaleElement={scaleElement} dragLineElement={dragLineElement} moveShapeKeypoint={moveShapeKeypoint} openLinkDialog={openLinkDialog} /> ), )}
{/* Viewport - the actual slide canvas */}
{/* Grid lines */} {gridLineSize > 0 && } {/* Mouse selection rectangle */} {mouseSelectionVisible && ( )} {/* Render all elements */} {elementList.map((element: PPTElement, index: number) => !hiddenElementIdList.includes(element.id) ? ( 1} selectElement={selectElement} openLinkDialog={openLinkDialog} /> ) : null, )}
{/* Ruler */} {showRuler && } {/* Drag mask when space key is pressed */} {spaceKeyState &&
} {/* TODO: Add LinkDialog modal */} {linkDialogVisible &&
LinkDialog placeholder
}
{contextmenus().map((item, index) => { if (item.divider) { return ; } // If has children, use submenu component if (item.children && item.children.length > 0) { return ( {item.children.map((child, childIndex) => child.divider ? ( ) : ( { e.stopPropagation(); child.handler?.(); }} disabled={child.disable} hidden={child.hide} > {child.text} {child.subText && ( {child.subText} )} ), )} ); } // Regular menu item return ( { e.stopPropagation(); item.handler?.(); }} disabled={item.disable} hidden={item.hide} > {item.text} {item.subText && {item.subText}} ); })} ); } export default Canvas;