Improves status, create session handling

This commit is contained in:
Steve Ruiz 2021-08-15 09:49:38 +01:00
parent ffc180fa1c
commit 40cbf2d92b
15 changed files with 478 additions and 266 deletions

View file

@ -3,16 +3,19 @@ import { useTLDrawContext } from '~hooks'
import type { Data } from '~types'
import styled from '~styles'
const statusSelector = (s: Data) => s.appState.status.current
const activeToolSelector = (s: Data) => s.appState.activeTool
export function StatusBar(): JSX.Element | null {
const { useSelector } = useTLDrawContext()
const status = useSelector(statusSelector)
const activeTool = useSelector(activeToolSelector)
return (
<StatusBarContainer size={{ '@sm': 'small' }}>
<Section>{activeTool}</Section>
{/* <Section>{shapesInView || '0'} Shapes</Section> */}
<Section>
{activeTool} | {status}
</Section>
</StatusBarContainer>
)
}

View file

@ -8,7 +8,6 @@ import {
Intersect,
TLHandle,
TLPointerInfo,
Svg,
} from '@tldraw/core'
import getStroke from 'perfect-freehand'
import { defaultStyle, getPerfectDashProps, getShapeStyle } from '~shape/shape-styles'
@ -27,7 +26,7 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
type = TLDrawShapeType.Arrow as const
toolType = TLDrawToolType.Handle
canStyleFill = false
simplePathCache = new WeakMap<ArrowShape, string>()
simplePathCache = new WeakMap<ArrowShape['handles'], string>()
pathCache = new WeakMap<ArrowShape, string>()
defaultProps = {
@ -72,12 +71,63 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
}
render = (shape: ArrowShape, { isDarkMode }: TLRenderInfo) => {
const { bend, handles, style } = shape
const { start, end, bend: _bend } = handles
const {
handles: { start, bend, end },
decorations = {},
style,
} = shape
const isStraightLine = Vec.dist(_bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
const isDraw = style.dash === DashStyle.Draw
const isDraw = shape.style.dash === DashStyle.Draw
// if (!isDraw) {
// const styles = getShapeStyle(style, isDarkMode)
// const { strokeWidth } = styles
// const arrowDist = Vec.dist(start.point, end.point)
// const sw = strokeWidth * 1.618
// const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
// arrowDist,
// sw,
// shape.style.dash,
// 2
// )
// const path = getArrowPath(shape)
// return (
// <g pointerEvents="none">
// <path
// d={path}
// fill="none"
// stroke="transparent"
// strokeWidth={Math.max(8, strokeWidth * 2)}
// strokeDasharray="none"
// strokeDashoffset="none"
// strokeLinecap="round"
// strokeLinejoin="round"
// pointerEvents="stroke"
// />
// <path
// d={path}
// fill={isDraw ? styles.stroke : 'none'}
// stroke={styles.stroke}
// strokeWidth={sw}
// strokeDasharray={strokeDasharray}
// strokeDashoffset={strokeDashoffset}
// strokeLinecap="round"
// strokeLinejoin="round"
// pointerEvents="stroke"
// />
// </g>
// )
// }
// TODO: Improve drawn arrows
const isStraightLine = Vec.dist(bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
const styles = getShapeStyle(style, isDarkMode)
@ -85,11 +135,11 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
const arrowDist = Vec.dist(start.point, end.point)
const arrowHeadlength = Math.min(arrowDist / 3, strokeWidth * 8)
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
let shaftPath: JSX.Element
let insetStart: number[]
let insetEnd: number[]
let startArrowHead: { left: number[]; right: number[] } | undefined
let endArrowHead: { left: number[]; right: number[] } | undefined
if (isStraightLine) {
const sw = strokeWidth * (isDraw ? 0.618 : 1.618)
@ -107,8 +157,13 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
2
)
insetStart = Vec.nudge(start.point, end.point, arrowHeadlength)
insetEnd = Vec.nudge(end.point, start.point, arrowHeadlength)
if (decorations.start) {
startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength)
}
if (decorations.end) {
endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength)
}
// Straight arrow path
shaftPath = (
@ -144,31 +199,37 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
const path = Utils.getFromCache(this.pathCache, shape, () =>
isDraw
? renderCurvedFreehandArrowShaft(shape, circle)
: getArrowArcPath(start, end, circle, bend)
: getArrowArcPath(start, end, circle, shape.bend)
)
const arcLength = Utils.getArcLength(
[circle[0], circle[1]],
circle[2],
start.point,
end.point
)
const { center, radius, length } = getArrowArc(shape)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
arcLength - 1,
length - 1,
sw,
shape.style.dash,
2
)
const center = [circle[0], circle[1]]
const radius = circle[2]
const sa = Vec.angle(center, start.point)
const ea = Vec.angle(center, end.point)
const t = arrowHeadlength / Math.abs(arcLength)
if (decorations.start) {
startArrowHead = getCurvedArrowHeadPoints(
start.point,
arrowHeadLength,
center,
radius,
length < 0
)
}
insetStart = Vec.nudgeAtAngle(center, Utils.lerpAngles(sa, ea, t), radius)
insetEnd = Vec.nudgeAtAngle(center, Utils.lerpAngles(ea, sa, t), radius)
if (decorations.end) {
endArrowHead = getCurvedArrowHeadPoints(
end.point,
arrowHeadLength,
center,
radius,
length >= 0
)
}
// Curved arrow path
shaftPath = (
@ -204,9 +265,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
return (
<g pointerEvents="none">
{shaftPath}
{shape.decorations?.start === Decoration.Arrow && (
{startArrowHead && (
<path
d={getArrowHeadPath(shape, start.point, insetStart)}
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
@ -217,9 +278,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
pointerEvents="stroke"
/>
)}
{shape.decorations?.end === Decoration.Arrow && (
{endArrowHead && (
<path
d={getArrowHeadPath(shape, end.point, insetEnd)}
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
@ -235,91 +296,9 @@ export class Arrow extends TLDrawShapeUtil<ArrowShape> {
}
renderIndicator(shape: ArrowShape) {
const {
decorations,
handles: { start, end, bend: _bend },
style,
} = shape
const path = Utils.getFromCache(this.simplePathCache, shape.handles, () => getArrowPath(shape))
const { strokeWidth } = getShapeStyle(style, false)
const arrowDist = Vec.dist(start.point, end.point)
const arrowHeadlength = Math.min(arrowDist / 3, strokeWidth * 8)
const aw = arrowHeadlength / 2
const path: (string | number)[] = []
const isStraightLine = Vec.dist(_bend.point, Vec.round(Vec.med(start.point, end.point))) < 1
if (isStraightLine) {
path.push(Svg.moveTo(start.point), Svg.lineTo(end.point))
if (decorations?.start) {
const point = start.point
const ints = Intersect.circle.lineSegment(start.point, aw, start.point, end.point).points
const int = ints[0]
path.push(
Svg.moveTo(Vec.nudge(Vec.rotWith(int, point, Math.PI / 6), point, -aw)),
Svg.lineTo(start.point),
Svg.lineTo(Vec.nudge(Vec.rotWith(int, point, -Math.PI / 6), point, -aw))
)
}
if (decorations?.end) {
const point = end.point
const ints = Intersect.circle.lineSegment(end.point, aw, start.point, end.point).points
const int = ints[0]
path.push(
Svg.moveTo(Vec.nudge(Vec.rotWith(int, point, Math.PI / 6), point, -aw)),
Svg.lineTo(end.point),
Svg.lineTo(Vec.nudge(Vec.rotWith(int, point, -Math.PI / 6), point, -aw))
)
}
} else {
const circle = getCtp(shape)
const center = [circle[0], circle[1]]
const radius = circle[2]
const sweep = Utils.getArcLength(center, radius, start.point, end.point) > 0
path.push(
Svg.moveTo(start.point),
`A ${radius} ${radius} 0 0 ${sweep ? '1' : '0'} ${end.point}`
)
if (decorations?.start) {
const point = start.point
const ints = Intersect.circle.circle(center, radius, point, aw).points
const int = sweep ? ints[0] : ints[1]
path.push(
Svg.moveTo(Vec.nudge(Vec.rotWith(int, point, Math.PI / 6), point, -aw)),
Svg.lineTo(start.point),
Svg.lineTo(Vec.nudge(Vec.rotWith(int, point, -Math.PI / 6), point, -aw))
)
}
if (decorations?.end) {
const point = end.point
const ints = Intersect.circle.circle(center, radius, point, aw).points
const int = sweep ? ints[1] : ints[0]
path.push(
Svg.moveTo(Vec.nudge(Vec.rotWith(int, point, Math.PI / 6), point, -aw)),
Svg.lineTo(end.point),
Svg.lineTo(Vec.nudge(Vec.rotWith(int, point, -Math.PI / 6), point, -aw))
)
}
}
return (
<g>
<path d={path.join()} />
</g>
)
return <path d={path} />
}
getBounds = (shape: ArrowShape) => {
@ -764,3 +743,143 @@ function getCtp(shape: ArrowShape) {
const { start, end, bend } = shape.handles
return Utils.circleFromThreePoints(start.point, end.point, bend.point)
}
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 = Utils.getArcLength(center, radius, start.point, end.point)
return { center, radius, length }
}
function getCurvedArrowHeadPoints(
A: number[],
r1: number,
C: number[],
r2: number,
sweep: boolean
) {
const ints = Intersect.circle.circle(A, r1 * 0.618, C, r2).points
const int = sweep ? ints[0] : ints[1]
const left = Vec.nudge(Vec.rotWith(int, A, Math.PI / 6), A, r1 * -0.382)
const right = Vec.nudge(Vec.rotWith(int, A, -Math.PI / 6), A, r1 * -0.382)
return { left, right }
}
function getStraightArrowHeadPoints(A: number[], B: number[], r: number) {
const ints = Intersect.circle.lineSegment(A, r, A, B).points
const int = ints[0]
const left = Vec.rotWith(int, A, Math.PI / 6)
const right = Vec.rotWith(int, A, -Math.PI / 6)
return { left, right }
}
function getCurvedArrowHeadPath(A: number[], r1: number, C: number[], r2: number, sweep: boolean) {
const { left, right } = getCurvedArrowHeadPoints(A, r1, C, r2, sweep)
return `M ${left} L ${A} ${right}`
}
function getStraightArrowHeadPath(A: number[], B: number[], r: number) {
const { left, right } = getStraightArrowHeadPoints(A, B, r)
return `M ${left} L ${A} ${right}`
}
function getArrowPath(shape: ArrowShape) {
const {
decorations,
handles: { start, end, bend: _bend },
style,
} = shape
const { strokeWidth } = getShapeStyle(style, false)
const arrowDist = Vec.dist(start.point, end.point)
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
const path: (string | number)[] = []
const isStraightLine = Vec.dist(_bend.point, Vec.round(Vec.med(start.point, end.point))) < 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))
}
// End arrow head
if (decorations?.end) {
path.push(getStraightArrowHeadPath(end.point, start.point, 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))
}
}
return path.join(' ')
}
function getDrawArrowPath(shape: ArrowShape) {
const {
decorations,
handles: { start, end, bend: _bend },
style,
} = shape
const { strokeWidth } = getShapeStyle(style, false)
const arrowDist = Vec.dist(start.point, end.point)
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8)
const path: (string | number)[] = []
const isStraightLine = Vec.dist(_bend.point, Vec.round(Vec.med(start.point, end.point))) < 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))
}
// End arrow head
if (decorations?.end) {
path.push(getStraightArrowHeadPath(end.point, start.point, 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))
}
}
return path.join(' ')
}

