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.

style-panel.tsx 6.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import styled from 'styles'
  2. import state, { useSelector } from 'state'
  3. import * as Panel from 'components/panel'
  4. import { useRef } from 'react'
  5. import { IconButton } from 'components/shared'
  6. import { Circle, Copy, Lock, Trash, Unlock, X } from 'react-feather'
  7. import {
  8. deepCompare,
  9. deepCompareArrays,
  10. getPage,
  11. getSelectedShapes,
  12. } from 'utils/utils'
  13. import { shades, fills, strokes } from 'lib/colors'
  14. import ColorPicker from './color-picker'
  15. import AlignDistribute from './align-distribute'
  16. import { MoveType, ShapeStyles } from 'types'
  17. import WidthPicker from './width-picker'
  18. import {
  19. AlignTopIcon,
  20. ArrowDownIcon,
  21. ArrowUpIcon,
  22. AspectRatioIcon,
  23. BoxIcon,
  24. CopyIcon,
  25. EyeClosedIcon,
  26. EyeOpenIcon,
  27. LockClosedIcon,
  28. LockOpen1Icon,
  29. PinBottomIcon,
  30. PinTopIcon,
  31. RotateCounterClockwiseIcon,
  32. TrashIcon,
  33. } from '@radix-ui/react-icons'
  34. const fillColors = { ...shades, ...fills }
  35. const strokeColors = { ...shades, ...strokes }
  36. export default function StylePanel() {
  37. const rContainer = useRef<HTMLDivElement>(null)
  38. const isOpen = useSelector((s) => s.data.settings.isStyleOpen)
  39. return (
  40. <StylePanelRoot ref={rContainer} isOpen={isOpen}>
  41. {isOpen ? (
  42. <SelectedShapeStyles />
  43. ) : (
  44. <IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
  45. <Circle />
  46. </IconButton>
  47. )}
  48. </StylePanelRoot>
  49. )
  50. }
  51. // This panel is going to be hard to keep cool, as we're selecting computed
  52. // information, based on the user's current selection. We might have to keep
  53. // track of this data manually within our state.
  54. function SelectedShapeStyles({}: {}) {
  55. const selectedIds = useSelector(
  56. (s) => Array.from(s.data.selectedIds.values()),
  57. deepCompareArrays
  58. )
  59. const isAllLocked = useSelector((s) => {
  60. const page = getPage(s.data)
  61. return selectedIds.every((id) => page.shapes[id].isLocked)
  62. })
  63. const isAllAspectLocked = useSelector((s) => {
  64. const page = getPage(s.data)
  65. return selectedIds.every((id) => page.shapes[id].isAspectRatioLocked)
  66. })
  67. const isAllHidden = useSelector((s) => {
  68. const page = getPage(s.data)
  69. return selectedIds.every((id) => page.shapes[id].isHidden)
  70. })
  71. const commonStyle = useSelector((s) => {
  72. const { currentStyle } = s.data
  73. if (selectedIds.length === 0) {
  74. return currentStyle
  75. }
  76. const page = getPage(s.data)
  77. const shapeStyles = selectedIds.map((id) => page.shapes[id].style)
  78. const commonStyle: Partial<ShapeStyles> = {}
  79. const overrides = new Set<string>([])
  80. for (const shapeStyle of shapeStyles) {
  81. for (let key in currentStyle) {
  82. if (overrides.has(key)) continue
  83. if (commonStyle[key] === undefined) {
  84. commonStyle[key] = shapeStyle[key]
  85. } else {
  86. if (commonStyle[key] === shapeStyle[key]) continue
  87. commonStyle[key] = currentStyle[key]
  88. overrides.add(key)
  89. }
  90. }
  91. }
  92. return commonStyle
  93. }, deepCompare)
  94. const hasSelection = selectedIds.length > 0
  95. return (
  96. <Panel.Layout>
  97. <Panel.Header side="right">
  98. <h3>Style</h3>
  99. <IconButton onClick={() => state.send('TOGGLED_STYLE_PANEL_OPEN')}>
  100. <X />
  101. </IconButton>
  102. </Panel.Header>
  103. <Content>
  104. <ColorPicker
  105. label="Fill"
  106. color={commonStyle.fill}
  107. colors={fillColors}
  108. onChange={(color) => state.send('CHANGED_STYLE', { fill: color })}
  109. />
  110. <ColorPicker
  111. label="Stroke"
  112. color={commonStyle.stroke}
  113. colors={strokeColors}
  114. onChange={(color) => state.send('CHANGED_STYLE', { stroke: color })}
  115. />
  116. <Row>
  117. <label htmlFor="width">Width</label>
  118. <WidthPicker strokeWidth={Number(commonStyle.strokeWidth)} />
  119. </Row>
  120. <ButtonsRow>
  121. <IconButton
  122. disabled={!hasSelection}
  123. onClick={() => state.send('DUPLICATED')}
  124. >
  125. <CopyIcon />
  126. </IconButton>
  127. <IconButton
  128. disabled={!hasSelection}
  129. onClick={() => state.send('ROTATED_CCW')}
  130. >
  131. <RotateCounterClockwiseIcon />
  132. </IconButton>
  133. <IconButton
  134. disabled={!hasSelection}
  135. onClick={() => state.send('TOGGLED_SHAPE_HIDE')}
  136. >
  137. {isAllHidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
  138. </IconButton>
  139. <IconButton
  140. disabled={!hasSelection}
  141. onClick={() => state.send('TOGGLED_SHAPE_LOCK')}
  142. >
  143. {isAllLocked ? <LockClosedIcon /> : <LockOpen1Icon />}
  144. </IconButton>
  145. <IconButton
  146. disabled={!hasSelection}
  147. onClick={() => state.send('TOGGLED_SHAPE_ASPECT_LOCK')}
  148. >
  149. {isAllAspectLocked ? <AspectRatioIcon /> : <BoxIcon />}
  150. </IconButton>
  151. </ButtonsRow>
  152. <ButtonsRow>
  153. <IconButton
  154. disabled={!hasSelection}
  155. onClick={() => state.send('MOVED', { type: MoveType.ToBack })}
  156. >
  157. <PinBottomIcon />
  158. </IconButton>
  159. <IconButton
  160. disabled={!hasSelection}
  161. onClick={() => state.send('MOVED', { type: MoveType.Backward })}
  162. >
  163. <ArrowDownIcon />
  164. </IconButton>
  165. <IconButton
  166. disabled={!hasSelection}
  167. onClick={() => state.send('MOVED', { type: MoveType.Forward })}
  168. >
  169. <ArrowUpIcon />
  170. </IconButton>
  171. <IconButton
  172. disabled={!hasSelection}
  173. onClick={() => state.send('MOVED', { type: MoveType.ToFront })}
  174. >
  175. <PinTopIcon />
  176. </IconButton>
  177. <IconButton
  178. disabled={!hasSelection}
  179. onClick={() => state.send('DELETED')}
  180. >
  181. <TrashIcon />
  182. </IconButton>
  183. </ButtonsRow>
  184. <AlignDistribute
  185. hasTwoOrMore={selectedIds.length > 1}
  186. hasThreeOrMore={selectedIds.length > 2}
  187. />
  188. </Content>
  189. </Panel.Layout>
  190. )
  191. }
  192. const StylePanelRoot = styled(Panel.Root, {
  193. minWidth: 1,
  194. width: 184,
  195. maxWidth: 184,
  196. overflow: 'hidden',
  197. position: 'relative',
  198. variants: {
  199. isOpen: {
  200. true: {},
  201. false: {
  202. height: 34,
  203. width: 34,
  204. },
  205. },
  206. },
  207. })
  208. const Content = styled(Panel.Content, {
  209. padding: 8,
  210. })
  211. const Row = styled('div', {
  212. position: 'relative',
  213. display: 'flex',
  214. width: '100%',
  215. background: 'none',
  216. border: 'none',
  217. cursor: 'pointer',
  218. outline: 'none',
  219. alignItems: 'center',
  220. justifyContent: 'space-between',
  221. padding: '4px 2px 4px 12px',
  222. '& label': {
  223. fontFamily: '$ui',
  224. fontSize: '$2',
  225. fontWeight: '$1',
  226. margin: 0,
  227. padding: 0,
  228. },
  229. '& > svg': {
  230. position: 'relative',
  231. },
  232. })
  233. const ButtonsRow = styled('div', {
  234. position: 'relative',
  235. display: 'flex',
  236. width: '100%',
  237. background: 'none',
  238. border: 'none',
  239. cursor: 'pointer',
  240. outline: 'none',
  241. alignItems: 'center',
  242. justifyContent: 'flex-start',
  243. padding: 4,
  244. })