You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

context-menu.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import * as _ContextMenu from '@radix-ui/react-context-menu'
  2. import styled from 'styles'
  3. import {
  4. IconWrapper,
  5. IconButton as _IconButton,
  6. RowButton,
  7. } from 'components/shared'
  8. import {
  9. commandKey,
  10. deepCompareArrays,
  11. getSelectedIds,
  12. getShape,
  13. isMobile,
  14. setToArray,
  15. } from 'utils'
  16. import state, { useSelector } from 'state'
  17. import {
  18. AlignType,
  19. DistributeType,
  20. MoveType,
  21. ShapeType,
  22. StretchType,
  23. } from 'types'
  24. import React, { useRef } from 'react'
  25. import {
  26. ChevronRightIcon,
  27. AlignBottomIcon,
  28. AlignCenterHorizontallyIcon,
  29. AlignCenterVerticallyIcon,
  30. AlignLeftIcon,
  31. AlignRightIcon,
  32. AlignTopIcon,
  33. SpaceEvenlyHorizontallyIcon,
  34. SpaceEvenlyVerticallyIcon,
  35. StretchHorizontallyIcon,
  36. StretchVerticallyIcon,
  37. } from '@radix-ui/react-icons'
  38. function alignTop() {
  39. state.send('ALIGNED', { type: AlignType.Top })
  40. }
  41. function alignCenterVertical() {
  42. state.send('ALIGNED', { type: AlignType.CenterVertical })
  43. }
  44. function alignBottom() {
  45. state.send('ALIGNED', { type: AlignType.Bottom })
  46. }
  47. function stretchVertically() {
  48. state.send('STRETCHED', { type: StretchType.Vertical })
  49. }
  50. function distributeVertically() {
  51. state.send('DISTRIBUTED', { type: DistributeType.Vertical })
  52. }
  53. function alignLeft() {
  54. state.send('ALIGNED', { type: AlignType.Left })
  55. }
  56. function alignCenterHorizontal() {
  57. state.send('ALIGNED', { type: AlignType.CenterHorizontal })
  58. }
  59. function alignRight() {
  60. state.send('ALIGNED', { type: AlignType.Right })
  61. }
  62. function stretchHorizontally() {
  63. state.send('STRETCHED', { type: StretchType.Horizontal })
  64. }
  65. function distributeHorizontally() {
  66. state.send('DISTRIBUTED', { type: DistributeType.Horizontal })
  67. }
  68. export default function ContextMenu({
  69. children,
  70. }: {
  71. children: React.ReactNode
  72. }): JSX.Element {
  73. const selectedShapeIds = useSelector(
  74. (s) => setToArray(getSelectedIds(s.data)),
  75. deepCompareArrays
  76. )
  77. const rContent = useRef<HTMLDivElement>(null)
  78. const hasGroupSelected = useSelector((s) =>
  79. selectedShapeIds.some((id) => getShape(s.data, id).type === ShapeType.Group)
  80. )
  81. const hasTwoOrMore = selectedShapeIds.length > 1
  82. const hasThreeOrMore = selectedShapeIds.length > 2
  83. return (
  84. <_ContextMenu.Root dir="ltr">
  85. <_ContextMenu.Trigger>{children}</_ContextMenu.Trigger>
  86. <StyledContent ref={rContent} isMobile={isMobile()}>
  87. {selectedShapeIds.length ? (
  88. <>
  89. {/* <Button onSelect={() => state.send('COPIED')}>
  90. <span>Copy</span>
  91. <kbd>
  92. <span>{commandKey()}</span>
  93. <span>C</span>
  94. </kbd>
  95. </Button>
  96. <Button onSelect={() => state.send('CUT')}>
  97. <span>Cut</span>
  98. <kbd>
  99. <span>{commandKey()}</span>
  100. <span>X</span>
  101. </kbd>
  102. </Button>
  103. */}
  104. <Button onSelect={() => state.send('DUPLICATED')}>
  105. <span>Duplicate</span>
  106. <kbd>
  107. <span>{commandKey()}</span>
  108. <span>D</span>
  109. </kbd>
  110. </Button>
  111. <StyledDivider />
  112. {hasGroupSelected ||
  113. (hasTwoOrMore && (
  114. <>
  115. {hasGroupSelected && (
  116. <Button onSelect={() => state.send('UNGROUPED')}>
  117. <span>Ungroup</span>
  118. <kbd>
  119. <span>{commandKey()}</span>
  120. <span>⇧</span>
  121. <span>G</span>
  122. </kbd>
  123. </Button>
  124. )}
  125. {hasTwoOrMore && (
  126. <Button onSelect={() => state.send('GROUPED')}>
  127. <span>Group</span>
  128. <kbd>
  129. <span>{commandKey()}</span>
  130. <span>G</span>
  131. </kbd>
  132. </Button>
  133. )}
  134. </>
  135. ))}
  136. <SubMenu label="Move">
  137. <Button
  138. onSelect={() =>
  139. state.send('MOVED', {
  140. type: MoveType.ToFront,
  141. })
  142. }
  143. >
  144. <span>To Front</span>
  145. <kbd>
  146. <span>{commandKey()}</span>
  147. <span>⇧</span>
  148. <span>]</span>
  149. </kbd>
  150. </Button>
  151. <Button
  152. onSelect={() =>
  153. state.send('MOVED', {
  154. type: MoveType.Forward,
  155. })
  156. }
  157. >
  158. <span>Forward</span>
  159. <kbd>
  160. <span>{commandKey()}</span>
  161. <span>]</span>
  162. </kbd>
  163. </Button>
  164. <Button
  165. onSelect={() =>
  166. state.send('MOVED', {
  167. type: MoveType.Backward,
  168. })
  169. }
  170. >
  171. <span>Backward</span>
  172. <kbd>
  173. <span>{commandKey()}</span>
  174. <span>[</span>
  175. </kbd>
  176. </Button>
  177. <Button
  178. onSelect={() =>
  179. state.send('MOVED', {
  180. type: MoveType.ToBack,
  181. })
  182. }
  183. >
  184. <span>To Back</span>
  185. <kbd>
  186. <span>{commandKey()}</span>
  187. <span>⇧</span>
  188. <span>[</span>
  189. </kbd>
  190. </Button>
  191. </SubMenu>
  192. {hasTwoOrMore && (
  193. <AlignDistributeSubMenu
  194. hasTwoOrMore={hasTwoOrMore}
  195. hasThreeOrMore={hasThreeOrMore}
  196. />
  197. )}
  198. <MoveToPageMenu />
  199. <Button onSelect={() => state.send('COPIED_TO_SVG')}>
  200. <span>Copy to SVG</span>
  201. <kbd>
  202. <span>{commandKey()}</span>
  203. <span>⇧</span>
  204. <span>C</span>
  205. </kbd>
  206. </Button>
  207. <StyledDivider />
  208. <Button onSelect={() => state.send('DELETED')}>
  209. <span>Delete</span>
  210. <kbd>
  211. <span>⌫</span>
  212. </kbd>
  213. </Button>
  214. </>
  215. ) : (
  216. <>
  217. <Button onSelect={() => state.send('UNDO')}>
  218. <span>Undo</span>
  219. <kbd>
  220. <span>{commandKey()}</span>
  221. <span>Z</span>
  222. </kbd>
  223. </Button>
  224. <Button onSelect={() => state.send('REDO')}>
  225. <span>Redo</span>
  226. <kbd>
  227. <span>{commandKey()}</span>
  228. <span>⇧</span>
  229. <span>Z</span>
  230. </kbd>
  231. </Button>
  232. </>
  233. )}
  234. </StyledContent>
  235. </_ContextMenu.Root>
  236. )
  237. }
  238. const StyledContent = styled(_ContextMenu.Content, {
  239. position: 'relative',
  240. backgroundColor: '$panel',
  241. borderRadius: '4px',
  242. overflow: 'hidden',
  243. pointerEvents: 'all',
  244. userSelect: 'none',
  245. zIndex: 200,
  246. padding: 3,
  247. boxShadow: '0px 2px 4px rgba(0,0,0,.2)',
  248. minWidth: 128,
  249. '& kbd': {
  250. marginLeft: '32px',
  251. fontSize: '$1',
  252. fontFamily: '$ui',
  253. },
  254. '& kbd > span': {
  255. display: 'inline-block',
  256. width: '12px',
  257. },
  258. variants: {
  259. isMobile: {
  260. true: {
  261. '& kbd': {
  262. display: 'none',
  263. },
  264. },
  265. },
  266. },
  267. })
  268. const StyledDivider = styled(_ContextMenu.Separator, {
  269. backgroundColor: '$hover',
  270. height: 1,
  271. margin: '3px -3px',
  272. })
  273. function Button({
  274. onSelect,
  275. children,
  276. disabled = false,
  277. }: {
  278. onSelect: () => void
  279. disabled?: boolean
  280. children: React.ReactNode
  281. }) {
  282. return (
  283. <_ContextMenu.Item
  284. as={RowButton}
  285. disabled={disabled}
  286. bp={{ '@initial': 'mobile', '@sm': 'small' }}
  287. onSelect={onSelect}
  288. >
  289. {children}
  290. </_ContextMenu.Item>
  291. )
  292. }
  293. function IconButton({
  294. onSelect,
  295. children,
  296. disabled = false,
  297. }: {
  298. onSelect: () => void
  299. disabled?: boolean
  300. children: React.ReactNode
  301. }) {
  302. return (
  303. <_ContextMenu.Item
  304. as={_IconButton}
  305. bp={{ '@initial': 'mobile', '@sm': 'small' }}
  306. disabled={disabled}
  307. onSelect={onSelect}
  308. >
  309. {children}
  310. </_ContextMenu.Item>
  311. )
  312. }
  313. function SubMenu({
  314. children,
  315. label,
  316. }: {
  317. label: string
  318. children: React.ReactNode
  319. }) {
  320. return (
  321. <_ContextMenu.Root dir="ltr">
  322. <_ContextMenu.TriggerItem
  323. as={RowButton}
  324. bp={{ '@initial': 'mobile', '@sm': 'small' }}
  325. >
  326. <span>{label}</span>
  327. <IconWrapper size="small">
  328. <ChevronRightIcon />
  329. </IconWrapper>
  330. </_ContextMenu.TriggerItem>
  331. <StyledContent sideOffset={2} alignOffset={-2} isMobile={isMobile()}>
  332. {children}
  333. <StyledArrow offset={13} />
  334. </StyledContent>
  335. </_ContextMenu.Root>
  336. )
  337. }
  338. function AlignDistributeSubMenu({
  339. hasThreeOrMore,
  340. }: {
  341. hasTwoOrMore: boolean
  342. hasThreeOrMore: boolean
  343. }) {
  344. return (
  345. <_ContextMenu.Root dir="ltr">
  346. <_ContextMenu.TriggerItem
  347. as={RowButton}
  348. bp={{ '@initial': 'mobile', '@sm': 'small' }}
  349. >
  350. <span>Align / Distribute</span>
  351. <IconWrapper size="small">
  352. <ChevronRightIcon />
  353. </IconWrapper>
  354. </_ContextMenu.TriggerItem>
  355. <StyledGrid
  356. sideOffset={2}
  357. alignOffset={-2}
  358. isMobile={isMobile()}
  359. selectedStyle={hasThreeOrMore ? 'threeOrMore' : 'twoOrMore'}
  360. >
  361. <IconButton onSelect={alignLeft}>
  362. <AlignLeftIcon />
  363. </IconButton>
  364. <IconButton onSelect={alignCenterHorizontal}>
  365. <AlignCenterHorizontallyIcon />
  366. </IconButton>
  367. <IconButton onSelect={alignRight}>
  368. <AlignRightIcon />
  369. </IconButton>
  370. <IconButton onSelect={stretchHorizontally}>
  371. <StretchHorizontallyIcon />
  372. </IconButton>
  373. {hasThreeOrMore && (
  374. <IconButton onSelect={distributeHorizontally}>
  375. <SpaceEvenlyHorizontallyIcon />
  376. </IconButton>
  377. )}
  378. <IconButton onSelect={alignTop}>
  379. <AlignTopIcon />
  380. </IconButton>
  381. <IconButton onSelect={alignCenterVertical}>
  382. <AlignCenterVerticallyIcon />
  383. </IconButton>
  384. <IconButton onSelect={alignBottom}>
  385. <AlignBottomIcon />
  386. </IconButton>
  387. <IconButton onSelect={stretchVertically}>
  388. <StretchVerticallyIcon />
  389. </IconButton>
  390. {hasThreeOrMore && (
  391. <IconButton onSelect={distributeVertically}>
  392. <SpaceEvenlyVerticallyIcon />
  393. </IconButton>
  394. )}
  395. <StyledArrow offset={13} />
  396. </StyledGrid>
  397. </_ContextMenu.Root>
  398. )
  399. }
  400. const StyledGrid = styled(StyledContent, {
  401. display: 'grid',
  402. variants: {
  403. selectedStyle: {
  404. threeOrMore: {
  405. gridTemplateColumns: 'repeat(5, auto)',
  406. },
  407. twoOrMore: {
  408. gridTemplateColumns: 'repeat(4, auto)',
  409. },
  410. },
  411. },
  412. })
  413. function MoveToPageMenu() {
  414. const documentPages = useSelector((s) => s.data.document.pages)
  415. const currentPageId = useSelector((s) => s.data.currentPageId)
  416. if (!documentPages[currentPageId]) return null
  417. const sorted = Object.values(documentPages)
  418. .sort((a, b) => a.childIndex - b.childIndex)
  419. .filter((a) => a.id !== currentPageId)
  420. if (sorted.length === 0) return null
  421. return (
  422. <_ContextMenu.Root dir="ltr">
  423. <_ContextMenu.TriggerItem
  424. as={RowButton}
  425. bp={{ '@initial': 'mobile', '@sm': 'small' }}
  426. >
  427. <span>Move To Page</span>
  428. <IconWrapper size="small">
  429. <ChevronRightIcon />
  430. </IconWrapper>
  431. </_ContextMenu.TriggerItem>
  432. <StyledContent sideOffset={2} alignOffset={-2} isMobile={isMobile()}>
  433. {sorted.map(({ id, name }) => (
  434. <Button
  435. key={id}
  436. disabled={id === currentPageId}
  437. onSelect={() => state.send('MOVED_TO_PAGE', { id })}
  438. >
  439. <span>{name}</span>
  440. </Button>
  441. ))}
  442. <StyledArrow offset={13} />
  443. </StyledContent>
  444. </_ContextMenu.Root>
  445. )
  446. }
  447. const StyledArrow = styled(_ContextMenu.Arrow, {
  448. fill: 'white',
  449. })