Tldraw/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx

401 wiersze
10 KiB
TypeScript
Czysty Zwykły widok Historia

2021-09-11 22:17:54 +00:00
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2021-08-10 16:12:55 +00:00
import * as React from 'react'
2021-10-28 16:50:58 +00:00
import { Utils, HTMLContainer, TLBounds } from '@tldraw/core'
import { defaultStyle, getShapeStyle, getFontStyle } from '../shape-styles'
2021-10-28 16:50:58 +00:00
import { TextShape, TLDrawMeta, TLDrawShapeType, TLDrawTransformInfo } from '~types'
import { TextAreaUtils } from '../shared'
import { BINDING_DISTANCE } from '~constants'
import { TLDrawShapeUtil } from '../TLDrawShapeUtil'
import { styled } from '~styles'
import Vec from '@tldraw/vec'
2021-08-10 16:12:55 +00:00
type T = TextShape
type E = HTMLDivElement
2021-08-10 16:12:55 +00:00
export class TextUtil extends TLDrawShapeUtil<T, E> {
type = TLDrawShapeType.Text as const
2021-09-13 15:38:42 +00:00
isAspectRatioLocked = true
2021-09-13 15:38:42 +00:00
canEdit = true
2021-09-13 15:38:42 +00:00
canBind = true
2021-08-10 16:12:55 +00:00
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
id: 'id',
type: TLDrawShapeType.Text,
name: 'Text',
parentId: 'page',
childIndex: 1,
point: [0, 0],
rotation: 0,
text: ' ',
style: defaultStyle,
},
props
2021-08-10 16:12:55 +00:00
)
}
2021-08-10 16:12:55 +00:00
2021-10-28 16:50:58 +00:00
Component = TLDrawShapeUtil.Component<T, E, TLDrawMeta>(
2021-10-27 16:16:07 +00:00
({ shape, isBinding, isEditing, onShapeBlur, onShapeChange, meta, events }, ref) => {
const rInput = React.useRef<HTMLTextAreaElement>(null)
const { text, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode)
const font = getFontStyle(shape.style)
2021-08-10 16:12:55 +00:00
2021-10-27 16:16:07 +00:00
const rIsMounted = React.useRef(false)
2021-10-27 16:16:07 +00:00
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
},
[shape]
)
2021-08-10 16:12:55 +00:00
2021-10-27 16:16:07 +00:00
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
2021-10-27 16:16:07 +00:00
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
e.currentTarget.blur()
return
2021-09-11 22:17:54 +00:00
}
2021-08-10 16:12:55 +00:00
2021-10-27 16:16:07 +00:00
if (e.key === 'Tab') {
e.preventDefault()
if (e.shiftKey) {
TextAreaUtils.unindent(e.currentTarget)
} else {
TextAreaUtils.indent(e.currentTarget)
}
2021-09-13 15:38:42 +00:00
2021-10-27 16:16:07 +00:00
onShapeChange?.({ ...shape, text: normalizeText(e.currentTarget.value) })
}
},
[shape, onShapeChange]
)
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!isEditing) return
if (rIsMounted.current) {
e.currentTarget.setSelectionRange(0, 0)
onShapeBlur?.()
}
},
[isEditing]
)
2021-09-13 15:38:42 +00:00
2021-10-27 16:16:07 +00:00
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!isEditing) return
if (!rIsMounted.current) return
2021-10-27 16:16:07 +00:00
if (document.activeElement === e.currentTarget) {
e.currentTarget.select()
}
},
[isEditing]
)
const handlePointerDown = React.useCallback(
(e) => {
if (isEditing) {
e.stopPropagation()
}
},
[isEditing]
)
2021-09-13 15:38:42 +00:00
2021-10-27 16:16:07 +00:00
React.useEffect(() => {
2021-09-11 22:17:54 +00:00
if (isEditing) {
2021-10-27 16:16:07 +00:00
requestAnimationFrame(() => {
rIsMounted.current = true
const elm = rInput.current!
elm.focus()
elm.select()
})
2021-09-11 15:24:03 +00:00
}
2021-10-27 16:16:07 +00:00
}, [isEditing])
return (
<HTMLContainer ref={ref} {...events}>
<Wrapper isEditing={isEditing} onPointerDown={handlePointerDown}>
<InnerWrapper
2021-10-27 16:16:07 +00:00
style={{
font,
color: styles.stroke,
}}
>
{isBinding && (
<div
className="tl-binding-indicator"
style={{
position: 'absolute',
top: -BINDING_DISTANCE,
left: -BINDING_DISTANCE,
width: `calc(100% + ${BINDING_DISTANCE * 2}px)`,
height: `calc(100% + ${BINDING_DISTANCE * 2}px)`,
backgroundColor: 'var(--tl-selectFill)',
}}
/>
)}
{isEditing ? (
<TextArea
2021-10-27 16:16:07 +00:00
ref={rInput}
style={{
font,
color: styles.stroke,
}}
name="text"
defaultValue={text}
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
placeholder=""
color={styles.stroke}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
autoFocus
wrap="off"
dir="auto"
datatype="wysiwyg"
/>
) : (
text
)}
</InnerWrapper>
</Wrapper>
2021-10-27 16:16:07 +00:00
</HTMLContainer>
)
}
)
2021-08-10 16:12:55 +00:00
2021-10-28 16:50:58 +00:00
Indicator = TLDrawShapeUtil.Indicator<T>(({ shape }) => {
const { width, height } = this.getBounds(shape)
return <rect x={0} y={0} width={width} height={height} />
2021-10-28 16:50:58 +00:00
})
2021-08-10 16:12:55 +00:00
getBounds = (shape: T) => {
2021-09-22 12:27:49 +00:00
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
if (!melm) {
// We're in SSR
return { minX: 0, minY: 0, maxX: 10, maxY: 10, width: 10, height: 10 }
}
melm.innerHTML = `${shape.text}&zwj;`
melm.style.font = getFontStyle(shape.style)
2021-08-10 16:12:55 +00:00
2021-09-22 12:27:49 +00:00
// In tests, offsetWidth and offsetHeight will be 0
const width = melm.offsetWidth || 1
const height = melm.offsetHeight || 1
return {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
}
})
2021-08-10 16:12:55 +00:00
return Utils.translateBounds(bounds, shape.point)
}
2021-08-10 16:12:55 +00:00
shouldRender = (prev: T, next: T): boolean => {
return (
next.text !== prev.text || next.style.scale !== prev.style.scale || next.style !== prev.style
)
}
transform = (
shape: T,
bounds: TLBounds,
{ initialShape, scaleX, scaleY }: TLDrawTransformInfo<T>
): Partial<T> => {
2021-08-10 16:12:55 +00:00
const {
rotation = 0,
style: { scale = 1 },
} = initialShape
const nextScale = scale * Math.abs(Math.min(scaleX, scaleY))
2021-08-10 16:12:55 +00:00
return {
point: [bounds.minX, bounds.minY],
rotation:
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0) ? -(rotation || 0) : rotation,
style: {
...initialShape.style,
scale: nextScale,
2021-08-10 16:12:55 +00:00
},
}
}
2021-08-10 16:12:55 +00:00
transformSingle = (
shape: T,
bounds: TLBounds,
{ initialShape, scaleX, scaleY }: TLDrawTransformInfo<T>
): Partial<T> | void => {
2021-08-10 16:12:55 +00:00
const {
style: { scale = 1 },
} = initialShape
return {
point: Vec.round([bounds.minX, bounds.minY]),
style: {
...initialShape.style,
scale: scale * Math.max(Math.abs(scaleY), Math.abs(scaleX)),
2021-08-10 16:12:55 +00:00
},
}
}
2021-08-10 16:12:55 +00:00
onDoubleClickBoundsHandle = (shape: T) => {
2021-08-10 16:12:55 +00:00
const center = this.getCenter(shape)
2021-09-06 11:07:15 +00:00
const newCenter = this.getCenter({
...shape,
style: {
...shape.style,
scale: 1,
},
})
2021-08-10 16:12:55 +00:00
return {
style: {
...shape.style,
scale: 1,
},
point: Vec.round(Vec.add(shape.point, Vec.sub(center, newCenter))),
}
}
}
2021-09-13 15:38:42 +00:00
/* -------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------- */
2021-08-10 16:12:55 +00:00
const LETTER_SPACING = -1.5
function normalizeText(text: string) {
return text.replace(/\r?\n|\r/g, '\n')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let melm: any
function getMeasurementDiv() {
// A div used for measurement
document.getElementById('__textMeasure')?.remove()
const pre = document.createElement('pre')
pre.id = '__textMeasure'
Object.assign(pre.style, {
whiteSpace: 'pre',
width: 'auto',
border: '1px solid red',
padding: '4px',
margin: '0px',
letterSpacing: `${LETTER_SPACING}px`,
opacity: '0',
position: 'absolute',
top: '-500px',
left: '0px',
zIndex: '9999',
pointerEvents: 'none',
userSelect: 'none',
alignmentBaseline: 'mathematical',
dominantBaseline: 'mathematical',
})
pre.tabIndex = -1
document.body.appendChild(pre)
return pre
}
if (typeof window !== 'undefined') {
melm = getMeasurementDiv()
}
const Wrapper = styled('div', {
2021-08-10 16:12:55 +00:00
width: '100%',
height: '100%',
2021-09-11 22:17:54 +00:00
variants: {
isEditing: {
false: {
pointerEvents: 'all',
2021-09-24 11:34:30 +00:00
userSelect: 'all',
2021-09-11 22:17:54 +00:00
},
true: {
pointerEvents: 'none',
2021-09-24 11:34:30 +00:00
userSelect: 'none',
2021-09-11 22:17:54 +00:00
},
},
},
})
const InnerWrapper = styled('div', {
2021-09-11 22:17:54 +00:00
position: 'absolute',
top: 'var(--tl-padding)',
left: 'var(--tl-padding)',
width: 'calc(100% - (var(--tl-padding) * 2))',
height: 'calc(100% - (var(--tl-padding) * 2))',
2021-08-10 16:12:55 +00:00
padding: '4px',
2021-09-24 11:34:30 +00:00
zIndex: 1,
2021-08-10 16:12:55 +00:00
minHeight: 1,
minWidth: 1,
lineHeight: 1.4,
letterSpacing: LETTER_SPACING,
2021-08-10 16:12:55 +00:00
outline: 0,
fontWeight: '500',
backfaceVisibility: 'hidden',
2021-09-24 11:34:30 +00:00
userSelect: 'none',
pointerEvents: 'none',
WebkitUserSelect: 'none',
2021-08-10 16:12:55 +00:00
WebkitTouchCallout: 'none',
2021-09-24 11:34:30 +00:00
isEditing: {
false: {},
true: {
pointerEvents: 'all',
background: '$boundsBg',
userSelect: 'text',
WebkitUserSelect: 'text',
2021-09-11 22:17:54 +00:00
},
},
})
2021-09-24 11:34:30 +00:00
const TextArea = styled('textarea', {
2021-09-24 11:34:30 +00:00
position: 'absolute',
top: 0,
left: 0,
zIndex: 1,
width: '100%',
height: '100%',
border: 'none',
padding: '4px',
whiteSpace: 'pre',
resize: 'none',
minHeight: 'inherit',
minWidth: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit',
outline: 0,
fontWeight: 'inherit',
overflow: 'hidden',
backfaceVisibility: 'hidden',
display: 'inline-block',
pointerEvents: 'all',
background: '$boundsBg',
userSelect: 'text',
WebkitUserSelect: 'text',
})