David Sheldrick 2024-05-09 15:42:31 +01:00
rodzic c6ba621c11
commit 506cb04b64
9 zmienionych plików z 453 dodań i 5 usunięć

Wyświetl plik

@ -1,5 +1,5 @@
import { react, useQuickReactor, useValue } from '@tldraw/state'
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
import { react, track, useQuickReactor, useValue } from '@tldraw/state'
import { TLHandle, TLShapeId, TLTextBinding } from '@tldraw/tlschema'
import { dedupe, modulate, objectMapValues } from '@tldraw/utils'
import classNames from 'classnames'
import { Fragment, JSX, useEffect, useRef, useState } from 'react'
@ -166,6 +166,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
<ShapeIndicators />
<HintedShapeIndicator />
<SnapIndicatorWrapper />
<TextLabelAlignmentIndicators />
<SelectionForegroundWrapper />
<LiveCollaborators />
</div>
@ -246,6 +247,118 @@ function SnapIndicatorWrapper() {
)
}
const TextLabelAlignmentIndicators = track(function TextLabelAlignmentIndicators() {
const editor = useEditor()
if (!editor.isIn('select.translating')) return null
const translatingShapes = editor.getSelectedShapes().filter((shape) => shape.type === 'text')
const bindingsToRender = translatingShapes.flatMap((shape) =>
editor.getBindingsFromShape<TLTextBinding>(shape.id, 'text')
)
return (
<svg className={classNames('tl-overlays__item')}>
{bindingsToRender.map((binding) => (
<TextBindingIndicator key={binding.id} binding={binding} />
))}
</svg>
)
})
const TextBindingIndicator = track(function TextBindingIndicator({
binding,
}: {
binding: TLTextBinding
}) {
const editor = useEditor()
const textShape = editor.getShape(binding.fromId)
const geoShape = editor.getShape(binding.toId)
if (!textShape || !geoShape) return null
const textShapeBounds = editor.getShapeGeometry(textShape).bounds
const geoShapeBounds = editor.getShapeGeometry(geoShape).bounds
const textShapeTransform = editor.getShapePageTransform(textShape.id)
const geoShapeTransform = editor.getShapePageTransform(geoShape.id)
const textShapeLeftEdgeCenter = editor.getPointInShapeSpace(
geoShape.id,
Mat.applyToPoint(textShapeTransform, new Vec(textShapeBounds.x, textShapeBounds.center.y))
)
const textShapeTopEdgeCenter = editor.getPointInShapeSpace(
geoShape.id,
Mat.applyToPoint(textShapeTransform, new Vec(textShapeBounds.center.x, textShapeBounds.y))
)
const textShapeRightEdgeCenter = editor.getPointInShapeSpace(
geoShape.id,
Mat.applyToPoint(textShapeTransform, new Vec(textShapeBounds.maxX, textShapeBounds.center.y))
)
const textShapeBottomEdgeCenter = editor.getPointInShapeSpace(
geoShape.id,
Mat.applyToPoint(textShapeTransform, new Vec(textShapeBounds.center.x, textShapeBounds.maxY))
)
const linesInGeoSpace = []
if (binding.props.x.type === 'center' || binding.props.x.edge === 'left') {
if (textShapeLeftEdgeCenter.x > geoShapeBounds.minX) {
linesInGeoSpace.push({
hardcore: binding.props.x.type === 'center',
start: textShapeLeftEdgeCenter,
end: new Vec(geoShapeBounds.minX, textShapeLeftEdgeCenter.y),
})
}
}
if (binding.props.x.type === 'center' || binding.props.x.edge === 'right') {
if (textShapeRightEdgeCenter.x < geoShapeBounds.maxX) {
linesInGeoSpace.push({
hardcore: binding.props.x.type === 'center',
start: textShapeRightEdgeCenter,
end: new Vec(geoShapeBounds.maxX, textShapeRightEdgeCenter.y),
})
}
}
if (binding.props.y.type === 'center' || binding.props.y.edge === 'top') {
if (textShapeTopEdgeCenter.y > geoShapeBounds.minY) {
linesInGeoSpace.push({
hardcore: binding.props.y.type === 'center',
start: textShapeTopEdgeCenter,
end: new Vec(textShapeTopEdgeCenter.x, geoShapeBounds.minY),
})
}
}
if (binding.props.y.type === 'center' || binding.props.y.edge === 'bottom') {
if (textShapeBottomEdgeCenter.y < geoShapeBounds.maxY) {
linesInGeoSpace.push({
hardcore: binding.props.y.type === 'center',
start: textShapeBottomEdgeCenter,
end: new Vec(textShapeBottomEdgeCenter.x, geoShapeBounds.maxY),
})
}
}
return (
<>
{linesInGeoSpace.map((l, i) => {
const start = Mat.applyToPoint(geoShapeTransform, l.start)
const end = Mat.applyToPoint(geoShapeTransform, l.end)
const hardcore = l.hardcore
return (
<line
key={i}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
strokeWidth={1}
strokeDasharray={hardcore ? '0' : '4 2'}
stroke={hardcore ? 'red' : 'grey'}
/>
)
})}
</>
)
})
function HandlesWrapper() {
const editor = useEditor()

Wyświetl plik

@ -0,0 +1,228 @@
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnShapeChangeOptions,
BindingOnShapeDeleteOptions,
BindingUtil,
Editor,
Mat,
TLShapeId,
TLTextBinding,
TLTextBindingProps,
TLTextShape,
Vec,
approximately,
getIndexAbove,
getIndexBetween,
textBindingMigrations,
textBindingProps,
} from '@tldraw/editor'
export class TextBindingUtil extends BindingUtil<TLTextBinding> {
static override type = 'text'
static override props = textBindingProps
static override migrations = textBindingMigrations
override getDefaultProps(): Partial<TLTextBindingProps> {
return {
x: { type: 'center' },
y: { type: 'center' },
}
}
// when the binding itself changes
override onAfterCreate({ binding }: BindingOnCreateOptions<TLTextBinding>): void {
makeTextGood(this.editor, binding.fromId)
}
// when the binding itself changes
override onAfterChange({ bindingAfter }: BindingOnChangeOptions<TLTextBinding>): void {
makeTextGood(this.editor, bindingAfter.fromId)
}
// when the text itself changes
override onAfterChangeFromShape({
shapeBefore,
shapeAfter,
binding,
}: BindingOnShapeChangeOptions<TLTextBinding>): void {
const edgeSlop = 25
if (shapeBefore.x !== shapeAfter.x || shapeBefore.y !== shapeAfter.y) {
const textShapeTransform = this.editor.getShapePageTransform(shapeAfter)
const textShapeCornersInToShapeSpace = Mat.applyToPoints(
textShapeTransform,
this.editor.getShapeGeometry(shapeAfter).bounds.cornersAndCenter
).map((p) => this.editor.getPointInShapeSpace(binding.toId, p))
const toShapeBounds = this.editor.getShapeGeometry(binding.toId).bounds
const left = textShapeCornersInToShapeSpace[0].x
const top = textShapeCornersInToShapeSpace[0].y
const right = textShapeCornersInToShapeSpace[2].x
const bottom = textShapeCornersInToShapeSpace[2].y
const textShapeCenterInToShapeSpace = textShapeCornersInToShapeSpace.pop()!
const toShapeCenter = this.editor.getShapeGeometry(binding.toId).bounds.center
{
// do x
const dist = Math.abs(textShapeCenterInToShapeSpace.x - toShapeCenter.x)
const distInScreenSpace = dist / this.editor.getZoomLevel()
if (distInScreenSpace > 10) {
const wasRight = binding.props.x.type === 'offset' && binding.props.x.edge === 'right'
const overrideRight = right > toShapeBounds.maxX - edgeSlop
const overrideLeft = left < toShapeBounds.minX + edgeSlop
this.editor.updateBinding({
...binding,
props: {
...binding.props,
x:
(wasRight && !overrideLeft) || overrideRight
? {
type: 'offset',
edge: 'right',
offsetInToShapeSpace: toShapeBounds.maxX - right,
}
: {
type: 'offset',
edge: 'left',
offsetInToShapeSpace: left - toShapeBounds.minX,
},
},
})
} else {
this.editor.updateBinding({
...binding,
props: {
...binding.props,
x: { type: 'center' },
},
})
}
binding = this.editor.getBinding(binding.id) as TLTextBinding
}
{
// do y
const dist = Math.abs(textShapeCenterInToShapeSpace.y - toShapeCenter.y)
const distInScreenSpace = dist / this.editor.getZoomLevel()
if (distInScreenSpace > 10) {
const wasBottom = binding.props.y.type === 'offset' && binding.props.y.edge === 'bottom'
const overrideBottom = bottom > toShapeBounds.maxY - edgeSlop
const overrideTop = top < toShapeBounds.minY + edgeSlop
this.editor.updateBinding({
...binding,
props: {
...binding.props,
y:
(wasBottom && !overrideTop) || overrideBottom
? {
type: 'offset',
edge: 'bottom',
offsetInToShapeSpace: toShapeBounds.maxY - bottom,
}
: { type: 'offset', edge: 'top', offsetInToShapeSpace: top - toShapeBounds.minY },
},
})
} else {
this.editor.updateBinding({
...binding,
props: {
...binding.props,
y: { type: 'center' },
},
})
}
binding = this.editor.getBinding(binding.id) as TLTextBinding
}
}
makeTextGood(this.editor, shapeAfter.id)
}
// when the shape an text is bound to changes
override onAfterChangeToShape({ binding }: BindingOnShapeChangeOptions<TLTextBinding>): void {
makeTextGood(this.editor, binding.fromId)
}
// when the shape the text is pointing to is deleted
override onBeforeDeleteToShape({ binding }: BindingOnShapeDeleteOptions<TLTextBinding>): void {
const text = this.editor.getShape<TLTextShape>(binding.fromId)
if (!text) return
this.editor.deleteShape(text.id)
}
}
function makeTextGood(editor: Editor, textId: TLShapeId) {
const textShape = editor.getShape<TLTextShape>(textId)
if (!textShape) return
const bindings = editor.getBindingsFromShape<TLTextBinding>(textId, 'text')
if (!bindings.length) return
if (bindings.length > 1) {
editor.deleteBindings(bindings.slice(1))
}
const binding = bindings[0]
// need to make sure this text is directly above the shape it is bound to and bound to the same parent
const boundShape = editor.getShape(bindings[0].toId)
if (!boundShape) return
const siblings = editor.getSortedChildIdsForParent(boundShape.parentId)
const fromIndex = boundShape.index
const nextSiblingId = siblings[siblings.findIndex((id) => id === boundShape.id) + 1]
if (textShape.parentId !== boundShape.parentId) {
const toIndex = nextSiblingId ? editor.getShape(nextSiblingId)?.index : undefined
editor.updateShape({
...textShape,
parentId: boundShape.parentId,
index: getIndexBetween(fromIndex, toIndex),
})
return
}
if (!nextSiblingId) {
editor.updateShape({ ...textShape, index: getIndexAbove(fromIndex) })
return
}
if (textShape.index < fromIndex) {
const toIndex = editor.getShape(nextSiblingId)?.index
editor.updateShape({ ...textShape, index: getIndexBetween(fromIndex, toIndex) })
return
}
if (textShape.rotation !== boundShape.rotation) {
editor.updateShape({ ...textShape, rotation: boundShape.rotation })
return
}
if (binding.props.x.type === 'center' && textShape.props.textAlign !== 'middle') {
editor.updateShape({ ...textShape, props: { ...textShape.props, textAlign: 'middle' } })
return
}
// position the text shape
const textBounds = editor.getShapeGeometry(textShape).bounds
const geoBounds = editor.getShapeGeometry(boundShape).bounds
const offset = Vec.Sub(geoBounds.center, new Vec(textBounds.width / 2, textBounds.height / 2))
if (binding.props.x.type === 'offset') {
if (binding.props.x.edge === 'right') {
offset.x = geoBounds.maxX - binding.props.x.offsetInToShapeSpace - textBounds.width
} else {
offset.x = geoBounds.minX + binding.props.x.offsetInToShapeSpace
}
}
if (binding.props.y.type === 'offset') {
if (binding.props.y.edge === 'bottom') {
offset.y = geoBounds.maxY - binding.props.y.offsetInToShapeSpace - textBounds.height
} else {
offset.y = geoBounds.minY + binding.props.y.offsetInToShapeSpace
}
}
const geoTransform = editor.getShapePageTransform(boundShape)
const { x, y } = editor.getPointInParentSpace(boundShape, Mat.applyToPoint(geoTransform, offset))
if (!approximately(textShape.x, x) || !approximately(textShape.y, y)) {
editor.updateShape({ ...textShape, x, y })
}
}

