mirror of
https://github.com/logseq/logseq.git
synced 2026-04-25 22:54:48 +00:00
296 lines
7.6 KiB
TypeScript
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}​</>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|
|
}
|