No description
Find a file
2021-07-02 14:53:34 +01:00
.github Create FUNDING.yml 2021-07-02 09:59:15 +01:00
.vscode Fix text rendering, text layout due to mismatched vertical alignments. 2021-07-02 11:48:49 +01:00
__tests__ Updates style panel 2021-07-01 23:11:09 +01:00
components Fix text rendering, text layout due to mismatched vertical alignments. 2021-07-02 11:48:49 +01:00
hooks Adding tab support for text area 2021-07-02 13:04:45 +01:00
pages remove pusher 2021-06-30 23:55:00 +01:00
public Updates cursor logic 2021-06-30 21:31:29 +01:00
scripts Adds initial docs for code editor 2021-06-27 14:53:06 +01:00
state Adding tab support for text area 2021-07-02 13:04:45 +01:00
styles Update globals.css 2021-06-27 22:31:11 +01:00
utils Adding tab support for text area 2021-07-02 13:04:45 +01:00
worker Annoying file system saving stuff 2021-06-11 23:06:09 +01:00
.eslintignore big cleanup 2021-06-21 22:35:28 +01:00
.eslintrc.json big cleanup 2021-06-21 22:35:28 +01:00
.gitattributes Initial commit 2021-05-09 12:48:30 +01:00
.gitignore Revert "Manual sentry integration?" 2021-06-19 17:12:48 +01:00
.prettierrc improves select display 2021-05-28 15:37:23 +01:00
babel.config.js Stubs tests. Updates types for controls. 2021-06-25 11:28:52 +01:00
decs.d.ts big cleanup 2021-06-21 22:35:28 +01:00
jest.config.js Stubs tests. Updates types for controls. 2021-06-25 11:28:52 +01:00
next-env.d.ts Make updating code controls async 2021-06-25 12:01:22 +01:00
next.config.js Update next.config.js 2021-06-29 20:32:11 +01:00
package.json Update package.json 2021-07-01 15:06:19 +01:00
README.md Update README.md 2021-07-02 14:53:34 +01:00
sentry.client.config.js Small cleanup 2021-06-21 22:35:54 +01:00
sentry.properties Revert "Manual sentry integration?" 2021-06-19 17:12:48 +01:00
sentry.server.config.js Small cleanup 2021-06-21 22:35:54 +01:00
tsconfig.json Adds tests, fixes bug on pre-complete sessions 2021-06-23 13:46:16 +01:00
types.ts Updates style panel 2021-07-01 23:11:09 +01:00
yarn.lock remove pusher 2021-06-30 23:55:00 +01:00

tldraw

A tiny little drawing app by steveruizok.

Visit tldraw.com.

Support

To support this project (and gain access to the project while it is in development) you can sponsor the author on GitHub. Thanks!

Documentation

...

Local Development

  1. Download or clone the repository.

    git clone https://github.com/tldraw/tldraw.git
    
  2. Install dependencies.

    yarn
    
  3. Start the development server.

    yarn dev
    
  4. Open the local site at https://localhost:3000.

This project is a Next.js project. If you've worked with Next.js before, the tldraw code-base and setup instructions should all be very familiar.

How it works

The app's state is a very large state machine located in state/state.ts. The machine is organized as a tree of state notes, such as selecting and pinching.

root
├── loading
└── ready
    ├── selecting
    │   ├── notPointing
    │   ├── pointingBounds
    │   ├── translatingSelection
    │   └── ...
    ├── usingTool
    ├── pinching
    └── ...

State Nodes

Nodes may be active or inactive. The root node is always active. Depending on what's happened in the app, different branches of the state tree may be active, while other branches may be inactive.

pinching: {
  onExit: { secretlyDo: 'updateZoomCSS' },
  initial: 'selectPinching',
  states: {
    selectPinching: {
      on: {
        STOPPED_PINCHING: { to: 'selecting' },
      },
    },
    toolPinching: {
      on: {
        STOPPED_PINCHING: { to: 'usingTool.previous' },
      },
    },
  },
},

State nodes are both a way to describe the state (e.g., "the pinching state is active") and a way of organizing events. Each node has a set of events (on). When the state receives an event, it will execute the event handlers on each of the machine's active states where the event is present.

Event Handlers

Event handlers contain references to event handler functions: actions, conditions, results, and asyncs. These are defined at the bottom of the state machine's configuration.

