|
|
|
|
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function Canvas(_props: CanvasProps) { |
| const canvasRef = useRef<HTMLDivElement>(null); |
| const viewportRef = useRef<HTMLDivElement>(null); |
|
|
| |
| const elements = useSceneSelector<SlideContent, PPTElement[]>( |
| (content) => content.canvas.elements, |
| ); |
|
|
| |
| 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(); |
|
|
| |
| const spaceKeyState = useKeyboardStore((state) => state.spaceKeyState); |
|
|
| const [alignmentLines, setAlignmentLines] = useState<AlignmentLineProps[]>([]); |
| const [linkDialogVisible, setLinkDialogVisible] = useState(false); |
|
|
| |
| const elementListRef = useRef<PPTElement[]>(elements || []); |
| const [elementList, setElementList] = useState<PPTElement[]>(elements || []); |
|
|
| |
| useEffect(() => { |
| const newElements = elements ? JSON.parse(JSON.stringify(elements)) : []; |
| elementListRef.current = newElements; |
| |
| setElementList(newElements); |
| }, [elements]); |
|
|
| |
| const { viewportStyles, dragViewport } = useViewportSize(canvasRef); |
|
|
| |
| useDrop(canvasRef); |
|
|
| |
| const { dragElement } = useDragElement(elementListRef, setElementList, setAlignmentLines); |
|
|
| |
| const { selectElement } = useSelectElement(elementListRef, dragElement); |
|
|
| |
| const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } = |
| useMouseSelection(elementListRef, viewportRef); |
|
|
| |
| 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); |
|
|
| |
| const { insertElementFromCreateSelection } = useInsertFromCreateSelection(viewportRef); |
|
|
| |
| const handleClickBlankArea = (e: React.MouseEvent) => { |
| |
| 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; |
| } |
|
|
| if (activeElementIdList.length) { |
| setActiveElementIdList([]); |
| } |
|
|
| if (!spaceKeyState) { |
| updateMouseSelection(e); |
| } else { |
| dragViewport(e); |
| } |
| }; |
|
|
| |
| const handleDblClick = (_e: React.MouseEvent) => { |
| if (activeElementIdList.length || creatingElement || creatingCustomShape) return; |
| if (!viewportRef.current) return; |
|
|
| const _viewportRect = viewportRef.current.getBoundingClientRect(); |
| |
| }; |
|
|
| 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 ( |
| <ContextMenu> |
| <ContextMenuTrigger> |
| <div |
| className="canvas relative h-full w-full overflow-hidden bg-gray-100 select-none" |
| ref={canvasRef} |
| onMouseDown={handleClickBlankArea} |
| onDoubleClick={handleDblClick} |
| > |
| {/* Element creation selection */} |
| {creatingElement && ( |
| <ElementCreateSelection onCreated={insertElementFromCreateSelection} /> |
| )} |
| |
| {/* Custom shape creation canvas */} |
| {creatingCustomShape && ( |
| <ShapeCreateCanvas |
| onCreated={(_data) => { |
| // TODO: implement insertCustomShape |
| }} |
| /> |
| )} |
| |
| {/* Viewport wrapper */} |
| <div |
| className="viewport-wrapper absolute shadow-[0_0_0_1px_rgba(0,0,0,0.01),0_0_12px_0_rgba(0,0,0,0.1)]" |
| style={{ |
| width: `${viewportStyles.width * canvasScale}px`, |
| height: `${viewportStyles.height * canvasScale}px`, |
| left: `${viewportStyles.left}px`, |
| top: `${viewportStyles.top}px`, |
| }} |
| > |
| {/* Operations layer - alignment lines and selection handles */} |
| <div className="operates absolute top-0 left-0 w-full h-full pointer-events-none"> |
| {/* Alignment lines */} |
| {alignmentLines.map((line, index) => ( |
| <AlignmentLine |
| key={`${line.type}-${line.axis.x}-${line.axis.y}-${index}`} |
| type={line.type} |
| axis={line.axis} |
| length={line.length} |
| canvasScale={canvasScale} |
| /> |
| ))} |
| |
| {/* Multi-select operations */} |
| {activeElementIdList.length > 1 && ( |
| <MultiSelectOperate |
| elementList={elementList} |
| scaleMultiElement={scaleMultiElement} |
| /> |
| )} |
| |
| {/* Single element operations */} |
| {elementList.map( |
| (element: PPTElement) => |
| !hiddenElementIdList.includes(element.id) && ( |
| <Operate |
| key={element.id} |
| elementInfo={element} |
| isSelected={activeElementIdList.includes(element.id)} |
| isActive={handleElementId === element.id} |
| isActiveGroupElement={activeGroupElementId === element.id} |
| isMultiSelect={activeElementIdList.length > 1} |
| rotateElement={rotateElement} |
| scaleElement={scaleElement} |
| dragLineElement={dragLineElement} |
| moveShapeKeypoint={moveShapeKeypoint} |
| openLinkDialog={openLinkDialog} |
| /> |
| ), |
| )} |
| |
| <ViewportBackground /> |
| </div> |
| |
| {/* Viewport - the actual slide canvas */} |
| <div |
| ref={viewportRef} |
| className="viewport absolute top-0 left-0 origin-top-left" |
| style={{ |
| width: `${viewportStyles.width}px`, |
| height: `${viewportStyles.height}px`, |
| transform: `scale(${canvasScale})`, |
| }} |
| > |
| {/* Grid lines */} |
| {gridLineSize > 0 && <GridLines />} |
| |
| {/* Mouse selection rectangle */} |
| {mouseSelectionVisible && ( |
| <MouseSelection |
| top={mouseSelection.top} |
| left={mouseSelection.left} |
| width={mouseSelection.width} |
| height={mouseSelection.height} |
| quadrant={mouseSelectionQuadrant} |
| canvasScale={canvasScale} |
| /> |
| )} |
| |
| {/* Render all elements */} |
| {elementList.map((element: PPTElement, index: number) => |
| !hiddenElementIdList.includes(element.id) ? ( |
| <EditableElement |
| key={element.id} |
| elementInfo={element} |
| elementIndex={index + 1} |
| isMultiSelect={activeElementIdList.length > 1} |
| selectElement={selectElement} |
| openLinkDialog={openLinkDialog} |
| /> |
| ) : null, |
| )} |
| </div> |
| </div> |
| |
| {/* Ruler */} |
| {showRuler && <Ruler viewportStyles={viewportStyles} elementList={elementList} />} |
| |
| {/* Drag mask when space key is pressed */} |
| {spaceKeyState && <div className="drag-mask absolute inset-0 cursor-grab" />} |
| |
| {/* TODO: Add LinkDialog modal */} |
| {linkDialogVisible && <div>LinkDialog placeholder</div>} |
| </div> |
| </ContextMenuTrigger> |
| <ContextMenuContent> |
| {contextmenus().map((item, index) => { |
| if (item.divider) { |
| return <ContextMenuSeparator key={index} />; |
| } |
| |
| // If has children, use submenu component |
| if (item.children && item.children.length > 0) { |
| return ( |
| <ContextMenuSub key={index}> |
| <ContextMenuSubTrigger disabled={item.disable} hidden={item.hide}> |
| {item.text} |
| {item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>} |
| </ContextMenuSubTrigger> |
| <ContextMenuSubContent> |
| {item.children.map((child, childIndex) => |
| child.divider ? ( |
| <ContextMenuSeparator key={childIndex} /> |
| ) : ( |
| <ContextMenuItem |
| key={childIndex} |
| onClick={(e) => { |
| e.stopPropagation(); |
| child.handler?.(); |
| }} |
| disabled={child.disable} |
| hidden={child.hide} |
| > |
| {child.text} |
| {child.subText && ( |
| <ContextMenuShortcut>{child.subText}</ContextMenuShortcut> |
| )} |
| </ContextMenuItem> |
| ), |
| )} |
| </ContextMenuSubContent> |
| </ContextMenuSub> |
| ); |
| } |
| |
| // Regular menu item |
| return ( |
| <ContextMenuItem |
| key={index} |
| onClick={(e) => { |
| e.stopPropagation(); |
| item.handler?.(); |
| }} |
| disabled={item.disable} |
| hidden={item.hide} |
| > |
| {item.text} |
| {item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>} |
| </ContextMenuItem> |
| ); |
| })} |
| </ContextMenuContent> |
| </ContextMenu> |
| ); |
| } |
|
|
| export default Canvas; |
|
|