[feature] Labels for shapes (#462)

* Adds generic text label

* Clean up text label / text util

* labels for ellipse and triangles

* Add arrow label

* Update filesystem.ts

* Double click bend to edit label, fix mask location

* refactor arrowutil

* fix arrow bindings

* Rename text to label, add labelPoint

* Fix arrow binding, styles on text labels, double click bounds edge to edit label

* Update ArrowSession.ts

* Update StyleMenu.tsx

* set version
This commit is contained in:
Steve Ruiz 2021-12-27 19:21:30 +00:00 committed by GitHub
parent e48f0c1794
commit d7a697647b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1996 additions and 1216 deletions

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
import { TDShapeType, Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
declare const window: Window & { app: TldrawApp }
@ -12,6 +12,13 @@ export default function Develop(): JSX.Element {
const handleMount = React.useCallback((app: TldrawApp) => {
window.app = app
rTldrawApp.current = app
// app.reset()
// app.createShapes({
// id: 'box1',
// type: TDShapeType.Rectangle,
// point: [200, 200],
// size: [200, 200],
// })
}, [])
const handleSignOut = React.useCallback(() => {

View file

@ -53,6 +53,7 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
camera: { zoom },
} = pageState
let _hideIndicators = hideIndicators
let _hideCloneHandles = true
let _isEditing = false
@ -63,9 +64,10 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
if (selectedShapes.length === 1) {
const shape = selectedShapes[0]
_isEditing = editingId === shape.id
if (_isEditing) _hideIndicators = true
const utils = shapeUtils[shape.type] as TLShapeUtil<any, any>
_hideCloneHandles = hideCloneHandles || !utils.showCloneHandles
if (shape.handles !== undefined) {
if (shape.handles !== undefined && !_isEditing) {
shapeWithHandles = shape
}
}
@ -76,7 +78,7 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
{shapeTree.map((node) => (
<ShapeNode key={node.shape.id} utils={shapeUtils} {...node} />
))}
{!hideIndicators &&
{!_hideIndicators &&
selectedShapes.map((shape) => (
<ShapeIndicator
key={'selected_' + shape.id}
@ -86,7 +88,7 @@ export const Page = observer(function _Page<T extends TLShape, M extends Record<
isEditing={_isEditing}
/>
))}
{!hideIndicators && hoveredId && hoveredId !== editingId && (
{!_hideIndicators && hoveredId && hoveredId !== editingId && (
<ShapeIndicator
key={'hovered_' + hoveredId}
shape={page.shapes[hoveredId]}

View file

@ -35,6 +35,7 @@ export const Shape = observer(function Shape<T extends TLShape, E extends Elemen
utils={utils as any}
meta={meta}
events={events}
bounds={bounds}
onShapeChange={callbacks.onShapeChange}
onShapeBlur={callbacks.onShapeBlur}
{...rest}

View file

@ -200,7 +200,6 @@ const tlcss = css`
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
contain: layout style size;
will-change: var(--tl-performance-all);
}
@ -233,7 +232,6 @@ const tlcss = css`
}
.tl-stroke-hitarea {
cursor: pointer;
fill: none;
stroke: transparent;
stroke-width: calc(24px * var(--tl-scale));
@ -243,7 +241,6 @@ const tlcss = css`
}
.tl-fill-hitarea {
cursor: pointer;
fill: transparent;
stroke: transparent;
stroke-width: calc(24px * var(--tl-scale));

View file

@ -91,6 +91,7 @@ export interface TLComponentProps<T extends TLShape, E = any, M = any> {
isSelected: boolean
isGhost?: boolean
isChildOfSelected?: boolean
bounds: TLBounds
meta: M
onShapeChange?: TLShapeChangeHandler<T, any>
onShapeBlur?: TLShapeBlurHandler<any>

View file

@ -22,6 +22,7 @@ import {
ShapeStyles,
FontStyle,
AlignStyle,
TDShapeType,
} from '~types'
import { styled } from '~styles'
import { breakpoints } from '~components/breakpoints'
@ -62,42 +63,61 @@ const ALIGN_ICONS = {
const themeSelector = (s: TDSnapshot) => (s.settings.isDarkMode ? 'dark' : 'light')
const showTextStylesSelector = (s: TDSnapshot) => {
const optionsSelector = (s: TDSnapshot) => {
const { activeTool, currentPageId: pageId } = s.appState
const page = s.document.pages[pageId]
switch (activeTool) {
case 'select': {
const page = s.document.pages[pageId]
let hasText = false
let hasLabel = false
for (const id of s.document.pageStates[pageId].selectedIds) {
if ('text' in page.shapes[id]) hasText = true
if ('label' in page.shapes[id]) hasLabel = true
}
return hasText ? 'text' : hasLabel ? 'label' : ''
}
case TDShapeType.Text: {
return 'text'
}
case TDShapeType.Rectangle: {
return 'label'
}
case TDShapeType.Ellipse: {
return 'label'
}
case TDShapeType.Triangle: {
return 'label'
}
case TDShapeType.Arrow: {
return 'label'
}
case TDShapeType.Line: {
return 'label'
}
}
return (
activeTool === 'text' ||
s.document.pageStates[pageId].selectedIds.some((id) => 'text' in page.shapes[id])
)
return false
}
export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
const app = useTldrawApp()
const theme = app.useStore(themeSelector)
const showTextStyles = app.useStore(showTextStylesSelector)
const options = app.useStore(optionsSelector)
const currentStyle = app.useStore(currentStyleSelector)
const selectedIds = app.useStore(selectedIdsSelector)
const [displayedStyle, setDisplayedStyle] = React.useState(currentStyle)
const rDisplayedStyle = React.useRef(currentStyle)
React.useEffect(() => {
const {
appState: { currentStyle },
page,
selectedIds,
} = app
let commonStyle = {} as ShapeStyles
if (selectedIds.length <= 0) {
commonStyle = currentStyle
} else {
const overrides = new Set<string>([])
app.selectedIds
.map((id) => page.shapes[id])
.forEach((shape) => {
@ -117,7 +137,6 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
})
})
}
// Until we can work out the correct logic for deciding whether or not to
// update the selected style, do a string comparison. Yuck!
if (JSON.stringify(commonStyle) !== JSON.stringify(rDisplayedStyle.current)) {
@ -125,34 +144,27 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
setDisplayedStyle(commonStyle)
}
}, [currentStyle, selectedIds])
const handleToggleFilled = React.useCallback((checked: boolean) => {
app.style({ isFilled: checked })
}, [])
const handleDashChange = React.useCallback((value: string) => {
app.style({ dash: value as DashStyle })
}, [])
const handleSizeChange = React.useCallback((value: string) => {
app.style({ size: value as SizeStyle })
}, [])
const handleFontChange = React.useCallback((value: string) => {
app.style({ font: value as FontStyle })
}, [])
const handleTextAlignChange = React.useCallback((value: string) => {
app.style({ textAlign: value as AlignStyle })
}, [])
const handleMenuOpenChange = React.useCallback(
(open: boolean) => {
app.setMenuOpen(open)
},
[app]
)
return (
<DropdownMenu.Root dir="ltr" onOpenChange={handleMenuOpenChange}>
<DropdownMenu.Trigger asChild>
@ -237,7 +249,7 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
))}
</StyledGroup>
</StyledRow>
{showTextStyles && (
{(options === 'text' || options === 'label') && (
<>
<Divider />
<StyledRow>
@ -256,26 +268,28 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
))}
</StyledGroup>
</StyledRow>
<StyledRow>
Align
<StyledGroup
dir="ltr"
value={displayedStyle.textAlign}
onValueChange={handleTextAlignChange}
>
{Object.values(AlignStyle).map((style) => (
<DMRadioItem
key={style}
isActive={style === displayedStyle.textAlign}
value={style}
onSelect={preventEvent}
bp={breakpoints}
>
{ALIGN_ICONS[style]}
</DMRadioItem>
))}
</StyledGroup>
</StyledRow>
{options === 'text' && (
<StyledRow>
Align
<StyledGroup
dir="ltr"
value={displayedStyle.textAlign}
onValueChange={handleTextAlignChange}
>
{Object.values(AlignStyle).map((style) => (
<DMRadioItem
key={style}
isActive={style === displayedStyle.textAlign}
value={style}
onSelect={preventEvent}
bp={breakpoints}
>
{ALIGN_ICONS[style]}
</DMRadioItem>
))}
</StyledGroup>
</StyledRow>
)}
</>
)}
</DMContent>

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const LETTER_SPACING = '-0.03em'
export const GRID_SIZE = 8
export const SVG_EXPORT_PADDING = 16
export const BINDING_DISTANCE = 16
@ -10,6 +11,7 @@ export const SLOW_SPEED = 10
export const VERY_SLOW_SPEED = 2.5
export const GHOSTED_OPACITY = 0.3
export const DEAD_ZONE = 3
export const LABEL_POINT = [0.5, 0.5]
import type { Easing } from '~types'

View file

@ -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&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro');
`
export function useStylesheet() {

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { mockDocument, TldrawTestApp } from '~test'
import { ArrowShape, ColorStyle, SessionType, TDShapeType } from '~types'
import { ArrowShape, ColorStyle, RectangleShape, SessionType, TDShapeType } from '~types'
import type { SelectTool } from './tools/SelectTool'
describe('TldrawTestApp', () => {

View file

@ -3364,7 +3364,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
getShapeUtil = TLDR.getShapeUtil
static version = 15.2
static version = 15.3
static defaultDocument: TDDocument = {
id: 'doc',

View file

@ -20,7 +20,12 @@ exports[` 1`] = `
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
}
},
\\"label\\": \\"\\",
\\"labelPoint\\": [
0.5,
0.5
]
},
{
\\"id\\": \\"rect2\\",
@ -40,7 +45,12 @@ exports[` 1`] = `
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
}
},
\\"label\\": \\"\\",
\\"labelPoint\\": [
0.5,
0.5
]
},
{
\\"id\\": \\"rect3\\",
@ -60,7 +70,12 @@ exports[` 1`] = `
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
}
},
\\"label\\": \\"\\",
\\"labelPoint\\": [
0.5,
0.5
]
}
]"
`;
@ -84,6 +99,11 @@ Array [
"rect1": Object {
"childIndex": 1,
"id": "rect1",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [
@ -145,6 +165,11 @@ Array [
"rect2": Object {
"childIndex": 1,
"id": "rect2",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [

View file

@ -72,15 +72,27 @@ export function migrate(document: TDDocument, newVersion: number): TDDocument {
document.assets = {}
}
if (version < 15.2) {
Object.values(document.pages).forEach((page) => {
Object.values(page.shapes).forEach((shape) => {
Object.values(document.pages).forEach((page) => {
Object.values(page.shapes).forEach((shape) => {
if (version < 15.2) {
if (shape.type === TDShapeType.Image || shape.type === TDShapeType.Video) {
shape.style.isFilled = true
}
})
}
if (version < 15.3) {
if (
shape.type === TDShapeType.Rectangle ||
shape.type === TDShapeType.Triangle ||
shape.type === TDShapeType.Ellipse ||
shape.type === TDShapeType.Arrow
) {
shape.label = (shape as any).text || ''
shape.labelPoint = [0.5, 0.5]
}
}
})
}
})
// Cleanup
Object.values(document.pageStates).forEach((pageState) => {

View file

@ -212,6 +212,8 @@ describe('When creating with an arrow session', () => {
it('Removes a binding when dragged away', () => {
const app = new TldrawTestApp()
.selectAll()
.delete()
.createShapes(
{ type: TDShapeType.Rectangle, id: 'rect1', point: [200, 200], size: [200, 200] },
{ type: TDShapeType.Rectangle, id: 'rect2', point: [400, 200], size: [200, 200] },
@ -260,7 +262,7 @@ describe('When drawing an arrow', () => {
expect(app.shapes.length).toBe(1)
})
it('creates a short arrow if at least one handle is bound to a shape', () => {
it('create a short arrow if at least one handle is bound to a shape', () => {
const app = new TldrawTestApp()
.createShapes({
type: TDShapeType.Rectangle,
@ -292,7 +294,7 @@ describe('When drawing an arrow', () => {
expect(app.shapes.length).toBe(1)
})
it('creates a short arrow if start handle is bound', () => {
it('create a short arrow if start handle is bound', () => {
const app = new TldrawTestApp()
.createShapes({
type: TDShapeType.Rectangle,

View file

@ -14,7 +14,7 @@ import { TLDR } from '~state/TLDR'
import { shapeUtils } from '~state/shapes'
import { BaseSession } from '../BaseSession'
import type { TldrawApp } from '../../internal'
import { TLPerformanceMode, Utils } from '@tldraw/core'
import { Utils } from '@tldraw/core'
export class ArrowSession extends BaseSession {
type = SessionType.Arrow
@ -40,9 +40,13 @@ export class ArrowSession extends BaseSession {
this.bindableShapeIds = TLDR.getBindableShapeIds(app.state).filter(
(id) => !(id === this.initialShape.id || id === this.initialShape.parentId)
)
const oppositeHandleBindingId =
this.initialShape.handles[handleId === 'start' ? 'end' : 'start']?.bindingId
if (oppositeHandleBindingId) {
const oppositeToId = page.bindings[oppositeHandleBindingId].toId
this.bindableShapeIds = this.bindableShapeIds.filter((id) => id !== oppositeToId)
}
const { originPoint } = this.app
if (this.isCreate) {
// If we're creating a new shape, should we bind its first point?
// The method may return undefined, which is correct if there is no
@ -52,11 +56,13 @@ export class ArrowSession extends BaseSession {
.find((shape) =>
Utils.pointInBounds(originPoint, TLDR.getShapeUtil(shape).getBounds(shape))
)?.id
if (this.startBindingShapeId) {
this.bindableShapeIds.splice(this.bindableShapeIds.indexOf(this.startBindingShapeId), 1)
}
} else {
// If we're editing an existing line, is there a binding already
// for the dragging handle?
const initialBindingId = this.initialShape.handles[this.handleId].bindingId
if (initialBindingId) {
this.initialBinding = page.bindings[initialBindingId]
} else {
@ -78,22 +84,15 @@ export class ArrowSession extends BaseSession {
currentGrid,
settings: { showGrid },
} = this.app
const shape = this.app.getShape<ArrowShape>(initialShape.id)
if (shape.isLocked) return
const handles = shape.handles
const handleId = this.handleId as keyof typeof handles
// If the handle can bind, then we need to search bindable shapes for
// a binding.
if (!handles[handleId].canBind) return
// First update the handle's next point
let delta = Vec.sub(currentPoint, handles[handleId].point)
if (shiftKey) {
const A = handles[handleId === 'start' ? 'end' : 'start'].point
const B = handles[handleId].point
@ -102,25 +101,18 @@ export class ArrowSession extends BaseSession {
const adjusted = Vec.rotWith(C, A, Utils.snapAngleToSegments(angle, 24) - angle)
delta = Vec.add(delta, Vec.sub(adjusted, C))
}
const nextPoint = Vec.sub(Vec.add(handles[handleId].point, delta), shape.point)
const handle = {
...handles[handleId],
point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint),
bindingId: undefined,
}
const utils = shapeUtils[TDShapeType.Arrow]
const change = utils.onHandleChange?.(shape, {
[handleId]: handle,
})
// If the handle changed produced no change, bail here
if (!change) return
// If nothing changes, we want these to be the same object reference as
// before. If it does change, we'll redefine this later on. And if we've
// made it this far, the shape should be a new object reference that
@ -129,29 +121,23 @@ export class ArrowSession extends BaseSession {
shape: Utils.deepMerge(shape, change),
bindings: {},
}
if (this.initialBinding) {
next.bindings[this.initialBinding.id] = undefined
}
// START BINDING
// If we have a start binding shape id, the recompute the binding
// point based on the current end handle position
if (this.startBindingShapeId) {
let startBinding: ArrowBinding | undefined
const target = this.app.page.shapes[this.startBindingShapeId]
const targetUtils = TLDR.getShapeUtil(target)
if (!metaKey) {
const center = targetUtils.getCenter(target)
const handle = next.shape.handles.start
const rayPoint = Vec.add(handle.point, next.shape.point)
const rayOrigin = center
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
const isInsideShape = targetUtils.hitTestPoint(target, currentPoint)
startBinding = this.findBindingPoint(
shape,
target,
@ -160,15 +146,12 @@ export class ArrowSession extends BaseSession {
center,
rayOrigin,
rayDirection,
false
isInsideShape
)
}
if (startBinding) {
this.didBind = true
next.bindings[this.newStartBindingId] = startBinding
next.shape.handles = {
...next.shape.handles,
start: {
@ -176,11 +159,8 @@ export class ArrowSession extends BaseSession {
bindingId: startBinding.id,
},
}
const target = this.app.page.shapes[this.startBindingShapeId]
const targetUtils = TLDR.getShapeUtil(target)
const arrowChange = TLDR.getShapeUtil<ArrowShape>(next.shape.type).onBindingChange?.(
next.shape,
startBinding,
@ -189,17 +169,12 @@ export class ArrowSession extends BaseSession {
targetUtils.getExpandedBounds(target),
targetUtils.getCenter(target)
)
if (arrowChange) {
Object.assign(next.shape, arrowChange)
}
if (arrowChange) Object.assign(next.shape, arrowChange)
} else {
this.didBind = this.didBind || false
if (this.app.page.bindings[this.newStartBindingId]) {
next.bindings[this.newStartBindingId] = undefined
}
if (shape.handles.start.bindingId === this.newStartBindingId) {
next.shape.handles = {
...next.shape.handles,
@ -211,20 +186,15 @@ export class ArrowSession extends BaseSession {
}
}
}
// DRAGGED POINT BINDING
let draggedBinding: ArrowBinding | undefined
if (!metaKey) {
const handle = next.shape.handles[this.handleId]
const oppositeHandle = next.shape.handles[this.handleId === 'start' ? 'end' : 'start']
const rayOrigin = Vec.add(oppositeHandle.point, next.shape.point)
const rayPoint = Vec.add(handle.point, next.shape.point)
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
const targets = this.bindableShapeIds.map((id) => this.app.page.shapes[id])
for (const target of targets) {
draggedBinding = this.findBindingPoint(
shape,
@ -236,16 +206,12 @@ export class ArrowSession extends BaseSession {
rayDirection,
altKey
)
if (draggedBinding) break
}
}
if (draggedBinding) {
this.didBind = true
next.bindings[this.draggedBindingId] = draggedBinding
next.shape.handles = {
...next.shape.handles,
[this.handleId]: {
@ -253,13 +219,9 @@ export class ArrowSession extends BaseSession {
bindingId: this.draggedBindingId,
},
}
const target = this.app.page.shapes[draggedBinding.toId]
const targetUtils = TLDR.getShapeUtil(target)
const utils = shapeUtils[TDShapeType.Arrow]
const arrowChange = utils.onBindingChange(
next.shape,
draggedBinding,
@ -268,21 +230,17 @@ export class ArrowSession extends BaseSession {
targetUtils.getExpandedBounds(target),
targetUtils.getCenter(target)
)
if (arrowChange) {
Object.assign(next.shape, arrowChange)
}
} else {
this.didBind = this.didBind || false
const currentBindingId = shape.handles[this.handleId].bindingId
if (currentBindingId) {
next.bindings = {
...next.bindings,
[currentBindingId]: undefined,
}
next.shape.handles = {
...next.shape.handles,
[this.handleId]: {
@ -292,7 +250,6 @@ export class ArrowSession extends BaseSession {
}
}
}
return {
document: {
pages: {
@ -315,12 +272,18 @@ export class ArrowSession extends BaseSession {
cancel = (): TldrawPatch | undefined => {
const { initialShape, initialBinding, newStartBindingId, draggedBindingId } = this
const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape
const isDeleting =
this.isCreate ||
Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) < 4
const afterBindings: Record<string, TDBinding | undefined> = {}
afterBindings[draggedBindingId] = undefined
if (initialBinding) {
afterBindings[initialBinding.id] = initialBinding
afterBindings[initialBinding.id] = isDeleting ? undefined : initialBinding
}
if (newStartBindingId) {
@ -332,14 +295,14 @@ export class ArrowSession extends BaseSession {
pages: {
[this.app.currentPageId]: {
shapes: {
[initialShape.id]: this.isCreate ? undefined : initialShape,
[initialShape.id]: isDeleting ? undefined : initialShape,
},
bindings: afterBindings,
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: this.isCreate ? [] : [initialShape.id],
selectedIds: isDeleting ? [] : [initialShape.id],
bindingId: undefined,
hoveredId: undefined,
editingId: undefined,
@ -351,36 +314,24 @@ export class ArrowSession extends BaseSession {
complete = (): TldrawPatch | TldrawCommand | undefined => {
const { initialShape, initialBinding, newStartBindingId, startBindingShapeId, handleId } = this
const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape
const currentBindingId = currentShape.handles[handleId].bindingId
if (
!(currentBindingId || initialBinding) &&
Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) < 4
) {
return this.cancel()
}
const length = Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point)
if (!(currentBindingId || initialBinding) && length < 4) return this.cancel()
const beforeBindings: Partial<Record<string, TDBinding>> = {}
const afterBindings: Partial<Record<string, TDBinding>> = {}
if (initialBinding) {
beforeBindings[initialBinding.id] = this.isCreate ? undefined : initialBinding
afterBindings[initialBinding.id] = undefined
}
if (currentBindingId) {
beforeBindings[currentBindingId] = undefined
afterBindings[currentBindingId] = this.app.page.bindings[currentBindingId]
}
if (startBindingShapeId) {
beforeBindings[newStartBindingId] = undefined
afterBindings[newStartBindingId] = this.app.page.bindings[newStartBindingId]
}
return {
id: 'arrow',
before: {

View file

@ -48,7 +48,6 @@ export class HandleSession extends BaseSession {
}
// First update the handle's next point
const change = TLDR.getShapeUtil(shape).onHandleChange?.(
shape,
{

View file

@ -0,0 +1,7 @@
import { mockDocument, TldrawTestApp } from '~test'
import { SessionType, TDShapeType, TDStatus } from '~types'
describe('Translate label session', () => {
it.todo('begins, updateSession')
it.todo('cancels session')
})

View file

@ -0,0 +1,112 @@
import { Vec } from '@tldraw/vec'
import {
SessionType,
ShapesWithProp,
TldrawCommand,
TldrawPatch,
TDStatus,
RectangleShape,
TriangleShape,
EllipseShape,
ArrowShape,
} from '~types'
import { TLDR } from '~state/TLDR'
import { BaseSession } from '../BaseSession'
import type { TldrawApp } from '../../internal'
import type { TLBounds } from '@tldraw/core'
export class TranslateLabelSession extends BaseSession {
type = SessionType.Handle
performanceMode = undefined
status = TDStatus.TranslatingHandle
initialShape: RectangleShape | TriangleShape | EllipseShape | ArrowShape
initialShapeBounds: TLBounds
constructor(app: TldrawApp, shapeId: string) {
super(app)
this.initialShape = this.app.getShape(shapeId)
this.initialShapeBounds = this.app.getShapeBounds(shapeId)
}
start = (): TldrawPatch | undefined => void null
update = (): TldrawPatch | undefined => {
const {
initialShapeBounds,
app: { currentPageId, currentPoint },
} = this
const newHandlePoint = [
Math.max(0, Math.min(1, currentPoint[0] / initialShapeBounds.width)),
Math.max(0, Math.min(1, currentPoint[1] / initialShapeBounds.height)),
]
// First update the handle's next point
const change = {
handlePoint: newHandlePoint,
} as Partial<typeof this.initialShape>
return {
document: {
pages: {
[currentPageId]: {
shapes: {
[this.initialShape.id]: change,
},
},
},
},
}
}
cancel = (): TldrawPatch | undefined => {
const {
initialShape,
app: { currentPageId },
} = this
return {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
},
}
}
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShape,
app: { currentPageId },
} = this
return {
before: {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: TLDR.onSessionComplete(this.app.getShape(this.initialShape.id)),
},
},
},
},
},
}
}
}

View file

@ -0,0 +1 @@
export * from './TranslateLabelSession'

View file

@ -1,7 +1,7 @@
import * as React from 'react'
import { Utils, TLBounds, SVGContainer } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { defaultStyle, getShapeStyle } from '../shared/shape-styles'
import { defaultStyle } from '../shared/shape-styles'
import {
ArrowShape,
TransformInfo,
@ -22,30 +22,32 @@ import {
intersectRayEllipse,
intersectRayLineSegment,
} from '@tldraw/intersect'
import { BINDING_DISTANCE, EASINGS, GHOSTED_OPACITY } from '~constants'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import {
getArcLength,
getArcPoints,
getArrowArc,
getArrowArcPath,
getArrowPath,
getBendPoint,
getCtp,
getCurvedArrowHeadPoints,
getStraightArrowHeadPoints,
isAngleBetween,
renderCurvedFreehandArrowShaft,
renderFreehandArrowShaft,
} from './arrowHelpers'
import { getTrianglePoints } from '../TriangleUtil'
import { getTrianglePoints } from '../TriangleUtil/triangleHelpers'
import { styled } from '~styles'
import { TextLabel, getFontStyle } from '../shared'
import { getTextLabelSize } from '../shared/getTextSize'
import { StraightArrow } from './components/StraightArrow'
import { CurvedArrow } from './components/CurvedArrow.tsx'
type T = ArrowShape
type E = SVGSVGElement
type E = HTMLDivElement
export class ArrowUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Arrow as const
hideBounds = true
canEdit = true
pathCache = new WeakMap<T, string>()
getShape = (props: Partial<T>): T => {
@ -88,194 +90,145 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
isFilled: false,
...props.style,
},
label: '',
labelPoint: [0.5, 0.5],
...props,
}
}
Component = TDShapeUtil.Component<T, E, TDMeta>(({ shape, isGhost, meta, events }, ref) => {
const {
handles: { start, bend, end },
decorations = {},
style,
} = shape
const isDraw = style.dash === DashStyle.Draw
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
const styles = getShapeStyle(style, meta.isDarkMode)
const { strokeWidth } = styles
const arrowDist = Vec.dist(start.point, end.point)
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
const sw = 1 + strokeWidth * 1.618
let shaftPath: JSX.Element | null
let startArrowHead: { left: number[]; right: number[] } | undefined
let endArrowHead: { left: number[]; right: number[] } | undefined
const getRandom = Utils.rng(shape.id)
const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic']
if (isStraightLine) {
const path = isDraw
? renderFreehandArrowShaft(shape)
: 'M' + Vec.toFixed(start.point) + 'L' + Vec.toFixed(end.point)
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
arrowDist,
strokeWidth * 1.618,
shape.style.dash,
2,
false
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isEditing, isGhost, meta, events, onShapeChange, onShapeBlur }, ref) => {
const {
id,
label = '',
handles: { start, bend, end },
decorations = {},
style,
} = shape
const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
const font = getFontStyle(style)
const labelSize = label || isEditing ? getTextLabelSize(label, font) : [0, 0]
const bounds = this.getBounds(shape)
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)))
)
if (decorations.start) {
startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength)
}
if (decorations.end) {
endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength)
}
// Straight arrow path
shaftPath =
arrowDist > 2 ? (
<>
<path className="tl-stroke-hitarea" d={path} />
<path
d={path}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={isDraw ? sw / 2 : sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
</>
) : null
} else {
const circle = getCtp(shape)
const { center, radius, length } = getArrowArc(shape)
const path = isDraw
? renderCurvedFreehandArrowShaft(shape, circle, length, easing)
: getArrowArcPath(start, end, circle, shape.bend)
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
Math.abs(length),
sw,
shape.style.dash,
2,
false
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])
const handleLabelChange = React.useCallback(
(label: string) => {
onShapeChange?.({ id, label })
},
[onShapeChange]
)
if (decorations.start) {
startArrowHead = getCurvedArrowHeadPoints(
start.point,
arrowHeadLength,
center,
radius,
length < 0
)
}
if (decorations.end) {
endArrowHead = getCurvedArrowHeadPoints(
end.point,
arrowHeadLength,
center,
radius,
length >= 0
)
}
// Curved arrow path
shaftPath = (
<>
<path className="tl-stroke-hitarea" d={path} />
<path
d={path}
fill={isDraw ? styles.stroke : 'none'}
stroke={styles.stroke}
strokeWidth={isDraw ? 0 : sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
const Component = isStraightLine ? StraightArrow : CurvedArrow
return (
<FullWrapper ref={ref} {...events}>
<TextLabel
isEditing={isEditing}
onChange={handleLabelChange}
onBlur={onShapeBlur}
isDarkMode={meta.isDarkMode}
font={font}
text={label}
offsetX={offset[0]}
offsetY={offset[1]}
scale={scale}
/>
</>
<SVGContainer id={shape.id + '_svg'}>
<defs>
<mask id={shape.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]}
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="black"
opacity={Math.max(scale, 0.8)}
/>
</mask>
</defs>
<g
pointerEvents="none"
opacity={isGhost ? GHOSTED_OPACITY : 1}
mask={label || isEditing ? `url(#${shape.id}_clip)` : ``}
>
<Component
id={id}
style={style}
start={start.point}
end={end.point}
bend={bend.point}
arrowBend={shape.bend}
decorationStart={decorations?.start}
decorationEnd={decorations?.end}
isDraw={style.dash === DashStyle.Draw}
isDarkMode={meta.isDarkMode}
/>
</g>
</SVGContainer>
</FullWrapper>
)
}
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<g pointerEvents="none" opacity={isGhost ? GHOSTED_OPACITY : 1}>
{shaftPath}
{startArrowHead && (
<>
<path
className="tl-stroke-hitarea"
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
/>
<path
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
/>
</>
)}
{endArrowHead && (
<>
<path
className="tl-stroke-hitarea"
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
/>
<path
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
/>
</>
)}
</g>
</SVGContainer>
)
})
)
Indicator = TDShapeUtil.Indicator<ArrowShape>(({ shape }) => {
return <path d={getArrowPath(shape)} />
const {
style,
decorations,
handles: { start, bend, end },
} = shape
return (
<path
d={getArrowPath(
style,
start.point,
bend.point,
end.point,
decorations?.start,
decorations?.end
)}
/>
)
})
getBounds = (shape: T) => {
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
return Utils.getBoundsFromPoints(getArcPoints(shape))
const {
handles: { start, bend, end },
} = shape
return Utils.getBoundsFromPoints(getArcPoints(start.point, bend.point, end.point))
})
return Utils.translateBounds(bounds, shape.point)
}
getRotatedBounds = (shape: T) => {
let points = getArcPoints(shape)
const {
handles: { start, bend, end },
} = shape
let points = getArcPoints(start.point, bend.point, end.point)
const { minX, minY, maxX, maxY } = Utils.getBoundsFromPoints(points)
if (shape.rotation !== 0) {
points = points.map((pt) =>
Vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], shape.rotation || 0)
@ -294,56 +247,52 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
return (
next.decorations !== prev.decorations ||
next.handles !== prev.handles ||
next.style !== prev.style
next.style !== prev.style ||
next.label !== prev.label
)
}
hitTestPoint = (shape: T, point: number[]): boolean => {
const {
handles: { start, bend, end },
} = shape
const pt = Vec.sub(point, shape.point)
const points = getArcPoints(shape)
const points = getArcPoints(start.point, bend.point, end.point)
for (let i = 1; i < points.length; i++) {
if (Vec.distanceToLineSegment(points[i - 1], points[i], pt) < 1) {
return true
}
}
return false
}
hitTestLineSegment = (shape: T, A: number[], B: number[]): boolean => {
const {
handles: { start, bend, end },
} = shape
const ptA = Vec.sub(A, shape.point)
const ptB = Vec.sub(B, shape.point)
const points = getArcPoints(shape)
const points = getArcPoints(start.point, bend.point, end.point)
for (let i = 1; i < points.length; i++) {
if (intersectLineSegmentLineSegment(points[i - 1], points[i], ptA, ptB).didIntersect) {
return true
}
}
return false
}
hitTestBounds = (shape: T, bounds: TLBounds) => {
const { start, end, bend } = shape.handles
const sp = Vec.add(shape.point, start.point)
const ep = Vec.add(shape.point, end.point)
if (Utils.pointInBounds(sp, bounds) || Utils.pointInBounds(ep, bounds)) {
return true
}
if (Vec.isEqual(Vec.med(start.point, end.point), bend.point)) {
return intersectLineSegmentBounds(sp, ep, bounds).length > 0
} else {
const [cx, cy, r] = getCtp(shape)
const [cx, cy, r] = getCtp(start.point, bend.point, end.point)
const cp = Vec.add(shape.point, [cx, cy])
return intersectArcBounds(cp, r, sp, ep, bounds).length > 0
}
}
@ -354,18 +303,12 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
{ initialShape, scaleX, scaleY }: TransformInfo<T>
): Partial<T> => {
const initialShapeBounds = this.getBounds(initialShape)
const handles: (keyof T['handles'])[] = ['start', 'end']
const nextHandles = { ...initialShape.handles }
handles.forEach((handle) => {
const [x, y] = nextHandles[handle].point
const nw = x / initialShapeBounds.width
const nh = y / initialShapeBounds.height
nextHandles[handle] = {
...nextHandles[handle],
point: [
@ -374,24 +317,16 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
],
}
})
const { start, bend, end } = nextHandles
const dist = Vec.dist(start.point, end.point)
const midPoint = Vec.med(start.point, end.point)
const bendDist = (dist / 2) * initialShape.bend
const u = Vec.uni(Vec.vec(start.point, end.point))
const point = Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist))
nextHandles['bend'] = {
...bend,
point: Vec.toFixed(Math.abs(bendDist) < 10 ? midPoint : point),
}
return {
point: Vec.toFixed([bounds.minX, bounds.minY]),
handles: nextHandles,
@ -442,7 +377,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
center: number[]
): Partial<T> | void => {
const handle = shape.handles[binding.handleId as keyof ArrowShape['handles']]
let handlePoint = Vec.sub(
Vec.add(
[expandedBounds.minX, expandedBounds.minY],
@ -453,19 +387,15 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
),
shape.point
)
if (binding.distance) {
const intersectBounds = Utils.expandBounds(targetBounds, binding.distance)
// The direction vector starts from the arrow's opposite handle
const origin = Vec.add(
shape.point,
shape.handles[handle.id === 'start' ? 'end' : 'start'].point
)
// And passes through the dragging handle
const direction = Vec.uni(Vec.sub(Vec.add(handlePoint, shape.point), origin))
if (target.type === TDShapeType.Ellipse) {
const hits = intersectRayEllipse(
origin,
@ -475,45 +405,32 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
(target as EllipseShape).radius[1] + binding.distance,
target.rotation || 0
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}
if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point)
} else if (target.type === TDShapeType.Triangle) {
const points = getTrianglePoints(target, BINDING_DISTANCE, target.rotation).map((pt) =>
const points = getTrianglePoints(target.size, BINDING_DISTANCE, target.rotation).map((pt) =>
Vec.add(pt, target.point)
)
const segments = Utils.pointsToLineSegments(points, true)
const hits = segments
.map((segment) => intersectRayLineSegment(origin, direction, segment[0], segment[1]))
.filter((intersection) => intersection.didIntersect)
.flatMap((intersection) => intersection.points)
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}
if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point)
} else {
let hits = intersectRayBounds(origin, direction, intersectBounds, target.rotation)
.filter((int) => int.didIntersect)
.map((int) => int.points[0])
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (hits.length < 2) {
hits = intersectRayBounds(origin, Vec.neg(direction), intersectBounds)
.filter((int) => int.didIntersect)
.map((int) => int.points[0])
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
}
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}
if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point)
}
}
return this.onHandleChange(shape, {
[handle.id]: {
...handle,
@ -525,7 +442,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
onHandleChange = (shape: T, handles: Partial<T['handles']>): Partial<T> | void => {
let nextHandles = Utils.deepMerge<ArrowShape['handles']>(shape.handles, handles)
let nextBend = shape.bend
nextHandles = {
...nextHandles,
start: {
@ -537,38 +453,27 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
point: Vec.toFixed(nextHandles.end.point),
},
}
// If the user is moving the bend handle, we want to move the bend point
if ('bend' in handles) {
const { start, end, bend } = nextHandles
const distance = Vec.dist(start.point, end.point)
const midPoint = Vec.med(start.point, end.point)
const angle = Vec.angle(start.point, end.point)
const u = Vec.uni(Vec.vec(start.point, end.point))
// Create a line segment perendicular to the line between the start and end points
const ap = Vec.add(midPoint, Vec.mul(Vec.per(u), distance / 2))
const bp = Vec.sub(midPoint, Vec.mul(Vec.per(u), distance / 2))
const bendPoint = Vec.nearestPointOnLineSegment(ap, bp, bend.point, true)
// Find the distance between the midpoint and the nearest point on the
// line segment to the bend handle's dragged point
const bendDist = Vec.dist(midPoint, bendPoint)
// The shape's "bend" is the ratio of the bend to the distance between
// the start and end points. If the bend is below a certain amount, the
// bend should be zero.
nextBend = Utils.clamp(bendDist / (distance / 2), -0.99, 0.99)
// If the point is to the left of the line segment, we make the bend
// negative, otherwise it's positive.
const angleToBend = Vec.angle(start.point, bendPoint)
// If resulting bend is low enough that the handle will snap to center,
// then also snap the bend to center
if (Vec.isEqual(midPoint, getBendPoint(nextHandles, nextBend))) {
@ -578,7 +483,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
nextBend *= -1
}
}
const nextShape = {
point: shape.point,
bend: nextBend,
@ -590,24 +494,19 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
},
},
}
// Zero out the handles to prevent handles with negative points. If a handle's x or y
// is below zero, we need to move the shape left or up to make it zero.
const topLeft = shape.point
const nextBounds = this.getBounds({ ...nextShape } as ArrowShape)
const offset = Vec.sub([nextBounds.minX, nextBounds.minY], topLeft)
if (!Vec.isEqual(offset, [0, 0])) {
Object.values(nextShape.handles).forEach((handle) => {
handle.point = Vec.toFixed(Vec.sub(handle.point, offset))
})
nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset))
}
return nextShape
}
}
const FullWrapper = styled('div', { width: '100%', height: '100%' })

View file

@ -36,6 +36,11 @@ Object {
},
},
"id": "arrow",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Arrow",
"parentId": "page",
"point": Array [

View file

@ -4,27 +4,22 @@ import Vec from '@tldraw/vec'
import getStroke from 'perfect-freehand'
import { EASINGS } from '~constants'
import { getShapeStyle } from '../shared/shape-styles'
import type { ArrowShape, TldrawHandle } from '~types'
import type { ArrowShape, Decoration, ShapeStyles } from '~types'
import { TLDR } from '../../TLDR'
export function getArrowArcPath(
start: TldrawHandle,
end: TldrawHandle,
circle: number[],
bend: number
) {
export function getArrowArcPath(start: number[], end: number[], circle: number[], bend: number) {
return [
'M',
start.point[0],
start.point[1],
start[0],
start[1],
'A',
circle[2],
circle[2],
0,
0,
bend < 0 ? 0 : 1,
end.point[0],
end.point[1],
end[0],
end[1],
].join(' ')
}
@ -46,23 +41,18 @@ export function getBendPoint(handles: ArrowShape['handles'], bend: number) {
return point
}
export function renderFreehandArrowShaft(shape: ArrowShape) {
const { style, id } = shape
const { start, end } = shape.handles
export function renderFreehandArrowShaft(
id: string,
style: ShapeStyles,
start: number[],
end: number[],
decorationStart: Decoration | undefined,
decorationEnd: Decoration | undefined
) {
const getRandom = Utils.rng(id)
const strokeWidth = getShapeStyle(style).strokeWidth
const startPoint = shape.decorations?.start
? Vec.nudge(start.point, end.point, strokeWidth)
: start.point
const endPoint = shape.decorations?.end
? Vec.nudge(end.point, start.point, strokeWidth)
: end.point
const startPoint = decorationStart ? Vec.nudge(start, end, strokeWidth) : start
const endPoint = decorationEnd ? Vec.nudge(end, start, strokeWidth) : end
const stroke = getStroke([startPoint, endPoint], {
size: strokeWidth,
thinning: 0.618 + getRandom() * 0.2,
@ -71,54 +61,34 @@ export function renderFreehandArrowShaft(shape: ArrowShape) {
streamline: 0,
last: true,
})
const path = Utils.getSvgPathFromStroke(stroke)
return path
return Utils.getSvgPathFromStroke(stroke)
}
export function renderCurvedFreehandArrowShaft(
shape: ArrowShape,
circle: number[],
id: string,
style: ShapeStyles,
start: number[],
end: number[],
decorationStart: Decoration | undefined,
decorationEnd: Decoration | undefined,
center: number[],
radius: number,
length: number,
easing: (t: number) => number
) {
const { style, id } = shape
const { start, end } = shape.handles
const getRandom = Utils.rng(id)
const strokeWidth = getShapeStyle(style).strokeWidth
const center = [circle[0], circle[1]]
const radius = circle[2]
const startPoint = shape.decorations?.start
? Vec.rotWith(start.point, center, strokeWidth / length)
: start.point
const endPoint = shape.decorations?.end
? Vec.rotWith(end.point, center, -(strokeWidth / length))
: end.point
const startPoint = decorationStart ? Vec.rotWith(start, center, strokeWidth / length) : start
const endPoint = decorationEnd ? Vec.rotWith(end, center, -(strokeWidth / length)) : end
const startAngle = Vec.angle(center, startPoint)
const endAngle = Vec.angle(center, endPoint)
const points: number[][] = []
const count = 8 + Math.floor((Math.abs(length) / 20) * 1 + getRandom() / 2)
for (let i = 0; i < count; i++) {
const t = easing(i / count)
const angle = Utils.lerpAngles(startAngle, endAngle, t)
points.push(Vec.toFixed(Vec.nudgeAtAngle(center, angle, radius)))
}
const stroke = getStroke([startPoint, ...points, endPoint], {
size: 1 + strokeWidth,
thinning: 0.618 + getRandom() * 0.2,
@ -127,27 +97,11 @@ export function renderCurvedFreehandArrowShaft(
streamline: 0,
last: true,
})
const path = Utils.getSvgPathFromStroke(stroke)
return path
return Utils.getSvgPathFromStroke(stroke)
}
export function getCtp(shape: ArrowShape) {
const { start, end, bend } = shape.handles
return Utils.circleFromThreePoints(start.point, end.point, bend.point)
}
export function getArrowArc(shape: ArrowShape) {
const { start, end, bend } = shape.handles
const [cx, cy, radius] = Utils.circleFromThreePoints(start.point, end.point, bend.point)
const center = [cx, cy]
const length = getArcLength(center, radius, start.point, end.point)
return { center, radius, length }
export function getCtp(start: number[], bend: number[], end: number[]) {
return Utils.circleFromThreePoints(start, end, bend)
}
export function getCurvedArrowHeadPoints(
@ -158,18 +112,13 @@ export function getCurvedArrowHeadPoints(
sweep: boolean
) {
const ints = intersectCircleCircle(A, r1 * 0.618, C, r2).points
if (!ints) {
TLDR.warn('Could not find an intersection for the arrow head.')
return { left: A, right: A }
}
const int = sweep ? ints[0] : ints[1]
const left = int ? Vec.nudge(Vec.rotWith(int, A, Math.PI / 6), A, r1 * -0.382) : A
const right = int ? Vec.nudge(Vec.rotWith(int, A, -Math.PI / 6), A, r1 * -0.382) : A
return { left, right }
}
@ -179,13 +128,9 @@ export function getStraightArrowHeadPoints(A: number[], B: number[], r: number)
TLDR.warn('Could not find an intersection for the arrow head.')
return { left: A, right: A }
}
const int = ints[0]
const left = int ? Vec.rotWith(int, A, Math.PI / 6) : A
const right = int ? Vec.rotWith(int, A, -Math.PI / 6) : A
return { left, right }
}
@ -197,88 +142,64 @@ export function getCurvedArrowHeadPath(
sweep: boolean
) {
const { left, right } = getCurvedArrowHeadPoints(A, r1, C, r2, sweep)
return `M ${left} L ${A} ${right}`
}
export function getStraightArrowHeadPath(A: number[], B: number[], r: number) {
const { left, right } = getStraightArrowHeadPoints(A, B, r)
return `M ${left} L ${A} ${right}`
}
export function getArrowPath(shape: ArrowShape) {
const {
decorations,
handles: { start, end, bend: _bend },
style,
} = shape
export function getArrowPath(
style: ShapeStyles,
start: number[],
bend: number[],
end: number[],
decorationStart: Decoration | undefined,
decorationEnd: Decoration | undefined
) {
const { strokeWidth } = getShapeStyle(style, false)
const arrowDist = Vec.dist(start.point, end.point)
const arrowDist = Vec.dist(start, end)
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
const path: (string | number)[] = []
const isStraightLine = Vec.dist(_bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1
const isStraightLine = Vec.dist(bend, Vec.toFixed(Vec.med(start, end))) < 1
if (isStraightLine) {
// Path (line segment)
path.push(`M ${start.point} L ${end.point}`)
// Start arrow head
if (decorations?.start) {
path.push(getStraightArrowHeadPath(start.point, end.point, arrowHeadLength))
path.push(`M ${start} L ${end}`)
if (decorationStart) {
path.push(getStraightArrowHeadPath(start, end, arrowHeadLength))
}
// End arrow head
if (decorations?.end) {
path.push(getStraightArrowHeadPath(end.point, start.point, arrowHeadLength))
if (decorationEnd) {
path.push(getStraightArrowHeadPath(end, start, arrowHeadLength))
}
} else {
const { center, radius, length } = getArrowArc(shape)
// Path (arc)
path.push(`M ${start.point} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end.point}`)
// Start Arrow head
if (decorations?.start) {
path.push(getCurvedArrowHeadPath(start.point, arrowHeadLength, center, radius, length < 0))
}
// End arrow head
if (decorations?.end) {
path.push(getCurvedArrowHeadPath(end.point, arrowHeadLength, center, radius, length >= 0))
const circle = getCtp(start, bend, end)
const center = [circle[0], circle[1]]
const radius = circle[2]
const length = getArcLength(center, radius, start, end)
path.push(`M ${start} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end}`)
if (decorationStart)
path.push(getCurvedArrowHeadPath(start, arrowHeadLength, center, radius, length < 0))
if (decorationEnd) {
path.push(getCurvedArrowHeadPath(end, arrowHeadLength, center, radius, length >= 0))
}
}
return path.join(' ')
}
export function getArcPoints(shape: ArrowShape) {
const { start, bend, end } = shape.handles
if (Vec.dist2(bend.point, Vec.med(start.point, end.point)) > 4) {
const points: number[][] = []
// We're an arc, calculate points along the arc
const { center, radius } = getArrowArc(shape)
const startAngle = Vec.angle(center, start.point)
const endAngle = Vec.angle(center, end.point)
for (let i = 1 / 20; i < 1; i += 1 / 20) {
const angle = Utils.lerpAngles(startAngle, endAngle, i)
points.push(Vec.nudgeAtAngle(center, angle, radius))
}
return points
} else {
return [start.point, end.point]
export function getArcPoints(start: number[], bend: number[], end: number[]) {
if (Vec.dist2(bend, Vec.med(start, end)) <= 4) return [start, end]
// The arc is curved; calculate twenty points along the arc
const points: number[][] = []
const circle = getCtp(start, bend, end)
const center = [circle[0], circle[1]]
const radius = circle[2]
const startAngle = Vec.angle(center, start)
const endAngle = Vec.angle(center, end)
for (let i = 1 / 20; i < 1; i += 1 / 20) {
const angle = Utils.lerpAngles(startAngle, endAngle, i)
points.push(Vec.nudgeAtAngle(center, angle, radius))
}
return points
}
export function isAngleBetween(a: number, b: number, c: number): boolean {

View file

@ -0,0 +1,26 @@
import * as React from 'react'
export interface ArrowheadProps {
left: number[]
middle: number[]
right: number[]
stroke: string
strokeWidth: number
}
export function Arrowhead({ left, middle, right, stroke, strokeWidth }: ArrowheadProps) {
return (
<g>
<path className="tl-stroke-hitarea" d={`M ${left} L ${middle} ${right}`} />
<path
d={`M ${left} L ${middle} ${right}`}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
/>
</g>
)
}

View file

@ -0,0 +1,116 @@
import { Utils } from '@tldraw/core'
import Vec from '@tldraw/vec'
import * as React from 'react'
import { EASINGS } from '~constants'
import { getShapeStyle } from '~state/shapes/shared'
import type { Decoration, ShapeStyles } from '~types'
import {
getArcLength,
getArrowArcPath,
getCtp,
getCurvedArrowHeadPoints,
renderCurvedFreehandArrowShaft,
} from '../arrowHelpers'
import { Arrowhead } from './ArrowHead'
interface ArrowSvgProps {
id: string
style: ShapeStyles
start: number[]
bend: number[]
end: number[]
arrowBend: number
decorationStart: Decoration | undefined
decorationEnd: Decoration | undefined
isDarkMode: boolean
isDraw: boolean
}
export const CurvedArrow = React.memo(function CurvedArrow({
id,
style,
start,
bend,
end,
arrowBend,
decorationStart,
decorationEnd,
isDraw,
isDarkMode,
}: ArrowSvgProps) {
const arrowDist = Vec.dist(start, end)
if (arrowDist < 2) return null
const styles = getShapeStyle(style, isDarkMode)
const { strokeWidth } = styles
const sw = 1 + strokeWidth * 1.618
// Calculate a path as a segment of a circle passing through the three points start, bend, and end
const circle = getCtp(start, bend, end)
const center = [circle[0], circle[1]]
const radius = circle[2]
const length = getArcLength(center, radius, start, end)
const getRandom = Utils.rng(id)
const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic']
const path = isDraw
? renderCurvedFreehandArrowShaft(
id,
style,
start,
end,
decorationStart,
decorationEnd,
center,
radius,
length,
easing
)
: getArrowArcPath(start, end, circle, arrowBend)
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
Math.abs(length),
sw,
style.dash,
2,
false
)
// Arrowheads
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
const startArrowHead = decorationStart
? getCurvedArrowHeadPoints(start, arrowHeadLength, center, radius, length < 0)
: null
const endArrowHead = decorationEnd
? getCurvedArrowHeadPoints(end, arrowHeadLength, center, radius, length >= 0)
: null
return (
<>
<path className="tl-stroke-hitarea" d={path} />
<path
d={path}
fill={isDraw ? styles.stroke : 'none'}
stroke={styles.stroke}
strokeWidth={isDraw ? 0 : sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
/>
{startArrowHead && (
<Arrowhead
left={startArrowHead.left}
middle={start}
right={startArrowHead.right}
stroke={styles.stroke}
strokeWidth={sw}
/>
)}
{endArrowHead && (
<Arrowhead
left={endArrowHead.left}
middle={end}
right={endArrowHead.right}
stroke={styles.stroke}
strokeWidth={sw}
/>
)}
</>
)
})

View file

@ -0,0 +1,90 @@
import { Utils } from '@tldraw/core'
import Vec from '@tldraw/vec'
import * as React from 'react'
import { getShapeStyle } from '~state/shapes/shared'
import type { Decoration, ShapeStyles } from '~types'
import { getStraightArrowHeadPoints, renderFreehandArrowShaft } from '../arrowHelpers'
import { Arrowhead } from './ArrowHead'
interface ArrowSvgProps {
id: string
style: ShapeStyles
start: number[]
bend: number[]
end: number[]
arrowBend: number
decorationStart: Decoration | undefined
decorationEnd: Decoration | undefined
isDarkMode: boolean
isDraw: boolean
}
export const StraightArrow = React.memo(function StraightArrow({
id,
style,
start,
end,
decorationStart,
decorationEnd,
isDraw,
isDarkMode,
}: ArrowSvgProps) {
const arrowDist = Vec.dist(start, end)
if (arrowDist < 2) return null
const styles = getShapeStyle(style, isDarkMode)
const { strokeWidth } = styles
const sw = 1 + strokeWidth * 1.618
// Path between start and end points
const path = isDraw
? renderFreehandArrowShaft(id, style, start, end, decorationStart, decorationEnd)
: 'M' + Vec.toFixed(start) + 'L' + Vec.toFixed(end)
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
arrowDist,
strokeWidth * 1.618,
style.dash,
2,
false
)
// Arrowheads
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
const startArrowHead = decorationStart
? getStraightArrowHeadPoints(start, end, arrowHeadLength)
: null
const endArrowHead = decorationEnd
? getStraightArrowHeadPoints(end, start, arrowHeadLength)
: null
return (
<>
<path className="tl-stroke-hitarea" d={path} />
<path
d={path}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={isDraw ? sw / 2 : sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
{startArrowHead && (
<Arrowhead
left={startArrowHead.left}
middle={start}
right={startArrowHead.right}
stroke={styles.stroke}
strokeWidth={sw}
/>
)}
{endArrowHead && (
<Arrowhead
left={endArrowHead.left}
middle={end}
right={endArrowHead.right}
stroke={styles.stroke}
strokeWidth={sw}
/>
)}
</>
)
})

View file

@ -1,19 +1,23 @@
import * as React from 'react'
import { Utils, SVGContainer, TLBounds } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { defaultStyle, getShapeStyle } from '~state/shapes/shared'
import { defaultStyle, getShapeStyle, getFontStyle } from '~state/shapes/shared'
import { EllipseShape, DashStyle, TDShapeType, TDShape, TransformInfo, TDMeta } from '~types'
import { GHOSTED_OPACITY } from '~constants'
import { GHOSTED_OPACITY, LABEL_POINT } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import {
intersectEllipseBounds,
intersectLineSegmentEllipse,
intersectRayEllipse,
} from '@tldraw/intersect'
import { getEllipseIndicatorPathTDSnapshot, getEllipsePath } from './ellipseHelpers'
import { getEllipseIndicatorPath } from './ellipseHelpers'
import { DrawEllipse } from './components/DrawEllipse'
import { DashedEllipse } from './components/DashedEllipse'
import { TextLabel } from '../shared/TextLabel'
import { styled } from '~styles'
type T = EllipseShape
type E = SVGSVGElement
type E = HTMLDivElement
type M = TDMeta
export class EllipseUtil extends TDShapeUtil<T, E> {
@ -21,6 +25,10 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
canBind = true
canClone = true
canEdit = true
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
@ -33,132 +41,88 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
radius: [1, 1],
rotation: 0,
style: defaultStyle,
label: '',
labelPoint: [0.5, 0.5],
},
props
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isGhost, isSelected, isBinding, meta, events }, ref) => {
const {
radius: [radiusX, radiusY],
style,
} = shape
(
{
shape,
isGhost,
isSelected,
isBinding,
isEditing,
meta,
bounds,
events,
onShapeChange,
onShapeBlur,
},
ref
) => {
const { id, radius, style, label = '', labelPoint = LABEL_POINT } = shape
const font = getFontStyle(shape.style)
const styles = getShapeStyle(style, meta.isDarkMode)
const strokeWidth = styles.strokeWidth
const sw = 1 + strokeWidth * 1.618
const rx = Math.max(0, radiusX - sw / 2)
const ry = Math.max(0, radiusY - sw / 2)
if (style.dash === DashStyle.Draw) {
const path = getEllipsePath(shape, this.getCenter(shape))
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
const rx = Math.max(0, radius[0] - sw / 2)
const ry = Math.max(0, radius[1] - sw / 2)
const Component = style.dash === DashStyle.Draw ? DrawEllipse : DashedEllipse
const handleLabelChange = React.useCallback(
(label: string) => onShapeChange?.({ id, label }),
[onShapeChange]
)
return (
<FullWrapper ref={ref} {...events}>
<TextLabel
isEditing={isEditing}
onChange={handleLabelChange}
onBlur={onShapeBlur}
isDarkMode={meta.isDarkMode}
font={font}
text={label}
offsetX={(labelPoint[0] - 0.5) * bounds.width}
offsetY={(labelPoint[1] - 0.5) * bounds.height}
/>
<SVGContainer id={shape.id + '_svg'} opacity={isGhost ? GHOSTED_OPACITY : 1}>
{isBinding && (
<ellipse
className="tl-binding-indicator"
cx={radiusX}
cy={radiusY}
cx={radius[0]}
cy={radius[1]}
rx={rx}
ry={ry}
strokeWidth={this.bindingDistance}
/>
)}
<ellipse
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
cx={radiusX}
cy={radiusY}
rx={radiusX}
ry={radiusY}
/>
<path
d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))}
stroke="none"
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="none"
/>
<path
d={path}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
opacity={isGhost ? GHOSTED_OPACITY : 1}
<Component
id={id}
radius={radius}
style={style}
isSelected={isSelected}
isDarkMode={meta.isDarkMode}
/>
</SVGContainer>
)
}
const perimeter = Utils.perimeterOfEllipse(rx, ry) // Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h)))
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
perimeter < 64 ? perimeter * 2 : perimeter,
strokeWidth * 1.618,
shape.style.dash,
4
)
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<ellipse
className="tl-binding-indicator"
cx={radiusX}
cy={radiusY}
rx={radiusX}
ry={radiusY}
strokeWidth={this.bindingDistance}
/>
)}
<ellipse
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
cx={radiusX}
cy={radiusY}
rx={radiusX}
ry={radiusY}
/>
<ellipse
cx={radiusX}
cy={radiusY}
rx={rx}
ry={ry}
fill={styles.fill}
stroke={styles.stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SVGContainer>
</FullWrapper>
)
}
)
Indicator = TDShapeUtil.Indicator<T, M>(({ shape }) => {
const {
radius: [radiusX, radiusY],
style,
} = shape
const { id, radius, style } = shape
const styles = getShapeStyle(style)
const strokeWidth = styles.strokeWidth
const sw = 1 + strokeWidth * 1.618
const rx = Math.max(0, radiusX - sw / 2)
const ry = Math.max(0, radiusY - sw / 2)
const rx = Math.max(0, radius[0] - sw / 2)
const ry = Math.max(0, radius[1] - sw / 2)
return style.dash === DashStyle.Draw ? (
<path d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))} />
<path d={getEllipseIndicatorPath(id, radius, style)} />
) : (
<ellipse cx={radiusX} cy={radiusY} rx={rx} ry={ry} />
<ellipse cx={radius[0]} cy={radius[1]} rx={rx} ry={ry} />
)
})
@ -224,7 +188,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
}
shouldRender = (prev: T, next: T): boolean => {
return next.radius !== prev.radius || next.style !== prev.style
return next.radius !== prev.radius || next.style !== prev.style || next.label !== prev.label
}
getCenter = (shape: T): number[] => {
@ -241,12 +205,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
) => {
{
const expandedBounds = this.getExpandedBounds(shape)
const center = this.getCenter(shape)
let bindingPoint: number[]
let distance: number
if (
!Utils.pointInEllipse(
point,
@ -254,9 +215,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
shape.radius[0] + this.bindingDistance,
shape.radius[1] + this.bindingDistance
)
)
) {
return
}
if (bindAnywhere) {
if (Vec.dist(point, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
@ -266,7 +227,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
expandedBounds.height,
])
}
distance = 0
} else {
let intersection = intersectRayEllipse(
@ -277,7 +237,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
shape.radius[1],
shape.rotation || 0
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))[0]
if (!intersection) {
intersection = intersectLineSegmentEllipse(
point,
@ -288,14 +247,11 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
shape.rotation || 0
).points.sort((a, b) => Vec.dist(a, point) - Vec.dist(b, point))[0]
}
if (!intersection) {
return undefined
}
// The anchor is a point between the handle and the intersection
const anchor = Vec.med(point, intersection)
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
// If we're close to the center, snap to the center
bindingPoint = [0.5, 0.5]
@ -306,7 +262,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
expandedBounds.height,
])
}
if (
Utils.pointInEllipse(point, center, shape.radius[0], shape.radius[1], shape.rotation || 0)
) {
@ -322,15 +277,10 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
shape.radius[1],
shape.rotation || 0
).points[0]
if (!innerIntersection) {
return undefined
}
if (!innerIntersection) return undefined
distance = Math.max(this.bindingDistance / 2, Vec.dist(point, innerIntersection))
}
}
return {
point: bindingPoint,
distance,
@ -344,7 +294,6 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
{ scaleX, scaleY, initialShape }: TransformInfo<T>
): Partial<T> => {
const { rotation = 0 } = initialShape
return {
point: [bounds.minX, bounds.minY],
radius: [bounds.width / 2, bounds.height / 2],
@ -362,3 +311,5 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
}
}
}
const FullWrapper = styled('div', { width: '100%', height: '100%' })

View file

@ -4,6 +4,11 @@ exports[`Ellipse shape Creates a shape: ellipse 1`] = `
Object {
"childIndex": 1,
"id": "ellipse",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Ellipse",
"parentId": "page",
"point": Array [

View file

@ -0,0 +1,56 @@
import * as React from 'react'
import { Utils } from '@tldraw/core'
import type { ShapeStyles } from '~types'
import { getShapeStyle } from '~state/shapes/shared'
interface EllipseSvgProps {
radius: number[]
style: ShapeStyles
isSelected: boolean
isDarkMode: boolean
}
export const DashedEllipse = React.memo(function DashedEllipse({
radius,
style,
isSelected,
isDarkMode,
}: EllipseSvgProps) {
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
const sw = 1 + strokeWidth * 1.618
const rx = Math.max(0, radius[0] - sw / 2)
const ry = Math.max(0, radius[1] - sw / 2)
const perimeter = Utils.perimeterOfEllipse(rx, ry)
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
perimeter < 64 ? perimeter * 2 : perimeter,
strokeWidth * 1.618,
style.dash,
4
)
return (
<>
<ellipse
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
cx={radius[0]}
cy={radius[1]}
rx={radius[0]}
ry={radius[1]}
/>
<ellipse
cx={radius[0]}
cy={radius[1]}
rx={rx}
ry={ry}
fill={fill}
stroke={stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)
})

View file

@ -0,0 +1,52 @@
import * as React from 'react'
import { getShapeStyle } from '~state/shapes/shared'
import type { ShapeStyles } from '~types'
import { getEllipseIndicatorPath, getEllipsePath } from '../ellipseHelpers'
interface EllipseSvgProps {
id: string
radius: number[]
style: ShapeStyles
isSelected: boolean
isDarkMode: boolean
}
export const DrawEllipse = React.memo(function DrawEllipse({
id,
radius,
style,
isSelected,
isDarkMode,
}: EllipseSvgProps) {
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
const innerPath = getEllipsePath(id, radius, style)
return (
<>
<ellipse
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
cx={radius[0]}
cy={radius[1]}
rx={radius[0]}
ry={radius[1]}
/>
{style.isFilled && (
<path
d={getEllipseIndicatorPath(id, radius, style)}
stroke="none"
fill={fill}
pointerEvents="none"
/>
)}
<path
d={innerPath}
fill={stroke}
stroke={stroke}
strokeWidth={strokeWidth}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)
})

View file

@ -1,45 +1,26 @@
import { Utils } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
import { EASINGS } from '~constants'
import type { EllipseShape } from '~types'
import type { ShapeStyles } from '~types'
import { getShapeStyle } from '../shared/shape-styles'
export function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number[]) {
const {
id,
radius: [radiusX, radiusY],
point,
style,
} = shape
export function getEllipseStrokePoints(id: string, radius: number[], style: ShapeStyles) {
const { strokeWidth } = getShapeStyle(style)
const getRandom = Utils.rng(id)
const center = Vec.sub(boundsCenter, point)
const rx = radiusX + getRandom() * strokeWidth * 2
const ry = radiusY + getRandom() * strokeWidth * 2
const rx = radius[0] + getRandom() * strokeWidth * 2
const ry = radius[1] + getRandom() * strokeWidth * 2
const perimeter = Utils.perimeterOfEllipse(rx, ry)
const points: number[][] = []
const start = Math.PI + Math.PI * getRandom()
const extra = Math.abs(getRandom())
const count = Math.max(16, perimeter / 10)
for (let i = 0; i < count; i++) {
const t = EASINGS.easeInOutSine(i / (count + 1))
const rads = start * 2 + Math.PI * (2 + extra) * t
const c = Math.cos(rads)
const s = Math.sin(rads)
points.push([rx * c + center[0], ry * s + center[1], t + 0.5 + getRandom() / 2])
points.push([rx * c + radius[0], ry * s + radius[1], t + 0.5 + getRandom() / 2])
}
return getStrokePoints(points, {
size: 1 + strokeWidth * 2,
thinning: 0.618,
@ -50,24 +31,14 @@ export function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number
})
}
export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
const {
id,
radius: [radiusX, radiusY],
style,
} = shape
export function getEllipsePath(id: string, radius: number[], style: ShapeStyles) {
const { strokeWidth } = getShapeStyle(style)
const getRandom = Utils.rng(id)
const rx = radiusX + getRandom() * strokeWidth * 2
const ry = radiusY + getRandom() * strokeWidth * 2
const rx = radius[0] + getRandom() * strokeWidth * 2
const ry = radius[1] + getRandom() * strokeWidth * 2
const perimeter = Utils.perimeterOfEllipse(rx, ry)
return Utils.getSvgPathFromStroke(
getStrokeOutlinePoints(getEllipseStrokePoints(shape, boundsCenter), {
getStrokeOutlinePoints(getEllipseStrokePoints(id, radius, style), {
size: 2 + strokeWidth * 2,
thinning: 0.618,
end: { taper: perimeter / 8 },
@ -78,9 +49,9 @@ export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
)
}
export function getEllipseIndicatorPathTDSnapshot(shape: EllipseShape, boundsCenter: number[]) {
export function getEllipseIndicatorPath(id: string, radius: number[], style: ShapeStyles) {
return Utils.getSvgPathFromStroke(
getEllipseStrokePoints(shape, boundsCenter).map((pt) => pt.point.slice(0, 2)),
getEllipseStrokePoints(id, radius, style).map((pt) => pt.point.slice(0, 2)),
false
)
}

View file

@ -1,20 +1,25 @@
import * as React from 'react'
import { Utils, SVGContainer } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { getStroke, getStrokePoints } from 'perfect-freehand'
import { RectangleShape, DashStyle, TDShapeType, TDMeta } from '~types'
import { GHOSTED_OPACITY } from '~constants'
import { GHOSTED_OPACITY, LABEL_POINT } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import {
defaultStyle,
getShapeStyle,
getBoundsRectangle,
transformRectangle,
getFontStyle,
transformSingleRectangle,
} from '~state/shapes/shared'
import { TextLabel } from '../shared/TextLabel'
import { getRectangleIndicatorPathTDSnapshot } from './rectangleHelpers'
import { DrawRectangle } from './components/DrawRectangle'
import { DashedRectangle } from './components/DashedRectangle'
import { BindingIndicator } from './components/BindingIndicator'
import { styled } from '~styles'
type T = RectangleShape
type E = SVGSVGElement
type E = HTMLDivElement
export class RectangleUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Rectangle as const
@ -23,6 +28,8 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
canClone = true
canEdit = true
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
@ -35,139 +42,72 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
size: [1, 1],
rotation: 0,
style: defaultStyle,
label: '',
labelPoint: [0.5, 0.5],
},
props
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => {
const { id, size, style } = shape
(
{
shape,
isEditing,
isBinding,
isSelected,
isGhost,
meta,
bounds,
events,
onShapeBlur,
onShapeChange,
},
ref
) => {
const { id, size, style, label = '', labelPoint = LABEL_POINT } = shape
const font = getFontStyle(style)
const styles = getShapeStyle(style, meta.isDarkMode)
const { strokeWidth } = styles
if (style.dash === DashStyle.Draw) {
const pathTDSnapshot = getRectanglePath(shape)
const indicatorPath = getRectangleIndicatorPathTDSnapshot(shape)
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<rect
className="tl-binding-indicator"
x={strokeWidth}
y={strokeWidth}
width={Math.max(0, size[0] - strokeWidth / 2)}
height={Math.max(0, size[1] - strokeWidth / 2)}
strokeWidth={this.bindingDistance * 2}
/>
)}
<path
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={indicatorPath}
/>
<path
d={indicatorPath}
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="none"
/>
<path
d={pathTDSnapshot}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="none"
opacity={isGhost ? GHOSTED_OPACITY : 1}
const Component = style.dash === DashStyle.Draw ? DrawRectangle : DashedRectangle
const handleLabelChange = React.useCallback(
(label: string) => onShapeChange?.({ id, label }),
[onShapeChange]
)
return (
<FullWrapper ref={ref} {...events}>
<TextLabel
isEditing={isEditing}
onChange={handleLabelChange}
onBlur={onShapeBlur}
isDarkMode={meta.isDarkMode}
font={font}
text={label}
offsetX={(labelPoint[0] - 0.5) * bounds.width}
offsetY={(labelPoint[1] - 0.5) * bounds.height}
/>
<SVGContainer id={shape.id + '_svg'} opacity={isGhost ? GHOSTED_OPACITY : 1}>
{isBinding && <BindingIndicator strokeWidth={styles.strokeWidth} size={size} />}
<Component
id={id}
style={style}
size={size}
isSelected={isSelected}
isDarkMode={meta.isDarkMode}
/>
</SVGContainer>
)
}
const sw = 1 + strokeWidth * 1.618
const w = Math.max(0, size[0] - sw / 2)
const h = Math.max(0, size[1] - sw / 2)
const strokes: [number[], number[], number][] = [
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
[[w, sw / 2], [w, h], h - sw / 2],
[[w, h], [sw / 2, h], w - sw / 2],
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
]
const paths = strokes.map(([start, end, length], i) => {
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
length,
strokeWidth * 1.618,
shape.style.dash
)
return (
<line
key={id + '_' + i}
x1={start[0]}
y1={start[1]}
x2={end[0]}
y2={end[1]}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
{isBinding && (
<rect
className="tl-binding-indicator"
x={0}
y={0}
width={size[0]}
height={size[1]}
strokeWidth={this.bindingDistance}
/>
)}
<rect
className={isSelected || style.isFilled ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
x={sw / 2}
y={sw / 2}
width={w}
height={h}
strokeWidth={this.bindingDistance}
/>
{style.isFilled && (
<rect
x={sw / 2}
y={sw / 2}
width={w}
height={h}
fill={styles.fill}
pointerEvents="none"
/>
)}
<g pointerEvents="none" stroke={styles.stroke} strokeWidth={sw} strokeLinecap="round">
{paths}
</g>
</g>
</SVGContainer>
</FullWrapper>
)
}
)
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const {
style,
size: [width, height],
} = shape
const { id, style, size } = shape
const styles = getShapeStyle(style, false)
const sw = styles.strokeWidth
if (style.dash === DashStyle.Draw) {
return <path d={getRectangleIndicatorPathTDSnapshot(shape)} />
return <path d={getRectangleIndicatorPathTDSnapshot(id, style, size)} />
}
return (
@ -176,8 +116,8 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
y={sw}
rx={1}
ry={1}
width={Math.max(1, width - sw * 2)}
height={Math.max(1, height - sw * 2)}
width={Math.max(1, size[0] - sw * 2)}
height={Math.max(1, size[1] - sw * 2)}
/>
)
})
@ -187,7 +127,7 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
}
shouldRender = (prev: T, next: T) => {
return next.size !== prev.size || next.style !== prev.style
return next.size !== prev.size || next.style !== prev.style || next.label !== prev.label
}
transform = transformRectangle
@ -195,102 +135,4 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
transformSingle = transformSingleRectangle
}
/* -------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------- */
function getRectangleDrawPoints(shape: RectangleShape) {
const styles = getShapeStyle(shape.style)
const getRandom = Utils.rng(shape.id)
const sw = styles.strokeWidth
// Dimensions
const w = Math.max(0, shape.size[0])
const h = Math.max(0, shape.size[1])
// Random corner offsets
const offsets = Array.from(Array(4)).map(() => {
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
})
// Corners
const tl = Vec.add([sw / 2, sw / 2], offsets[0])
const tr = Vec.add([w - sw / 2, sw / 2], offsets[1])
const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2])
const bl = Vec.add([sw / 2, h - sw / 2], offsets[3])
// Which side to start drawing first
const rm = Math.round(Math.abs(getRandom() * 2 * 4))
// Corner radii
const rx = Math.min(w / 2, sw * 2)
const ry = Math.min(h / 2, sw * 2)
// Number of points per side
const px = Math.max(8, Math.floor(w / 16))
const py = Math.max(8, Math.floor(h / 16))
// Inset each line by the corner radii and let the freehand algo
// interpolate points for the corners.
const lines = Utils.rotateArray(
[
Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px),
Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py),
Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px),
Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py),
],
rm
)
// For the final points, include the first half of the first line again,
// so that the line wraps around and avoids ending on a sharp corner.
// This has a bit of finesse and magic—if you change the points between
// function, then you'll likely need to change this one too.
const points = [...lines.flat(), ...lines[0]].slice(
5,
Math.floor((rm % 2 === 0 ? px : py) / -2) + 3
)
return {
points,
}
}
function getDrawStrokeInfo(shape: RectangleShape) {
const { points } = getRectangleDrawPoints(shape)
const { strokeWidth } = getShapeStyle(shape.style)
const options = {
size: strokeWidth,
thinning: 0.65,
streamline: 0.3,
smoothing: 1,
simulatePressure: false,
last: true,
}
return { points, options }
}
function getRectanglePath(shape: RectangleShape) {
const { points, options } = getDrawStrokeInfo(shape)
const stroke = getStroke(points, options)
return Utils.getSvgPathFromStroke(stroke)
}
function getRectangleIndicatorPathTDSnapshot(shape: RectangleShape) {
const { points, options } = getDrawStrokeInfo(shape)
const strokePoints = getStrokePoints(points, options)
return Utils.getSvgPathFromStroke(
strokePoints.map((pt) => pt.point.slice(0, 2)),
false
)
}
const FullWrapper = styled('div', { width: '100%', height: '100%' })

View file

@ -4,6 +4,11 @@ exports[`Rectangle shape Creates a shape: rectangle 1`] = `
Object {
"childIndex": 1,
"id": "rectangle",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page",
"point": Array [

View file

@ -0,0 +1,19 @@
import * as React from 'react'
import { BINDING_DISTANCE } from '~constants'
interface BindingIndicatorProps {
strokeWidth: number
size: number[]
}
export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) {
return (
<rect
className="tl-binding-indicator"
x={strokeWidth}
y={strokeWidth}
width={Math.max(0, size[0] - strokeWidth / 2)}
height={Math.max(0, size[1] - strokeWidth / 2)}
strokeWidth={BINDING_DISTANCE * 2}
/>
)
}

View file

@ -0,0 +1,74 @@
import * as React from 'react'
import { Utils } from '@tldraw/core'
import { BINDING_DISTANCE } from '~constants'
import type { ShapeStyles } from '~types'
import { getShapeStyle } from '~state/shapes/shared'
interface RectangleSvgProps {
id: string
style: ShapeStyles
isSelected: boolean
size: number[]
isDarkMode: boolean
}
export const DashedRectangle = React.memo(function DashedRectangle({
id,
style,
size,
isSelected,
isDarkMode,
}: RectangleSvgProps) {
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
const sw = 1 + strokeWidth * 1.618
const w = Math.max(0, size[0] - sw / 2)
const h = Math.max(0, size[1] - sw / 2)
const strokes: [number[], number[], number][] = [
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
[[w, sw / 2], [w, h], h - sw / 2],
[[w, h], [sw / 2, h], w - sw / 2],
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
]
const paths = strokes.map(([start, end, length], i) => {
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
length,
strokeWidth * 1.618,
style.dash
)
return (
<line
key={id + '_' + i}
x1={start[0]}
y1={start[1]}
x2={end[0]}
y2={end[1]}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})
return (
<>
<rect
className={isSelected || style.isFilled ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
x={sw / 2}
y={sw / 2}
width={w}
height={h}
strokeWidth={BINDING_DISTANCE}
/>
{style.isFilled && (
<rect x={sw / 2} y={sw / 2} width={w} height={h} fill={fill} pointerEvents="none" />
)}
<g pointerEvents="none" stroke={stroke} strokeWidth={sw} strokeLinecap="round">
{paths}
</g>
</>
)
})

View file

@ -0,0 +1,42 @@
import * as React from 'react'
import { getShapeStyle } from '~state/shapes/shared'
import type { ShapeStyles } from '~types'
import { getRectangleIndicatorPathTDSnapshot, getRectanglePath } from '../rectangleHelpers'
interface RectangleSvgProps {
id: string
style: ShapeStyles
isSelected: boolean
isDarkMode: boolean
size: number[]
}
export const DrawRectangle = React.memo(function DrawRectangle({
id,
style,
size,
isSelected,
isDarkMode,
}: RectangleSvgProps) {
const { isFilled } = style
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
const pathTDSnapshot = getRectanglePath(id, style, size)
const innerPath = getRectangleIndicatorPathTDSnapshot(id, style, size)
return (
<>
<path
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={innerPath}
/>
{isFilled && <path d={innerPath} fill={fill} pointerEvents="none" />}
<path
d={pathTDSnapshot}
fill={stroke}
stroke={stroke}
strokeWidth={strokeWidth}
pointerEvents="none"
/>
</>
)
})

View file

@ -0,0 +1,98 @@
import { Utils } from '@tldraw/core'
import Vec from '@tldraw/vec'
import getStroke, { getStrokePoints } from 'perfect-freehand'
import type { ShapeStyles } from '~types'
import { getShapeStyle } from '../shared'
function getRectangleDrawPoints(id: string, style: ShapeStyles, size: number[]) {
const styles = getShapeStyle(style)
const getRandom = Utils.rng(id)
const sw = styles.strokeWidth
// Dimensions
const w = Math.max(0, size[0])
const h = Math.max(0, size[1])
// Random corner offsets
const offsets = Array.from(Array(4)).map(() => {
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
})
// Corners
const tl = Vec.add([sw / 2, sw / 2], offsets[0])
const tr = Vec.add([w - sw / 2, sw / 2], offsets[1])
const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2])
const bl = Vec.add([sw / 2, h - sw / 2], offsets[3])
// Which side to start drawing first
const rm = Math.round(Math.abs(getRandom() * 2 * 4))
// Corner radii
const rx = Math.min(w / 2, sw * 2)
const ry = Math.min(h / 2, sw * 2)
// Number of points per side
const px = Math.max(8, Math.floor(w / 16))
const py = Math.max(8, Math.floor(h / 16))
// Inset each line by the corner radii and let the freehand algo
// interpolate points for the corners.
const lines = Utils.rotateArray(
[
Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px),
Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py),
Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px),
Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py),
],
rm
)
// For the final points, include the first half of the first line again,
// so that the line wraps around and avoids ending on a sharp corner.
// This has a bit of finesse and magic—if you change the points between
// function, then you'll likely need to change this one too.
const points = [...lines.flat(), ...lines[0]].slice(
5,
Math.floor((rm % 2 === 0 ? px : py) / -2) + 3
)
return {
points,
}
}
function getDrawStrokeInfo(id: string, style: ShapeStyles, size: number[]) {
const { points } = getRectangleDrawPoints(id, style, size)
const { strokeWidth } = getShapeStyle(style)
const options = {
size: strokeWidth,
thinning: 0.65,
streamline: 0.3,
smoothing: 1,
simulatePressure: false,
last: true,
}
return { points, options }
}
export function getRectanglePath(id: string, style: ShapeStyles, size: number[]) {
const { points, options } = getDrawStrokeInfo(id, style, size)
const stroke = getStroke(points, options)
return Utils.getSvgPathFromStroke(stroke)
}
export function getRectangleIndicatorPathTDSnapshot(
id: string,
style: ShapeStyles,
size: number[]
) {
const { points, options } = getDrawStrokeInfo(id, style, size)
const strokePoints = getStrokePoints(points, options)
return Utils.getSvgPathFromStroke(
strokePoints.map((pt) => pt.point.slice(0, 2)),
false
)
}

View file

@ -65,7 +65,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
e.stopPropagation()
}, [])
const handleTextChange = React.useCallback(
const handleLabelChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onShapeChange?.({
id: shape.id,
@ -212,7 +212,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
ref={rTextArea}
onPointerDown={handlePointerDown}
value={shape.text}
onChange={handleTextChange}
onChange={handleLabelChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}

View file

@ -4,7 +4,7 @@ import { Utils, HTMLContainer, TLBounds } from '@tldraw/core'
import { defaultTextStyle, getShapeStyle, getFontStyle } from '../shared/shape-styles'
import { TextShape, TDMeta, TDShapeType, TransformInfo, AlignStyle } from '~types'
import { TextAreaUtils } from '../shared'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import { BINDING_DISTANCE, GHOSTED_OPACITY, LETTER_SPACING } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import { styled } from '~styles'
import { Vec } from '@tldraw/vec'
@ -48,11 +48,11 @@ export class TextUtil extends TDShapeUtil<T, E> {
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isBinding, isGhost, isEditing, onShapeBlur, onShapeChange, meta, events }, ref) => {
const rInput = React.useRef<HTMLTextAreaElement>(null)
const { text, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode)
const font = getFontStyle(shape.style)
const rInput = React.useRef<HTMLTextAreaElement>(null)
const rIsMounted = React.useRef(false)
const handleChange = React.useCallback(
@ -209,7 +209,6 @@ export class TextUtil extends TDShapeUtil<T, E> {
color: styles.stroke,
}}
name="text"
defaultValue={text}
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
@ -217,16 +216,17 @@ export class TextUtil extends TDShapeUtil<T, E> {
autoSave="false"
autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
color={styles.stroke}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onPointerDown={handlePointerDown}
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
onContextMenu={stopPropagation}
/>
) : (
@ -351,8 +351,6 @@ export class TextUtil extends TDShapeUtil<T, E> {
/* Helpers */
/* -------------------------------------------------- */
const LETTER_SPACING = -1.5
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let melm: any

View file

@ -4,10 +4,10 @@ import { TriangleShape, TDShapeType, TDMeta, TDShape, DashStyle } from '~types'
import { TDShapeUtil } from '../TDShapeUtil'
import {
defaultStyle,
getShapeStyle,
getBoundsRectangle,
transformRectangle,
transformSingleRectangle,
getFontStyle,
} from '~state/shapes/shared'
import {
intersectBoundsPolygon,
@ -15,12 +15,16 @@ import {
intersectRayLineSegment,
} from '@tldraw/intersect'
import Vec from '@tldraw/vec'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import { getOffsetPolygon } from '../shared/PolygonUtils'
import getStroke, { getStrokePoints } from 'perfect-freehand'
import { BINDING_DISTANCE, GHOSTED_OPACITY, LABEL_POINT } from '~constants'
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 { TriangleBindingIndicator } from './components/TriangleBindingIndicator'
type T = TriangleShape
type E = SVGSVGElement
type E = HTMLDivElement
export class TriangleUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Triangle as const
@ -29,6 +33,8 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
canClone = true
canEdit = true
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
@ -41,104 +47,71 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
size: [1, 1],
rotation: 0,
style: defaultStyle,
label: '',
labelPoint: [0.5, 0.5],
},
props
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => {
const { id, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode)
const { strokeWidth } = styles
const sw = 1 + strokeWidth * 1.618
if (style.dash === DashStyle.Draw) {
const pathTDSnapshot = getTrianglePath(shape)
const indicatorPath = getTriangleIndicatorPathTDSnapshot(shape)
const trianglePoints = getTrianglePoints(shape).join()
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<polygon
className="tl-binding-indicator"
points={trianglePoints}
strokeWidth={this.bindingDistance * 2}
/>
)}
<path
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={indicatorPath}
/>
<path
d={indicatorPath}
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="none"
/>
<path
d={pathTDSnapshot}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="none"
opacity={isGhost ? GHOSTED_OPACITY : 1}
(
{
shape,
bounds,
isBinding,
isEditing,
isSelected,
isGhost,
meta,
events,
onShapeChange,
onShapeBlur,
},
ref
) => {
const { id, label = '', size, style, labelPoint = LABEL_POINT } = shape
const font = getFontStyle(style)
const Component = style.dash === DashStyle.Draw ? DrawTriangle : DashedTriangle
const handleLabelChange = React.useCallback(
(label: string) => onShapeChange?.({ id, label }),
[onShapeChange]
)
const offsetY = React.useMemo(() => {
const center = Vec.div(size, 2)
const centroid = getTriangleCentroid(size)
return (centroid[1] - center[1]) * 0.72
}, [size])
return (
<FullWrapper ref={ref} {...events}>
<TextLabel
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} />}
<Component
id={id}
style={style}
size={size}
isSelected={isSelected}
isDarkMode={meta.isDarkMode}
/>
</SVGContainer>
)
}
const points = getTrianglePoints(shape)
const sides = Utils.pointsToLineSegments(points, true)
const paths = sides.map(([start, end], i) => {
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
Vec.dist(start, end),
strokeWidth * 1.618,
shape.style.dash
)
return (
<line
key={id + '_' + i}
x1={start[0]}
y1={start[1]}
x2={end[0]}
y2={end[1]}
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<polygon
className="tl-binding-indicator"
points={points.join()}
strokeWidth={this.bindingDistance * 2}
/>
)}
<polygon
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
points={points.join()}
/>
<g pointerEvents="stroke">{paths}</g>
</SVGContainer>
</FullWrapper>
)
}
)
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const { style } = shape
const styles = getShapeStyle(style, false)
const sw = styles.strokeWidth
return <polygon points={getTrianglePoints(shape).join()} />
const { size } = shape
return <polygon points={getTrianglePoints(size).join()} />
})
private getPoints(shape: T) {
@ -155,7 +128,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
}
shouldRender = (prev: T, next: T) => {
return next.size !== prev.size || next.style !== prev.style
return next.size !== prev.size || next.style !== prev.style || next.label !== prev.label
}
getBounds = (shape: T) => {
@ -164,7 +137,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
getExpandedBounds = (shape: T) => {
return Utils.getBoundsFromPoints(
getTrianglePoints(shape, this.bindingDistance).map((pt) => Vec.add(pt, shape.point))
getTrianglePoints(shape.size, this.bindingDistance).map((pt) => Vec.add(pt, shape.point))
)
}
@ -193,9 +166,9 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
if (!Utils.pointInBounds(point, expandedBounds)) return
const points = getTrianglePoints(shape).map((pt) => Vec.add(pt, shape.point))
const points = getTrianglePoints(shape.size).map((pt) => Vec.add(pt, shape.point))
const expandedPoints = getTrianglePoints(shape, this.bindingDistance).map((pt) =>
const expandedPoints = getTrianglePoints(shape.size, this.bindingDistance).map((pt) =>
Vec.add(pt, shape.point)
)
@ -216,7 +189,7 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
if (!intersections.length) return
// The center of the triangle
const center = Vec.add(getTriangleCentroid(shape), shape.point)
const center = Vec.add(getTriangleCentroid(shape.size), shape.point)
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
const intersection = intersections.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
@ -260,127 +233,4 @@ export class TriangleUtil extends TDShapeUtil<T, E> {
transformSingle = transformSingleRectangle
}
/* -------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------- */
export function getTrianglePoints(shape: T, offset = 0, rotation = 0) {
const {
size: [w, h],
} = shape
let points = [
[w / 2, 0],
[w, h],
[0, h],
]
if (offset) points = getOffsetPolygon(points, offset)
if (rotation) points = points.map((pt) => Vec.rotWith(pt, [w / 2, h / 2], rotation))
return points
}
export function getTriangleCentroid(shape: T) {
const {
size: [w, h],
} = shape
const points = [
[w / 2, 0],
[w, h],
[0, h],
]
return [
(points[0][0] + points[1][0] + points[2][0]) / 3,
(points[0][1] + points[1][1] + points[2][1]) / 3,
]
}
function getTriangleDrawPoints(shape: TriangleShape) {
const styles = getShapeStyle(shape.style)
const {
size: [w, h],
} = shape
const getRandom = Utils.rng(shape.id)
const sw = styles.strokeWidth
// Random corner offsets
const offsets = Array.from(Array(3)).map(() => {
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
})
// Corners
const corners = [
Vec.add([w / 2, 0], offsets[0]),
Vec.add([w, h], offsets[1]),
Vec.add([0, h], offsets[2]),
]
// Which side to start drawing first
const rm = Math.round(Math.abs(getRandom() * 2 * 3))
// Number of points per side
// Inset each line by the corner radii and let the freehand algo
// interpolate points for the corners.
const lines = Utils.rotateArray(
[
Vec.pointsBetween(corners[0], corners[1], 32),
Vec.pointsBetween(corners[1], corners[2], 32),
Vec.pointsBetween(corners[2], corners[0], 32),
],
rm
)
// For the final points, include the first half of the first line again,
// so that the line wraps around and avoids ending on a sharp corner.
// This has a bit of finesse and magic—if you change the points between
// function, then you'll likely need to change this one too.
const points = [...lines.flat(), ...lines[0]]
return {
points,
}
}
function getDrawStrokeInfo(shape: TriangleShape) {
const { points } = getTriangleDrawPoints(shape)
const { strokeWidth } = getShapeStyle(shape.style)
const options = {
size: strokeWidth,
thinning: 0.65,
streamline: 0.3,
smoothing: 1,
simulatePressure: false,
last: true,
}
return { points, options }
}
function getTrianglePath(shape: TriangleShape) {
const { points, options } = getDrawStrokeInfo(shape)
const stroke = getStroke(points, options)
return Utils.getSvgPathFromStroke(stroke)
}
function getTriangleIndicatorPathTDSnapshot(shape: TriangleShape) {
const { points, options } = getDrawStrokeInfo(shape)
const strokePoints = getStrokePoints(points, options)
return Utils.getSvgPathFromStroke(
strokePoints.map((pt) => pt.point.slice(0, 2)),
false
)
}
const FullWrapper = styled('div', { width: '100%', height: '100%' })

View file

@ -4,6 +4,11 @@ exports[`Triangle shape Creates a shape: triangle 1`] = `
Object {
"childIndex": 1,
"id": "triangle",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Triangle",
"parentId": "page",
"point": Array [

View file

@ -0,0 +1,62 @@
import * as React from 'react'
import { Utils } from '@tldraw/core'
import type { ShapeStyles } from '~types'
import { getShapeStyle } from '~state/shapes/shared'
import { getTrianglePoints } from '../triangleHelpers'
import Vec from '@tldraw/vec'
interface TriangleSvgProps {
id: string
size: number[]
style: ShapeStyles
isSelected: boolean
isDarkMode: boolean
}
export const DashedTriangle = React.memo(function DashedTriangle({
id,
size,
style,
isSelected,
isDarkMode,
}: TriangleSvgProps) {
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
const sw = 1 + strokeWidth * 1.618
const points = getTrianglePoints(size)
const sides = Utils.pointsToLineSegments(points, true)
const paths = sides.map(([start, end], i) => {
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
Vec.dist(start, end),
strokeWidth * 1.618,
style.dash
)
return (
<line
key={id + '_' + i}
x1={start[0]}
y1={start[1]}
x2={end[0]}
y2={end[1]}
stroke={stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})
const bgPath = points.join()
return (
<>
<polygon
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
points={bgPath}
/>
{style.isFilled && <polygon fill={fill} points={bgPath} pointerEvents="none" />}
<g pointerEvents="stroke">{paths}</g>
</>
)
})

View file

@ -0,0 +1,40 @@
import * as React from 'react'
import { getShapeStyle } from '~state/shapes/shared'
import type { ShapeStyles } from '~types'
import { getTriangleIndicatorPathTDSnapshot, getTrianglePath } from '../triangleHelpers'
interface TriangleSvgProps {
id: string
size: number[]
style: ShapeStyles
isSelected: boolean
isDarkMode: boolean
}
export const DrawTriangle = React.memo(function DrawTriangle({
id,
size,
style,
isSelected,
isDarkMode,
}: TriangleSvgProps) {
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode)
const pathTDSnapshot = getTrianglePath(id, size, style)
const indicatorPath = getTriangleIndicatorPathTDSnapshot(id, size, style)
return (
<>
<path
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={indicatorPath}
/>
{style.isFilled && <path d={indicatorPath} fill={fill} pointerEvents="none" />}
<path
d={pathTDSnapshot}
fill={stroke}
stroke={stroke}
strokeWidth={strokeWidth}
pointerEvents="none"
/>
</>
)
})

View file

@ -0,0 +1,18 @@
import * as React from 'react'
import { BINDING_DISTANCE } from '~constants'
import { getTrianglePoints } from '../triangleHelpers'
interface TriangleBindingIndicatorProps {
size: number[]
}
export function TriangleBindingIndicator({ size }: TriangleBindingIndicatorProps) {
const trianglePoints = getTrianglePoints(size).join()
return (
<polygon
className="tl-binding-indicator"
points={trianglePoints}
strokeWidth={BINDING_DISTANCE * 2}
/>
)
}

View file

@ -0,0 +1,97 @@
import { Utils } from '@tldraw/core'
import Vec from '@tldraw/vec'
import getStroke, { getStrokePoints } from 'perfect-freehand'
import type { ShapeStyles } from '~types'
import { getShapeStyle } from '../shared'
import { getOffsetPolygon } from '../shared/PolygonUtils'
export function getTrianglePoints(size: number[], offset = 0, rotation = 0) {
const [w, h] = size
let points = [
[w / 2, 0],
[w, h],
[0, h],
]
if (offset) points = getOffsetPolygon(points, offset)
if (rotation) points = points.map((pt) => Vec.rotWith(pt, [w / 2, h / 2], rotation))
return points
}
export function getTriangleCentroid(size: number[]) {
const [w, h] = size
const points = [
[w / 2, 0],
[w, h],
[0, h],
]
return [
(points[0][0] + points[1][0] + points[2][0]) / 3,
(points[0][1] + points[1][1] + points[2][1]) / 3,
]
}
function getTriangleDrawPoints(id: string, size: number[], strokeWidth: number) {
const [w, h] = size
const getRandom = Utils.rng(id)
// Random corner offsets
const offsets = Array.from(Array(3)).map(() => {
return [getRandom() * strokeWidth * 0.75, getRandom() * strokeWidth * 0.75]
})
// Corners
const corners = [
Vec.add([w / 2, 0], offsets[0]),
Vec.add([w, h], offsets[1]),
Vec.add([0, h], offsets[2]),
]
// Which side to start drawing first
const rm = Math.round(Math.abs(getRandom() * 2 * 3))
// Number of points per side
// Inset each line by the corner radii and let the freehand algo
// interpolate points for the corners.
const lines = Utils.rotateArray(
[
Vec.pointsBetween(corners[0], corners[1], 32),
Vec.pointsBetween(corners[1], corners[2], 32),
Vec.pointsBetween(corners[2], corners[0], 32),
],
rm
)
// For the final points, include the first half of the first line again,
// so that the line wraps around and avoids ending on a sharp corner.
// This has a bit of finesse and magic—if you change the points between
// function, then you'll likely need to change this one too.
const points = [...lines.flat(), ...lines[0]]
return {
points,
}
}
function getDrawStrokeInfo(id: string, size: number[], style: ShapeStyles) {
const { strokeWidth } = getShapeStyle(style)
const { points } = getTriangleDrawPoints(id, size, strokeWidth)
const options = {
size: strokeWidth,
thinning: 0.65,
streamline: 0.3,
smoothing: 1,
simulatePressure: false,
last: true,
}
return { points, options }
}
export function getTrianglePath(id: string, size: number[], style: ShapeStyles) {
const { points, options } = getDrawStrokeInfo(id, size, style)
const stroke = getStroke(points, options)
return Utils.getSvgPathFromStroke(stroke)
}
export function getTriangleIndicatorPathTDSnapshot(id: string, size: number[], style: ShapeStyles) {
const { points, options } = getDrawStrokeInfo(id, size, style)
const strokePoints = getStrokePoints(points, options)
return Utils.getSvgPathFromStroke(
strokePoints.map((pt) => pt.point.slice(0, 2)),
false
)
}

View file

@ -0,0 +1,276 @@
import * as React from 'react'
import { stopPropagation } from '~components/stopPropagation'
import { GHOSTED_OPACITY, LABEL_POINT, LETTER_SPACING } from '~constants'
import { TLDR } from '~state/TLDR'
import { styled } from '~styles'
import { TextAreaUtils } from '.'
import { getTextLabelSize } from './getTextSize'
export interface TextLabelProps {
font: string
text: string
isDarkMode: boolean
onBlur?: () => void
onChange: (text: string) => void
offsetY?: number
offsetX?: number
scale?: number
isEditing?: boolean
}
export const TextLabel = React.memo(function TextLabel({
isDarkMode,
font,
text,
offsetX = 0,
offsetY = 0,
scale = 1,
isEditing = false,
onBlur,
onChange,
}: TextLabelProps) {
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>) => {
onChange(TLDR.normalizeText(e.currentTarget.value))
},
[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 handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
e.currentTarget.setSelectionRange(0, 0)
onBlur?.()
},
[onBlur]
)
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 {
onBlur?.()
}
}, [isEditing, onBlur])
const rInnerWrapper = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
const elm = rInnerWrapper.current
if (!elm) return
elm.style.transform = `scale(${scale}, ${scale}) translate(${offsetX}px, ${offsetY}px)`
elm.style.width = size[0] + 'px'
elm.style.height = size[1] + 'px'
}, [size, offsetY, offsetX, scale])
return (
<TextWrapper>
<InnerWrapper
ref={rInnerWrapper}
hasText={!!text}
isEditing={isEditing}
style={{
font,
color,
}}
>
{isEditing ? (
<TextArea
ref={rInput}
style={{
font,
color,
}}
name="text"
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
autoFocus
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
color={color}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onPointerDown={handlePointerDown}
onContextMenu={stopPropagation}
/>
) : (
text
)}
&#8203;
</InnerWrapper>
</TextWrapper>
)
})
const TextWrapper = styled('div', {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
userSelect: 'none',
variants: {
isGhost: {
false: { opacity: 1 },
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
},
},
})
const commonTextWrapping = {
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
}
const InnerWrapper = styled('div', {
position: 'absolute',
padding: '4px',
zIndex: 1,
minHeight: 1,
minWidth: 1,
lineHeight: 1,
letterSpacing: LETTER_SPACING,
outline: 0,
fontWeight: '500',
textAlign: 'center',
backfaceVisibility: 'hidden',
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
variants: {
hasText: {
false: {
pointerEvents: 'none',
},
true: {
pointerEvents: 'all',
},
},
isEditing: {
false: {
userSelect: 'none',
},
true: {
background: '$boundsBg',
userSelect: 'text',
WebkitUserSelect: 'text',
},
},
},
...commonTextWrapping,
})
const TextArea = styled('textarea', {
position: 'absolute',
top: 0,
left: 0,
zIndex: 1,
width: '100%',
height: '100%',
border: 'none',
padding: '4px',
resize: 'none',
textAlign: 'inherit',
minHeight: 'inherit',
minWidth: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit',
outline: 0,
fontWeight: 'inherit',
overflow: 'hidden',
backfaceVisibility: 'hidden',
display: 'inline-block',
pointerEvents: 'all',
background: '$boundsBg',
userSelect: 'text',
WebkitUserSelect: 'text',
...commonTextWrapping,
})

View file

@ -0,0 +1,71 @@
import { LETTER_SPACING } from '~constants'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let melm: any
function getMeasurementDiv() {
// A div used for measurement
document.getElementById('__textLabelMeasure')?.remove()
const pre = document.createElement('pre')
pre.id = '__textLabelMeasure'
Object.assign(pre.style, {
whiteSpace: 'pre',
width: 'auto',
border: '1px solid transparent',
padding: '4px',
margin: '0px',
letterSpacing: `${LETTER_SPACING}px`,
opacity: '0',
position: 'absolute',
top: '-500px',
left: '0px',
zIndex: '9999',
pointerEvents: 'none',
userSelect: 'none',
alignmentBaseline: 'mathematical',
dominantBaseline: 'mathematical',
})
pre.tabIndex = -1
document.body.appendChild(pre)
return pre
}
if (typeof window !== 'undefined') {
melm = getMeasurementDiv()
}
let prevText = ''
let prevFont = ''
let prevSize = [0, 0]
export function getTextLabelSize(text: string, font: string) {
if (!text) {
return [16, 32]
}
if (!melm) {
// We're in SSR
return [10, 10]
}
if (text === prevText && font === prevFont) {
return prevSize
}
prevText = text
prevFont = font
melm.innerHTML = `${text}&zwj;`
melm.style.font = font
// In tests, offsetWidth and offsetHeight will be 0
const width = melm.offsetWidth || 1
const height = melm.offsetHeight || 1
prevSize = [width, height]
return prevSize
}

View file

@ -3,3 +3,5 @@ export * from './transformRectangle'
export * from './transformSingleRectangle'
export * from './TextAreaUtils'
export * from './shape-styles'
export * from './getTextAlign'
export * from './TextLabel'

View file

@ -60,7 +60,6 @@ export const fills: Record<Theme, Record<ColorStyle, string>> = {
Object.entries(colors).map(([k, v]) => [k, Utils.lerpColor(v, canvasLight, 0.82)])
) as Record<ColorStyle, string>),
[ColorStyle.White]: '#fefefe',
[ColorStyle.Black]: '#4d4d4d',
},
dark: {
...(Object.fromEntries(

View file

@ -21,13 +21,13 @@ describe('When double clicking link controls', () => {
{
id: 'rect2',
type: TDShapeType.Rectangle,
point: [100, 0],
point: [200, 0],
size: [100, 100],
},
{
id: 'rect3',
type: TDShapeType.Rectangle,
point: [200, 0],
point: [400, 0],
size: [100, 100],
},
{
@ -38,7 +38,7 @@ describe('When double clicking link controls', () => {
{
id: 'arrow2',
type: TDShapeType.Arrow,
point: [200, 200],
point: [210, 210],
}
)
.select('arrow1')
@ -48,16 +48,16 @@ describe('When double clicking link controls', () => {
.completeSession()
.movePointer({ x: 200, y: 200 })
.startSession(SessionType.Arrow, 'arrow1', 'end')
.movePointer({ x: 150, y: 50 })
.movePointer({ x: 250, y: 50 })
.completeSession()
.select('arrow2')
.movePointer({ x: 200, y: 200 })
.startSession(SessionType.Arrow, 'arrow2', 'start')
.movePointer({ x: 150, y: 50 })
.movePointer({ x: 250, y: 50 })
.completeSession()
.movePointer({ x: 200, y: 200 })
.startSession(SessionType.Arrow, 'arrow2', 'end')
.movePointer({ x: 250, y: 50 })
.movePointer({ x: 450, y: 50 })
.completeSession()
.selectNone().document
@ -68,22 +68,22 @@ describe('When double clicking link controls', () => {
.pointBoundsHandle('center', [100, 100])
.expectShapesToBeAtPoints({
rect1: [0, 0],
rect2: [100, 0],
rect3: [200, 0],
rect2: [200, 0],
rect3: [400, 0],
})
app.movePointer([200, 200]).expectShapesToBeAtPoints({
rect1: [100, 100],
rect2: [200, 100],
rect3: [300, 100],
rect2: [300, 100],
rect3: [500, 100],
})
app.completeSession().undo()
app.expectShapesToBeAtPoints({
rect1: [0, 0],
rect2: [100, 0],
rect3: [200, 0],
rect2: [200, 0],
rect3: [400, 0],
})
})
@ -95,8 +95,8 @@ describe('When double clicking link controls', () => {
.movePointer({ x: 100, y: 100 })
expect(app.getShape('rect1').point).toEqual([100, 100])
expect(app.getShape('rect2').point).toEqual([200, 100])
expect(app.getShape('rect3').point).toEqual([300, 100])
expect(app.getShape('rect2').point).toEqual([300, 100])
expect(app.getShape('rect3').point).toEqual([500, 100])
})
it('moves all downstream shapes when center is dragged', () => {
@ -107,8 +107,8 @@ describe('When double clicking link controls', () => {
.movePointer({ x: 100, y: 100 })
expect(app.getShape('rect1').point).toEqual([0, 0])
expect(app.getShape('rect2').point).toEqual([200, 100])
expect(app.getShape('rect3').point).toEqual([300, 100])
expect(app.getShape('rect2').point).toEqual([300, 100])
expect(app.getShape('rect3').point).toEqual([500, 100])
})
it('selects all linked shapes when center is double clicked', () => {

View file

@ -182,37 +182,47 @@ export class SelectTool extends BaseTool<Status> {
this.setStatus(Status.Idle)
}
onKeyDown: TLKeyboardEventHandler = (key) => {
if (key === 'Escape') {
this.onCancel()
return
}
onKeyDown: TLKeyboardEventHandler = (key, info, e) => {
switch (key) {
case 'Escape': {
this.onCancel()
break
}
case ' ': {
if (this.status === Status.Idle) {
this.setStatus(Status.SpacePanning)
}
break
}
case 'Tab': {
if (this.status === Status.Idle && this.app.selectedIds.length === 1) {
const [selectedId] = this.app.selectedIds
const clonedShape = this.getShapeClone(selectedId, 'right')
if (key === ' ' && this.status === Status.Idle) {
this.setStatus(Status.SpacePanning)
}
if (key === 'Tab') {
if (this.status === Status.Idle && this.app.selectedIds.length === 1) {
const [selectedId] = this.app.selectedIds
const clonedShape = this.getShapeClone(selectedId, 'right')
if (clonedShape) {
this.app.createShapes(clonedShape)
this.setStatus(Status.Idle)
if (clonedShape.type === TDShapeType.Sticky) {
this.app.select(clonedShape.id)
this.app.setEditingId(clonedShape.id)
if (clonedShape) {
this.app.createShapes(clonedShape)
this.setStatus(Status.Idle)
if (clonedShape.type === TDShapeType.Sticky) {
this.app.select(clonedShape.id)
this.app.setEditingId(clonedShape.id)
}
}
}
break
}
case 'Meta':
case 'Control':
case 'Alt': {
this.app.updateSession()
break
}
case 'Enter': {
const { pageState } = this.app
if (pageState.selectedIds.length === 1 && !pageState.editingId) {
this.app.setEditingId(pageState.selectedIds[0])
e.preventDefault()
}
}
return
}
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.app.updateSession()
return
}
}
@ -624,19 +634,29 @@ export class SelectTool extends BaseTool<Status> {
}
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = (info) => {
if (info.target === 'center' || info.target === 'left' || info.target === 'right') {
this.app.select(
...TLDR.getLinkedShapeIds(
this.app.state,
this.app.currentPageId,
info.target,
info.shiftKey
switch (info.target) {
case 'center':
case 'left':
case 'right': {
this.app.select(
...TLDR.getLinkedShapeIds(
this.app.state,
this.app.currentPageId,
info.target,
info.shiftKey
)
)
)
}
if (this.app.selectedIds.length === 1) {
this.app.resetBounds(this.app.selectedIds)
break
}
default: {
if (this.app.selectedIds.length === 1) {
this.app.resetBounds(this.app.selectedIds)
const shape = this.app.getShape(this.app.selectedIds[0])
if ('label' in shape) {
this.app.setEditingId(shape.id)
}
}
}
}
}
@ -652,6 +672,19 @@ export class SelectTool extends BaseTool<Status> {
}
onDoubleClickHandle: TLPointerEventHandler = (info) => {
if (info.target === 'bend') {
const { selectedIds } = this.app
if (selectedIds.length !== 1) return
const shape = this.app.getShape(selectedIds[0])
if (
TLDR.getShapeUtil(shape.type).canEdit &&
(shape.parentId === this.app.currentPageId || shape.parentId === this.selectedGroupId)
) {
this.app.setEditingId(shape.id)
}
return
}
this.app.toggleDecoration(info.target)
}

View file

@ -21,6 +21,7 @@ export const mockDocument: TDDocument = {
size: SizeStyle.Medium,
color: ColorStyle.Blue,
},
label: '',
},
rect2: {
id: 'rect2',
@ -35,6 +36,8 @@ export const mockDocument: TDDocument = {
size: SizeStyle.Medium,
color: ColorStyle.Blue,
},
label: '',
labelPoint: [0.5, 0.5],
},
rect3: {
id: 'rect3',
@ -49,6 +52,8 @@ export const mockDocument: TDDocument = {
size: SizeStyle.Medium,
color: ColorStyle.Blue,
},
label: '',
labelPoint: [0.5, 0.5],
},
},
bindings: {},

View file

@ -193,6 +193,7 @@ export enum TDStatus {
PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds',
PointingBoundsHandle = 'pointingBoundsHandle',
TranslatingLabel = 'translatingLabel',
TranslatingHandle = 'translatingHandle',
Translating = 'translating',
Transforming = 'transforming',
@ -295,7 +296,6 @@ export interface TDBaseShape extends TLShape {
handles?: Record<string, TldrawHandle>
}
// The shape created with the draw tool
export interface DrawShape extends TDBaseShape {
type: TDShapeType.Draw
points: number[][]
@ -308,6 +308,27 @@ export interface TldrawHandle extends TLHandle {
bindingId?: string
}
export interface RectangleShape extends TDBaseShape {
type: TDShapeType.Rectangle
size: number[]
label?: string
labelPoint?: number[]
}
export interface EllipseShape extends TDBaseShape {
type: TDShapeType.Ellipse
radius: number[]
label?: string
labelPoint?: number[]
}
export interface TriangleShape extends TDBaseShape {
type: TDShapeType.Triangle
size: number[]
label?: string
labelPoint?: number[]
}
// The shape created with the arrow tool
export interface ArrowShape extends TDBaseShape {
type: TDShapeType.Arrow
@ -322,6 +343,8 @@ export interface ArrowShape extends TDBaseShape {
end?: Decoration
middle?: Decoration
}
label?: string
labelPoint?: number[]
}
export interface ArrowBinding extends TLBinding {
@ -332,18 +355,6 @@ export interface ArrowBinding extends TLBinding {
export type TDBinding = ArrowBinding
// The shape created by the ellipse tool
export interface EllipseShape extends TDBaseShape {
type: TDShapeType.Ellipse
radius: number[]
}
// The shape created by the rectangle tool
export interface RectangleShape extends TDBaseShape {
type: TDShapeType.Rectangle
size: number[]
}
export interface ImageShape extends TDBaseShape {
type: TDShapeType.Image
size: number[]
@ -358,12 +369,6 @@ export interface VideoShape extends TDBaseShape {
currentTime: number
}
// The shape created by the Triangle tool
export interface TriangleShape extends TDBaseShape {
type: TDShapeType.Triangle
size: number[]
}
// The shape created by the text tool
export interface TextShape extends TDBaseShape {
type: TDShapeType.Text

View file

@ -505,6 +505,20 @@ export class Vec {
if (A[0] === B[0]) return NaN
return (A[1] - B[1]) / (A[0] - B[0])
}
/**
* Get a vector comprised of the maximum of two or more vectors.
*/
static max = (...v: number[][]) => {
return [Math.max(...v.map((a) => a[0])), Math.max(...v.map((a) => a[1]))]
}
/**
* Get a vector comprised of the minimum of two or more vectors.
*/
static min = (...v: number[][]) => {
return [Math.max(...v.map((a) => a[0])), Math.max(...v.map((a) => a[1]))]
}
}
export default Vec