TextShape.tsx 8.1 KB

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