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.

text.tsx 7.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { uniqueId, getFromCache } from 'utils/utils'
  2. import vec from 'utils/vec'
  3. import TextAreaUtils from 'utils/text-area'
  4. import { TextShape, ShapeType } from 'types'
  5. import {
  6. defaultStyle,
  7. getFontSize,
  8. getFontStyle,
  9. getShapeStyle,
  10. } from 'state/shape-styles'
  11. import styled from 'styles'
  12. import state from 'state'
  13. import { registerShapeUtils } from './register'
  14. // A div used for measurement
  15. document.getElementById('__textMeasure')?.remove()
  16. const mdiv = document.createElement('pre')
  17. mdiv.id = '__textMeasure'
  18. Object.assign(mdiv.style, {
  19. whiteSpace: 'pre',
  20. width: 'auto',
  21. border: '1px solid red',
  22. padding: '4px',
  23. margin: '0px',
  24. opacity: '0',
  25. position: 'absolute',
  26. top: '-500px',
  27. left: '0px',
  28. zIndex: '9999',
  29. pointerEvents: 'none',
  30. userSelect: 'none',
  31. alignmentBaseline: 'mathematical',
  32. dominantBaseline: 'mathematical',
  33. })
  34. mdiv.tabIndex = -1
  35. document.body.appendChild(mdiv)
  36. function normalizeText(text: string) {
  37. return text.replace(/\r?\n|\r/g, '\n')
  38. }
  39. const text = registerShapeUtils<TextShape>({
  40. isForeignObject: true,
  41. canChangeAspectRatio: false,
  42. canEdit: true,
  43. boundsCache: new WeakMap([]),
  44. defaultProps: {
  45. id: uniqueId(),
  46. type: ShapeType.Text,
  47. name: 'Text',
  48. parentId: 'page1',
  49. childIndex: 0,
  50. point: [0, 0],
  51. rotation: 0,
  52. style: defaultStyle,
  53. text: '',
  54. scale: 1,
  55. },
  56. shouldRender(shape, prev) {
  57. return (
  58. shape.text !== prev.text ||
  59. shape.scale !== prev.scale ||
  60. shape.style !== prev.style
  61. )
  62. },
  63. render(shape, { isEditing, isDarkMode, ref }) {
  64. const { id, text, style } = shape
  65. const styles = getShapeStyle(style, isDarkMode)
  66. const font = getFontStyle(shape.scale, shape.style)
  67. const bounds = this.getBounds(shape)
  68. function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
  69. state.send('EDITED_SHAPE', {
  70. id,
  71. change: { text: normalizeText(e.currentTarget.value) },
  72. })
  73. }
  74. function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
  75. if (e.key === 'Escape') return
  76. e.stopPropagation()
  77. if (e.key === 'Tab') {
  78. e.preventDefault()
  79. if (e.shiftKey) {
  80. TextAreaUtils.unindent(e.currentTarget)
  81. } else {
  82. TextAreaUtils.indent(e.currentTarget)
  83. }
  84. state.send('EDITED_SHAPE', {
  85. id,
  86. change: {
  87. text: normalizeText(e.currentTarget.value),
  88. },
  89. })
  90. }
  91. }
  92. function handleBlur(e: React.FocusEvent<HTMLTextAreaElement>) {
  93. if (isEditing) {
  94. e.currentTarget.focus()
  95. e.currentTarget.select()
  96. return
  97. }
  98. setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0)
  99. }
  100. function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
  101. e.currentTarget.select()
  102. state.send('FOCUSED_EDITING_SHAPE', { id })
  103. }
  104. function handlePointerDown() {
  105. if (ref.current.selectionEnd !== 0) {
  106. ref.current.selectionEnd = 0
  107. }
  108. }
  109. const fontSize = getFontSize(shape.style.size) * shape.scale
  110. const lineHeight = fontSize * 1.4
  111. if (!isEditing) {
  112. return (
  113. <>
  114. {text.split('\n').map((str, i) => (
  115. <text
  116. key={i}
  117. x={4}
  118. y={4 + fontSize / 2 + i * lineHeight}
  119. fontFamily="Verveine Regular"
  120. fontStyle="normal"
  121. fontWeight="500"
  122. fontSize={fontSize}
  123. width={bounds.width}
  124. height={bounds.height}
  125. fill={styles.stroke}
  126. color={styles.stroke}
  127. stroke="none"
  128. xmlSpace="preserve"
  129. dominantBaseline="mathematical"
  130. alignmentBaseline="mathematical"
  131. >
  132. {str}
  133. </text>
  134. ))}
  135. </>
  136. )
  137. }
  138. if (ref === undefined) {
  139. throw Error('This component should receive a ref when editing.')
  140. }
  141. return (
  142. <foreignObject
  143. width={bounds.width}
  144. height={bounds.height}
  145. pointerEvents="none"
  146. onPointerDown={(e) => e.stopPropagation()}
  147. >
  148. <StyledTextArea
  149. ref={ref as React.RefObject<HTMLTextAreaElement>}
  150. style={{
  151. font,
  152. color: styles.stroke,
  153. }}
  154. name="text"
  155. defaultValue={text}
  156. tabIndex={-1}
  157. autoComplete="false"
  158. autoCapitalize="false"
  159. autoCorrect="false"
  160. autoSave="false"
  161. placeholder=""
  162. color={styles.stroke}
  163. autoFocus={true}
  164. onFocus={handleFocus}
  165. onBlur={handleBlur}
  166. onKeyDown={handleKeyDown}
  167. onChange={handleChange}
  168. onPointerDown={handlePointerDown}
  169. />
  170. </foreignObject>
  171. )
  172. },
  173. getBounds(shape) {
  174. const bounds = getFromCache(this.boundsCache, shape, (cache) => {
  175. mdiv.innerHTML = `${shape.text}&zwj;`
  176. mdiv.style.font = getFontStyle(shape.scale, shape.style)
  177. const [minX, minY] = shape.point
  178. const [width, height] = [mdiv.offsetWidth, mdiv.offsetHeight]
  179. cache.set(shape, {
  180. minX,
  181. maxX: minX + width,
  182. minY,
  183. maxY: minY + height,
  184. width,
  185. height,
  186. })
  187. })
  188. return bounds
  189. },
  190. hitTest() {
  191. return true
  192. },
  193. transform(shape, bounds, { initialShape, scaleX, scaleY }) {
  194. if (shape.rotation === 0 && !shape.isAspectRatioLocked) {
  195. shape.point = [bounds.minX, bounds.minY]
  196. shape.scale = initialShape.scale * Math.abs(scaleX)
  197. } else {
  198. shape.point = [bounds.minX, bounds.minY]
  199. shape.rotation =
  200. (scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
  201. ? -initialShape.rotation
  202. : initialShape.rotation
  203. shape.scale = initialShape.scale * Math.abs(Math.min(scaleX, scaleY))
  204. }
  205. return this
  206. },
  207. transformSingle(shape, bounds, { initialShape, scaleX }) {
  208. shape.point = [bounds.minX, bounds.minY]
  209. shape.scale = initialShape.scale * Math.abs(scaleX)
  210. return this
  211. },
  212. onBoundsReset(shape) {
  213. const center = this.getCenter(shape)
  214. this.boundsCache.delete(shape)
  215. shape.scale = 1
  216. const newCenter = this.getCenter(shape)
  217. shape.point = vec.add(shape.point, vec.sub(center, newCenter))
  218. return this
  219. },
  220. applyStyles(shape, style) {
  221. const center = this.getCenter(shape)
  222. this.boundsCache.delete(shape)
  223. Object.assign(shape.style, style)
  224. const newCenter = this.getCenter(shape)
  225. shape.point = vec.add(shape.point, vec.sub(center, newCenter))
  226. return this
  227. },
  228. shouldDelete(shape) {
  229. return shape.text.length === 0
  230. },
  231. })
  232. export default text
  233. const StyledTextArea = styled('textarea', {
  234. zIndex: 1,
  235. width: '100%',
  236. height: '100%',
  237. border: 'none',
  238. padding: '4px',
  239. whiteSpace: 'pre',
  240. alignmentBaseline: 'mathematical',
  241. dominantBaseline: 'mathematical',
  242. resize: 'none',
  243. minHeight: 1,
  244. minWidth: 1,
  245. lineHeight: 1.4,
  246. outline: 0,
  247. fontWeight: '500',
  248. backgroundColor: '$boundsBg',
  249. overflow: 'hidden',
  250. pointerEvents: 'all',
  251. backfaceVisibility: 'hidden',
  252. display: 'inline-block',
  253. userSelect: 'text',
  254. WebkitUserSelect: 'text',
  255. WebkitTouchCallout: 'none',
  256. })