/* eslint-disable react-hooks/rules-of-hooks */ import { Box, Editor, Rectangle2d, ShapeUtil, SvgExportContext, TLGeometryOpts, TLResizeInfo, TLShapeId, TLTextShape, Vec, createComputedCache, getColorValue, getFontsFromRichText, isEqual, resizeScaled, textShapeMigrations, textShapeProps, toRichText, useColorMode, useEditor, } from '@tldraw/editor' import { useCallback } from 'react' import { renderHtmlFromRichTextForMeasurement, renderPlaintextFromRichText, } from '../../utils/text/richText' import { FONT_SIZES, TEXT_PROPS, getFontFamily } from '../shared/default-shape-constants' import { getThemeFontFaces } from '../shared/defaultFonts' import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues' import { RichTextLabel, RichTextSVG } from '../shared/RichTextLabel' // Export-only slack (as a fraction of font size) added around a text shape's advance box in `toSvg`, // so italic/cursive glyph ink that spills past the box — side bearings, slanted ascenders — isn't // clipped at the edge on export (#8802). The text still renders in the same place; // the export's auto-trim crops the extra room back to the real pixels, so the PNG stays tight. This // is deliberately export-only: the shape's geometry (selection, hit-testing, the text-edit cursor) // stays the advance box, which is the right box for those. const TEXT_EXPORT_INK_MARGIN = 0.3 const sizeCache = createComputedCache( 'text size', (editor: Editor, shape: TLTextShape) => { editor.fonts.trackFontsForShape(shape) const util = editor.getShapeUtil(shape) as TextShapeUtil const dv = getDisplayValues(util, shape) return getTextSize(editor, shape.props, dv) }, { areRecordsEqual: (a, b) => a.props === b.props } ) /** @public */ export interface TextShapeUtilDisplayValues { color: string fontFamily: string fontSize: number lineHeight: number fontWeight: string fontStyle: string fontVariant: string } /** @public */ export interface TextShapeOptions extends ShapeOptionsWithDisplayValues< TLTextShape, TextShapeUtilDisplayValues > { /** How much addition padding should be added to the horizontal geometry of the shape when binding to an arrow? */ extraArrowHorizontalPadding: number /** Whether to show the outline of the text shape (using the same color as the canvas). This helps with overlapping shapes. It does not show up on Safari, where text outline is a performance issues. */ showTextOutline: boolean } /** @public */ export class TextShapeUtil extends ShapeUtil { static override type = 'text' as const static override props = textShapeProps static override migrations = textShapeMigrations override options: TextShapeOptions = { extraArrowHorizontalPadding: 10, showTextOutline: true, getDefaultDisplayValues(_editor, shape, theme, colorMode): TextShapeUtilDisplayValues { const { color, font, size } = shape.props return { color: getColorValue(theme.colors[colorMode], color, 'solid'), fontFamily: getFontFamily(theme, font), fontSize: theme.fontSize * FONT_SIZES[size], lineHeight: theme.lineHeight, fontWeight: TEXT_PROPS.fontWeight, fontStyle: TEXT_PROPS.fontStyle, fontVariant: TEXT_PROPS.fontVariant, } }, getCustomDisplayValues(): Partial { return {} }, } getDefaultProps(): TLTextShape['props'] { return { color: 'black', size: 'm', w: 8, font: 'draw', textAlign: 'start', autoSize: true, scale: 1, richText: toRichText(''), } } getMinDimensions(shape: TLTextShape) { return sizeCache.get(this.editor, shape.id)! } getGeometry(shape: TLTextShape, opts: TLGeometryOpts) { const { scale } = shape.props const { width, height } = this.getMinDimensions(shape)! const context = opts?.context ?? 'none' return new Rectangle2d({ x: (context === '@tldraw/arrow-without-arrowhead' ? -this.options.extraArrowHorizontalPadding : 0) * scale, width: (width + (context === '@tldraw/arrow-without-arrowhead' ? this.options.extraArrowHorizontalPadding * 2 : 0)) * scale, height: height * scale, isFilled: true, isLabel: true, }) } override getFontFaces(shape: TLTextShape) { const themeFaces = getThemeFontFaces(this.editor.getCurrentTheme(), shape.props.font) if (themeFaces) return themeFaces return getFontsFromRichText(this.editor, shape.props.richText, { family: `tldraw_${shape.props.font}`, weight: 'normal', style: 'normal', }) } override getText(shape: TLTextShape) { return renderPlaintextFromRichText(this.editor, shape.props.richText) } override canEdit(shape: TLTextShape) { return true } override isAspectRatioLocked(shape: TLTextShape) { return true } // WAIT NO THIS IS HARD CODED IN THE RESIZE HANDLER component(shape: TLTextShape) { const { id, props: { richText, scale, textAlign }, } = shape const { width, height } = this.getMinDimensions(shape) const isSelected = shape.id === this.editor.getOnlySelectedShapeId() const colorMode = useColorMode() const dv = getDisplayValues(this, shape, colorMode) const handleKeyDown = useTextShapeKeydownHandler(id) return ( ) } override getIndicatorPath(shape: TLTextShape): Path2D | undefined { if (shape.props.autoSize && this.editor.getEditingShapeId() === shape.id) return undefined const bounds = this.editor.getShapeGeometry(shape).bounds const path = new Path2D() path.rect(0, 0, bounds.width, bounds.height) return path } override toSvg(shape: TLTextShape, ctx: SvgExportContext) { const bounds = this.editor.getShapeGeometry(shape).bounds const width = bounds.width / (shape.props.scale ?? 1) const height = bounds.height / (shape.props.scale ?? 1) const dv = getDisplayValues(this, shape, ctx.colorMode) // Pad the exported foreignObject by a margin so glyph ink can spill past the advance box // without being clipped at its edge; the text is inset by the same margin so it renders in the // same place, and the export's trim crops the slack back to content. See TEXT_EXPORT_INK_MARGIN. const inkMargin = Math.ceil(dv.fontSize * TEXT_EXPORT_INK_MARGIN) const exportBounds = new Box( -inkMargin, -inkMargin, width + inkMargin * 2, height + inkMargin * 2 ) return ( ) } override onResize(shape: TLTextShape, info: TLResizeInfo) { const { newPoint, initialBounds, initialShape, scaleX, handle } = info if (info.mode === 'scale_shape' || (handle !== 'right' && handle !== 'left')) { return { id: shape.id, type: shape.type, ...resizeScaled(shape, info), } } else { const nextWidth = Math.max(1, Math.abs(initialBounds.width * scaleX)) const { x, y } = scaleX < 0 ? Vec.Sub(newPoint, Vec.FromAngle(shape.rotation).mul(nextWidth)) : newPoint return { id: shape.id, type: shape.type, x, y, props: { w: nextWidth / initialShape.props.scale, autoSize: false, }, } } } override onEditEnd(shape: TLTextShape) { // todo: find a way to check if the rich text has any nodes that aren't empty spaces const trimmedText = renderPlaintextFromRichText(this.editor, shape.props.richText).trimEnd() if (trimmedText.length === 0) { this.editor.deleteShapes([shape.id]) } } override onBeforeUpdate(prev: TLTextShape, next: TLTextShape) { if (!next.props.autoSize) return const styleDidChange = prev.props.size !== next.props.size || prev.props.textAlign !== next.props.textAlign || prev.props.font !== next.props.font || (prev.props.scale !== 1 && next.props.scale === 1) const textDidChange = !isEqual(prev.props.richText, next.props.richText) // Only update position if either changed if (!styleDidChange && !textDidChange) return // Might return a cached value for the bounds const boundsA = this.getMinDimensions(prev) // Will always be a fresh call to getTextSize const dv = getDisplayValues(this, next) const boundsB = getTextSize(this.editor, next.props, dv) const wA = boundsA.width * prev.props.scale const hA = boundsA.height * prev.props.scale const wB = boundsB.width * next.props.scale const hB = boundsB.height * next.props.scale let delta: Vec | undefined switch (next.props.textAlign) { case 'middle': { delta = new Vec((wB - wA) / 2, textDidChange ? 0 : (hB - hA) / 2) break } case 'end': { delta = new Vec(wB - wA, textDidChange ? 0 : (hB - hA) / 2) break } default: { if (textDidChange) break delta = new Vec(0, (hB - hA) / 2) break } } if (delta) { // account for shape rotation when writing text: delta.rot(next.rotation) const { x, y } = next return { ...next, x: x - delta.x, y: y - delta.y, props: { ...next.props, w: wB }, } } else { return { ...next, props: { ...next.props, w: wB }, } } } // todo: The edge doubleclicking feels like a mistake more often than // not, especially on multiline text. Removed June 16 2024 // override onDoubleClickEdge = (shape: TLTextShape) => { // // If the shape has a fixed width, set it to autoSize. // if (!shape.props.autoSize) { // return { // id: shape.id, // type: shape.type, // props: { // autoSize: true, // }, // } // } // // If the shape is scaled, reset the scale to 1. // if (shape.props.scale !== 1) { // return { // id: shape.id, // type: shape.type, // props: { // scale: 1, // }, // } // } // } } function getTextSize(editor: Editor, props: TLTextShape['props'], dv: TextShapeUtilDisplayValues) { const { richText, w } = props const minWidth = 16 const maybeFixedWidth = props.autoSize ? null : Math.max(minWidth, Math.floor(w)) const html = renderHtmlFromRichTextForMeasurement(editor, richText) const result = editor.textMeasure.measureHtml(html, { lineHeight: dv.lineHeight, fontWeight: dv.fontWeight, fontStyle: dv.fontStyle, padding: '0px', fontFamily: dv.fontFamily, fontSize: dv.fontSize, maxWidth: maybeFixedWidth, }) // If we're autosizing the measureText will essentially `Math.floor` // the numbers so `19` rather than `19.3`, this means we must +1 to // whatever we get to avoid wrapping. return { width: maybeFixedWidth ?? Math.max(minWidth, result.w + 1), height: Math.max(dv.fontSize, result.h), } } function useTextShapeKeydownHandler(id: TLShapeId) { const editor = useEditor() return useCallback( (e: KeyboardEvent) => { if (editor.getEditingShapeId() !== id) return switch (e.key) { case 'Enter': { if (e.ctrlKey || e.metaKey) { editor.complete() } break } } }, [editor, id] ) }