Wyświetl plik

@ -1,5 +1,9 @@
import { TLAnyBindingUtilConstructor } from '@tldraw/editor'
import { ArrowBindingUtil } from './bindings/arrow/ArrowBindingUtil'
import { TextBindingUtil } from './bindings/arrow/TextBindingUtil'
/** @public */
export const defaultBindingUtils: TLAnyBindingUtilConstructor[] = [ArrowBindingUtil]
export const defaultBindingUtils: TLAnyBindingUtilConstructor[] = [
ArrowBindingUtil,
TextBindingUtil,
]

Wyświetl plik

@ -93,6 +93,20 @@ export class Pointing extends StateNode {
])
.select(id)
const boundShape = this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, {
filter: (shape) => shape.type === 'geo',
hitInside: true,
hitFrameInside: false,
})
if (boundShape) {
this.editor.createBinding({
type: 'text',
fromId: id,
toId: boundShape.id,
})
}
this.editor.setEditingShape(id)
this.editor.setCurrentTool('select')
this.editor.root.getCurrent()?.transition('editing_shape')

Wyświetl plik

@ -818,6 +818,12 @@ export class StyleProp<Type> implements T.Validatable<Type> {
// @public (undocumented)
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never;
// @public (undocumented)
export const textBindingMigrations: TLPropsMigrations;
// @public (undocumented)
export const textBindingProps: RecordProps<TLTextBinding>;
// @public (undocumented)
export const textShapeMigrations: TLPropsMigrations;
@ -982,7 +988,7 @@ export interface TLCursor {
export type TLCursorType = SetValue<typeof TL_CURSOR_TYPES>;
// @public
export type TLDefaultBinding = TLArrowBinding;
export type TLDefaultBinding = TLArrowBinding | TLTextBinding;
// @public (undocumented)
export type TLDefaultColorStyle = T.TypeOf<typeof DefaultColorStyle>;
@ -1344,6 +1350,27 @@ export type TLStoreSchema = StoreSchema<TLRecord, TLStoreProps>;
// @public (undocumented)
export type TLStoreSnapshot = StoreSnapshot<TLRecord>;
// @public (undocumented)
export type TLTextBinding = TLBaseBinding<'text', TLTextBindingProps>;
// @public (undocumented)
export type TLTextBindingProps = {
x: {
edge: 'left' | 'right';
offsetInToShapeSpace: number;
type: 'offset';
} | {
type: 'center';
};
y: {
edge: 'bottom' | 'top';
offsetInToShapeSpace: number;
type: 'offset';
} | {
type: 'center';
};
};
// @public (undocumented)
export type TLTextShape = TLBaseShape<'text', TLTextShapeProps>;

Wyświetl plik

@ -0,0 +1,53 @@
import { T } from '@tldraw/validate'
import { createBindingPropsMigrationSequence } from '../records/TLBinding'
import { RecordProps } from '../recordsWithProps'
import { TLBaseBinding } from './TLBaseBinding'
/** @public */
export type TLTextBindingProps = {
y:
| {
type: 'offset'
edge: 'top' | 'bottom'
offsetInToShapeSpace: number
}
| { type: 'center' }
x:
| {
type: 'offset'
edge: 'left' | 'right'
offsetInToShapeSpace: number
}
| { type: 'center' }
}
/** @public */
export const textBindingProps: RecordProps<TLTextBinding> = {
y: T.union('type', {
offset: T.object({
type: T.literal('offset'),
edge: T.literalEnum('top', 'bottom'),
offsetInToShapeSpace: T.number,
}),
center: T.object({ type: T.literal('center') }),
}),
x: T.union('type', {
offset: T.object({
type: T.literal('offset'),
edge: T.literalEnum('left', 'right'),
offsetInToShapeSpace: T.number,
}),
center: T.object({ type: T.literal('center') }),
}),
}
/** @public */
export type TLTextBinding = TLBaseBinding<'text', TLTextBindingProps>
export const textBindingVersions = {} as const
/** @public */
export const textBindingMigrations = createBindingPropsMigrationSequence({
sequence: [],
})

Wyświetl plik

@ -5,6 +5,7 @@ import { bookmarkAssetMigrations } from './assets/TLBookmarkAsset'
import { imageAssetMigrations } from './assets/TLImageAsset'
import { videoAssetMigrations } from './assets/TLVideoAsset'
import { arrowBindingMigrations, arrowBindingProps } from './bindings/TLArrowBinding'
import { textBindingMigrations, textBindingProps } from './bindings/TLTextBinding'
import { AssetRecordType, assetMigrations } from './records/TLAsset'
import { TLBinding, TLDefaultBinding, createBindingRecordType } from './records/TLBinding'
import { CameraRecordType, cameraMigrations } from './records/TLCamera'
@ -74,6 +75,7 @@ export const defaultShapeSchemas: { [T in TLDefaultShape['type']]: SchemaPropsIn
/** @public */
export const defaultBindingSchemas: { [T in TLDefaultBinding['type']]: SchemaPropsInfo } = {
arrow: { migrations: arrowBindingMigrations, props: arrowBindingProps },
text: { migrations: textBindingMigrations, props: textBindingProps },
}
/**

Wyświetl plik

@ -20,6 +20,12 @@ export {
createBindingValidator,
type TLBaseBinding,
} from './bindings/TLBaseBinding'
export {
textBindingMigrations,
textBindingProps,
type TLTextBinding,
type TLTextBindingProps,
} from './bindings/TLTextBinding'
export { createPresenceStateDerivation } from './createPresenceStateDerivation'
export {
createTLSchema,

Wyświetl plik

@ -10,6 +10,7 @@ import { T } from '@tldraw/validate'
import { nanoid } from 'nanoid'
import { TLArrowBinding } from '../bindings/TLArrowBinding'
import { TLBaseBinding, createBindingValidator } from '../bindings/TLBaseBinding'
import { TLTextBinding } from '../bindings/TLTextBinding'
import { SchemaPropsInfo } from '../createTLSchema'
import { TLPropsMigrations } from '../recordsWithProps'
@ -17,7 +18,7 @@ import { TLPropsMigrations } from '../recordsWithProps'
* The default set of bindings that are available in the editor.
*
* @public */
export type TLDefaultBinding = TLArrowBinding
export type TLDefaultBinding = TLArrowBinding | TLTextBinding
/**
* A type for a binding that is available in the editor but whose type is