Files
logseq/tldraw/apps/tldraw-logseq/src/lib/shapes/TextShape.tsx
2022-07-03 21:38:09 +08:00

296 lines
7.6 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
import { HTMLContainer, TLComponentProps, TLTextMeasure } from '@tldraw/react'
import { TextUtils, TLBounds, TLResizeStartInfo, TLTextShape, TLTextShapeProps } from '@tldraw/core'
import { observer } from 'mobx-react-lite'
import { CustomStyleProps, withClampedStyles } from './style-props'
export interface TextShapeProps extends TLTextShapeProps, CustomStyleProps {
borderRadius: number
fontFamily: string
fontSize: number
fontWeight: number
lineHeight: number
padding: number
type: 'text'
}
export class TextShape extends TLTextShape<TextShapeProps> {
static id = 'text'
static defaultProps: TextShapeProps = {
id: 'box',
parentId: 'page',
type: 'text',
point: [0, 0],
size: [100, 100],
isSizeLocked: true,
text: '',
lineHeight: 1.2,
fontSize: 20,
fontWeight: 400,
padding: 4,
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
borderRadius: 0,
stroke: 'var(--tl-foreground)',
fill: '#ffffff',
strokeWidth: 2,
opacity: 1,
}
ReactComponent = observer(({ events, isErasing, isEditing, onEditingEnd }: TLComponentProps) => {
const {
props: { opacity, fontFamily, fontSize, fontWeight, lineHeight, text, stroke, padding },
} = this
const rInput = React.useRef<HTMLTextAreaElement>(null)
const rIsMounted = React.useRef(false)
const rInnerWrapper = React.useRef<HTMLDivElement>(null)
// When the text changes, update the text—and,
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { isSizeLocked } = this.props
const text = TextUtils.normalizeText(e.currentTarget.value)
if (isSizeLocked) {
this.update({ text, size: this.getAutoSizedBoundingBox({ text }) })
return
}
// If not autosizing, update just the text
this.update({ text })
}, [])
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.metaKey) e.stopPropagation()
switch (e.key) {
case 'Meta': {
e.stopPropagation()
break
}
case 'z': {
if (e.metaKey) {
if (e.shiftKey) {
document.execCommand('redo', false)
} else {
document.execCommand('undo', false)
}
e.preventDefault()
}
break
}
case 'Enter': {
if (e.ctrlKey || e.metaKey) {
e.currentTarget.blur()
}
break
}
case 'Tab': {
e.preventDefault()
if (e.shiftKey) {
TextUtils.unindent(e.currentTarget)
} else {
TextUtils.indent(e.currentTarget)
}
this.update({ text: TextUtils.normalizeText(e.currentTarget.value) })
break
}
}
}, [])
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
e.currentTarget.setSelectionRange(0, 0)
onEditingEnd?.()
},
[onEditingEnd]
)
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!isEditing) return
if (!rIsMounted.current) return
if (document.activeElement === e.currentTarget) {
e.currentTarget.select()
}
},
[isEditing]
)
const handlePointerDown = React.useCallback(
e => {
if (isEditing) e.stopPropagation()
},
[isEditing]
)
React.useEffect(() => {
if (isEditing) {
requestAnimationFrame(() => {
rIsMounted.current = true
const elm = rInput.current
if (elm) {
elm.focus()
elm.select()
}
})
} else {
onEditingEnd?.()
}
}, [isEditing, onEditingEnd])
React.useLayoutEffect(() => {
const { fontFamily, fontSize, fontWeight, lineHeight, padding } = this.props
const { width, height } = this.measure.measureText(
text,
{ fontFamily, fontSize, fontWeight, lineHeight },
padding
)
this.update({ size: [width, height] })
}, [])
return (
<HTMLContainer {...events} opacity={isErasing ? 0.2 : opacity}>
<div
ref={rInnerWrapper}
className="text-shape-wrapper"
data-hastext={!!text}
data-isediting={isEditing}
style={{
fontFamily,
fontSize,
fontWeight,
padding,
lineHeight,
color: stroke,
}}
>
{isEditing ? (
<textarea
ref={rInput}
className="text-shape-input"
name="text"
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
// autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onPointerDown={handlePointerDown}
// onContextMenu={stopPropagation}
/>
) : (
<>{text}&#8203;</>
)}
</div>
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const {
props: { borderRadius },
bounds,
} = this
return (
<rect
width={bounds.width}
height={bounds.height}
rx={borderRadius}
ry={borderRadius}
fill="transparent"
/>
)
})
validateProps = (props: Partial<TextShapeProps>) => {
if (props.isSizeLocked || this.props.isSizeLocked) {
props.size = this.getAutoSizedBoundingBox(props)
}
return withClampedStyles(props)
}
// Custom
private measure = new TLTextMeasure()
getAutoSizedBoundingBox(props = {} as Partial<TextShapeProps>) {
const {
text = this.props.text,
fontFamily = this.props.fontFamily,
fontSize = this.props.fontSize,
fontWeight = this.props.fontWeight,
lineHeight = this.props.lineHeight,
padding = this.props.padding,
} = props
const { width, height } = this.measure.measureText(
text,
{ fontFamily, fontSize, lineHeight, fontWeight },
padding
)
return [width, height]
}
getBounds = (): TLBounds => {
const [x, y] = this.props.point
const [width, height] = this.props.size
return {
minX: x,
minY: y,
maxX: x + width,
maxY: y + height,
width,
height,
}
}
onResizeStart = ({ isSingle }: TLResizeStartInfo) => {
if (!isSingle) return this
this.scale = [...(this.props.scale ?? [1, 1])]
return this.update({
isSizeLocked: false,
})
}
onResetBounds = () => {
this.update({
size: this.getAutoSizedBoundingBox(),
isSizeLocked: true,
})
return this
}
getShapeSVGJsx() {
const {
props: { text, stroke, fontSize, fontFamily },
} = this
// Stretch to the bound size
const bounds = this.getBounds()
return (
<text
style={{
transformOrigin: 'top left',
}}
transform={`translate(${bounds.width / 2}, ${bounds.height / 2})`}
textAnchor="middle"
fontFamily={fontFamily}
fontSize={fontSize}
stroke={stroke}
fill={stroke}
>
{text}
</text>
)
}
}