EditableElement.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { useMemo } from 'react';
  2. import { ElementTypes, type PPTElement } from '@/lib/types/slides';
  3. import { ImageElement } from '../../components/element/ImageElement';
  4. import { TextElement } from '../../components/element/TextElement';
  5. import { LineElement } from '../../components/element/LineElement';
  6. import { ShapeElement } from '../../components/element/ShapeElement';
  7. import { ChartElement } from '../../components/element/ChartElement';
  8. import { LatexElement } from '../../components/element/LatexElement';
  9. import { TableElement } from '../../components/element/TableElement';
  10. import { VideoElement } from '../../components/element/VideoElement';
  11. import {
  12. ContextMenu,
  13. ContextMenuContent,
  14. ContextMenuItem,
  15. ContextMenuSeparator,
  16. ContextMenuShortcut,
  17. ContextMenuSub,
  18. ContextMenuSubContent,
  19. ContextMenuSubTrigger,
  20. ContextMenuTrigger,
  21. } from '@/components/ui/context-menu';
  22. import { ElementOrderCommands, ElementAlignCommands } from '@/lib/types/edit';
  23. import { useCanvasOperations } from '@/lib/hooks/use-canvas-operations';
  24. export interface ContextmenuItem {
  25. text?: string;
  26. subText?: string;
  27. divider?: boolean;
  28. disable?: boolean;
  29. hide?: boolean;
  30. children?: ContextmenuItem[];
  31. handler?: () => void;
  32. }
  33. interface EditableElementProps {
  34. readonly elementInfo: PPTElement;
  35. readonly elementIndex: number;
  36. readonly isMultiSelect: boolean;
  37. readonly selectElement: (
  38. e: React.MouseEvent | React.TouchEvent,
  39. element: PPTElement,
  40. canMove?: boolean,
  41. ) => void;
  42. readonly openLinkDialog: () => void;
  43. }
  44. export function EditableElement({
  45. elementInfo,
  46. elementIndex,
  47. isMultiSelect,
  48. selectElement,
  49. openLinkDialog,
  50. }: EditableElementProps) {
  51. const CurrentElementComponent = useMemo(() => {
  52. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- element components have varying prop signatures
  53. const elementTypeMap: Record<string, any> = {
  54. [ElementTypes.IMAGE]: ImageElement,
  55. [ElementTypes.TEXT]: TextElement,
  56. [ElementTypes.SHAPE]: ShapeElement,
  57. [ElementTypes.LINE]: LineElement,
  58. [ElementTypes.CHART]: ChartElement,
  59. [ElementTypes.LATEX]: LatexElement,
  60. [ElementTypes.TABLE]: TableElement,
  61. [ElementTypes.VIDEO]: VideoElement,
  62. // TODO: Add other element types
  63. // [ElementTypes.AUDIO]: AudioElement,
  64. };
  65. return elementTypeMap[elementInfo.type] || null;
  66. }, [elementInfo.type]);
  67. const {
  68. copyElement,
  69. pasteElement,
  70. cutElement,
  71. deleteElement,
  72. lockElement,
  73. unlockElement,
  74. selectAllElements,
  75. alignElementToCanvas,
  76. orderElement,
  77. combineElements,
  78. uncombineElements,
  79. } = useCanvasOperations();
  80. const contextmenus = (): ContextmenuItem[] => {
  81. if (elementInfo.lock) {
  82. return [
  83. {
  84. text: '解锁',
  85. handler: () => unlockElement(elementInfo),
  86. },
  87. ];
  88. }
  89. return [
  90. {
  91. text: '剪切',
  92. subText: 'Ctrl + X',
  93. handler: cutElement,
  94. },
  95. {
  96. text: '复制',
  97. subText: 'Ctrl + C',
  98. handler: copyElement,
  99. },
  100. {
  101. text: '粘贴',
  102. subText: 'Ctrl + V',
  103. handler: pasteElement,
  104. },
  105. { divider: true },
  106. {
  107. text: '水平居中',
  108. handler: () => alignElementToCanvas(ElementAlignCommands.HORIZONTAL),
  109. children: [
  110. {
  111. text: '水平垂直居中',
  112. handler: () => alignElementToCanvas(ElementAlignCommands.CENTER),
  113. },
  114. {
  115. text: '水平居中',
  116. handler: () => alignElementToCanvas(ElementAlignCommands.HORIZONTAL),
  117. },
  118. {
  119. text: '左对齐',
  120. handler: () => alignElementToCanvas(ElementAlignCommands.LEFT),
  121. },
  122. {
  123. text: '右对齐',
  124. handler: () => alignElementToCanvas(ElementAlignCommands.RIGHT),
  125. },
  126. ],
  127. },
  128. {
  129. text: '垂直居中',
  130. handler: () => alignElementToCanvas(ElementAlignCommands.VERTICAL),
  131. children: [
  132. {
  133. text: '水平垂直居中',
  134. handler: () => alignElementToCanvas(ElementAlignCommands.CENTER),
  135. },
  136. {
  137. text: '垂直居中',
  138. handler: () => alignElementToCanvas(ElementAlignCommands.VERTICAL),
  139. },
  140. {
  141. text: '顶部对齐',
  142. handler: () => alignElementToCanvas(ElementAlignCommands.TOP),
  143. },
  144. {
  145. text: '底部对齐',
  146. handler: () => alignElementToCanvas(ElementAlignCommands.BOTTOM),
  147. },
  148. ],
  149. },
  150. { divider: true },
  151. {
  152. text: '置于顶层',
  153. disable: isMultiSelect && !elementInfo.groupId,
  154. handler: () => orderElement(elementInfo, ElementOrderCommands.TOP),
  155. children: [
  156. {
  157. text: '置于顶层',
  158. handler: () => orderElement(elementInfo, ElementOrderCommands.TOP),
  159. },
  160. {
  161. text: '上移一层',
  162. handler: () => orderElement(elementInfo, ElementOrderCommands.UP),
  163. },
  164. ],
  165. },
  166. {
  167. text: '置于底层',
  168. disable: isMultiSelect && !elementInfo.groupId,
  169. handler: () => orderElement(elementInfo, ElementOrderCommands.BOTTOM),
  170. children: [
  171. {
  172. text: '置于底层',
  173. handler: () => orderElement(elementInfo, ElementOrderCommands.BOTTOM),
  174. },
  175. {
  176. text: '下移一层',
  177. handler: () => orderElement(elementInfo, ElementOrderCommands.DOWN),
  178. },
  179. ],
  180. },
  181. { divider: true },
  182. {
  183. text: '设置链接',
  184. handler: openLinkDialog,
  185. disable: true,
  186. },
  187. {
  188. text: elementInfo.groupId ? '取消组合' : '组合',
  189. subText: 'Ctrl + G',
  190. handler: elementInfo.groupId ? uncombineElements : combineElements,
  191. hide: !isMultiSelect,
  192. },
  193. {
  194. text: '全选',
  195. subText: 'Ctrl + A',
  196. handler: selectAllElements,
  197. },
  198. {
  199. text: '锁定',
  200. subText: 'Ctrl + L',
  201. handler: lockElement,
  202. },
  203. {
  204. text: '删除',
  205. subText: 'Delete',
  206. handler: deleteElement,
  207. },
  208. ];
  209. };
  210. if (!CurrentElementComponent) {
  211. return (
  212. <div
  213. id={`editable-element-${elementInfo.id}`}
  214. className="editable-element absolute"
  215. style={{
  216. zIndex: elementIndex,
  217. left: elementInfo.left + 'px',
  218. top: elementInfo.top + 'px',
  219. width: elementInfo.width + 'px',
  220. }}
  221. >
  222. <div className="p-2 bg-gray-100 border border-gray-300 text-xs text-gray-500">
  223. {elementInfo.type} element (not implemented)
  224. </div>
  225. </div>
  226. );
  227. }
  228. return (
  229. <div
  230. id={`editable-element-${elementInfo.id}`}
  231. className="editable-element absolute"
  232. style={{
  233. zIndex: elementIndex,
  234. }}
  235. >
  236. <ContextMenu>
  237. <ContextMenuTrigger>
  238. <CurrentElementComponent elementInfo={elementInfo} selectElement={selectElement} />
  239. </ContextMenuTrigger>
  240. <ContextMenuContent>
  241. {contextmenus().map((item, index) => {
  242. if (item.divider) {
  243. return <ContextMenuSeparator key={index} />;
  244. }
  245. // If has children, use submenu component
  246. if (item.children && item.children.length > 0) {
  247. return (
  248. <ContextMenuSub key={index}>
  249. <ContextMenuSubTrigger disabled={item.disable} hidden={item.hide}>
  250. {item.text}
  251. {item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>}
  252. </ContextMenuSubTrigger>
  253. <ContextMenuSubContent>
  254. {item.children.map((child, childIndex) =>
  255. child.divider ? (
  256. <ContextMenuSeparator key={childIndex} />
  257. ) : (
  258. <ContextMenuItem
  259. key={childIndex}
  260. onClick={(e) => {
  261. e.stopPropagation();
  262. child.handler?.();
  263. }}
  264. disabled={child.disable}
  265. hidden={child.hide}
  266. >
  267. {child.text}
  268. {child.subText && (
  269. <ContextMenuShortcut>{child.subText}</ContextMenuShortcut>
  270. )}
  271. </ContextMenuItem>
  272. ),
  273. )}
  274. </ContextMenuSubContent>
  275. </ContextMenuSub>
  276. );
  277. }
  278. // Regular menu item
  279. return (
  280. <ContextMenuItem
  281. key={index}
  282. onClick={(e) => {
  283. e.stopPropagation();
  284. item.handler?.();
  285. }}
  286. disabled={item.disable}
  287. hidden={item.hide}
  288. >
  289. {item.text}
  290. {item.subText && <ContextMenuShortcut>{item.subText}</ContextMenuShortcut>}
  291. </ContextMenuItem>
  292. );
  293. })}
  294. </ContextMenuContent>
  295. </ContextMenu>
  296. </div>
  297. );
  298. }