View file

@ -45,11 +45,11 @@ describe('Move command', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['b'])
tlstate.moveToBack()
expect(getSortedShapeIds(tlstate.getState())).toBe('bacd')
expect(getSortedShapeIds(tlstate.data)).toBe('bacd')
tlstate.undo()
expect(getSortedShapeIds(tlstate.getState())).toBe('abcd')
expect(getSortedShapeIds(tlstate.data)).toBe('abcd')
tlstate.redo()
expect(getSortedShapeIds(tlstate.getState())).toBe('bacd')
expect(getSortedShapeIds(tlstate.data)).toBe('bacd')
})
describe('to back', () => {
@ -57,21 +57,21 @@ describe('Move command', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['b'])
tlstate.moveToBack()
expect(getSortedShapeIds(tlstate.getState())).toBe('bacd')
expect(getSortedShapeIds(tlstate.data)).toBe('bacd')
})
it('moves two adjacent siblings to back', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['b', 'c'])
tlstate.moveToBack()
expect(getSortedShapeIds(tlstate.getState())).toBe('bcad')
expect(getSortedShapeIds(tlstate.data)).toBe('bcad')
})
it('moves two non-adjacent siblings to back', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['b', 'd'])
tlstate.moveToBack()
expect(getSortedShapeIds(tlstate.getState())).toBe('bdac')
expect(getSortedShapeIds(tlstate.data)).toBe('bdac')
})
})
@ -80,35 +80,35 @@ describe('Move command', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['c'])
tlstate.moveBackward()
expect(getSortedShapeIds(tlstate.getState())).toBe('acbd')
expect(getSortedShapeIds(tlstate.data)).toBe('acbd')
})
it('moves a shape at first index backward', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['a'])
tlstate.moveBackward()
expect(getSortedShapeIds(tlstate.getState())).toBe('abcd')
expect(getSortedShapeIds(tlstate.data)).toBe('abcd')
})
it('moves two adjacent siblings backward', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['c', 'd'])
tlstate.moveBackward()
expect(getSortedShapeIds(tlstate.getState())).toBe('acdb')
expect(getSortedShapeIds(tlstate.data)).toBe('acdb')
})
it('moves two non-adjacent siblings backward', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['b', 'd'])
tlstate.moveBackward()
expect(getSortedShapeIds(tlstate.getState())).toBe('badc')
expect(getSortedShapeIds(tlstate.data)).toBe('badc')
})
it('moves two adjacent siblings backward at zero index', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['a', 'b'])
tlstate.moveBackward()
expect(getSortedShapeIds(tlstate.getState())).toBe('abcd')
expect(getSortedShapeIds(tlstate.data)).toBe('abcd')
})
})
@ -117,7 +117,7 @@ describe('Move command', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['c'])
tlstate.moveForward()
expect(getSortedShapeIds(tlstate.getState())).toBe('abdc')
expect(getSortedShapeIds(tlstate.data)).toBe('abdc')
})
it('moves a shape forward at the top index', () => {
@ -126,28 +126,28 @@ describe('Move command', () => {
tlstate.moveForward()
tlstate.moveForward()
tlstate.moveForward()
expect(getSortedShapeIds(tlstate.getState())).toBe('acdb')
expect(getSortedShapeIds(tlstate.data)).toBe('acdb')
})
it('moves two adjacent siblings forward', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['a', 'b'])
tlstate.moveForward()
expect(getSortedShapeIds(tlstate.getState())).toBe('cabd')
expect(getSortedShapeIds(tlstate.data)).toBe('cabd')
})
it('moves two non-adjacent siblings forward', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['a', 'c'])
tlstate.moveForward()
expect(getSortedShapeIds(tlstate.getState())).toBe('badc')
expect(getSortedShapeIds(tlstate.data)).toBe('badc')
})
it('moves two adjacent siblings forward at top index', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['c', 'd'])
tlstate.moveForward()
expect(getSortedShapeIds(tlstate.getState())).toBe('abcd')
expect(getSortedShapeIds(tlstate.data)).toBe('abcd')
})
})
@ -156,28 +156,28 @@ describe('Move command', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['b'])
tlstate.moveToFront()
expect(getSortedShapeIds(tlstate.getState())).toBe('acdb')
expect(getSortedShapeIds(tlstate.data)).toBe('acdb')
})
it('moves two adjacent siblings to front', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['a', 'b'])
tlstate.moveToFront()
expect(getSortedShapeIds(tlstate.getState())).toBe('cdab')
expect(getSortedShapeIds(tlstate.data)).toBe('cdab')
})
it('moves two non-adjacent siblings to front', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['a', 'c'])
tlstate.moveToFront()
expect(getSortedShapeIds(tlstate.getState())).toBe('bdac')
expect(getSortedShapeIds(tlstate.data)).toBe('bdac')
})
it('moves siblings already at front to front', () => {
tlstate.loadDocument(doc)
tlstate.setSelectedIds(['c', 'd'])
tlstate.moveToFront()
expect(getSortedShapeIds(tlstate.getState())).toBe('abcd')
expect(getSortedShapeIds(tlstate.data)).toBe('abcd')
})
})
})

