tldraw/state/shape-utils/text.tsx

304 lines
7 KiB
TypeScript
Raw Normal View History

import { uniqueId, isMobile } from 'utils/utils'
2021-06-17 22:24:53 +00:00
import vec from 'utils/vec'
2021-07-02 12:04:45 +00:00
import TextAreaUtils from 'utils/text-area'
2021-06-25 12:21:33 +00:00
import { TextShape, ShapeType } from 'types'
2021-06-21 13:13:16 +00:00
import {
defaultStyle,
getFontSize,
getFontStyle,
getShapeStyle,
2021-06-21 21:35:28 +00:00
} from 'state/shape-styles'
import styled from 'styles'
import state from 'state'
2021-06-21 21:35:28 +00:00
import { registerShapeUtils } from './register'
2021-06-17 22:24:53 +00:00
// A div used for measurement
if (document.getElementById('__textMeasure')) {
document.getElementById('__textMeasure').remove()
}
2021-06-17 21:50:04 +00:00
// A div used for measurement
const mdiv = document.createElement('pre')
mdiv.id = '__textMeasure'
2021-06-17 22:24:53 +00:00
Object.assign(mdiv.style, {
whiteSpace: 'pre',
width: 'auto',
border: '1px solid red',
padding: '4px',
margin: '0px',
opacity: '0',
position: 'absolute',
top: '-500px',
left: '0px',
zIndex: '9999',
2021-06-18 14:39:07 +00:00
pointerEvents: 'none',
userSelect: 'none',
alignmentBaseline: 'mathematical',
dominantBaseline: 'mathematical',
2021-06-17 22:24:53 +00:00
})
2021-06-17 21:50:04 +00:00
mdiv.tabIndex = -1
2021-06-17 22:24:53 +00:00
document.body.appendChild(mdiv)
2021-06-17 21:50:04 +00:00
function normalizeText(text: string) {
2021-07-02 12:04:45 +00:00
return text.replace(/\r?\n|\r/g, '\n')
2021-06-17 21:50:04 +00:00
}
const text = registerShapeUtils<TextShape>({
isForeignObject: true,
canChangeAspectRatio: false,
canEdit: true,
boundsCache: new WeakMap([]),
defaultProps: {
id: uniqueId(),
type: ShapeType.Text,
isGenerated: false,
name: 'Text',
parentId: 'page1',
childIndex: 0,
point: [0, 0],
rotation: 0,
isAspectRatioLocked: false,
isLocked: false,
isHidden: false,
style: defaultStyle,
text: '',
scale: 1,
},
shouldRender(shape, prev) {
return (
shape.text !== prev.text ||
shape.scale !== prev.scale ||
shape.style !== prev.style
)
},
2021-06-17 10:43:55 +00:00
render(shape, { isEditing, ref }) {
const { id, text, style } = shape
const styles = getShapeStyle(style)
const font = getFontStyle(shape.scale, shape.style)
const bounds = this.getBounds(shape)
2021-06-17 21:50:04 +00:00
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
state.send('EDITED_SHAPE', {
id,
2021-06-17 21:50:04 +00:00
change: { text: normalizeText(e.currentTarget.value) },
})
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Escape') return
2021-06-17 21:50:04 +00:00
e.stopPropagation()
2021-06-17 21:50:04 +00:00
if (e.key === 'Tab') {
e.preventDefault()
2021-07-02 12:04:45 +00:00
if (e.shiftKey) {
TextAreaUtils.unindent(e.currentTarget)
} else {
TextAreaUtils.indent(e.currentTarget)
}
state.send('EDITED_SHAPE', {
id,
change: {
text: normalizeText(e.currentTarget.value),
},
})
2021-06-17 21:50:04 +00:00
}
}
function handleBlur() {
setTimeout(() => state.send('BLURRED_EDITING_SHAPE', { id }), 0)
2021-06-17 21:50:04 +00:00
}
function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
e.currentTarget.select()
state.send('FOCUSED_EDITING_SHAPE', { id })
2021-06-17 21:50:04 +00:00
}
2021-07-02 12:04:45 +00:00
function handlePointerDown(e: React.PointerEvent<HTMLTextAreaElement>) {
if (e.currentTarget.selectionEnd !== 0) {
e.currentTarget.selectionEnd = 0
}
}
2021-06-21 13:13:16 +00:00
const fontSize = getFontSize(shape.style.size) * shape.scale
const lineHeight = fontSize * 1.4
2021-06-21 13:13:16 +00:00
if (!isEditing) {
return (
<g id={id} pointerEvents="none">
{text.split('\n').map((str, i) => (
<text
key={i}
x={4}
y={4 + fontSize / 2 + i * lineHeight}
2021-06-21 13:13:16 +00:00
fontFamily="Verveine Regular"
fontStyle="normal"
fontWeight="regular"
fontSize={fontSize}
width={bounds.width}
height={bounds.height}
2021-06-25 12:21:33 +00:00
fill={styles.stroke}
2021-07-03 16:30:06 +00:00
color={styles.stroke}
stroke={styles.stroke}
xmlSpace="preserve"
dominantBaseline="mathematical"
alignmentBaseline="mathematical"
2021-06-21 13:13:16 +00:00
>
{str}
</text>
))}
</g>
)
}
return (
<foreignObject
id={id}
width={bounds.width}
height={bounds.height}
pointerEvents="none"
>
2021-06-21 13:13:16 +00:00
<StyledTextArea
ref={ref}
style={{
font,
color: styles.stroke,
}}
2021-07-02 12:04:45 +00:00
name="text"
defaultValue={text}
tabIndex={-1}
2021-06-21 13:13:16 +00:00
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
placeholder=""
2021-07-03 16:30:06 +00:00
color={styles.stroke}
2021-06-21 13:13:16 +00:00
autoFocus={isMobile() ? true : false}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onChange={handleChange}
2021-07-02 12:04:45 +00:00
onPointerDown={handlePointerDown}
2021-06-21 13:13:16 +00:00
/>
</foreignObject>
)
},
getBounds(shape) {
2021-06-17 10:43:55 +00:00
if (!this.boundsCache.has(shape)) {
2021-06-17 21:50:04 +00:00
mdiv.innerHTML = shape.text + '&zwj;'
mdiv.style.font = getFontStyle(shape.scale, shape.style)
2021-06-17 10:43:55 +00:00
const [minX, minY] = shape.point
const [width, height] = [mdiv.offsetWidth, mdiv.offsetHeight]
this.boundsCache.set(shape, {
minX,
maxX: minX + width,
minY,
maxY: minY + height,
width,
height,
})
}
2021-06-17 10:43:55 +00:00
return this.boundsCache.get(shape)
},
2021-06-21 21:35:28 +00:00
hitTest() {
return true
},
2021-06-21 21:35:28 +00:00
transform(shape, bounds, { initialShape, scaleX, scaleY }) {
if (shape.rotation === 0 && !shape.isAspectRatioLocked) {
shape.point = [bounds.minX, bounds.minY]
2021-06-17 10:43:55 +00:00
shape.scale = initialShape.scale * Math.abs(scaleX)
} else {
2021-06-17 10:43:55 +00:00
shape.point = [bounds.minX, bounds.minY]
shape.rotation =
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
? -initialShape.rotation
: initialShape.rotation
2021-06-17 10:43:55 +00:00
shape.scale = initialShape.scale * Math.abs(Math.min(scaleX, scaleY))
}
return this
},
2021-06-17 10:43:55 +00:00
transformSingle(shape, bounds, { initialShape, scaleX }) {
shape.point = [bounds.minX, bounds.minY]
2021-06-17 10:43:55 +00:00
shape.scale = initialShape.scale * Math.abs(scaleX)
return this
},
onBoundsReset(shape) {
const center = this.getCenter(shape)
this.boundsCache.delete(shape)
shape.scale = 1
const newCenter = this.getCenter(shape)
shape.point = vec.add(shape.point, vec.sub(center, newCenter))
return this
},
applyStyles(shape, style) {
const center = this.getCenter(shape)
this.boundsCache.delete(shape)
Object.assign(shape.style, style)
const newCenter = this.getCenter(shape)
shape.point = vec.add(shape.point, vec.sub(center, newCenter))
return this
},
2021-06-17 10:43:55 +00:00
shouldDelete(shape) {
return shape.text.length === 0
},
})
export default text
2021-06-17 10:43:55 +00:00
const StyledTextArea = styled('textarea', {
2021-06-18 14:39:07 +00:00
zIndex: 1,
2021-06-17 10:43:55 +00:00
width: '100%',
height: '100%',
border: 'none',
padding: '4px',
whiteSpace: 'pre',
alignmentBaseline: 'mathematical',
dominantBaseline: 'mathematical',
2021-06-17 10:43:55 +00:00
resize: 'none',
minHeight: 1,
minWidth: 1,
lineHeight: 1.4,
2021-06-17 21:50:04 +00:00
outline: 0,
2021-06-17 10:43:55 +00:00
backgroundColor: '$boundsBg',
2021-06-17 21:50:04 +00:00
overflow: 'hidden',
2021-06-17 10:43:55 +00:00
pointerEvents: 'all',
2021-06-17 21:50:04 +00:00
backfaceVisibility: 'hidden',
display: 'inline-block',
2021-06-18 13:55:36 +00:00
userSelect: 'text',
WebkitUserSelect: 'text',
2021-06-18 14:39:07 +00:00
WebkitTouchCallout: 'none',
})