| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- 'use client';
- 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:
- * <SceneProvider>
- * <Canvas />
- * </SceneProvider>
- */
- export function Canvas(_props: CanvasProps) {
- const canvasRef = useRef<HTMLDivElement>(null);
- const viewportRef = useRef<HTMLDivElement>(null);
- // Subscribe to specific parts for performance optimization
- const elements = useSceneSelector<SlideContent, PPTElement[]>(
- (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<AlignmentLineProps[]>([]);
- const [linkDialogVisible, setLinkDialogVisible] = useState(false);
- // Local element list for drag/scale/rotate operations
- const elementListRef = useRef<PPTElement[]>(elements || []);
- const [elementList, setElementList] = useState<PPTElement[]>(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 (
- <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;
|