Add docs for tools, sessions, cleans up tools.

This commit is contained in:
Steve Ruiz 2021-10-16 08:33:25 +01:00
parent c9abaca8d9
commit 7d9fcc763d
11 changed files with 212 additions and 312 deletions

View file

@ -0,0 +1,37 @@
# Sessions
A session is a class that handles events for interactions that have a beginning, middle and end.
They contrast with Commands, such as `duplicate`, which occur once.
The `TLDrawState` may only have one active session at a time (`TLDrawState.session`), or it may have no session. It may never have two sessions simulataneously—if a session begins while another session is already in progress, `TLDrawState` will throw an error. In this way, sessions function similar to a set of finite states: once a session begins, it must end before a new session can begin.
## Creating a Session
Sessions are created with `TLDrawState.startSession`. In this method, sessions are creating using the `new` keyword. Every session's constructor receives the `TLDrawState` instance's current state (`TLDrawState.state`), together with any additional parameters it defines in its constructor.
## Life Cycle Methods
A session has four life-cycle methods: `start`, `update`, `cancel` and `complete`.
### Start
When a session is created using `TLDrawState.startSession`, `TLDrawState` also calls the session's `start` method, passing in the state as the only parameter. If the `start` method returns a patch, then that patch is applied to the state.
### Update
When a session is updated using `TLDrawState.updateSession`, `TLDrawState` calls the session's `update` method, again passing in the state as well as several additional parameters: `point`, `shiftKey`, `altKey`, and `metaKey`. If the `update` method returns a patch, then that patch is applied to the state.
A session may use whatever information is wishes internally in order to produce its update patch. Often this means saving information about the initial state, point, or initial selected shapes, in order to compare against the update's parameters. For example, `RotateSession.update` saves the center of the selection bounds, as well as the initial angle from this center to the user's initial point, in order to compare this angle against the angle from this center to the user's current point.
### Cancel
A session may be cancelled using `TLDrawState.cancelSession`. When a session is cancelled, `TLDrawState` calls the session's `cancel` method passing in the state as the only parameter. If the `cancel` method returns a patch, then that patch is applied to the state.
A cancel method is expected to revert any changes made to the state since the session began. For example, `RotateSession.cancel` should restore the rotations of the user's selected shapes to their original rotations. If no change has occurred (e.g. if the rotation began and was immediately cancelled) then the `cancel` method should return `undefined` so as to avoid updating the state.
### Complete
A session may be cancelled using `TLDrawState.complete`. When a session is cancelled, `TLDrawState` calls the session's `complete` method passing in the state as the only parameter. If the `complete` method returns a patch, then that patch is applied to the state; if it returns a `command`, then that command is patched and added to the state's history.
If the `complete` method returns a command, then it is expected that the command's `before` patch will revert any changes made to the state since the session began, including any changes introduced in the command's `after` patch.

