[improvement] labels (#467)
* Improve appearance of arrow shape labels, set label text color to match stroke * fix svg copy fonts for text, labels * Reduce opacity effect of scaled arrow label, update font in export, adds command+enter to finish text
This commit is contained in:
parent
f57d6bda94
commit
d0fd712e5e
22 changed files with 253 additions and 156 deletions
|
@ -2,7 +2,7 @@
|
|||
import * as React from 'react'
|
||||
import Utils from '../utils'
|
||||
import type { TLBounds, TLComponentProps, TLForwardedRef, TLShape, TLUser } from '../types'
|
||||
import { intersectPolygonBounds, intersectPolylineBounds } from '@tldraw/intersect'
|
||||
import { intersectPolygonBounds } from '@tldraw/intersect'
|
||||
|
||||
export abstract class TLShapeUtil<T extends TLShape, E extends Element = any, M = any> {
|
||||
refMap = new Map<string, React.RefObject<E>>()
|
||||
|
@ -21,6 +21,7 @@ export abstract class TLShapeUtil<T extends TLShape, E extends Element = any, M
|
|||
shape: T
|
||||
meta: M
|
||||
user?: TLUser<T>
|
||||
bounds: TLBounds
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
}) => React.ReactElement | null
|
||||
|
@ -64,6 +65,7 @@ export abstract class TLShapeUtil<T extends TLShape, E extends Element = any, M
|
|||
meta: M
|
||||
isHovered: boolean
|
||||
isSelected: boolean
|
||||
bounds: TLBounds
|
||||
}) => JSX.Element
|
||||
) => component
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ export const HTMLContainer = React.forwardRef<HTMLDivElement, HTMLContainerProps
|
|||
return (
|
||||
<Observer>
|
||||
{() => (
|
||||
<div ref={ref} className={`tl-positioned-div ${className}`} {...rest}>
|
||||
<div ref={ref} className={`tl-positioned-div ${className}`} draggable={false} {...rest}>
|
||||
<div className="tl-inner-div">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -28,6 +28,7 @@ export const ShapeIndicator = observer(function ShapeIndicator<T extends TLShape
|
|||
return (
|
||||
<div
|
||||
ref={rPositioned}
|
||||
draggable={false}
|
||||
className={[
|
||||
'tl-indicator',
|
||||
'tl-absolute',
|
||||
|
@ -41,6 +42,7 @@ export const ShapeIndicator = observer(function ShapeIndicator<T extends TLShape
|
|||
shape={shape}
|
||||
meta={meta}
|
||||
user={user}
|
||||
bounds={bounds}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
/>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export const LETTER_SPACING = '-0.03em'
|
||||
export const LINE_HEIGHT = 1.3
|
||||
export const GRID_SIZE = 8
|
||||
export const SVG_EXPORT_PADDING = 16
|
||||
export const BINDING_DISTANCE = 16
|
||||
|
|
|
@ -4,7 +4,7 @@ const styles = new Map<string, HTMLStyleElement>()
|
|||
|
||||
const UID = `Tldraw-fonts`
|
||||
const CSS = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');
|
||||
`
|
||||
|
||||
export function useStylesheet() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { mockDocument, TldrawTestApp } from '~test'
|
||||
import { ArrowShape, ColorStyle, RectangleShape, SessionType, TDShapeType } from '~types'
|
||||
import { ArrowShape, ColorStyle, SessionType, TDShapeType } from '~types'
|
||||
import type { SelectTool } from './tools/SelectTool'
|
||||
|
||||
describe('TldrawTestApp', () => {
|
||||
|
|
|
@ -15,7 +15,6 @@ import {
|
|||
Utils,
|
||||
TLBounds,
|
||||
TLDropEventHandler,
|
||||
TLPerformanceMode,
|
||||
} from '@tldraw/core'
|
||||
import {
|
||||
FlipType,
|
||||
|
@ -1821,7 +1820,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
// Embed our custom fonts
|
||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
|
||||
style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');`
|
||||
style.textContent = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');`
|
||||
defs.appendChild(style)
|
||||
svg.appendChild(defs)
|
||||
// Get the shapes in order
|
||||
|
|
|
@ -227,8 +227,8 @@ Array [
|
|||
]
|
||||
`;
|
||||
|
||||
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" fill=\\"transparent\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');</style></defs><g/></svg>"`;
|
||||
exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" fill=\\"transparent\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');</style></defs><g/></svg>"`;
|
||||
|
||||
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\" fill=\\"transparent\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');</style></defs></svg>"`;
|
||||
exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\" fill=\\"transparent\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');</style></defs></svg>"`;
|
||||
|
||||
exports[`TldrawTestApp When copying to SVG Respects child index: copied svg with reordered elements 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" fill=\\"transparent\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap');</style></defs></svg>"`;
|
||||
exports[`TldrawTestApp When copying to SVG Respects child index: copied svg with reordered elements 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 232 232\\" width=\\"200\\" height=\\"200\\" fill=\\"transparent\\"><defs><style>@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');</style></defs></svg>"`;
|
||||
|
|
|
@ -33,10 +33,11 @@ import {
|
|||
} from './arrowHelpers'
|
||||
import { getTrianglePoints } from '../TriangleUtil/triangleHelpers'
|
||||
import { styled } from '~styles'
|
||||
import { TextLabel, getFontStyle } from '../shared'
|
||||
import { TextLabel, getFontStyle, getShapeStyle } from '../shared'
|
||||
import { getTextLabelSize } from '../shared/getTextSize'
|
||||
import { StraightArrow } from './components/StraightArrow'
|
||||
import { CurvedArrow } from './components/CurvedArrow.tsx'
|
||||
import { LabelMask } from '../shared/LabelMask'
|
||||
|
||||
type T = ArrowShape
|
||||
type E = HTMLDivElement
|
||||
|
@ -107,6 +108,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
} = shape
|
||||
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
|
||||
const font = getFontStyle(style)
|
||||
const styles = getShapeStyle(style)
|
||||
const labelSize = label || isEditing ? getTextLabelSize(label, font) : [0, 0]
|
||||
const bounds = this.getBounds(shape)
|
||||
const dist = React.useMemo(() => {
|
||||
|
@ -137,15 +139,15 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={offset[0]}
|
||||
offsetY={offset[1]}
|
||||
scale={scale}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'}>
|
||||
<defs>
|
||||
|
@ -165,7 +167,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="black"
|
||||
opacity={Math.max(scale, 0.8)}
|
||||
opacity={Math.max(scale, 0.9)}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
|
@ -193,23 +195,66 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
|
|||
}
|
||||
)
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<ArrowShape>(({ shape }) => {
|
||||
Indicator = TDShapeUtil.Indicator<ArrowShape>(({ shape, bounds }) => {
|
||||
const {
|
||||
style,
|
||||
decorations,
|
||||
label,
|
||||
handles: { start, bend, end },
|
||||
} = shape
|
||||
const font = getFontStyle(style)
|
||||
const labelSize = label ? getTextLabelSize(label, font) : [0, 0]
|
||||
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
|
||||
const dist = React.useMemo(() => {
|
||||
const { start, bend, end } = shape.handles
|
||||
if (isStraightLine) return Vec.dist(start.point, end.point)
|
||||
const circle = getCtp(start.point, bend.point, end.point)
|
||||
const center = circle.slice(0, 2)
|
||||
const radius = circle[2]
|
||||
const length = getArcLength(center, radius, start.point, end.point)
|
||||
return Math.abs(length)
|
||||
}, [shape.handles])
|
||||
const scale = Math.max(
|
||||
0.5,
|
||||
Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128)))
|
||||
)
|
||||
const offset = React.useMemo(() => {
|
||||
const bounds = this.getBounds(shape)
|
||||
const offset = Vec.sub(shape.handles.bend.point, [bounds.width / 2, bounds.height / 2])
|
||||
return offset
|
||||
}, [shape, scale])
|
||||
return (
|
||||
<path
|
||||
d={getArrowPath(
|
||||
style,
|
||||
start.point,
|
||||
bend.point,
|
||||
end.point,
|
||||
decorations?.start,
|
||||
decorations?.end
|
||||
<>
|
||||
<LabelMask
|
||||
id={shape.id}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
bounds={bounds}
|
||||
labelSize={labelSize}
|
||||
/>
|
||||
<path
|
||||
d={getArrowPath(
|
||||
style,
|
||||
start.point,
|
||||
bend.point,
|
||||
end.point,
|
||||
decorations?.start,
|
||||
decorations?.end
|
||||
)}
|
||||
mask={label ? `url(#${shape.id}_clip)` : ``}
|
||||
/>
|
||||
{label && (
|
||||
<rect
|
||||
x={bounds.width / 2 - (labelSize[0] / 2) * scale + offset[0]}
|
||||
y={bounds.height / 2 - (labelSize[1] / 2) * scale + offset[1]}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="transparent"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -82,9 +82,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
|
|||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import { Utils, HTMLContainer } from '@tldraw/core'
|
||||
import { TDShapeType, TDMeta, ImageShape, TDAsset, TDImageAsset } from '~types'
|
||||
import { TDShapeType, TDMeta, ImageShape, TDImageAsset } from '~types'
|
||||
import { GHOSTED_OPACITY } from '~constants'
|
||||
import { TDShapeUtil } from '../TDShapeUtil'
|
||||
import {
|
||||
|
@ -193,5 +193,6 @@ const ImageElement = styled('img', {
|
|||
minWidth: '100%',
|
||||
pointerEvents: 'none',
|
||||
objectFit: 'cover',
|
||||
userSelect: 'none',
|
||||
borderRadius: 2,
|
||||
})
|
||||
|
|
|
@ -79,9 +79,9 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
|
|||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
|
|
|
@ -275,15 +275,19 @@ export class StickyUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
getSvgElement = (shape: T): SVGElement | void => {
|
||||
const bounds = this.getBounds(shape)
|
||||
const textElm = getTextSvgElement(shape, bounds)
|
||||
const textBounds = Utils.expandBounds(bounds, -PADDING)
|
||||
const textElm = getTextSvgElement(shape.text, shape.style, textBounds)
|
||||
const style = getStickyShapeStyle(shape.style)
|
||||
textElm.setAttribute('fill', style.color)
|
||||
textElm.setAttribute('transform', `translate(${PADDING}, ${PADDING})`)
|
||||
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
||||
rect.setAttribute('width', bounds.width + '')
|
||||
rect.setAttribute('height', bounds.height + '')
|
||||
rect.setAttribute('fill', style.fill)
|
||||
rect.setAttribute('rx', '3')
|
||||
rect.setAttribute('ry', '3')
|
||||
|
||||
g.appendChild(rect)
|
||||
g.appendChild(textElm)
|
||||
|
|
|
@ -11,6 +11,9 @@ import { Vec } from '@tldraw/vec'
|
|||
import type { TDBinding, TDMeta, TDShape, TransformInfo } from '~types'
|
||||
import * as React from 'react'
|
||||
import { BINDING_DISTANCE } from '~constants'
|
||||
import { getTextSvgElement } from './shared/getTextSvgElement'
|
||||
import { getTextLabelSize } from './shared/getTextSize'
|
||||
import { getFontStyle, getShapeStyle } from './shared'
|
||||
|
||||
export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> extends TLShapeUtil<
|
||||
T,
|
||||
|
@ -188,6 +191,24 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
|
|||
onSessionComplete?: (shape: T) => Partial<T> | void
|
||||
|
||||
getSvgElement = (shape: T): SVGElement | void => {
|
||||
return document.getElementById(shape.id + '_svg')?.cloneNode(true) as SVGElement
|
||||
const elm = document.getElementById(shape.id + '_svg')?.cloneNode(true) as SVGElement
|
||||
if (!elm) return // possibly in test mode
|
||||
if ('label' in shape && shape.label !== undefined) {
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
|
||||
const bounds = this.getBounds(shape)
|
||||
const labelElm = getTextSvgElement(shape.label, shape.style, bounds)
|
||||
labelElm.setAttribute('fill', getShapeStyle(shape.style).stroke)
|
||||
const font = getFontStyle(shape.style)
|
||||
const size = getTextLabelSize(shape.label, font)
|
||||
labelElm.setAttribute('transform-origin', 'top left')
|
||||
labelElm.setAttribute(
|
||||
'transform',
|
||||
`translate(${(bounds.width - size[0]) / 2}, ${(bounds.height - size[1]) / 2})`
|
||||
)
|
||||
g.appendChild(elm)
|
||||
g.appendChild(labelElm)
|
||||
return g
|
||||
}
|
||||
return elm
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { TLDR } from '~state/TLDR'
|
|||
import { getTextAlign } from '../shared/getTextAlign'
|
||||
import { getTextSvgElement } from '../shared/getTextSvgElement'
|
||||
import { stopPropagation } from '~components/stopPropagation'
|
||||
import { useTextKeyboardEvents } from '../shared/useTextKeyboardEvents'
|
||||
|
||||
type T = TextShape
|
||||
type E = HTMLDivElement
|
||||
|
@ -86,57 +87,23 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
|||
}
|
||||
|
||||
onShapeChange?.({
|
||||
...shape,
|
||||
id: shape.id,
|
||||
point: Vec.sub(shape.point, delta),
|
||||
text: TLDR.normalizeText(e.currentTarget.value),
|
||||
})
|
||||
},
|
||||
[shape]
|
||||
[shape.id, shape.point]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// If this keydown was just the meta key or a shortcut
|
||||
// that includes holding the meta key like (Command+V)
|
||||
// then leave the event untouched. We also have to explicitly
|
||||
// Implement undo/redo for some reason in order to get this working
|
||||
// in the vscode extension. Without the below code the following doesn't work
|
||||
//
|
||||
// - You can't cut/copy/paste when when text-editing/focused
|
||||
// - You can't undo/redo when when text-editing/focused
|
||||
// - You can't use Command+A to select all the text, when when text-editing/focused
|
||||
if (!(e.key === 'Meta' || e.metaKey)) {
|
||||
e.stopPropagation()
|
||||
} else if (e.key === 'z' && e.metaKey) {
|
||||
if (e.shiftKey) {
|
||||
document.execCommand('redo', false)
|
||||
} else {
|
||||
document.execCommand('undo', false)
|
||||
}
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
onShapeChange?.({ ...shape, text: TLDR.normalizeText(e.currentTarget.value) })
|
||||
}
|
||||
const onChange = React.useCallback(
|
||||
(text: string) => {
|
||||
onShapeChange?.({ id: shape.id, text })
|
||||
},
|
||||
[shape, onShapeChange]
|
||||
[shape.id]
|
||||
)
|
||||
|
||||
const handleKeyDown = useTextKeyboardEvents(onChange)
|
||||
|
||||
const handleBlur = React.useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
e.currentTarget.setSelectionRange(0, 0)
|
||||
onShapeBlur?.()
|
||||
|
@ -341,7 +308,7 @@ export class TextUtil extends TDShapeUtil<T, E> {
|
|||
|
||||
getSvgElement = (shape: T): SVGElement | void => {
|
||||
const bounds = this.getBounds(shape)
|
||||
const elm = getTextSvgElement(shape, bounds)
|
||||
const elm = getTextSvgElement(shape.text, shape.style, bounds)
|
||||
elm.setAttribute('fill', getShapeStyle(shape.style).stroke)
|
||||
return elm
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import { getTriangleCentroid, getTrianglePoints } from './triangleHelpers'
|
|||
import { styled } from '~styles'
|
||||
import { DrawTriangle } from './components/DrawTriangle'
|
||||
import { DashedTriangle } from './components/DashedTriangle'
|
||||
import { TextLabel } from '../shared/TextLabel'
|
||||
import { TextLabel, getShapeStyle } from '../shared'
|
||||
import { TriangleBindingIndicator } from './components/TriangleBindingIndicator'
|
||||
|
||||
type T = TriangleShape
|
||||
|
@ -72,6 +72,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
) => {
|
||||
const { id, label = '', size, style, labelPoint = LABEL_POINT } = shape
|
||||
const font = getFontStyle(style)
|
||||
const styles = getShapeStyle(style)
|
||||
const Component = style.dash === DashStyle.Draw ? DrawTriangle : DashedTriangle
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
|
@ -85,14 +86,14 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
|
|||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={offsetY + (labelPoint[1] - 0.5) * bounds.height}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
font={font}
|
||||
text={label}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={offsetY + (labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'} opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
{isBinding && <TriangleBindingIndicator size={size} />}
|
||||
|
|
|
@ -237,5 +237,6 @@ const VideoElement = styled('video', {
|
|||
minWidth: '100%',
|
||||
pointerEvents: 'none',
|
||||
objectFit: 'cover',
|
||||
userSelect: 'none',
|
||||
borderRadius: 2,
|
||||
})
|
||||
|
|
36
packages/tldraw/src/state/shapes/shared/LabelMask.tsx
Normal file
36
packages/tldraw/src/state/shapes/shared/LabelMask.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { TLBounds } from '@tldraw/core'
|
||||
import * as React from 'react'
|
||||
|
||||
interface WithLabelMaskProps {
|
||||
id: string
|
||||
bounds: TLBounds
|
||||
labelSize: number[]
|
||||
offset?: number[]
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function LabelMask({ id, bounds, labelSize, offset, scale = 1 }: WithLabelMaskProps) {
|
||||
return (
|
||||
<defs>
|
||||
<mask id={id + '_clip'}>
|
||||
<rect
|
||||
x={-100}
|
||||
y={-100}
|
||||
width={bounds.width + 200}
|
||||
height={bounds.height + 200}
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x={bounds.width / 2 - (labelSize[0] / 2) * scale + (offset?.[0] || 0)}
|
||||
y={bounds.height / 2 - (labelSize[1] / 2) * scale + (offset?.[1] || 0)}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="black"
|
||||
opacity={Math.max(scale, 0.8)}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
)
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import { stopPropagation } from '~components/stopPropagation'
|
||||
import { GHOSTED_OPACITY, LABEL_POINT, LETTER_SPACING } from '~constants'
|
||||
import { GHOSTED_OPACITY, LETTER_SPACING } from '~constants'
|
||||
import { TLDR } from '~state/TLDR'
|
||||
import { styled } from '~styles'
|
||||
import { TextAreaUtils } from '.'
|
||||
import { getTextLabelSize } from './getTextSize'
|
||||
import { useTextKeyboardEvents } from './useTextKeyboardEvents'
|
||||
|
||||
export interface TextLabelProps {
|
||||
font: string
|
||||
text: string
|
||||
isDarkMode: boolean
|
||||
color: string
|
||||
onBlur?: () => void
|
||||
onChange: (text: string) => void
|
||||
offsetY?: number
|
||||
|
@ -19,9 +20,9 @@ export interface TextLabelProps {
|
|||
}
|
||||
|
||||
export const TextLabel = React.memo(function TextLabel({
|
||||
isDarkMode,
|
||||
font,
|
||||
text,
|
||||
color,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
scale = 1,
|
||||
|
@ -32,7 +33,6 @@ export const TextLabel = React.memo(function TextLabel({
|
|||
const rInput = React.useRef<HTMLTextAreaElement>(null)
|
||||
const rIsMounted = React.useRef(false)
|
||||
const size = getTextLabelSize(text, font)
|
||||
const color = isDarkMode ? 'white' : 'black'
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
@ -41,48 +41,7 @@ export const TextLabel = React.memo(function TextLabel({
|
|||
[onChange]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// If this keydown was just the meta key or a shortcut
|
||||
// that includes holding the meta key like (Command+V)
|
||||
// then leave the event untouched. We also have to explicitly
|
||||
// Implement undo/redo for some reason in order to get this working
|
||||
// in the vscode extension. Without the below code the following doesn't work
|
||||
//
|
||||
// - You can't cut/copy/paste when when text-editing/focused
|
||||
// - You can't undo/redo when when text-editing/focused
|
||||
// - You can't use Command+A to select all the text, when when text-editing/focused
|
||||
if (!(e.key === 'Meta' || e.metaKey)) {
|
||||
e.stopPropagation()
|
||||
} else if (e.key === 'z' && e.metaKey) {
|
||||
if (e.shiftKey) {
|
||||
document.execCommand('redo', false)
|
||||
} else {
|
||||
document.execCommand('undo', false)
|
||||
}
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
onChange(TLDR.normalizeText(e.currentTarget.value))
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
const handleKeyDown = useTextKeyboardEvents(onChange)
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
|
|
|
@ -1,43 +1,39 @@
|
|||
import type { TLBounds } from '@tldraw/core'
|
||||
import { AlignStyle, StickyShape, TextShape } from '~types'
|
||||
import { AlignStyle, ShapeStyles } from '~types'
|
||||
import { getFontFace, getFontSize } from './shape-styles'
|
||||
import { getTextAlign } from './getTextAlign'
|
||||
import { LINE_HEIGHT } from '~constants'
|
||||
|
||||
export function getTextSvgElement(shape: TextShape | StickyShape, bounds: TLBounds) {
|
||||
const { text, style } = shape
|
||||
const fontSize = getFontSize(shape.style.size, shape.style.font)
|
||||
|
||||
export function getTextSvgElement(text: string, style: ShapeStyles, bounds: TLBounds) {
|
||||
const fontSize = getFontSize(style.size, style.font)
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
|
||||
|
||||
const LINE_HEIGHT = fontSize * 1.3
|
||||
|
||||
const textLines = text.split('\n').map((line, i) => {
|
||||
const textElm = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
||||
textElm.textContent = line
|
||||
textElm.setAttribute('font-family', getFontFace(style.font))
|
||||
textElm.setAttribute('font-size', fontSize + 'px')
|
||||
textElm.setAttribute('text-anchor', 'start')
|
||||
textElm.setAttribute('alignment-baseline', 'central')
|
||||
textElm.setAttribute('text-align', getTextAlign(style.textAlign))
|
||||
textElm.setAttribute('y', LINE_HEIGHT * (0.5 + i) + '')
|
||||
textElm.setAttribute('y', LINE_HEIGHT * fontSize * (0.5 + i) + '')
|
||||
g.appendChild(textElm)
|
||||
|
||||
return textElm
|
||||
})
|
||||
|
||||
if (style.textAlign === AlignStyle.Middle) {
|
||||
textLines.forEach((textElm) => {
|
||||
textElm.setAttribute('x', bounds.width / 2 + '')
|
||||
textElm.setAttribute('text-align', 'center')
|
||||
textElm.setAttribute('text-anchor', 'middle')
|
||||
})
|
||||
} else if (style.textAlign === AlignStyle.End) {
|
||||
textLines.forEach((textElm) => {
|
||||
textElm.setAttribute('x', bounds.width + '')
|
||||
textElm.setAttribute('text-align', 'right')
|
||||
textElm.setAttribute('text-anchor', 'end')
|
||||
})
|
||||
g.setAttribute('font-size', fontSize + '')
|
||||
g.setAttribute('font-family', getFontFace(style.font).slice(1, -1))
|
||||
g.setAttribute('text-align', getTextAlign(style.textAlign))
|
||||
switch (style.textAlign) {
|
||||
case AlignStyle.Middle: {
|
||||
g.setAttribute('text-align', 'center')
|
||||
g.setAttribute('text-anchor', 'middle')
|
||||
textLines.forEach((textElm) => textElm.setAttribute('x', bounds.width / 2 + ''))
|
||||
break
|
||||
}
|
||||
case AlignStyle.End: {
|
||||
g.setAttribute('text-align', 'right')
|
||||
g.setAttribute('text-anchor', 'end')
|
||||
textLines.forEach((textElm) => textElm.setAttribute('x', bounds.width + ''))
|
||||
break
|
||||
}
|
||||
case AlignStyle.Start: {
|
||||
g.setAttribute('text-anchor', 'start')
|
||||
g.setAttribute('alignment-baseline', 'central')
|
||||
}
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
|
|
@ -63,7 +63,7 @@ export const fills: Record<Theme, Record<ColorStyle, string>> = {
|
|||
},
|
||||
dark: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(colors).map(([k, v]) => [k, Utils.lerpColor(v, canvasDark, 0.618)])
|
||||
Object.entries(colors).map(([k, v]) => [k, Utils.lerpColor(v, canvasDark, 0.82)])
|
||||
) as Record<ColorStyle, string>),
|
||||
[ColorStyle.White]: 'rgb(30,33,37)',
|
||||
[ColorStyle.Black]: '#1e1e1f',
|
||||
|
@ -86,7 +86,7 @@ const fontSizes = {
|
|||
const fontFaces = {
|
||||
[FontStyle.Script]: '"Caveat Brush"',
|
||||
[FontStyle.Sans]: '"Source Sans Pro", sans-serif',
|
||||
[FontStyle.Serif]: '"Source Serif Pro", serif',
|
||||
[FontStyle.Serif]: '"Crimson Pro", serif',
|
||||
[FontStyle.Mono]: '"Source Code Pro", monospace',
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import * as React from 'react'
|
||||
import { TLDR } from '~state/TLDR'
|
||||
import { TextAreaUtils } from '.'
|
||||
|
||||
export function useTextKeyboardEvents(onChange: (text: string) => void) {
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// If this keydown was just the meta key or a shortcut
|
||||
// that includes holding the meta key like (Command+V)
|
||||
// then leave the event untouched. We also have to explicitly
|
||||
// Implement undo/redo for some reason in order to get this working
|
||||
// in the vscode extension. Without the below code the following doesn't work
|
||||
//
|
||||
// - You can't cut/copy/paste when when text-editing/focused
|
||||
// - You can't undo/redo when when text-editing/focused
|
||||
// - You can't use Command+A to select all the text, when when text-editing/focused
|
||||
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 'Escape': {
|
||||
e.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Tab': {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget)
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget)
|
||||
}
|
||||
|
||||
onChange(TLDR.normalizeText(e.currentTarget.value))
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
return handleKeyDown
|
||||
}
|
Loading…
Reference in a new issue