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

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