View file

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { StateManager } from 'rko'
import {
TLBoundsCorner,
TLBoundsEdge,
TLBoundsEventHandler,
TLBoundsHandleEventHandler,
TLKeyboardEventHandler,
@ -40,7 +38,7 @@ import {
ExceptFirst,
} from '~types'
import { TLDR } from './tldr'
import { defaultStyle } from '~shape'
import { defaultStyle, tldrawShapeUtils } from '~shape'
import * as Commands from './command'
import { ArgsOfType, getSession } from './session'
import { sample, USER_COLORS } from './utils'
@ -133,16 +131,8 @@ export class TLDrawState extends StateManager<Data> {
session?: Session
pointedId?: string
pointedHandle?: string
pointedBoundsHandle?: TLBoundsCorner | TLBoundsEdge | 'rotate'
isCreating = false
selectedGroupId?: string
// The editor's bounding client rect
bounds: TLBounds = {
minX: 0,
@ -172,7 +162,6 @@ export class TLDrawState extends StateManager<Data> {
this._onMount = onMount
this.session = undefined
this.pointedId = undefined
}
/* -------------------- Internal -------------------- */
@ -534,8 +523,12 @@ export class TLDrawState extends StateManager<Data> {
if (tool === this.currentTool) return this
this.currentTool.onExit()
this.currentTool = tool
this.currentTool.onEnter()
return this.patchState(
{
appState: {
@ -571,9 +564,11 @@ export class TLDrawState extends StateManager<Data> {
resetDocument = (): this => {
if (this.session) return this
this.session = undefined
this.selectedGroupId = undefined
this.currentTool.setStatus(TLDrawStatus.Idle)
this.pasteInfo.offset = [0, 0]
this.tools = createTools(this)
this.currentTool = this.tools.select
this.resetHistory()
.clearSelectHistory()
.loadDocument(defaultDocument)
@ -770,7 +765,7 @@ export class TLDrawState extends StateManager<Data> {
this.resetHistory()
this.clearSelectHistory()
this.session = undefined
this.selectedGroupId = undefined
return this.replaceState(
{
...defaultState,
@ -1617,6 +1612,10 @@ export class TLDrawState extends StateManager<Data> {
* @param args arguments of the session's start method.
*/
startSession = <T extends SessionType>(type: T, ...args: ExceptFirst<ArgsOfType<T>>): this => {
if (this.session) {
throw Error(`Already in a session! (${this.session.constructor.name})`)
}
const Session = getSession(type)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@ -1838,6 +1837,40 @@ export class TLDrawState extends StateManager<Data> {
)
}
createTextShapeAtPoint(point: number[]) {
const {
shapes,
appState: { currentPageId, currentStyle },
} = this
const childIndex =
shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const id = Utils.uniqueId()
const Text = tldrawShapeUtils.text
const newShape = Text.create({
id,
parentId: currentPageId,
childIndex,
point,
style: { ...currentStyle },
})
const bounds = Text.getBounds(newShape)
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
this.createShapes(newShape)
this.setEditingId(id)
}
/**
* Create one or more shapes.
* @param shapes An array of shapes.

View file

@ -2,27 +2,11 @@ import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler } from '@tldraw/core'
import { Arrow } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
import { BaseTool, Status } from '../BaseTool'
export class ArrowTool extends BaseTool {
type = TLDrawShapeType.Arrow
status = Status.Idle
/* --------------------- Methods -------------------- */
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
@ -50,19 +34,4 @@ export class ArrowTool extends BaseTool {
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

@ -1,3 +1,4 @@
import Vec from '@tldraw/vec'
import type {
TLBoundsEventHandler,
TLBoundsHandleEventHandler,
@ -13,25 +14,41 @@ import Utils from '~../../core/src/utils'
import type { TLDrawState } from '~state'
import type { TLDrawShapeType } from '~types'
export abstract class BaseTool {
export enum Status {
Idle = 'idle',
Creating = 'creating',
Pinching = 'pinching',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export abstract class BaseTool<T extends string = any> {
abstract type: TLDrawShapeType | 'select'
state: TLDrawState
status: string = 'idle' as const
setStatus = (status: typeof this.status) => {
this.status = status
this.state.setStatus(this.status)
}
status: Status | T = Status.Idle
constructor(state: TLDrawState) {
this.state = state
}
abstract onEnter: () => void
protected readonly setStatus = (status: Status | T) => {
this.status = status as Status | T
this.state.setStatus(this.status as string)
}
abstract onExit: () => void
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
onCancel = () => {
this.state.cancelSession()
this.setStatus(Status.Idle)
}
getNextChildIndex = () => {
const {
@ -46,19 +63,78 @@ export abstract class BaseTool {
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
}
onCancel = () => {
if (this.status === 'creating') {
this.state.cancelSession()
/* --------------------- Camera --------------------- */
onPinchStart: TLPinchEventHandler = () => {
this.state.cancelSession()
this.setStatus(Status.Pinching)
}
onPinchEnd: TLPinchEventHandler = () => {
if (Utils.isMobileSafari()) {
this.state.undoSelect()
}
this.setStatus(Status.Idle)
}
onPinch: TLPinchEventHandler = (info, e) => {
if (this.status !== 'pinching') return
this.state.pinchZoom(info.point, info.delta, info.delta[2])
this.onPointerMove?.(info, e as unknown as React.PointerEvent)
}
/* ---------------------- Keys ---------------------- */
onKeyDown: TLKeyboardEventHandler = (key, info) => {
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
return
}
}
onKeyUp: TLKeyboardEventHandler = (key, info) => {
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
return
}
}
/* --------------------- Pointer -------------------- */
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
/* --------------------- Others --------------------- */
// Camera Events
onPan?: TLWheelEventHandler
onZoom?: TLWheelEventHandler
// Pointer Events
onPointerMove?: TLPointerEventHandler
onPointerUp?: TLPointerEventHandler
onPointerDown?: TLPointerEventHandler
// Canvas (background)
@ -107,52 +183,4 @@ export abstract class BaseTool {
// Misc
onShapeBlur?: TLShapeBlurHandler
onShapeClone?: TLShapeCloneHandler
/* --------------------- Camera --------------------- */
onPinchStart: TLPinchEventHandler = () => {
this.state.cancelSession()
this.setStatus('pinching')
}
onPinchEnd: TLPinchEventHandler = () => {
if (Utils.isMobileSafari()) {
this.state.undoSelect()
}
this.setStatus('idle')
}
onPinch: TLPinchEventHandler = (info, e) => {
if (this.status !== 'pinching') return
this.state.pinchZoom(info.point, info.delta, info.delta[2])
this.onPointerMove?.(info, e as unknown as React.PointerEvent)
}
/* ---------------------- Keys ---------------------- */
onKeyDown: TLKeyboardEventHandler = (key, info) => {
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
return
}
}
onKeyUp: TLKeyboardEventHandler = (key, info) => {
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.state.updateSession(
this.state.getPagePoint(info.point),
info.shiftKey,
info.altKey,
info.metaKey
)
return
}
}
}

View file

@ -3,28 +3,11 @@ import type { TLPointerEventHandler } from '~../../core/src/types'
import Utils from '~../../core/src/utils'
import { Draw } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
import { BaseTool, Status } from '../BaseTool'
export class DrawTool extends BaseTool {
type = TLDrawShapeType.Draw
status = Status.Idle
/* --------------------- Methods -------------------- */
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {

View file

@ -2,27 +2,11 @@ import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler, TLKeyboardEventHandler, TLBoundsCorner } from '@tldraw/core'
import { Ellipse } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
import { BaseTool, Status } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
export class EllipseTool extends BaseTool {
type = TLDrawShapeType.Ellipse
status = Status.Idle
/* --------------------- Methods -------------------- */
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
@ -55,31 +39,4 @@ export class EllipseTool extends BaseTool {
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onKeyDown: TLKeyboardEventHandler = (key, info) => {
if (
(this.status === Status.Creating && key === 'Shift') ||
key === 'Meta' ||
key === 'Alt' ||
key === 'Ctrl'
) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

@ -1,29 +1,12 @@
import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler, TLKeyboardEventHandler, TLBoundsCorner } from '@tldraw/core'
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
import { Rectangle } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
import { BaseTool, Status } from '../BaseTool'
export class RectangleTool extends BaseTool {
type = TLDrawShapeType.Rectangle
status = Status.Idle
/* --------------------- Methods -------------------- */
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
@ -56,31 +39,4 @@ export class RectangleTool extends BaseTool {
this.setStatus(Status.Creating)
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onKeyDown: TLKeyboardEventHandler = (key, info) => {
if (
(this.status === Status.Creating && key === 'Shift') ||
key === 'Meta' ||
key === 'Alt' ||
key === 'Ctrl'
) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()
}
this.setStatus(Status.Idle)
}
}

View file

@ -16,6 +16,8 @@ import { TLDR } from '~state/tldr'
enum Status {
Idle = 'idle',
Creating = 'creating',
Pinching = 'pinching',
PointingCanvas = 'pointingCanvas',
PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds',
@ -26,18 +28,15 @@ enum Status {
Translating = 'translating',
Transforming = 'transforming',
Rotating = 'rotating',
Pinching = 'pinching',
Brushing = 'brushing',
GridCloning = 'gridCloning',
ClonePainting = 'clonePainting',
SpacePanning = 'spacePanning',
}
export class SelectTool extends BaseTool {
export class SelectTool extends BaseTool<Status> {
type = 'select' as const
status: Status = Status.Idle
pointedId?: string
selectedGroupId?: string
@ -162,7 +161,6 @@ export class SelectTool extends BaseTool {
onCancel = () => {
this.deselectAll()
// TODO: Make all cancel sessions have no arguments
this.state.cancelSession()
this.setStatus(Status.Idle)
}
@ -422,9 +420,8 @@ export class SelectTool extends BaseTool {
onDoubleClickCanvas: TLCanvasEventHandler = (info) => {
const pagePoint = this.state.getPagePoint(info.point)
this.state.selectTool(TLDrawShapeType.Text)
const tool = this.state.tools[TLDrawShapeType.Text]
this.setStatus(Status.Idle)
tool.createTextShapeAtPoint(pagePoint)
this.state.createTextShapeAtPoint(pagePoint)
}
// Shape

View file

@ -3,30 +3,13 @@ import type { TLPointerEventHandler } from '@tldraw/core'
import { Utils } from '@tldraw/core'
import { Sticky } from '~shape/shapes'
import { SessionType, TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
import { BaseTool, Status } from '../BaseTool'
export class StickyTool extends BaseTool {
type = TLDrawShapeType.Sticky
status = Status.Idle
shapeId?: string
/* --------------------- Methods -------------------- */
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = (info) => {
@ -44,16 +27,10 @@ export class StickyTool extends BaseTool {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
const {
shapes,
appState: { currentPageId, currentStyle },
} = this.state
const childIndex =
shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const childIndex = this.getNextChildIndex()
const id = Utils.uniqueId()
@ -79,13 +56,6 @@ export class StickyTool extends BaseTool {
}
}
onPointerMove: TLPointerEventHandler = (info) => {
if (this.status === Status.Creating) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.state.updateSession(pagePoint, info.shiftKey, info.altKey, info.metaKey)
}
}
onPointerUp: TLPointerEventHandler = () => {
if (this.status === Status.Creating) {
this.state.completeSession()

View file

@ -1,29 +1,13 @@
import Vec from '@tldraw/vec'
import { Utils, TLPointerEventHandler, TLKeyboardEventHandler } from '@tldraw/core'
import { Text } from '~shape/shapes'
import type { TLPointerEventHandler, TLKeyboardEventHandler } from '@tldraw/core'
import { TLDrawShapeType } from '~types'
import { BaseTool } from '../BaseTool'
enum Status {
Idle = 'idle',
Creating = 'creating',
}
import { BaseTool, Status } from '../BaseTool'
export class TextTool extends BaseTool {
type = TLDrawShapeType.Text
status = Status.Idle
/* --------------------- Methods -------------------- */
onEnter = () => {
this.setStatus(Status.Idle)
}
onExit = () => {
this.setStatus(Status.Idle)
}
stopEditingShape = () => {
this.setStatus(Status.Idle)
@ -32,54 +16,21 @@ export class TextTool extends BaseTool {
}
}
createTextShapeAtPoint = (point: number[]) => {
const {
shapes,
appState: { currentPageId, currentStyle },
} = this.state
const childIndex =
shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === currentPageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const id = Utils.uniqueId()
const newShape = Text.create({
id,
parentId: currentPageId,
childIndex,
point,
style: { ...currentStyle },
})
const bounds = Text.getBounds(newShape)
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
this.state.createShapes(newShape)
this.state.setEditingId(id)
this.setStatus(Status.Creating)
}
/* ----------------- Event Handlers ----------------- */
onKeyUp: TLKeyboardEventHandler = (key, info) => {
onKeyUp: TLKeyboardEventHandler = () => {
// noop
}
onKeyDown: TLKeyboardEventHandler = (key, info) => {
onKeyDown: TLKeyboardEventHandler = () => {
// noop
}
onPointerDown: TLPointerEventHandler = (info) => {
if (this.status === Status.Idle) {
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
this.createTextShapeAtPoint(pagePoint)
const point = Vec.round(this.state.getPagePoint(info.point))
this.state.createTextShapeAtPoint(point)
this.setStatus(Status.Creating)
return
}

View file

@ -0,0 +1,19 @@
# Tools
Tools are classes that handle events. A TLDrawState instance has a set of tools (`tools`) and one current tool (`currentTool`). The state delegates events (such as `onPointerMove`) to its current tool for handling.
In this way, tools function as a finite state machine: events are always handled by a tool and will only ever be handled by one tool.
## BaseTool
Each tool extends `BaseTool`, which comes with several default methods used by the majority of other tools. If a tool overrides one of the BaseTool methods, consider re-implementing the functionality found in BaseTool. For example, see how `StickyTool` overrides `onPointerUp` so that, in addition to completing the current session, the it also sets the state's `editingId` to the new sticky shape.
## Enter and Exit Methods
When the state changes from one tool to another, it will:
1. run the previous tool's `onExit` method
2. switch to the new tool
3. run the new current tool's `onEnter` method
Each tool has a status (`status`) that may be set with `setStatus`.