TextShape.tsx 8.1 KB

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