TextShape.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import * as React from 'react'
  3. import { HTMLContainer, TLComponentProps, TLTextMeasure } from '@tldraw/react'
  4. import { TextUtils, TLBounds, TLResizeStartInfo, TLTextShape, TLTextShapeProps } from '@tldraw/core'
  5. import { observer } from 'mobx-react-lite'
  6. import { CustomStyleProps, withClampedStyles } from './style-props'
  7. export interface TextShapeProps extends TLTextShapeProps, CustomStyleProps {
  8. borderRadius: number
  9. fontFamily: string
  10. fontSize: number
  11. fontWeight: number
  12. lineHeight: number
  13. padding: number
  14. type: 'text'
  15. }
  16. export class TextShape extends TLTextShape<TextShapeProps> {
  17. static id = 'text'
  18. static defaultProps: TextShapeProps = {
  19. id: 'box',
  20. parentId: 'page',
  21. type: 'text',
  22. point: [0, 0],
  23. size: [100, 100],
  24. isSizeLocked: true,
  25. text: '',
  26. lineHeight: 1.2,
  27. fontSize: 20,
  28. fontWeight: 400,
  29. padding: 4,
  30. fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
  31. borderRadius: 0,
  32. stroke: 'var(--tl-foreground, #000)',
  33. fill: '#ffffff',
  34. strokeWidth: 2,
  35. opacity: 1,
  36. }
  37. ReactComponent = observer(({ events, isErasing, isEditing, onEditingEnd }: TLComponentProps) => {
  38. const {
  39. props: { opacity, fontFamily, fontSize, fontWeight, lineHeight, text, stroke, padding },
  40. } = this
  41. const rInput = React.useRef<HTMLTextAreaElement>(null)
  42. const rIsMounted = React.useRef(false)
  43. const rInnerWrapper = React.useRef<HTMLDivElement>(null)
  44. // When the text changes, update the text—and,
  45. const handleChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
  46. const { isSizeLocked } = this.props
  47. const text = TextUtils.normalizeText(e.currentTarget.value)
  48. if (isSizeLocked) {
  49. this.update({ text, size: this.getAutoSizedBoundingBox({ text }) })
  50. return
  51. }
  52. // If not autosizing, update just the text
  53. this.update({ text })
  54. }, [])
  55. const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  56. if (e.metaKey) e.stopPropagation()
  57. switch (e.key) {
  58. case 'Meta': {
  59. e.stopPropagation()
  60. break
  61. }
  62. case 'z': {
  63. if (e.metaKey) {
  64. if (e.shiftKey) {
  65. document.execCommand('redo', false)
  66. } else {
  67. document.execCommand('undo', false)
  68. }
  69. e.preventDefault()
  70. }
  71. break
  72. }
  73. case 'Enter': {
  74. if (e.ctrlKey || e.metaKey) {
  75. e.currentTarget.blur()
  76. }
  77. break
  78. }
  79. case 'Tab': {
  80. e.preventDefault()
  81. if (e.shiftKey) {
  82. TextUtils.unindent(e.currentTarget)
  83. } else {
  84. TextUtils.indent(e.currentTarget)
  85. }
  86. this.update({ text: TextUtils.normalizeText(e.currentTarget.value) })
  87. break
  88. }
  89. }
  90. }, [])
  91. const handleBlur = React.useCallback(
  92. (e: React.FocusEvent<HTMLTextAreaElement>) => {
  93. e.currentTarget.setSelectionRange(0, 0)
  94. onEditingEnd?.()
  95. },
  96. [onEditingEnd]
  97. )
  98. const handleFocus = React.useCallback(
  99. (e: React.FocusEvent<HTMLTextAreaElement>) => {
  100. if (!isEditing) return
  101. if (!rIsMounted.current) return
  102. if (document.activeElement === e.currentTarget) {
  103. e.currentTarget.select()
  104. }
  105. },
  106. [isEditing]
  107. )
  108. const handlePointerDown = React.useCallback(
  109. e => {
  110. if (isEditing) e.stopPropagation()
  111. },
  112. [isEditing]
  113. )
  114. React.useEffect(() => {
  115. if (isEditing) {
  116. requestAnimationFrame(() => {
  117. rIsMounted.current = true
  118. const elm = rInput.current
  119. if (elm) {
  120. elm.focus()
  121. elm.select()
  122. }
  123. })
  124. } else {
  125. onEditingEnd?.()
  126. }
  127. }, [isEditing, onEditingEnd])
  128. React.useLayoutEffect(() => {
  129. const { fontFamily, fontSize, fontWeight, lineHeight, padding } = this.props
  130. const { width, height } = this.measure.measureText(
  131. text,
  132. { fontFamily, fontSize, fontWeight, lineHeight },
  133. padding
  134. )
  135. this.update({ size: [width, height] })
  136. }, [])
  137. return (
  138. <HTMLContainer {...events} opacity={isErasing ? 0.2 : opacity}>
  139. <div
  140. ref={rInnerWrapper}
  141. className="text-shape-wrapper"
  142. data-hastext={!!text}
  143. data-isediting={isEditing}
  144. style={{
  145. fontFamily,
  146. fontSize,
  147. fontWeight,
  148. padding,
  149. lineHeight,
  150. color: stroke,
  151. }}
  152. >
  153. {isEditing ? (
  154. <textarea
  155. ref={rInput}
  156. className="text-shape-input"
  157. name="text"
  158. tabIndex={-1}
  159. autoComplete="false"
  160. autoCapitalize="false"
  161. autoCorrect="false"
  162. autoSave="false"
  163. // autoFocus
  164. placeholder=""
  165. spellCheck="true"
  166. wrap="off"
  167. dir="auto"
  168. datatype="wysiwyg"
  169. defaultValue={text}
  170. onFocus={handleFocus}
  171. onChange={handleChange}
  172. onKeyDown={handleKeyDown}
  173. onBlur={handleBlur}
  174. onPointerDown={handlePointerDown}
  175. // onContextMenu={stopPropagation}
  176. />
  177. ) : (
  178. <>{text}&#8203;</>
  179. )}
  180. </div>
  181. </HTMLContainer>
  182. )
  183. })
  184. ReactIndicator = observer(() => {
  185. const {
  186. props: { borderRadius },
  187. bounds,
  188. } = this
  189. return (
  190. <rect
  191. width={bounds.width}
  192. height={bounds.height}
  193. rx={borderRadius}
  194. ry={borderRadius}
  195. fill="transparent"
  196. />
  197. )
  198. })
  199. validateProps = (props: Partial<TextShapeProps>) => {
  200. if (props.isSizeLocked || this.props.isSizeLocked) {
  201. props.size = this.getAutoSizedBoundingBox(props)
  202. }
  203. return withClampedStyles(props)
  204. }
  205. // Custom
  206. private measure = new TLTextMeasure()
  207. getAutoSizedBoundingBox(props = {} as Partial<TextShapeProps>) {
  208. const {
  209. text = this.props.text,
  210. fontFamily = this.props.fontFamily,
  211. fontSize = this.props.fontSize,
  212. fontWeight = this.props.fontWeight,
  213. lineHeight = this.props.lineHeight,
  214. padding = this.props.padding,
  215. } = props
  216. const { width, height } = this.measure.measureText(
  217. text,
  218. { fontFamily, fontSize, lineHeight, fontWeight },
  219. padding
  220. )
  221. return [width, height]
  222. }
  223. getBounds = (): TLBounds => {
  224. const [x, y] = this.props.point
  225. const [width, height] = this.props.size
  226. return {
  227. minX: x,
  228. minY: y,
  229. maxX: x + width,
  230. maxY: y + height,
  231. width,
  232. height,
  233. }
  234. }
  235. onResizeStart = ({ isSingle }: TLResizeStartInfo) => {
  236. if (!isSingle) return this
  237. this.scale = [...(this.props.scale ?? [1, 1])]
  238. return this.update({
  239. isSizeLocked: false,
  240. })
  241. }
  242. onResetBounds = () => {
  243. this.update({
  244. size: this.getAutoSizedBoundingBox(),
  245. isSizeLocked: true,
  246. })
  247. return this
  248. }
  249. getShapeSVGJsx() {
  250. const {
  251. props: { text, stroke, fontSize, fontFamily },
  252. } = this
  253. // Stretch to the bound size
  254. const bounds = this.getBounds()
  255. return (
  256. <text
  257. style={{
  258. transformOrigin: 'top left',
  259. }}
  260. transform={`translate(${bounds.width / 2}, ${bounds.height / 2})`}
  261. textAnchor="middle"
  262. fontFamily={fontFamily}
  263. fontSize={fontSize}
  264. stroke={stroke}
  265. fill={stroke}
  266. >
  267. {text}
  268. </text>
  269. )
  270. }
  271. }