use-canvas-operations.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. /**
  2. * Canvas Element Operations Hook
  3. *
  4. * Provides convenient element CRUD methods to avoid repetitive definitions in each component
  5. *
  6. * @example
  7. * function MyComponent() {
  8. * const { addElement, updateElement, deleteElement } = useCanvasOperations();
  9. *
  10. * const handleAdd = () => {
  11. * addElement({
  12. * id: 'new-1',
  13. * type: 'text',
  14. * // ...
  15. * });
  16. * };
  17. * }
  18. */
  19. import { useSceneData, useSceneSelector } from '@/lib/contexts/scene-context';
  20. import {
  21. useCanvasStore,
  22. type SpotlightOptions,
  23. type HighlightOverlayOptions,
  24. } from '@/lib/store/canvas';
  25. import type { SlideContent } from '@/lib/types/stage';
  26. import type { PPTElement, Slide } from '@/lib/types/slides';
  27. import { useCallback, useMemo } from 'react';
  28. import { useHistorySnapshot } from '@/lib/hooks/use-history-snapshot';
  29. import { toast } from 'sonner';
  30. import { ElementAlignCommands, ElementOrderCommands } from '@/lib/types/edit';
  31. import { getElementListRange } from '@/lib/utils/element';
  32. import { useOrderElement } from './use-order-element';
  33. import { nanoid } from 'nanoid';
  34. type PPTElementKey = keyof PPTElement;
  35. interface RemovePropData {
  36. id: string;
  37. propName: PPTElementKey | PPTElementKey[];
  38. }
  39. interface UpdateElementData {
  40. id: string | string[];
  41. props: Partial<PPTElement>;
  42. slideId?: string;
  43. }
  44. export function useCanvasOperations() {
  45. const { updateSceneData } = useSceneData<SlideContent>();
  46. const currentSlide = useSceneSelector<SlideContent, Slide>((content) => content.canvas);
  47. const activeElementIdList = useCanvasStore.use.activeElementIdList();
  48. const activeElementList = useMemo(
  49. () => currentSlide.elements.filter((el) => activeElementIdList.includes(el.id)),
  50. [currentSlide.elements, activeElementIdList],
  51. );
  52. const activeGroupElementId = useCanvasStore.use.activeGroupElementId();
  53. const setActiveElementIdList = useCanvasStore.use.setActiveElementIdList();
  54. const handleElementId = useCanvasStore.use.handleElementId();
  55. const hiddenElementIdList = useCanvasStore.use.hiddenElementIdList();
  56. const viewportSize = useCanvasStore.use.viewportSize();
  57. const viewportRatio = useCanvasStore.use.viewportRatio();
  58. const _setEditorareaFocus = useCanvasStore.use.setEditorAreaFocus();
  59. const { addHistorySnapshot } = useHistorySnapshot();
  60. const { moveUpElement, moveDownElement, moveTopElement, moveBottomElement } = useOrderElement();
  61. /**
  62. * Add element(s)
  63. * @param element Single element or element array
  64. * @param autoSelect Whether to auto-select newly added elements (default true)
  65. */
  66. const addElement = useCallback(
  67. (element: PPTElement | PPTElement[], autoSelect = true) => {
  68. const elements = Array.isArray(element) ? element : [element];
  69. updateSceneData((draft) => {
  70. draft.canvas.elements.push(...elements);
  71. });
  72. // Auto-select newly added elements
  73. if (autoSelect) {
  74. const newIds = elements.map((el) => el.id);
  75. setActiveElementIdList(newIds);
  76. }
  77. },
  78. [updateSceneData, setActiveElementIdList],
  79. );
  80. // Delete all selected elements
  81. // If a group member is selected for independent operation, delete that element first. Otherwise delete all selected elements.
  82. // If elementId is provided, only delete that element
  83. const deleteElement = (elementId?: string) => {
  84. let newElementList: PPTElement[] = [];
  85. if (elementId) {
  86. // Delete specified element
  87. newElementList = currentSlide.elements.filter((el) => el.id !== elementId);
  88. setActiveElementIdList(activeElementIdList.filter((id) => id !== elementId));
  89. } else {
  90. // Original logic: delete selected elements
  91. if (!activeElementIdList.length) return;
  92. if (activeGroupElementId) {
  93. newElementList = currentSlide.elements.filter((el) => el.id !== activeGroupElementId);
  94. } else {
  95. newElementList = currentSlide.elements.filter((el) => !activeElementIdList.includes(el.id));
  96. }
  97. setActiveElementIdList([]);
  98. }
  99. updateSlide({ elements: newElementList });
  100. addHistorySnapshot();
  101. };
  102. // Delete all elements on the page (regardless of selection)
  103. const deleteAllElements = () => {
  104. if (!currentSlide.elements.length) return;
  105. setActiveElementIdList([]);
  106. updateSlide({ elements: [] });
  107. addHistorySnapshot();
  108. };
  109. /**
  110. * Update element properties
  111. * @param props Properties to update
  112. */
  113. const updateElement = useCallback(
  114. (data: UpdateElementData) => {
  115. const { id, props } = data;
  116. const elementIds = Array.isArray(id) ? id : [id];
  117. updateSceneData((draft) => {
  118. draft.canvas.elements.forEach((el) => {
  119. if (elementIds.includes(el.id)) {
  120. Object.assign(el, props);
  121. }
  122. });
  123. });
  124. },
  125. [updateSceneData],
  126. );
  127. /**
  128. * Update slide content
  129. */
  130. const updateSlide = useCallback(
  131. (props: Partial<Slide>) => {
  132. updateSceneData((draft) => {
  133. Object.assign(draft.canvas, props);
  134. });
  135. },
  136. [updateSceneData],
  137. );
  138. /**
  139. * Remove element properties
  140. */
  141. const removeElementProps = useCallback(
  142. (data: RemovePropData) => {
  143. const { id, propName } = data;
  144. const elementIds = Array.isArray(id) ? id : [id];
  145. const propNames = Array.isArray(propName) ? propName : [propName];
  146. updateSceneData((draft) => {
  147. draft.canvas.elements.forEach((el) => {
  148. if (elementIds.includes(el.id)) {
  149. propNames.forEach((name) => {
  150. delete el[name];
  151. });
  152. }
  153. });
  154. });
  155. },
  156. [updateSceneData],
  157. );
  158. // Copy selected element data to clipboard
  159. const copyElement = () => {
  160. // if (!activeElementIdList.length) return
  161. // const text = JSON.stringify({
  162. // type: 'elements',
  163. // data: activeElementList,
  164. // })
  165. // copyText(text).then(() => {
  166. // setEditorareaFocus(true)
  167. // })
  168. toast.warning('Not implemented');
  169. };
  170. // Copy and delete selected elements (cut)
  171. const cutElement = () => {
  172. // copyElement()
  173. // deleteElement()
  174. toast.warning('Not implemented');
  175. };
  176. // Attempt to paste element data from clipboard
  177. const pasteElement = () => {
  178. // readClipboard().then(text => {
  179. // pasteTextClipboardData(text)
  180. // }).catch(err => toast.warning(err))
  181. toast.warning('Not implemented');
  182. };
  183. // Copy and immediately paste selected elements
  184. const _quickCopyElement = () => {
  185. copyElement();
  186. pasteElement();
  187. };
  188. // Lock selected elements and clear selection state
  189. const lockElement = () => {
  190. const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
  191. for (const element of newElementList) {
  192. if (activeElementIdList.includes(element.id)) element.lock = true;
  193. }
  194. updateSlide({ elements: newElementList });
  195. setActiveElementIdList([]);
  196. addHistorySnapshot();
  197. };
  198. /**
  199. * Unlock an element and set it as the current selection
  200. * @param handleElement The element to unlock
  201. */
  202. const unlockElement = (handleElement: PPTElement) => {
  203. const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
  204. if (handleElement.groupId) {
  205. const groupElementIdList = [];
  206. for (const element of newElementList) {
  207. if (element.groupId === handleElement.groupId) {
  208. element.lock = false;
  209. groupElementIdList.push(element.id);
  210. }
  211. }
  212. updateSlide({ elements: newElementList });
  213. setActiveElementIdList(groupElementIdList);
  214. } else {
  215. for (const element of newElementList) {
  216. if (element.id === handleElement.id) {
  217. element.lock = false;
  218. break;
  219. }
  220. }
  221. updateSlide({ elements: newElementList });
  222. setActiveElementIdList([handleElement.id]);
  223. }
  224. addHistorySnapshot();
  225. };
  226. // Select all elements on the current page
  227. const selectAllElements = () => {
  228. const unlockedElements = currentSlide.elements.filter(
  229. (el) => !el.lock && !hiddenElementIdList.includes(el.id),
  230. );
  231. const newActiveElementIdList = unlockedElements.map((el) => el.id);
  232. setActiveElementIdList(newActiveElementIdList);
  233. };
  234. // Select a specific element
  235. const selectElement = (id: string) => {
  236. if (handleElementId === id) return;
  237. if (hiddenElementIdList.includes(id)) return;
  238. const lockedElements = currentSlide.elements.filter((el) => el.lock);
  239. if (lockedElements.some((el) => el.id === id)) return;
  240. setActiveElementIdList([id]);
  241. };
  242. /**
  243. * Align all selected elements to the canvas
  244. * @param command Alignment direction
  245. */
  246. const alignElementToCanvas = (command: ElementAlignCommands) => {
  247. const viewportWidth = viewportSize;
  248. const viewportHeight = viewportSize * viewportRatio;
  249. const { minX, maxX, minY, maxY } = getElementListRange(activeElementList);
  250. const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
  251. for (const element of newElementList) {
  252. if (!activeElementIdList.includes(element.id)) continue;
  253. // Center horizontally and vertically
  254. if (command === ElementAlignCommands.CENTER) {
  255. const offsetY = minY + (maxY - minY) / 2 - viewportHeight / 2;
  256. const offsetX = minX + (maxX - minX) / 2 - viewportWidth / 2;
  257. element.top = element.top - offsetY;
  258. element.left = element.left - offsetX;
  259. }
  260. // Align to top
  261. if (command === ElementAlignCommands.TOP) {
  262. const offsetY = minY - 0;
  263. element.top = element.top - offsetY;
  264. }
  265. // Center vertically
  266. else if (command === ElementAlignCommands.VERTICAL) {
  267. const offsetY = minY + (maxY - minY) / 2 - viewportHeight / 2;
  268. element.top = element.top - offsetY;
  269. }
  270. // Align to bottom
  271. else if (command === ElementAlignCommands.BOTTOM) {
  272. const offsetY = maxY - viewportHeight;
  273. element.top = element.top - offsetY;
  274. }
  275. // Align to left
  276. else if (command === ElementAlignCommands.LEFT) {
  277. const offsetX = minX - 0;
  278. element.left = element.left - offsetX;
  279. }
  280. // Center horizontally
  281. else if (command === ElementAlignCommands.HORIZONTAL) {
  282. const offsetX = minX + (maxX - minX) / 2 - viewportWidth / 2;
  283. element.left = element.left - offsetX;
  284. }
  285. // Align to right
  286. else if (command === ElementAlignCommands.RIGHT) {
  287. const offsetX = maxX - viewportWidth;
  288. element.left = element.left - offsetX;
  289. }
  290. }
  291. updateSlide({ elements: newElementList });
  292. addHistorySnapshot();
  293. };
  294. /**
  295. * Adjust element z-order
  296. * @param element The element to reorder
  297. * @param command Reorder command: move up, move down, bring to front, send to back
  298. */
  299. const orderElement = (element: PPTElement, command: ElementOrderCommands) => {
  300. let newElementList;
  301. if (command === ElementOrderCommands.UP)
  302. newElementList = moveUpElement(currentSlide.elements, element);
  303. else if (command === ElementOrderCommands.DOWN)
  304. newElementList = moveDownElement(currentSlide.elements, element);
  305. else if (command === ElementOrderCommands.TOP)
  306. newElementList = moveTopElement(currentSlide.elements, element);
  307. else if (command === ElementOrderCommands.BOTTOM)
  308. newElementList = moveBottomElement(currentSlide.elements, element);
  309. if (!newElementList) return;
  310. updateSlide({ elements: newElementList });
  311. addHistorySnapshot();
  312. };
  313. /**
  314. * Check if current selected elements can be grouped
  315. */
  316. const _canCombine = useMemo(() => {
  317. if (activeElementList.length < 2) return false;
  318. const firstGroupId = activeElementList[0].groupId;
  319. if (!firstGroupId) return true;
  320. const inSameGroup = activeElementList.every((el) => el.groupId && el.groupId === firstGroupId);
  321. return !inSameGroup;
  322. }, [activeElementList]);
  323. /**
  324. * Group current selected elements: assign the same group ID to all selected elements
  325. */
  326. const combineElements = () => {
  327. if (!activeElementList.length) return;
  328. // Create a new element list for subsequent operations
  329. let newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
  330. // Generate group ID
  331. const groupId = nanoid(10);
  332. // Collect elements to be grouped and assign the unique group ID
  333. const combineElementList: PPTElement[] = [];
  334. for (const element of newElementList) {
  335. if (activeElementIdList.includes(element.id)) {
  336. element.groupId = groupId;
  337. combineElementList.push(element);
  338. }
  339. }
  340. // Ensure all group members have consecutive z-order levels:
  341. // First find the highest z-level member, remove all group members from the element list,
  342. // then insert the collected group members back at the appropriate position based on the highest level
  343. const combineElementMaxLevel = newElementList.findIndex(
  344. (_element) => _element.id === combineElementList[combineElementList.length - 1].id,
  345. );
  346. const combineElementIdList = combineElementList.map((_element) => _element.id);
  347. newElementList = newElementList.filter(
  348. (_element) => !combineElementIdList.includes(_element.id),
  349. );
  350. const insertLevel = combineElementMaxLevel - combineElementList.length + 1;
  351. newElementList.splice(insertLevel, 0, ...combineElementList);
  352. updateSlide({ elements: newElementList });
  353. addHistorySnapshot();
  354. };
  355. /**
  356. * Ungroup elements: remove the group ID from selected elements
  357. */
  358. const uncombineElements = () => {
  359. if (!activeElementList.length) return;
  360. const hasElementInGroup = activeElementList.some((item) => item.groupId);
  361. if (!hasElementInGroup) return;
  362. const newElementList: PPTElement[] = JSON.parse(JSON.stringify(currentSlide.elements));
  363. for (const element of newElementList) {
  364. if (activeElementIdList.includes(element.id) && element.groupId) delete element.groupId;
  365. }
  366. updateSlide({ elements: newElementList });
  367. // After ungrouping, reset active element state
  368. // Default to the currently handled element, or empty if none exists
  369. const handleElementIdList = handleElementId ? [handleElementId] : [];
  370. setActiveElementIdList(handleElementIdList);
  371. addHistorySnapshot();
  372. };
  373. /**
  374. * Update background
  375. * @param background New background settings
  376. */
  377. const updateBackground = useCallback(
  378. (background: SlideContent['canvas']['background']) => {
  379. updateSceneData((draft) => {
  380. draft.canvas.background = background;
  381. });
  382. },
  383. [updateSceneData],
  384. );
  385. /**
  386. * Update theme
  387. * @param theme Theme settings (partial)
  388. */
  389. const updateTheme = useCallback(
  390. (theme: Partial<SlideContent['canvas']['theme']>) => {
  391. updateSceneData((draft) => {
  392. draft.canvas.theme = {
  393. ...draft.canvas.theme,
  394. ...theme,
  395. };
  396. });
  397. },
  398. [updateSceneData],
  399. );
  400. /**
  401. * Spotlight focus on an element
  402. * @param elementId Element ID
  403. * @param options Spotlight options
  404. */
  405. const spotlightElement = useCallback((elementId: string, options?: SpotlightOptions) => {
  406. useCanvasStore.getState().setSpotlight(elementId, options);
  407. }, []);
  408. /**
  409. * Clear spotlight
  410. */
  411. const clearSpotlight = useCallback(() => {
  412. useCanvasStore.getState().clearSpotlight();
  413. }, []);
  414. /**
  415. * Highlight elements
  416. * @param elementIds Element ID list
  417. * @param options Highlight options
  418. */
  419. const highlightElements = useCallback(
  420. (elementIds: string[], options?: HighlightOverlayOptions) => {
  421. useCanvasStore.getState().setHighlight(elementIds, options);
  422. },
  423. [],
  424. );
  425. /**
  426. * Clear highlight
  427. */
  428. const clearHighlight = useCallback(() => {
  429. useCanvasStore.getState().clearHighlight();
  430. }, []);
  431. /**
  432. * Laser pointer effect
  433. * @param elementId Element ID
  434. * @param options Laser pointer options
  435. */
  436. const laserElement = useCallback(
  437. (elementId: string, options?: { color?: string; duration?: number }) => {
  438. useCanvasStore.getState().setLaser(elementId, options);
  439. },
  440. [],
  441. );
  442. /**
  443. * Clear laser pointer
  444. */
  445. const clearLaser = useCallback(() => {
  446. useCanvasStore.getState().clearLaser();
  447. }, []);
  448. /**
  449. * Zoom an element
  450. * @param elementId Element ID
  451. * @param scale Zoom scale
  452. */
  453. const zoomElement = useCallback((elementId: string, scale: number) => {
  454. useCanvasStore.getState().setZoom(elementId, scale);
  455. }, []);
  456. /**
  457. * Clear zoom
  458. */
  459. const clearZoom = useCallback(() => {
  460. useCanvasStore.getState().clearZoom();
  461. }, []);
  462. /**
  463. * Clear all teaching effects (spotlight + highlight + laser + zoom)
  464. */
  465. const clearAllEffects = useCallback(() => {
  466. useCanvasStore.getState().clearSpotlight();
  467. useCanvasStore.getState().clearHighlight();
  468. useCanvasStore.getState().clearLaser();
  469. useCanvasStore.getState().clearZoom();
  470. }, []);
  471. return {
  472. // Basic operations
  473. addElement,
  474. deleteElement,
  475. deleteAllElements,
  476. updateElement,
  477. updateSlide,
  478. removeElementProps,
  479. copyElement,
  480. pasteElement,
  481. cutElement,
  482. // Advanced operations
  483. lockElement,
  484. unlockElement,
  485. selectAllElements,
  486. selectElement,
  487. alignElementToCanvas,
  488. orderElement,
  489. combineElements,
  490. uncombineElements,
  491. // Canvas operations
  492. updateBackground,
  493. updateTheme,
  494. // Teaching features
  495. spotlightElement,
  496. clearSpotlight,
  497. highlightElements,
  498. clearHighlight,
  499. laserElement,
  500. clearLaser,
  501. zoomElement,
  502. clearZoom,
  503. clearAllEffects,
  504. };
  505. }
  506. // Export type
  507. export type CanvasOperations = ReturnType<typeof useCanvasOperations>;