index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. 'use client';
  2. import { useRef, useState, useEffect } from 'react';
  3. import { useCanvasStore } from '@/lib/store/canvas';
  4. import { useSceneSelector } from '@/lib/contexts/scene-context';
  5. import { useKeyboardStore } from '@/lib/store/keyboard';
  6. import { useViewportSize } from './hooks/useViewportSize';
  7. import { useSelectElement } from './hooks/useSelectElement';
  8. import { useDragElement } from './hooks/useDragElement';
  9. import { useRotateElement } from './hooks/useRotateElement';
  10. import { useMouseSelection } from './hooks/useMouseSelection';
  11. import { useScaleElement } from './hooks/useScaleElement';
  12. import { useDragLineElement } from './hooks/useDragLineElement';
  13. import { useMoveShapeKeypoint } from './hooks/useMoveShapeKeypoint';
  14. import { useInsertFromCreateSelection } from './hooks/useInsertFromCreateSelection';
  15. import { useDrop } from './hooks/useDrop';
  16. import { AlignmentLine } from './AlignmentLine';
  17. import { MouseSelection } from './MouseSelection';
  18. import { ViewportBackground } from './ViewportBackground';
  19. import { EditableElement } from './EditableElement';
  20. import { Operate } from './Operate';
  21. import { MultiSelectOperate } from './Operate/MultiSelectOperate';
  22. import { ElementCreateSelection } from './ElementCreateSelection';
  23. import { ShapeCreateCanvas } from './ShapeCreateCanvas';
  24. import { Ruler } from './Ruler';
  25. import { GridLines } from './GridLines';
  26. import type { PPTElement } from '@/lib/types/slides';
  27. import type { AlignmentLineProps } from '@/lib/types/edit';
  28. import type { ContextmenuItem } from './EditableElement';
  29. import type { SlideContent } from '@/lib/types/stage';
  30. import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
  31. import {
  32. ContextMenu,
  33. ContextMenuTrigger,
  34. ContextMenuContent,
  35. ContextMenuSeparator,
  36. ContextMenuSub,
  37. ContextMenuSubTrigger,
  38. ContextMenuSubContent,
  39. ContextMenuShortcut,
  40. ContextMenuItem,
  41. } from '@/components/ui/context-menu';
  42. export interface CanvasProps {
  43. editable?: boolean;
  44. }
  45. /**
  46. * Canvas component
  47. *
  48. * Architecture:
  49. * - Slide data (elements, background) → Scene Context (from stageStore)
  50. * - Local element list → useRef + useState (for drag/scale/rotate operations)
  51. * - Canvas UI state (selection, toolbar) → Canvas Store
  52. * - Keyboard state → Keyboard Store
  53. *
  54. * Usage:
  55. * <SceneProvider>
  56. * <Canvas />
  57. * </SceneProvider>
  58. */
  59. export function Canvas(_props: CanvasProps) {
  60. const canvasRef = useRef<HTMLDivElement>(null);
  61. const viewportRef = useRef<HTMLDivElement>(null);
  62. // Subscribe to specific parts for performance optimization
  63. const elements = useSceneSelector<SlideContent, PPTElement[]>(
  64. (content) => content.canvas.elements,
  65. );
  66. // Canvas UI state
  67. const canvasScale = useCanvasStore.use.canvasScale();
  68. const activeElementIdList = useCanvasStore.use.activeElementIdList();
  69. const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
  70. const handleElementId = useCanvasStore.use.handleElementId();
  71. const hiddenElementIdList = useCanvasStore.use.hiddenElementIdList();
  72. const creatingElement = useCanvasStore.use.creatingElement();
  73. const creatingCustomShape = useCanvasStore.use.creatingCustomShape();
  74. const showRuler = useCanvasStore.use.showRuler();
  75. const gridLineSize = useCanvasStore.use.gridLineSize();
  76. const setActiveElementIdList = useCanvasStore.use.setActiveElementIdList();
  77. const setGridLineSize = useCanvasStore.use.setGridLineSize();
  78. const setRulerState = useCanvasStore.use.setRulerState();
  79. // Keyboard state
  80. const spaceKeyState = useKeyboardStore((state) => state.spaceKeyState);
  81. const [alignmentLines, setAlignmentLines] = useState<AlignmentLineProps[]>([]);
  82. const [linkDialogVisible, setLinkDialogVisible] = useState(false);
  83. // Local element list for drag/scale/rotate operations
  84. const elementListRef = useRef<PPTElement[]>(elements || []);
  85. const [elementList, setElementList] = useState<PPTElement[]>(elements || []);
  86. // Sync store elements to local state
  87. useEffect(() => {
  88. const newElements = elements ? JSON.parse(JSON.stringify(elements)) : [];
  89. elementListRef.current = newElements;
  90. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync store elements to local state
  91. setElementList(newElements);
  92. }, [elements]);
  93. // Viewport size and positioning
  94. const { viewportStyles, dragViewport } = useViewportSize(canvasRef);
  95. // Initialize drop handler
  96. useDrop(canvasRef);
  97. // Element drag (with alignment snapping)
  98. const { dragElement } = useDragElement(elementListRef, setElementList, setAlignmentLines);
  99. // Element selection
  100. const { selectElement } = useSelectElement(elementListRef, dragElement);
  101. // Mouse selection
  102. const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } =
  103. useMouseSelection(elementListRef, viewportRef);
  104. // Element operations
  105. const { scaleElement, scaleMultiElement } = useScaleElement(
  106. elementListRef,
  107. setElementList,
  108. setAlignmentLines,
  109. );
  110. const { rotateElement } = useRotateElement(
  111. elementListRef,
  112. setElementList,
  113. viewportRef,
  114. canvasScale,
  115. );
  116. const { dragLineElement } = useDragLineElement(elementListRef, setElementList);
  117. const { moveShapeKeypoint } = useMoveShapeKeypoint(elementListRef, setElementList, canvasScale);
  118. // Create element from selection
  119. const { insertElementFromCreateSelection } = useInsertFromCreateSelection(viewportRef);
  120. // Click on blank canvas area: clear active elements
  121. const handleClickBlankArea = (e: React.MouseEvent) => {
  122. // Check if the click target is a context menu element (menu content in Portal)
  123. const target = e.target as HTMLElement;
  124. if (
  125. target.closest('[data-slot="context-menu-content"]') ||
  126. target.closest('[data-slot="context-menu-sub-content"]') ||
  127. target.closest('[data-slot="context-menu-item"]') ||
  128. target.closest('[data-slot="context-menu-sub-trigger"]')
  129. ) {
  130. return; // Skip blank area handling if clicking on context menu
  131. }
  132. if (activeElementIdList.length) {
  133. setActiveElementIdList([]);
  134. }
  135. if (!spaceKeyState) {
  136. updateMouseSelection(e);
  137. } else {
  138. dragViewport(e);
  139. }
  140. };
  141. // Double-click blank area to insert text
  142. const handleDblClick = (_e: React.MouseEvent) => {
  143. if (activeElementIdList.length || creatingElement || creatingCustomShape) return;
  144. if (!viewportRef.current) return;
  145. const _viewportRect = viewportRef.current.getBoundingClientRect();
  146. // TODO: implement createTextElement (use _viewportRect + e.pageX/Y + canvasScale)
  147. };
  148. const openLinkDialog = () => {
  149. setLinkDialogVisible(true);
  150. };
  151. const { pasteElement, selectAllElements, deleteAllElements } = useCanvasOperations();
  152. const contextmenus = (): ContextmenuItem[] => {
  153. return [
  154. {
  155. text: '粘贴',
  156. subText: 'Ctrl + V',
  157. handler: pasteElement,
  158. },
  159. {
  160. text: '全选',
  161. subText: 'Ctrl + A',
  162. handler: selectAllElements,
  163. },
  164. {
  165. text: '标尺',
  166. subText: showRuler ? '√' : '',
  167. handler: () => setRulerState(!showRuler),
  168. },
  169. {
  170. text: '网格线',
  171. handler: () => setGridLineSize(gridLineSize ? 0 : 50),
  172. children: [
  173. {
  174. text: '无',
  175. subText: gridLineSize === 0 ? '√' : '',
  176. handler: () => setGridLineSize(0),
  177. },
  178. {
  179. text: '小',
  180. subText: gridLineSize === 25 ? '√' : '',
  181. handler: () => setGridLineSize(25),
  182. },
  183. {
  184. text: '中',
  185. subText: gridLineSize === 50 ? '√' : '',
  186. handler: () => setGridLineSize(50),
  187. },
  188. {
  189. text: '大',
  190. subText: gridLineSize === 100 ? '√' : '',
  191. handler: () => setGridLineSize(100),
  192. },
  193. ],
  194. },
  195. {
  196. text: '重置当前页',
  197. handler: deleteAllElements,
  198. },
  199. ];
  200. };
  201. return (
  202. <ContextMenu>
  203. <ContextMenuTrigger>
  204. <div
  205. className="canvas relative h-full w-full overflow-hidden bg-gray-100 select-none"
  206. ref={canvasRef}
  207. onMouseDown={handleClickBlankArea}
  208. onDoubleClick={handleDblClick}
  209. >
  210. {/* Element creation selection */}
  211. {creatingElement && (
  212. <ElementCreateSelection onCreated={insertElementFromCreateSelection} />
  213. )}
  214. {/* Custom shape creation canvas */}
  215. {creatingCustomShape && (
  216. <ShapeCreateCanvas
  217. onCreated={(_data) => {
  218. // TODO: implement insertCustomShape
  219. }}
  220. />
  221. )}
  222. {/* Viewport wrapper */}
  223. <div
  224. 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)]"
  225. style={{
  226. width: `${viewportStyles.width * canvasScale}px`,
  227. height: `${viewportStyles.height * canvasScale}px`,
  228. left: `${viewportStyles.left}px`,
  229. top: `${viewportStyles.top}px`,
  230. }}
  231. >
  232. {/* Operations layer - alignment lines and selection handles */}
  233. <div className="operates absolute top-0 left-0 w-full h-full pointer-events-none">
  234. {/* Alignment lines */}
  235. {alignmentLines.map((line, index) => (
  236. <AlignmentLine
  237. key={`${line.type}-${line.axis.x}-${line.axis.y}-${index}`}
  238. type={line.type}
  239. axis={line.axis}
  240. length={line.length}
  241. canvasScale={canvasScale}
  242. />
  243. ))}
  244. {/* Multi-select operations */}
  245. {activeElementIdList.length > 1 && (
  246. <MultiSelectOperate
  247. elementList={elementList}
  248. scaleMultiElement={scaleMultiElement}
  249. />
  250. )}
  251. {/* Single element operations */}
  252. {elementList.map(
  253. (element: PPTElement) =>
  254. !hiddenElementIdList.includes(element.id) && (
  255. <Operate
  256. key={element.id}
  257. elementInfo={element}
  258. isSelected={activeElementIdList.includes(element.id)}
  259. isActive={handleElementId === element.id}
  260. isActiveGroupElement={activeGroupElementId === element.id}
  261. isMultiSelect={activeElementIdList.length > 1}
  262. rotateElement={rotateElement}
  263. scaleElement={scaleElement}
  264. dragLineElement={dragLineElement}
  265. moveShapeKeypoint={moveShapeKeypoint}
  266. openLinkDialog={openLinkDialog}
  267. />
  268. ),
  269. )}
  270. <ViewportBackground />
  271. </div>
  272. {/* Viewport - the actual slide canvas */}
  273. <div
  274. ref={viewportRef}
  275. className="viewport absolute top-0 left-0 origin-top-left"
  276. style={{
  277. width: `${viewportStyles.width}px`,
  278. height: `${viewportStyles.height}px`,
  279. transform: `scale(${canvasScale})`,
  280. }}
  281. >
  282. {/* Grid lines */}
  283. {gridLineSize > 0 && <GridLines />}
  284. {/* Mouse selection rectangle */}
  285. {mouseSelectionVisible && (
  286. <MouseSelection
  287. top={mouseSelection.top}
  288. left={mouseSelection.left}
  289. width={mouseSelection.width}
  290. height={mouseSelection.height}
  291. quadrant={mouseSelectionQuadrant}
  292. canvasScale={canvasScale}
  293. />
  294. )}
  295. {/* Render all elements */}
  296. {elementList.map((element: PPTElement, index: number) =>
  297. !hiddenElementIdList.includes(element.id) ? (
  298. <EditableElement
  299. key={element.id}
  300. elementInfo={element}
  301. elementIndex={index + 1}
  302. isMultiSelect={activeElementIdList.length > 1}
  303. selectElement={selectElement}
  304. openLinkDialog={openLinkDialog}
  305. />
  306. ) : null,
  307. )}
  308. </div>
  309. </div>
  310. {/* Ruler */}
  311. {showRuler && <Ruler viewportStyles={viewportStyles} elementList={elementList} />}
  312. {/* Drag mask when space key is pressed */}
  313. {spaceKeyState && <div className="drag-mask absolute inset-0 cursor-grab" />}
  314. {/* TODO: Add LinkDialog modal */}
  315. {linkDialogVisible && <div>LinkDialog placeholder</div>}
  316. </div>
  317. </ContextMenuTrigger>
  318. <ContextMenuContent>
  319. {contextmenus().map((item, index) => {
  320. if (item.divider) {
  321. return <ContextMenuSeparator key={index} />;
  322. }
  323. // If has children, use submenu component
  324. if (item.children && item.children.length > 0) {
  325. return (
  326. <ContextMenuSub key={index}>
  327. <ContextMenuSubTrigger disabled={item.disable} hidden={item.hide}>
  328. {item.text}
  329. {item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>}
  330. </ContextMenuSubTrigger>
  331. <ContextMenuSubContent>
  332. {item.children.map((child, childIndex) =>
  333. child.divider ? (
  334. <ContextMenuSeparator key={childIndex} />
  335. ) : (
  336. <ContextMenuItem
  337. key={childIndex}
  338. onClick={(e) => {
  339. e.stopPropagation();
  340. child.handler?.();
  341. }}
  342. disabled={child.disable}
  343. hidden={child.hide}
  344. >
  345. {child.text}
  346. {child.subText && (
  347. <ContextMenuShortcut>{child.subText}</ContextMenuShortcut>
  348. )}
  349. </ContextMenuItem>
  350. ),
  351. )}
  352. </ContextMenuSubContent>
  353. </ContextMenuSub>
  354. );
  355. }
  356. // Regular menu item
  357. return (
  358. <ContextMenuItem
  359. key={index}
  360. onClick={(e) => {
  361. e.stopPropagation();
  362. item.handler?.();
  363. }}
  364. disabled={item.disable}
  365. hidden={item.hide}
  366. >
  367. {item.text}
  368. {item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>}
  369. </ContextMenuItem>
  370. );
  371. })}
  372. </ContextMenuContent>
  373. </ContextMenu>
  374. );
  375. }
  376. export default Canvas;