textfields [1 of 3]: add text into speech bubble; also add rich text example (#3050)

This is the first of three textfield changes. This starts with making
the speech bubble actually have text. Also, it creates a TipTap example
and how that would be wired up.

🎵 this is dangerous, I walk through textfields so watch your head rock 🎵

### Change Type

- [x] `minor` — New feature

### Release Notes

- Refactor textfields be composable/swappable.
This commit is contained in:
Mime Čuvalo 2024-03-27 09:33:48 +00:00 committed by GitHub
parent 3593799d9e
commit d76d53db95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 616 additions and 346 deletions

View file

@ -2,7 +2,7 @@
title: Speech bubble
component: ./CustomShapeWithHandles.tsx
category: shapes/tools
priority: 2
priority: 1
---
A custom shape with handles

View file

@ -1,19 +1,24 @@
import { ShapePropsType } from '@tldraw/tlschema/src/shapes/TLBaseShape'
import {
DefaultColorStyle,
DefaultFontStyle,
DefaultHorizontalAlignStyle,
DefaultSizeStyle,
DefaultVerticalAlignStyle,
FONT_FAMILIES,
Geometry2d,
LABEL_FONT_SIZES,
Polygon2d,
ShapeUtil,
T,
TEXT_PROPS,
TLBaseShape,
TLDefaultColorStyle,
TLDefaultSizeStyle,
TLHandle,
TLOnBeforeUpdateHandler,
TLOnHandleDragHandler,
TLOnResizeHandler,
TextLabel,
Vec,
VecModel,
ZERO_INDEX_KEY,
getDefaultColorTheme,
resizeBox,
@ -33,28 +38,28 @@ export const STROKE_SIZES = {
// There's a guide at the bottom of this file!
// [1]
export type SpeechBubbleShape = TLBaseShape<
'speech-bubble',
{
w: number
h: number
size: TLDefaultSizeStyle
color: TLDefaultColorStyle
tail: VecModel
}
>
export const speechBubbleShapeProps = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
font: DefaultFontStyle,
align: DefaultHorizontalAlignStyle,
verticalAlign: DefaultVerticalAlignStyle,
growY: T.positiveNumber,
text: T.string,
tail: vecModelValidator,
}
export type SpeechBubbleShapeProps = ShapePropsType<typeof speechBubbleShapeProps>
export type SpeechBubbleShape = TLBaseShape<'speech-bubble', SpeechBubbleShapeProps>
export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
static override type = 'speech-bubble' as const
// [2]
static override props = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
tail: vecModelValidator,
}
static override props = speechBubbleShapeProps
override isAspectRatioLocked = (_shape: SpeechBubbleShape) => false
@ -62,17 +67,28 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
override canBind = (_shape: SpeechBubbleShape) => true
override canEdit = () => true
// [3]
getDefaultProps(): SpeechBubbleShape['props'] {
getDefaultProps(): SpeechBubbleShapeProps {
return {
w: 200,
h: 130,
color: 'black',
size: 'm',
font: 'draw',
align: 'middle',
verticalAlign: 'start',
growY: 0,
text: '',
tail: { x: 0.5, y: 1.5 },
}
}
getHeight(shape: SpeechBubbleShape) {
return shape.props.h + shape.props.growY
}
getGeometry(shape: SpeechBubbleShape): Geometry2d {
const speechBubbleGeometry = getSpeechBubbleVertices(shape)
const body = new Polygon2d({
@ -84,7 +100,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
// [4]
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
const { tail, w, h } = shape.props
const { tail, w } = shape.props
return [
{
@ -94,7 +110,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
// props.tail coordinates are normalized
// but here we need them in shape space
x: tail.x * w,
y: tail.y * h,
y: tail.y * this.getHeight(shape),
},
]
}
@ -105,29 +121,34 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
props: {
tail: {
x: handle.x / shape.props.w,
y: handle.y / shape.props.h,
y: handle.y / this.getHeight(shape),
},
},
}
}
override onBeforeCreate = (next: SpeechBubbleShape) => {
return this.getGrowY(next, next.props.growY)
}
// [5]
override onBeforeUpdate: TLOnBeforeUpdateHandler<SpeechBubbleShape> | undefined = (
_: SpeechBubbleShape,
prev: SpeechBubbleShape,
shape: SpeechBubbleShape
) => {
const { w, h, tail } = shape.props
const { w, tail } = shape.props
const fullHeight = this.getHeight(shape)
const { segmentsIntersection, insideShape } = getTailIntersectionPoint(shape)
const slantedLength = Math.hypot(w, h)
const slantedLength = Math.hypot(w, fullHeight)
const MIN_DISTANCE = slantedLength / 5
const MAX_DISTANCE = slantedLength / 1.5
const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
const tailInShapeSpace = new Vec(tail.x * w, tail.y * fullHeight)
const distanceToIntersection = tailInShapeSpace.dist(segmentsIntersection)
const center = new Vec(w / 2, h / 2)
const center = new Vec(w / 2, fullHeight / 2)
const tailDirection = Vec.Sub(tailInShapeSpace, center).uni()
let newPoint = tailInShapeSpace
@ -144,12 +165,17 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
const next = structuredClone(shape)
next.props.tail.x = newPoint.x / w
next.props.tail.y = newPoint.y / h
next.props.tail.y = newPoint.y / fullHeight
return next
return this.getGrowY(next, prev.props.growY)
}
component(shape: SpeechBubbleShape) {
const {
id,
type,
props: { color, font, size, align, text },
} = shape
const theme = getDefaultColorTheme({
isDarkMode: this.editor.user.getIsDarkMode(),
})
@ -161,11 +187,24 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
<svg className="tl-svg-container">
<path
d={pathData}
strokeWidth={STROKE_SIZES[shape.props.size]}
stroke={theme[shape.props.color].solid}
strokeWidth={STROKE_SIZES[size]}
stroke={theme[color].solid}
fill={'none'}
/>
</svg>
<TextLabel
id={id}
type={type}
font={font}
fontSize={LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign="start"
text={text}
labelColor={color}
wrap
/>
</>
)
}
@ -185,6 +224,37 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
next.props.h = resized.props.h
return next
}
getGrowY(shape: SpeechBubbleShape, prevGrowY = 0) {
const PADDING = 17
const nextTextSize = this.editor.textMeasure.measureText(shape.props.text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size],
maxWidth: shape.props.w - PADDING * 2,
})
const nextHeight = nextTextSize.h + PADDING * 2
let growY = 0
if (nextHeight > shape.props.h) {
growY = nextHeight - shape.props.h
} else {
if (prevGrowY) {
growY = 0
}
}
return {
...shape,
props: {
...shape.props,
growY,
},
}
}
}
/*

View file

@ -2,14 +2,20 @@ import { Vec, VecLike, lerp, pointInPolygon } from 'tldraw'
import { SpeechBubbleShape } from './SpeechBubbleUtil'
export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
const { w, h, tail } = shape.props
const { w, tail } = shape.props
const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
const fullHeight = shape.props.h + shape.props.growY
const tailInShapeSpace = new Vec(tail.x * w, tail.y * fullHeight)
const [tl, tr, br, bl] = [new Vec(0, 0), new Vec(w, 0), new Vec(w, h), new Vec(0, h)]
const [tl, tr, br, bl] = [
new Vec(0, 0),
new Vec(w, 0),
new Vec(w, fullHeight),
new Vec(0, fullHeight),
]
const offsetH = w / 10
const offsetV = h / 10
const offsetV = fullHeight / 10
const { adjustedIntersection, intersectionSegmentIndex } = getTailIntersectionPoint(shape)
@ -73,11 +79,12 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
}
export function getTailIntersectionPoint(shape: SpeechBubbleShape) {
const { w, h, tail } = shape.props
const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
const { w, tail } = shape.props
const fullHeight = shape.props.h + shape.props.growY
const tailInShapeSpace = new Vec(tail.x * w, tail.y * fullHeight)
const center = new Vec(w / 2, h / 2)
const corners = [new Vec(0, 0), new Vec(w, 0), new Vec(w, h), new Vec(0, h)]
const center = new Vec(w / 2, fullHeight / 2)
const corners = [new Vec(0, 0), new Vec(w, 0), new Vec(w, fullHeight), new Vec(0, fullHeight)]
const segments = [
[corners[0], corners[1]],
[corners[1], corners[2]],
@ -132,7 +139,7 @@ export function getTailIntersectionPoint(shape: SpeechBubbleShape) {
const squared = mapRange(-1, 1, 0, totalDistance, squaredRelative) // -1 to 1 -> absolute
//keep it away from the edges
const offset = (segments.indexOf(intersectionSegment) % 2 === 0 ? w / 10 : h / 10) * 3
const offset = (segments.indexOf(intersectionSegment) % 2 === 0 ? w / 10 : fullHeight / 10) * 3
const constrained = mapRange(0, totalDistance, offset, totalDistance - offset, distance)
// combine the two

View file

@ -2,3 +2,9 @@
.tl-user-handles {
z-index: 101;
}
/* The text label doesn't normally deal with text that goes sideways,
* so this accounts for that */
.tl-shape[data-shape-type='speech-bubble'] .tl-text-label {
justify-content: flex-start !important;
}