An event handler may be a single action:

on: {
  MOVED_POINTER: 'updateRotateSession',
}

Or it may be an array of actions:

on: {
  MOVED_TO_PAGE: ['moveSelectionToPage', 'zoomCameraToSelectionActual'],
}

Or it may be an object with conditions under if or unless and actions under do:

on: {
  SAVED_CODE: {
    unless: 'isReadOnly',
    do: 'saveCode',
  }
}

An event handler may also contain transitions under to:

on: {
  STOPPED_PINCHING: { to: 'selecting' },
},

As well as nested event handlers under control flow, then and else.

on: {
  STOPPED_POINTING: {
    if: 'isPressingShiftKey',
    then: {
      if: 'isPointedShapeSelected',
      do: 'pullPointedIdFromSelectedIds',
    },
    else: {
      if: 'isPointingShape',
      do: [
        'clearSelectedIds',
        'setPointedId',
        'pushPointedIdToSelectedIds',
      ],
    },
  },
}

And finally, an event handler may have arrays of event handler objects.

on: {
  STOPPED_POINTING: [
    'completeSession',
    {
      if: 'isToolLocked',
      to: 'dot.creating',
      else: {
        to: 'selecting'
      },
    },
  ],
}

Event Handler Functions

The configuration's event handlers work by calling event handler functions: actions, conditions, results, or async. While event handler function does a different job, all event handler functions receive the same three parameters:

  1. The machine's current data draft
  2. The payload sent by the event that has caused the function to run
  3. The most recent result returned by a result function

Note: The payload and result parameters must be typed manually inline.

eventHandlerFn(data, payload: { id: string }, result: Shape) {}

Results may return any value.

pageById(data, payload: { id: string }) {
  return data.document.pages[payload.id]
}

Conditions must return true or false.

pageIsCurrentPage(data, payload, result: Page) {
  return data.currentPageId === result.id
}

Actions may mutate the draft.

setCurrentPageId(data, payload, result: Page) {
  data.currentPageId = result.id
}

In event handlers, event handler functions are referred to by name.

on: {
  SOME_EVENT: {
    get: "pageById"
    unless: "pageIsCurrentPage",
    do: "setCurrentPageId"
  }
}

Asyncs are asyncronous functions. They work like results, but resolve data instead.

async getCurrentUser(data) {
  return fetch(`https://tldraw/api/users/${data.currentUserId}`)
}

These are used in asynchronous event handlers:

loadingUser: {
  async: {
    await: "getCurrentUser",
    onResolve: { to: "user" },
    onReject: { to: "error" },
  }
}

Events

Events are sent from the user interface to the state.

import state from 'state'

state.send('SELECTED_DRAW_TOOL')

Events may also include payloads of data.

state.send('ALIGNED', { type: AlignType.Right })

Note that with very few exceptions, we send events to the state regardless of whether the state can handle the event. Whether the event should have an effect—and what that effect should be—is left entirely to the state machine.

Note: You can send an event to the state from anywhere in the app: even from components that are not subscribed to the state. See the components/style-panel files for examples.

State Updates

The state will update each time it:

  1. receives an event...
  2. that causes it to run an event handler...
  3. that passes its conditions...
  4. and that contains either an action or a transition

Such updates are batched: while a single event may cause several event handlers to run, the state will update only once provided that at least one of the event handlers caused an action or transition to occur.

When a state updates, it will cause any subscribed components to update via hooks.

Subscribing to State

To use the state's data reactively, we use the useSelector hook.

import state, { useSelector } from 'state'

function SomeComponent() {
  const pointingId = useSelector((s) => s.data.pointingId)

  return <div>The pointing id is {pointingId}</div>
}

Each time the state updates, the hook will check whether the data returned by the selector function matches its previous data. If the answer is false (ie if the data is new) then hook will update and the new data will become the hook's previous data.

The hook may also accept a second parameter, a comparison function. If the selector function returns anything other than a primitive, we will often use a comparison function to correctly find changes.

import state, { useSelector } from 'state'
import { deepCompareArrays } from 'utils'

function SomeComponent() {
  const selectedIds = useSelector(
    (s) => tld.getSelectedShapes(s.data).map((shape) => shape.id),
    deepCompareArrays
  )

  return <div>The selected ids are {selectedIds.toString()}</div>
}