View file

@ -33,3 +33,13 @@ When we mutate shapes inside of a command, we:
- When the history "does" the command, merge the "redo" data into the current `Data`.
- When the history "undoes" the command, merge the "undo" data into the current `Data`.
- When the history "redoes" the command, merge the "redo" data into the current `Data`.
## onChange Events
When something changes in the state, we need to produce an onChange event that is compatible with multiplayer implementations. This still requires some research, however at minimum we want to include:
- The current user's id
- The current document id
- The event patch (what's changed)
The first step would be to implement onChange events for commands. These are already set up as patches and always produce a history entry.

View file

@ -1,9 +1,18 @@
import type { ArrowBinding, ArrowShape, TLDrawShape, TLDrawBinding, Data, Session } from '~types'
import {
ArrowBinding,
ArrowShape,
TLDrawShape,
TLDrawBinding,
Data,
Session,
TLDrawStatus,
} from '~types'
import { Vec, Utils } from '@tldraw/core'
import { TLDR } from '~state/tldr'
export class ArrowSession implements Session {
id = 'transform_single'
status = TLDrawStatus.TranslatingHandle
newBindingId = Utils.uniqueId()
delta = [0, 0]
offset = [0, 0]

View file

@ -1,10 +1,11 @@
import { brushUpdater, Utils, Vec } from '@tldraw/core'
import type { Data, Session } from '~types'
import { Data, Session, TLDrawStatus } from '~types'
import { getShapeUtils } from '~shape'
import { TLDR } from '~state/tldr'
export class BrushSession implements Session {
id = 'brush'
status = TLDrawStatus.Brushing
origin: number[]
snapshot: BrushSnapshot

View file

@ -1,9 +1,10 @@
import { Utils, Vec } from '@tldraw/core'
import type { Data, DrawShape, Session } from '~types'
import { Data, DrawShape, Session, TLDrawStatus } from '~types'
import { TLDR } from '~state/tldr'
export class DrawSession implements Session {
id = 'draw'
status = TLDrawStatus.Creating
origin: number[]
previous: number[]
last: number[]

View file

@ -1,11 +1,12 @@
import { Vec } from '@tldraw/core'
import type { ShapesWithProp } from '~types'
import { ShapesWithProp, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class HandleSession implements Session {
id = 'transform_single'
status = TLDrawStatus.TranslatingHandle
commandId: string
delta = [0, 0]
origin: number[]

View file

@ -1,5 +1,5 @@
import { Utils, Vec } from '@tldraw/core'
import type { Session } from '~types'
import { Session, TLDrawStatus } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
@ -7,6 +7,7 @@ const PI2 = Math.PI * 2
export class RotateSession implements Session {
id = 'rotate'
status = TLDrawStatus.Transforming
delta = [0, 0]
origin: number[]
snapshot: RotateSnapshot

View file

@ -1,10 +1,11 @@
import type { TextShape } from '~types'
import { TextShape, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class TextSession implements Session {
id = 'text'
status = TLDrawStatus.EditingText
initialShape: TextShape
constructor(data: Data, id?: string) {

View file

@ -1,11 +1,12 @@
import { TLBoundsCorner, TLBoundsEdge, Utils, Vec } from '@tldraw/core'
import type { TLDrawShape } from '~types'
import { TLDrawShape, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class TransformSingleSession implements Session {
id = 'transform_single'
status = TLDrawStatus.Transforming
commandId: string
transformType: TLBoundsEdge | TLBoundsCorner
origin: number[]

View file

@ -1,10 +1,11 @@
import { TLBoundsCorner, TLBoundsEdge, Utils, Vec } from '@tldraw/core'
import type { Session } from '~types'
import { Session, TLDrawStatus } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
export class TransformSession implements Session {
id = 'transform'
status = TLDrawStatus.Transforming
scaleX = 1
scaleY = 1
transformType: TLBoundsEdge | TLBoundsCorner

View file

@ -1,9 +1,18 @@
import { Utils, Vec } from '@tldraw/core'
import type { TLDrawShape, TLDrawBinding, PagePartial, Session, Data, Command } from '~types'
import {
TLDrawShape,
TLDrawBinding,
PagePartial,
Session,
Data,
Command,
TLDrawStatus,
} from '~types'
import { TLDR } from '~state/tldr'
export class TranslateSession implements Session {
id = 'translate'
status = TLDrawStatus.Translating
delta = [0, 0]
prev = [0, 0]
origin: number[]

View file

@ -62,6 +62,10 @@ const initialData: Data = {
isToolLocked: false,
isStyleOpen: false,
isEmptyCanvas: false,
status: {
current: TLDrawStatus.Idle,
previous: TLDrawStatus.Idle,
},
},
page: {
id: 'page',
@ -87,10 +91,6 @@ export class TLDrawState implements TLCallbacks {
}
clipboard?: TLDrawShape[]
session?: Session
status: { current: TLDrawStatus; previous: TLDrawStatus } = {
current: 'idle',
previous: 'idle',
}
pointedId?: string
pointedHandle?: string
editingId?: string
@ -99,12 +99,28 @@ export class TLDrawState implements TLCallbacks {
currentPageId = 'page'
pages: Record<string, TLPage<TLDrawShape, TLDrawBinding>> = { page: initialData.page }
pageStates: Record<string, TLPageState> = { page: initialData.pageState }
isCreating = false
_onChange?: (state: TLDrawState, reason: string) => void
// Low API
getState = this.store.getState
private getState = this.store.getState
setState = <T extends keyof Data>(data: Partial<Data> | ((data: Data) => Partial<Data>)) => {
private setStatus(status: TLDrawStatus) {
this.store.setState((state) => ({
appState: {
...state.appState,
status: {
current: status,
previous: state.appState.status.current,
},
},
}))
}
private setState = <T extends keyof Data>(
data: Partial<Data> | ((data: Data) => Partial<Data>),
status?: TLDrawStatus
) => {
const current = this.getState()
// Apply incoming change
@ -190,6 +206,16 @@ export class TLDrawState implements TLCallbacks {
}
}
if (status) {
next.appState = {
...next.appState,
status: {
current: status,
previous: next.appState.status.current,
},
}
}
this.store.setState(next as PartialState<Data, T, T, T>)
this.pages[next.page.id] = next.page
this.pageStates[next.page.id] = next.pageState
@ -249,12 +275,12 @@ export class TLDrawState implements TLCallbacks {
return this
}
/* --------------------- Status --------------------- */
setStatus(status: TLDrawStatus) {
this.status.previous = this.status.current
this.status.current = status
// console.log(this.status.previous, ' -> ', this.status.current)
return this
}
// setStatus(status: TLDrawStatus) {
// this.status.previous = this.status.current
// this.status.current = status
// // console.log(this.status.previous, ' -> ', this.status.current)
// return this
// }
/* -------------------- App State ------------------- */
reset = () => {
this.setState((data) => ({
@ -525,7 +551,7 @@ export class TLDrawState implements TLCallbacks {
/* -------------------- Sessions -------------------- */
startSession<T extends Session>(session: T, ...args: ParametersExceptFirst<T['start']>) {
this.session = session
this.setState((data) => session.start(data, ...args))
this.setState((data) => session.start(data, ...args), session.status)
this._onChange?.(this, `session:start_${session.id}`)
return this
}
@ -542,10 +568,13 @@ export class TLDrawState implements TLCallbacks {
const { session } = this
if (!session) return this
this.setState((data) => session.cancel(data, ...args))
this.setStatus('idle')
this.setState((data) => session.cancel(data, ...args), TLDrawStatus.Idle)
this.session = undefined
this.isCreating = false
this._onChange?.(this, `session:cancel:${session.id}`)
return this
}
@ -553,16 +582,9 @@ export class TLDrawState implements TLCallbacks {
const { session } = this
if (!session) return this
this.setStatus('idle')
const current = this.getState()
const result = session.complete(this.store.getState(), ...args)
if ('after' in result) {
this.do(result)
} else {
this.setState((data) => Utils.deepMerge<Data>(data, result))
this._onChange?.(this, `session:complete:${session.id}`)
}
const result = session.complete(current, ...args)
const { isToolLocked, activeTool } = this.appState
@ -571,6 +593,35 @@ export class TLDrawState implements TLCallbacks {
}
this.session = undefined
if ('after' in result) {
// Session ended with a command
if (this.isCreating) {
// We're currently creating a shape. Override the command's
// before state so that when we undo the command, we remove
// the shape we just created.
result.before = {
page: {
shapes: Object.fromEntries(current.pageState.selectedIds.map((id) => [id, undefined])),
},
pageState: {
selectedIds: [],
editingId: undefined,
bindingId: undefined,
hoveredId: undefined,
},
}
}
this.isCreating = false
this.do(result)
} else {
this.setState((data) => Utils.deepMerge<Data>(data, result), TLDrawStatus.Idle)
this._onChange?.(this, `session:complete:${session.id}`)
}
return this
}
@ -586,12 +637,14 @@ export class TLDrawState implements TLCallbacks {
history.pointer = history.stack.length - 1
this.setState((data) =>
Object.fromEntries(
Object.entries(command.after).map(([key, partial]) => {
return [key, Utils.deepMerge(data[key as keyof Data], partial)]
})
)
this.setState(
(data) =>
Object.fromEntries(
Object.entries(command.after).map(([key, partial]) => {
return [key, Utils.deepMerge(data[key as keyof Data], partial)]
})
),
TLDrawStatus.Idle
)
this._onChange?.(this, `command:${command.id}`)
@ -606,12 +659,14 @@ export class TLDrawState implements TLCallbacks {
const command = history.stack[history.pointer]
this.setState((data) =>
Object.fromEntries(
Object.entries(command.before).map(([key, partial]) => {
return [key, Utils.deepMerge(data[key as keyof Data], partial)]
})
)
this.setState(
(data) =>
Object.fromEntries(
Object.entries(command.before).map(([key, partial]) => {
return [key, Utils.deepMerge(data[key as keyof Data], partial)]
})
),
TLDrawStatus.Idle
)
history.pointer--
@ -630,13 +685,16 @@ export class TLDrawState implements TLCallbacks {
const command = history.stack[history.pointer]
this.setState((data) =>
Object.fromEntries(
Object.entries(command.after).map(([key, partial]) => {
return [key, Utils.deepMerge(data[key as keyof Data], partial)]
})
)
this.setState(
(data) =>
Object.fromEntries(
Object.entries(command.after).map(([key, partial]) => {
return [key, Utils.deepMerge(data[key as keyof Data], partial)]
})
),
TLDrawStatus.Idle
)
this._onChange?.(this, `redo:${command.id}`)
return this
@ -834,7 +892,14 @@ export class TLDrawState implements TLCallbacks {
}
cancel = () => {
switch (this.status.current) {
if (this.isCreating) {
this.cancelSession()
this.delete()
this.isCreating = false
return
}
switch (this.appState.status.current) {
case 'idle': {
this.deselectAll()
this.selectTool('select')
@ -857,11 +922,6 @@ export class TLDrawState implements TLCallbacks {
this.cancelSession()
break
}
case 'creating': {
this.cancelSession()
this.delete()
break
}
}
return this
@ -968,7 +1028,6 @@ export class TLDrawState implements TLCallbacks {
}
/* -------------------- Sessions -------------------- */
startBrushSession = (point: number[]) => {
this.setStatus('brushing')
this.startSession(new Sessions.BrushSession(this.store.getState(), point))
return this
}
@ -979,7 +1038,6 @@ export class TLDrawState implements TLCallbacks {
}
startTranslateSession = (point: number[]) => {
this.setStatus('translating')
this.startSession(new Sessions.TranslateSession(this.store.getState(), point))
return this
}
@ -998,8 +1056,6 @@ export class TLDrawState implements TLCallbacks {
if (selectedIds.length === 0) return this
this.setStatus('transforming')
this.pointedBoundsHandle = handle
if (this.pointedBoundsHandle === 'rotate') {
@ -1032,7 +1088,6 @@ export class TLDrawState implements TLCallbacks {
startTextSession = (id?: string) => {
this.editingId = id
this.setStatus('editing-text')
this.startSession(new Sessions.TextSession(this.store.getState(), id))
return this
}
@ -1043,7 +1098,6 @@ export class TLDrawState implements TLCallbacks {
}
startDrawSession = (id: string, point: number[]) => {
this.setStatus('creating')
this.startSession(new Sessions.DrawSession(this.store.getState(), id, point))
return this
}
@ -1077,44 +1131,41 @@ export class TLDrawState implements TLCallbacks {
return this
}
updatenPointerMove: TLPointerEventHandler = (info) => {
updateOnPointerMove: TLPointerEventHandler = (info) => {
switch (this.status.current) {
case 'pointingBoundsHandle': {
case TLDrawStatus.PointingBoundsHandle: {
if (!this.pointedBoundsHandle) throw Error('No pointed bounds handle')
if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus('transforming')
this.startTransformSession(this.getPagePoint(info.origin), this.pointedBoundsHandle)
}
break
}
case 'pointingHandle': {
case TLDrawStatus.PointingHandle: {
if (!this.pointedHandle) throw Error('No pointed handle')
if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus('translatingHandle')
this.startHandleSession(this.getPagePoint(info.origin), this.pointedHandle)
}
break
}
case 'pointingBounds': {
case TLDrawStatus.PointingBounds: {
if (Vec.dist(info.origin, info.point) > 4) {
this.setStatus('translating')
this.startTranslateSession(this.getPagePoint(info.origin))
}
break
}
case 'brushing': {
case TLDrawStatus.Brushing: {
this.updateBrushSession(this.getPagePoint(info.point), info.metaKey)
break
}
case 'translating': {
case TLDrawStatus.Translating: {
this.updateTranslateSession(this.getPagePoint(info.point), info.shiftKey, info.altKey)
break
}
case 'transforming': {
case TLDrawStatus.Transforming: {
this.updateTransformSession(this.getPagePoint(info.point), info.shiftKey, info.altKey)
break
}
case 'translatingHandle': {
case TLDrawStatus.TranslatingHandle: {
this.updateHandleSession(
this.getPagePoint(info.point),
info.shiftKey,
@ -1123,7 +1174,7 @@ export class TLDrawState implements TLCallbacks {
)
break
}
case 'creating': {
case TLDrawStatus.Creating: {
switch (this.appState.activeToolType) {
case 'draw': {
this.updateDrawSession(this.getPagePoint(info.point), info.pressure, info.shiftKey)
@ -1193,7 +1244,9 @@ export class TLDrawState implements TLCallbacks {
selectedIds: [id],
},
}
})
}, TLDrawStatus.Creating)
this.isCreating = true
const { activeTool, activeToolType } = this.getAppState()
@ -1231,10 +1284,10 @@ export class TLDrawState implements TLCallbacks {
}
switch (this.status.current) {
case 'idle': {
case TLDrawStatus.Idle: {
break
}
case 'brushing': {
case TLDrawStatus.Brushing: {
if (key === 'Meta' || key === 'Control') {
this.updateBrushSession(this.getPagePoint(info.point), info.metaKey)
return
@ -1242,7 +1295,7 @@ export class TLDrawState implements TLCallbacks {
break
}
case 'translating': {
case TLDrawStatus.Translating: {
if (key === 'Escape') {
this.cancelSession(this.getPagePoint(info.point))
}
@ -1252,7 +1305,7 @@ export class TLDrawState implements TLCallbacks {
}
break
}
case 'transforming': {
case TLDrawStatus.Transforming: {
if (key === 'Escape') {
this.cancelSession(this.getPagePoint(info.point))
}
@ -1262,7 +1315,7 @@ export class TLDrawState implements TLCallbacks {
}
break
}
case 'translatingHandle': {
case TLDrawStatus.TranslatingHandle: {
if (key === 'Escape') {
this.cancelSession(this.getPagePoint(info.point))
}
@ -1282,25 +1335,25 @@ export class TLDrawState implements TLCallbacks {
onKeyUp = (key: string, info: TLKeyboardInfo) => {
switch (this.status.current) {
case 'brushing': {
case TLDrawStatus.Brushing: {
if (key === 'Meta' || key === 'Control') {
this.updateBrushSession(this.getPagePoint(info.point), info.metaKey)
}
break
}
case 'transforming': {
case TLDrawStatus.Transforming: {
if (key === 'Shift' || key === 'Alt') {
this.updateTransformSession(this.getPagePoint(info.point), info.shiftKey, info.altKey)
}
break
}
case 'translating': {
case TLDrawStatus.Translating: {
if (key === 'Shift' || key === 'Alt') {
this.updateTransformSession(this.getPagePoint(info.point), info.shiftKey, info.altKey)
}
break
}
case 'translatingHandle': {
case TLDrawStatus.TranslatingHandle: {
if (key === 'Escape') {
this.cancelSession(this.getPagePoint(info.point))
}
@ -1320,7 +1373,7 @@ export class TLDrawState implements TLCallbacks {
/* ------------- Renderer Event Handlers ------------ */
onPinchStart: TLPinchEventHandler = () => {
this.setStatus('pinching')
this.setStatus(TLDrawStatus.Pinching)
}
onPinchEnd: TLPinchEventHandler = () => {
@ -1328,14 +1381,14 @@ export class TLDrawState implements TLCallbacks {
}
onPinch: TLPinchEventHandler = (info, e) => {
if (this.status.current !== 'pinching') return
if (this.status.current !== TLDrawStatus.Pinching) return
this.pinchZoom(info.origin, info.delta, info.delta[2] / 350)
this.updatenPointerMove(info, e as any)
this.updateOnPointerMove(info, e as any)
}
onPan: TLWheelEventHandler = (info, e) => {
if (this.status.current === 'pinching') return
if (this.status.current === TLDrawStatus.Pinching) return
// TODO: Pan and pinchzoom are firing at the same time. Considering turning one of them off!
const delta = Vec.div(info.delta, this.getPageState().camera.zoom)
@ -1345,36 +1398,32 @@ export class TLDrawState implements TLCallbacks {
if (Vec.isEqual(next, prev)) return
this.pan(delta)
this.updatenPointerMove(info, e as any)
this.updateOnPointerMove(info, e as any)
}
onZoom: TLWheelEventHandler = (info, e) => {
this.zoom(info.delta[2] / 100)
this.updatenPointerMove(info, e as any)
this.updateOnPointerMove(info, e as any)
}
// Pointer Events
onPointerDown: TLPointerEventHandler = (info) => {
switch (this.status.current) {
case 'idle': {
case TLDrawStatus.Idle: {
switch (this.appState.activeTool) {
case 'draw': {
this.setStatus('creating')
this.createActiveToolShape(info.point)
break
}
case 'rectangle': {
this.setStatus('creating')
this.createActiveToolShape(info.point)
break
}
case 'ellipse': {
this.setStatus('creating')
this.createActiveToolShape(info.point)
break
}
case 'arrow': {
this.setStatus('creating')
this.createActiveToolShape(info.point)
break
}
@ -1384,14 +1433,14 @@ export class TLDrawState implements TLCallbacks {
}
onPointerMove: TLPointerEventHandler = (info, e) => {
this.updatenPointerMove(info, e)
this.updateOnPointerMove(info, e)
}
onPointerUp: TLPointerEventHandler = (info) => {
const data = this.getState()
switch (this.status.current) {
case 'pointingBounds': {
case TLDrawStatus.PointingBounds: {
if (info.target === 'bounds') {
// If we just clicked the selecting bounds's background, clear the selection
this.deselectAll()
@ -1412,41 +1461,41 @@ export class TLDrawState implements TLCallbacks {
this.pointedId = undefined
}
this.setStatus('idle')
this.setStatus(TLDrawStatus.Idle)
this.pointedId = undefined
break
}
case 'pointingBoundsHandle': {
this.setStatus('idle')
case TLDrawStatus.PointingBoundsHandle: {
this.setStatus(TLDrawStatus.Idle)
this.pointedBoundsHandle = undefined
break
}
case 'pointingHandle': {
this.setStatus('idle')
case TLDrawStatus.PointingHandle: {
this.setStatus(TLDrawStatus.Idle)
this.pointedHandle = undefined
break
}
case 'translatingHandle': {
case TLDrawStatus.TranslatingHandle: {
this.completeSession<Sessions.HandleSession>()
this.pointedHandle = undefined
break
}
case 'brushing': {
case TLDrawStatus.Brushing: {
this.completeSession<Sessions.BrushSession>()
brushUpdater.clear()
break
}
case 'translating': {
this.completeSession(this.getPagePoint(info.point))
case TLDrawStatus.Translating: {
this.completeSession<Sessions.TranslateSession>()
this.pointedId = undefined
break
}
case 'transforming': {
this.completeSession(this.getPagePoint(info.point))
case TLDrawStatus.Transforming: {
this.completeSession<Sessions.TransformSession>()
this.pointedBoundsHandle = undefined
break
}
case 'creating': {
case TLDrawStatus.Creating: {
this.completeSession(this.getPagePoint(info.point))
this.pointedHandle = undefined
}
@ -1485,7 +1534,6 @@ export class TLDrawState implements TLCallbacks {
switch (this.appState.activeTool) {
case 'text': {
// Create a text shape
this.setStatus('creating')
this.createActiveToolShape(info.point)
break
}
@ -1530,7 +1578,7 @@ export class TLDrawState implements TLCallbacks {
this.setSelectedIds([info.target], info.shiftKey)
}
this.setStatus('pointingBounds')
this.setStatus(TLDrawStatus.PointingBounds)
break
}
}
@ -1581,7 +1629,7 @@ export class TLDrawState implements TLCallbacks {
// Bounds (bounding box background)
onPointBounds: TLBoundsEventHandler = () => {
this.setStatus('pointingBounds')
this.setStatus(TLDrawStatus.PointingBounds)
}
onDoubleClickBounds: TLBoundsEventHandler = () => {
@ -1606,11 +1654,11 @@ export class TLDrawState implements TLCallbacks {
onReleaseBounds: TLBoundsEventHandler = (info) => {
switch (this.status.current) {
case 'translating': {
case TLDrawStatus.Translating: {
this.completeSession(this.getPagePoint(info.point))
break
}
case 'brushing': {
case TLDrawStatus.Brushing: {
this.completeSession<Sessions.BrushSession>()
brushUpdater.clear()
break
@ -1621,7 +1669,7 @@ export class TLDrawState implements TLCallbacks {
// Bounds handles (corners, edges)
onPointBoundsHandle: TLBoundsHandleEventHandler = (info) => {
this.pointedBoundsHandle = info.target
this.setStatus('pointingBoundsHandle')
this.setStatus(TLDrawStatus.PointingBoundsHandle)
}
onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = () => {
@ -1651,7 +1699,7 @@ export class TLDrawState implements TLCallbacks {
// Handles (ie the handles of a selected arrow)
onPointHandle: TLPointerEventHandler = (info) => {
this.pointedHandle = info.target
this.setStatus('pointingHandle')
this.setStatus(TLDrawStatus.PointingHandle)
}
onDoubleClickHandle: TLPointerEventHandler = (info) => {
@ -1766,4 +1814,8 @@ export class TLDrawState implements TLCallbacks {
get appState() {
return this.data.appState
}
get status() {
return this.appState.status
}
}

View file

@ -35,6 +35,7 @@ export interface Data {
isToolLocked: boolean
isStyleOpen: boolean
isEmptyCanvas: boolean
status: { current: TLDrawStatus; previous: TLDrawStatus }
}
}
export interface PagePartial {
@ -63,25 +64,27 @@ export interface History {
export interface Session {
id: string
status: TLDrawStatus
start: (data: Readonly<Data>, ...args: any[]) => Partial<Data>
update: (data: Readonly<Data>, ...args: any[]) => Partial<Data>
complete: (data: Readonly<Data>, ...args: any[]) => Partial<Data> | Command
cancel: (data: Readonly<Data>, ...args: any[]) => Partial<Data>
}
export type TLDrawStatus =
| 'idle'
| 'pointingHandle'
| 'pointingBounds'
| 'pointingBoundsHandle'
| 'translatingHandle'
| 'translating'
| 'transforming'
| 'rotating'
| 'pinching'
| 'brushing'
| 'creating'
| 'editing-text'
export enum TLDrawStatus {
Idle = 'idle',
PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds',
PointingBoundsHandle = 'pointingBoundsHandle',
TranslatingHandle = 'translatingHandle',
Translating = 'translating',
Transforming = 'transforming',
Rotating = 'rotating',
Pinching = 'pinching',
Brushing = 'brushing',
Creating = 'creating',
EditingText = 'editing-text',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ParametersExceptFirst<F> = F extends (arg0: any, ...rest: infer R) => any ? R : never