View file

@ -682,7 +682,7 @@ input,
/* ------------------- Text Shape ------------------- */
.tl-text-shape__wrapper {
.tl-text-shape-label {
position: relative;
font-weight: normal;
min-width: 1px;
@ -698,35 +698,38 @@ input,
text-shadow: var(--tl-text-outline);
}
.tl-text-shape__wrapper[data-align='start'] {
text-align: left;
}
.tl-text-shape__wrapper[data-align='middle'] {
text-align: center;
}
.tl-text-shape__wrapper[data-align='end'] {
text-align: right;
}
.tl-text-shape__wrapper[data-font='draw'] {
.tl-text-wrapper[data-font='draw'] {
font-family: var(--tl-font-draw);
}
.tl-text-shape__wrapper[data-font='sans'] {
.tl-text-wrapper[data-font='sans'] {
font-family: var(--tl-font-sans);
}
.tl-text-shape__wrapper[data-font='serif'] {
.tl-text-wrapper[data-font='serif'] {
font-family: var(--tl-font-serif);
}
.tl-text-shape__wrapper[data-font='mono'] {
.tl-text-wrapper[data-font='mono'] {
font-family: var(--tl-font-mono);
}
.tl-text-shape__wrapper[data-isediting='true'] .tl-text-content {
.tl-text-wrapper[data-align='start'],
.tl-text-wrapper[data-align='start-legacy'] {
text-align: left;
}
.tl-text-wrapper[data-align='middle'],
.tl-text-wrapper[data-align='middle-legacy'] {
text-align: center;
}
.tl-text-wrapper[data-align='end'],
.tl-text-wrapper[data-align='end-legacy'] {
text-align: right;
}
.tl-text-wrapper[data-isediting='true'] .tl-text-content {
opacity: 0;
}
@ -995,10 +998,6 @@ input,
z-index: 10;
}
.tl-text-label[data-isediting='true'] .tl-text-content {
opacity: 0;
}
.tl-text-label[data-hastext='false'][data-isediting='false'] > .tl-text-label__inner {
width: 40px;
height: 40px;
@ -1053,21 +1052,6 @@ input,
opacity: 0;
}
.tl-text-label[data-align='start'],
.tl-text-label[data-align='start-legacy'] {
text-align: left;
}
.tl-text-label[data-align='middle'],
.tl-text-label[data-align='middle-legacy'] {
text-align: center;
}
.tl-text-label[data-align='end'],
.tl-text-label[data-align='end-legacy'] {
text-align: right;
}
.tl-arrow-hint {
stroke: var(--color-text-1);
fill: none;
@ -1075,26 +1059,6 @@ input,
overflow: visible;
}
.tl-arrow-label[data-font='draw'],
.tl-text-label[data-font='draw'] {
font-family: var(--tl-font-draw);
}
.tl-arrow-label[data-font='sans'],
.tl-text-label[data-font='sans'] {
font-family: var(--tl-font-sans);
}
.tl-arrow-label[data-font='serif'],
.tl-text-label[data-font='serif'] {
font-family: var(--tl-font-serif);
}
.tl-arrow-label[data-font='mono'],
.tl-text-label[data-font='mono'] {
font-family: var(--tl-font-mono);
}
/* ------------------- Arrow Shape ------------------ */
.tl-arrow-label {
@ -1107,6 +1071,7 @@ input,
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: var(--color-text);
text-shadow: var(--tl-text-outline);
}
@ -1131,40 +1096,7 @@ input,
align-items: center;
}
.tl-arrow-label p,
.tl-arrow-label textarea {
margin: 0px;
padding: 0px;
border: 0px;
color: inherit;
caret-color: var(--color-text);
background: none;
border-image: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
font-variant: inherit;
font-style: inherit;
text-align: inherit;
letter-spacing: inherit;
text-shadow: inherit;
outline: none;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
pointer-events: all;
text-rendering: auto;
text-transform: none;
text-indent: 0px;
display: inline-block;
appearance: auto;
column-count: initial !important;
writing-mode: horizontal-tb !important;
word-spacing: 0px;
}
.tl-arrow-label p {
.tl-arrow-label .tl-arrow {
position: relative;
height: max-content;
z-index: 2;

View file

@ -62,8 +62,13 @@ import { TLBookmarkShape } from '@tldraw/editor';
import { TLCancelEvent } from '@tldraw/editor';
import { TLClickEvent } from '@tldraw/editor';
import { TLClickEventInfo } from '@tldraw/editor';
import { TLDefaultColorStyle } from '@tldraw/editor';
import { TLDefaultColorTheme } from '@tldraw/editor';
import { TLDefaultFillStyle } from '@tldraw/editor';
import { TLDefaultFontStyle } from '@tldraw/editor';
import { TLDefaultHorizontalAlignStyle } from '@tldraw/editor';
import { TLDefaultSizeStyle } from '@tldraw/editor';
import { TLDefaultVerticalAlignStyle } from '@tldraw/editor';
import { TldrawEditorBaseProps } from '@tldraw/editor';
import { TLDrawShape } from '@tldraw/editor';
import { TLDrawShapeSegment } from '@tldraw/editor';
@ -613,6 +618,9 @@ export function fitFrameToContent(editor: Editor, id: TLShapeId, opts?: {
// @public (undocumented)
export function FitFrameToContentMenuItem(): JSX_2.Element | null;
// @public (undocumented)
export const FONT_FAMILIES: Record<TLDefaultFontStyle, string>;
// @public (undocumented)
export class FrameShapeTool extends BaseBoxShapeTool {
// (undocumented)
@ -966,6 +974,9 @@ export function isGifAnimated(file: Blob): Promise<boolean>;
// @public (undocumented)
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
// @public (undocumented)
export const LABEL_FONT_SIZES: Record<TLDefaultSizeStyle, number>;
// @public (undocumented)
export function LanguageMenu(): JSX_2.Element;
@ -1279,6 +1290,18 @@ export function StackMenuItems(): JSX_2.Element;
// @public (undocumented)
export function StarToolbarItem(): JSX_2.Element;
// @public (undocumented)
export const TEXT_PROPS: {
lineHeight: number;
fontWeight: string;
fontVariant: string;
fontStyle: string;
padding: string;
};
// @public (undocumented)
export const TextLabel: React_2.NamedExoticComponent<TextLabelProps>;
// @public (undocumented)
export class TextShapeTool extends StateNode {
// (undocumented)
@ -2475,6 +2498,19 @@ export function useDefaultHelpers(): {
// @public (undocumented)
export function useDialogs(): TLUiDialogsContextType;
// @public (undocumented)
export function useEditableText(id: TLShapeId, type: string, text: string): {
rInput: React_2.RefObject<HTMLTextAreaElement>;
isEditing: boolean;
handleFocus: () => void;
handleBlur: () => void;
handleKeyDown: (e: React_2.KeyboardEvent<HTMLTextAreaElement>) => void;
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
handleInputPointerDown: (e: React_2.PointerEvent) => void;
handleDoubleClick: (e: any) => any;
isEmpty: boolean;
};
// @public (undocumented)
export function useExportAs(): (ids: TLShapeId[], format: TLExportType | undefined, name: string | undefined) => void;

View file

@ -6887,6 +6887,43 @@
"parameters": [],
"name": "FitFrameToContentMenuItem"
},
{
"kind": "Variable",
"canonicalReference": "tldraw!FONT_FAMILIES:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "FONT_FAMILIES: "
},
{
"kind": "Reference",
"text": "Record",
"canonicalReference": "!Record:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLDefaultFontStyle",
"canonicalReference": "@tldraw/tlschema!TLDefaultFontStyle:type"
},
{
"kind": "Content",
"text": ", string>"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts",
"isReadonly": true,
"releaseTag": "Public",
"name": "FONT_FAMILIES",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
}
},
{
"kind": "Class",
"canonicalReference": "tldraw!FrameShapeTool:class",
@ -11244,6 +11281,43 @@
"parameters": [],
"name": "KeyboardShortcutsMenuItem"
},
{
"kind": "Variable",
"canonicalReference": "tldraw!LABEL_FONT_SIZES:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "LABEL_FONT_SIZES: "
},
{
"kind": "Reference",
"text": "Record",
"canonicalReference": "!Record:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLDefaultSizeStyle",
"canonicalReference": "@tldraw/tlschema!TLDefaultSizeStyle:type"
},
{
"kind": "Content",
"text": ", number>"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts",
"isReadonly": true,
"releaseTag": "Public",
"name": "LABEL_FONT_SIZES",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
}
},
{
"kind": "Function",
"canonicalReference": "tldraw!LanguageMenu:function(1)",
@ -14997,6 +15071,66 @@
"parameters": [],
"name": "StarToolbarItem"
},
{
"kind": "Variable",
"canonicalReference": "tldraw!TEXT_PROPS:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "TEXT_PROPS: "
},
{
"kind": "Content",
"text": "{\n lineHeight: number;\n fontWeight: string;\n fontVariant: string;\n fontStyle: string;\n padding: string;\n}"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts",
"isReadonly": true,
"releaseTag": "Public",
"name": "TEXT_PROPS",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "Variable",
"canonicalReference": "tldraw!TextLabel:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "TextLabel: "
},
{
"kind": "Reference",
"text": "React.NamedExoticComponent",
"canonicalReference": "@types/react!React.NamedExoticComponent:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TextLabelProps",
"canonicalReference": "tldraw!~TextLabelProps:type"
},
{
"kind": "Content",
"text": ">"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/TextLabel.tsx",
"isReadonly": true,
"releaseTag": "Public",
"name": "TextLabel",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
}
},
{
"kind": "Class",
"canonicalReference": "tldraw!TextShapeTool:class",
@ -27289,6 +27423,147 @@
"parameters": [],
"name": "useDialogs"
},
{
"kind": "Function",
"canonicalReference": "tldraw!useEditableText:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function useEditableText(id: "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": ", type: "
},
{
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": ", text: "
},
{
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "{\n rInput: "
},
{
"kind": "Reference",
"text": "React.RefObject",
"canonicalReference": "@types/react!React.RefObject:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "HTMLTextAreaElement",
"canonicalReference": "!HTMLTextAreaElement:interface"
},
{
"kind": "Content",
"text": ">;\n isEditing: boolean;\n handleFocus: () => void;\n handleBlur: () => void;\n handleKeyDown: (e: "
},
{
"kind": "Reference",
"text": "React.KeyboardEvent",
"canonicalReference": "@types/react!React.KeyboardEvent:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "HTMLTextAreaElement",
"canonicalReference": "!HTMLTextAreaElement:interface"
},
{
"kind": "Content",
"text": ">) => void;\n handleChange: (e: "
},
{
"kind": "Reference",
"text": "React.ChangeEvent",
"canonicalReference": "@types/react!React.ChangeEvent:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "HTMLTextAreaElement",
"canonicalReference": "!HTMLTextAreaElement:interface"
},
{
"kind": "Content",
"text": ">) => void;\n handleInputPointerDown: (e: "
},
{
"kind": "Reference",
"text": "React.PointerEvent",
"canonicalReference": "@types/react!React.PointerEvent:interface"
},
{
"kind": "Content",
"text": ") => void;\n handleDoubleClick: (e: any) => any;\n isEmpty: boolean;\n}"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 22
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "id",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "type",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "text",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
}
],
"name": "useEditableText"
},
{
"kind": "Function",
"canonicalReference": "tldraw!useExportAs:function(1)",

View file

@ -33,6 +33,7 @@ export { LineShapeTool } from './lib/shapes/line/LineShapeTool'
export { LineShapeUtil } from './lib/shapes/line/LineShapeUtil'
export { NoteShapeTool } from './lib/shapes/note/NoteShapeTool'
export { NoteShapeUtil } from './lib/shapes/note/NoteShapeUtil'
export { TextLabel } from './lib/shapes/shared/TextLabel'
export { TextShapeTool } from './lib/shapes/text/TextShapeTool'
export { TextShapeUtil } from './lib/shapes/text/TextShapeUtil'
export { VideoShapeUtil } from './lib/shapes/video/VideoShapeUtil'
@ -42,6 +43,7 @@ export { LaserTool } from './lib/tools/LaserTool/LaserTool'
export { SelectTool } from './lib/tools/SelectTool/SelectTool'
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
// UI
export { useEditableText } from './lib/shapes/shared/useEditableText'
export { TldrawUi, type TldrawUiBaseProps, type TldrawUiProps } from './lib/ui/TldrawUi'
export { setDefaultUiAssetUrls, type TLUiAssetUrlOverrides } from './lib/ui/assetUrls'
export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator'
@ -422,3 +424,11 @@ export {
TldrawUiMenuSubmenu,
type TLUiMenuSubmenuProps,
} from './lib/ui/components/primitives/menus/TldrawUiMenuSubmenu'
/* ----------------- Constants ---------------- */
export {
FONT_FAMILIES,
LABEL_FONT_SIZES,
TEXT_PROPS,
} from './lib/shapes/shared/default-shape-constants'

View file

@ -516,9 +516,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
}
component(shape: TLArrowShape) {
// Not a class component, but eslint can't tell that :(
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
const onlySelectedShape = this.editor.getOnlySelectedShape()
const shouldDisplayHandles =
this.editor.isInAny(
@ -549,7 +546,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
size={shape.props.size}
position={labelPosition.box.center}
width={labelPosition.box.w}
labelColor={theme[shape.props.labelColor].solid}
labelColor={shape.props.labelColor}
/>
</>
)

View file

@ -1,8 +1,7 @@
import { TLArrowShape, TLShapeId, VecLike, stopEventPropagation } from '@tldraw/editor'
import { TLArrowShape, TLDefaultColorStyle, TLShapeId, VecLike } from '@tldraw/editor'
import * as React from 'react'
import { TextHelpers } from '../../shared/TextHelpers'
import { TextLabel } from '../../shared/TextLabel'
import { ARROW_LABEL_FONT_SIZES, TEXT_PROPS } from '../../shared/default-shape-constants'
import { useEditableText } from '../../shared/useEditableText'
export const ArrowTextLabel = React.memo(function ArrowTextLabel({
id,
@ -12,77 +11,26 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
position,
width,
labelColor,
}: { id: TLShapeId; position: VecLike; width?: number; labelColor: string } & Pick<
}: { id: TLShapeId; position: VecLike; width?: number; labelColor: TLDefaultColorStyle } & Pick<
TLArrowShape['props'],
'text' | 'size' | 'font'
>) {
const {
rInput,
isEditing,
handleFocus,
handleBlur,
handleKeyDown,
handleChange,
isEmpty,
handleInputPointerDown,
handleDoubleClick,
} = useEditableText(id, 'arrow', text)
const finalText = TextHelpers.normalizeTextForDom(text)
const hasText = finalText.trim().length > 0
if (!isEditing && !hasText) {
return null
}
return (
<div
className="tl-arrow-label"
data-font={font}
data-align={'center'}
data-hastext={!isEmpty}
data-isediting={isEditing}
<TextLabel
id={id}
classNamePrefix="tl-arrow"
type="arrow"
font={font}
fontSize={ARROW_LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align="middle"
verticalAlign="middle"
text={text}
labelColor={labelColor}
textWidth={width}
style={{
textAlign: 'center',
fontSize: ARROW_LABEL_FONT_SIZES[size],
lineHeight: ARROW_LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 'px',
transform: `translate(${position.x}px, ${position.y}px)`,
color: labelColor,
}}
>
<div className="tl-arrow-label__inner">
<p style={{ width: width ? width : '9px' }}>
{text ? TextHelpers.normalizeTextForDom(text) : ' '}
</p>
{isEditing && (
// Consider replacing with content-editable
<textarea
ref={rInput}
className="tl-text tl-text-input"
name="text"
tabIndex={-1}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
autoSave="off"
autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onTouchEnd={stopEventPropagation}
onContextMenu={stopEventPropagation}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>
)}
</div>
</div>
/>
)
})

View file

@ -31,6 +31,7 @@ import { TextLabel } from '../shared/TextLabel'
import {
FONT_FAMILIES,
LABEL_FONT_SIZES,
LABEL_PADDING,
STROKE_SIZES,
TEXT_PROPS,
} from '../shared/default-shape-constants'
@ -46,7 +47,6 @@ import { GeoShapeBody } from './components/GeoShapeBody'
import { getOvalIndicatorPath } from './components/SolidStyleOval'
import { getLines } from './getLines'
const LABEL_PADDING = 16
const MIN_SIZE_WITH_LABEL = 17 * 3
/** @public */
@ -396,8 +396,9 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
id={id}
type={type}
font={font}
fontSize={LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
fill={fill}
size={size}
align={align}
verticalAlign={verticalAlign}
text={text}

View file

@ -83,7 +83,8 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
id={id}
type={type}
font={font}
size={size}
fontSize={LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign={verticalAlign}
text={text}

View file

@ -4,36 +4,23 @@ import {
TLDefaultFillStyle,
TLDefaultFontStyle,
TLDefaultHorizontalAlignStyle,
TLDefaultSizeStyle,
TLDefaultVerticalAlignStyle,
TLShape,
stopEventPropagation,
TLShapeId,
getDefaultColorTheme,
useIsDarkMode,
} from '@tldraw/editor'
import React from 'react'
import { useDefaultColorTheme } from './ShapeFill'
import { TextArea } from '../text/TextArea'
import { TextHelpers } from './TextHelpers'
import { LABEL_FONT_SIZES, TEXT_PROPS } from './default-shape-constants'
import { isLegacyAlign } from './legacyProps'
import { useEditableText } from './useEditableText'
export const TextLabel = React.memo(function TextLabel<
T extends Extract<TLShape, { props: { text: string } }>,
>({
id,
type,
text,
size,
labelColor,
font,
align,
verticalAlign,
wrap,
bounds,
}: {
id: T['id']
type: T['type']
size: TLDefaultSizeStyle
type TextLabelProps = {
id: TLShapeId
type: string
font: TLDefaultFontStyle
fontSize: number
lineHeight: number
fill?: TLDefaultFillStyle
align: TLDefaultHorizontalAlignStyle
verticalAlign: TLDefaultVerticalAlignStyle
@ -41,32 +28,47 @@ export const TextLabel = React.memo(function TextLabel<
text: string
labelColor: TLDefaultColorStyle
bounds?: Box
}) {
const {
rInput,
isEmpty,
isEditing,
handleFocus,
handleChange,
handleKeyDown,
handleBlur,
handleInputPointerDown,
handleDoubleClick,
} = useEditableText(id, type, text)
classNamePrefix?: string
style?: React.CSSProperties
textWidth?: number
textHeight?: number
}
/** @public */
export const TextLabel = React.memo(function TextLabel({
id,
type,
text,
labelColor,
font,
fontSize,
lineHeight,
align,
verticalAlign,
wrap,
bounds,
classNamePrefix,
style,
textWidth,
textHeight,
}: TextLabelProps) {
const { rInput, isEmpty, isEditing, ...editableTextRest } = useEditableText(id, type, text)
const finalText = TextHelpers.normalizeTextForDom(text)
const hasText = finalText.length > 0
const legacyAlign = isLegacyAlign(align)
const theme = useDefaultColorTheme()
const theme = getDefaultColorTheme({ isDarkMode: useIsDarkMode() })
if (!isEditing && !hasText) {
return null
}
// TODO: probably combine tl-text and tl-arrow eventually
const cssPrefix = classNamePrefix || 'tl-text'
return (
<div
className="tl-text-label"
className={`${cssPrefix}-label tl-text-wrapper`}
data-font={font}
data-align={align}
data-hastext={!isEmpty}
@ -84,48 +86,25 @@ export const TextLabel = React.memo(function TextLabel<
position: 'absolute',
}
: {}),
...style,
}}
>
<div
className="tl-text-label__inner"
className={`${cssPrefix}-label__inner`}
style={{
fontSize: LABEL_FONT_SIZES[size],
lineHeight: LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 'px',
minHeight: TEXT_PROPS.lineHeight + 32,
minWidth: 0,
fontSize,
lineHeight: fontSize * lineHeight + 'px',
minHeight: lineHeight + 32,
minWidth: textWidth || 0,
color: theme[labelColor].solid,
width: textWidth,
height: textHeight,
}}
>
<div className="tl-text tl-text-content" dir="ltr">
<div className={`${cssPrefix} tl-text tl-text-content`} dir="ltr">
{finalText}
</div>
{isEditing && (
<textarea
ref={rInput}
className="tl-text tl-text-input"
name="text"
tabIndex={-1}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
autoSave="off"
autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onTouchEnd={stopEventPropagation}
onContextMenu={stopEventPropagation}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>
)}
{isEditing && <TextArea ref={rInput} text={text} {...editableTextRest} />}
</div>
</div>
)

View file

@ -53,3 +53,5 @@ export const FONT_FAMILIES: Record<TLDefaultFontStyle, string> = {
export const LABEL_TO_ARROW_PADDING = 20
/** @internal */
export const ARROW_LABEL_PADDING = 4.25
/** @internal */
export const LABEL_PADDING = 16

View file

@ -2,6 +2,7 @@
import {
TLShape,
TLShapeId,
TLUnknownShape,
getPointerInfo,
preventDefault,
@ -12,11 +13,8 @@ import {
import React, { useCallback, useEffect, useRef } from 'react'
import { INDENT, TextHelpers } from './TextHelpers'
export function useEditableText<T extends Extract<TLShape, { props: { text: string } }>>(
id: T['id'],
type: T['type'],
text: string
) {
/** @public */
export function useEditableText(id: TLShapeId, type: string, text: string) {
const editor = useEditor()
const rInput = useRef<HTMLTextAreaElement>(null)

View file

@ -0,0 +1,53 @@
import { stopEventPropagation } from '@tldraw/editor'
import { forwardRef } from 'react'
type TextAreaProps = {
text: string
handleFocus: () => void
handleBlur: () => void
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
handleInputPointerDown: (e: React.PointerEvent<HTMLTextAreaElement>) => void
handleDoubleClick: (e: any) => any
}
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
{
text,
handleFocus,
handleChange,
handleKeyDown,
handleBlur,
handleInputPointerDown,
handleDoubleClick,
},
ref
) {
return (
<textarea
ref={ref}
className="tl-text tl-text-input"
name="text"
tabIndex={-1}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
autoSave="off"
autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onTouchEnd={stopEventPropagation}
onContextMenu={stopEventPropagation}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>
)
})

View file

@ -12,18 +12,16 @@ import {
TLTextShape,
Vec,
WeakMapCache,
getDefaultColorTheme,
stopEventPropagation,
textShapeMigrations,
textShapeProps,
toDomPrecision,
useEditor,
} from '@tldraw/editor'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
import { resizeScaled } from '../shared/resizeScaled'
import { useEditableText } from '../shared/useEditableText'
const sizeCache = new WeakMapCache<TLTextShape['props'], { height: number; width: number }>()
@ -67,75 +65,32 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
component(shape: TLTextShape) {
const {
id,
type,
props: { text, color },
props: { font, size, text, color, scale, align },
} = shape
const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() })
const { width, height } = this.getMinDimensions(shape)
const {
rInput,
isEmpty,
isEditing,
handleFocus,
handleChange,
handleKeyDown,
handleBlur,
handleInputPointerDown,
handleDoubleClick,
} = useEditableText(id, type, text)
return (
<HTMLContainer id={shape.id}>
<div
className="tl-text-shape__wrapper tl-text-shadow"
data-font={shape.props.font}
data-align={shape.props.align}
data-hastext={!isEmpty}
data-isediting={isEditing}
data-textwrap={true}
<TextLabel
id={id}
classNamePrefix="tl-text-shape"
type="text"
font={font}
fontSize={FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
align={align}
verticalAlign="middle"
text={text}
labelColor={color}
textWidth={width}
textHeight={height}
style={{
fontSize: FONT_SIZES[shape.props.size],
lineHeight: FONT_SIZES[shape.props.size] * TEXT_PROPS.lineHeight + 'px',
transform: `scale(${shape.props.scale})`,
transform: `scale(${scale})`,
transformOrigin: 'top left',
width: Math.max(1, width),
height: Math.max(FONT_SIZES[shape.props.size] * TEXT_PROPS.lineHeight, height),
color: theme[color].solid,
}}
>
<div className="tl-text tl-text-content" dir="ltr">
{text}
</div>
{isEditing ? (
<textarea
ref={rInput}
className="tl-text tl-text-input"
name="text"
tabIndex={-1}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
autoSave="off"
autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onTouchEnd={stopEventPropagation}
onContextMenu={stopEventPropagation}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>
) : null}
</div>
wrap
/>
</HTMLContainer>
)
}