[feature] multi-scribbles (#2125)

This PR adds support for multiple scribbles at the same time. It
prevents the sudden disappearance of existing scribbles when new ones
are added. It simplifies the management of scribbles by moving the
scribble manager to the editor.

![Kapture 2023-10-29 at 10 17
48](https://github.com/tldraw/tldraw/assets/23072548/23089047-6247-4714-bb79-c4972370140f)

### Change Type

- [x] `minor` — New feature

### Test Plan

1. Use the eraser, scribble select, and laser pointer tools

- [x] Unit Tests

### Release Notes

- [feature] multi scribbles
This commit is contained in:
Steve Ruiz 2023-10-29 14:37:36 +00:00 committed by GitHub
parent 1c2611aab6
commit 19645b771d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 372 additions and 311 deletions

View file

@ -823,6 +823,7 @@ export class Editor extends EventEmitter<TLEventMap> {
y: number; y: number;
z: number; z: number;
}; };
readonly scribbles: ScribbleManager;
select(...shapes: TLShape[] | TLShapeId[]): this; select(...shapes: TLShape[] | TLShapeId[]): this;
selectAll(): this; selectAll(): this;
get selectedShapeIds(): TLShapeId[]; get selectedShapeIds(): TLShapeId[];

View file

@ -15254,6 +15254,37 @@
"isAbstract": false, "isAbstract": false,
"name": "screenToPage" "name": "screenToPage"
}, },
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!Editor#scribbles:member",
"docComment": "/**\n * A manager for the editor's scribbles.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly scribbles: "
},
{
"kind": "Reference",
"text": "ScribbleManager",
"canonicalReference": "@tldraw/editor!~ScribbleManager:class"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": true,
"isOptional": false,
"releaseTag": "Public",
"name": "scribbles",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#select:member(1)", "canonicalReference": "@tldraw/editor!Editor#select:member(1)",

View file

@ -145,13 +145,24 @@ function GridWrapper() {
function ScribbleWrapper() { function ScribbleWrapper() {
const editor = useEditor() const editor = useEditor()
const scribble = useValue('scribble', () => editor.instanceState.scribble, [editor]) const scribbles = useValue('scribbles', () => editor.instanceState.scribbles, [editor])
const zoomLevel = useValue('zoomLevel', () => editor.zoomLevel, [editor]) const zoomLevel = useValue('zoomLevel', () => editor.zoomLevel, [editor])
const { Scribble } = useEditorComponents() const { Scribble } = useEditorComponents()
if (!(Scribble && scribble)) return null if (!(Scribble && scribbles.length)) return null
return <Scribble className="tl-user-scribble" scribble={scribble} zoom={zoomLevel} /> return (
<>
{scribbles.map((scribble) => (
<Scribble
key={scribble.id}
className="tl-user-scribble"
scribble={scribble}
zoom={zoomLevel}
/>
))}
</>
)
} }
function BrushWrapper() { function BrushWrapper() {

View file

@ -81,7 +81,7 @@ const Collaborator = track(function Collaborator({
} = useEditorComponents() } = useEditorComponents()
const { viewportPageBounds, zoomLevel } = editor const { viewportPageBounds, zoomLevel } = editor
const { userId, chatMessage, brush, scribble, selectedShapeIds, userName, cursor, color } = const { userId, chatMessage, brush, scribbles, selectedShapeIds, userName, cursor, color } =
latestPresence latestPresence
// Add a little padding to the top-left of the viewport // Add a little padding to the top-left of the viewport
@ -124,15 +124,19 @@ const Collaborator = track(function Collaborator({
viewport={viewportPageBounds} viewport={viewportPageBounds}
/> />
) : null} ) : null}
{scribble && CollaboratorScribble ? ( {CollaboratorScribble && scribbles.length ? (
<CollaboratorScribble <>
className="tl-collaborator__scribble" {scribbles.map((scribble) => (
key={userId + '_scribble'} <CollaboratorScribble
scribble={scribble} key={userId + '_scribble_' + scribble.id}
color={color} className="tl-collaborator__scribble"
zoom={zoomLevel} scribble={scribble}
opacity={scribble.color === 'laser' ? 0.5 : 0.1} color={color}
/> zoom={zoomLevel}
opacity={scribble.color === 'laser' ? 0.5 : 0.1}
/>
))}
</>
) : null} ) : null}
{CollaboratorShapeIndicator && {CollaboratorShapeIndicator &&
selectedShapeIds.map((shapeId) => ( selectedShapeIds.map((shapeId) => (

View file

@ -105,6 +105,7 @@ import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage
import { ClickManager } from './managers/ClickManager' import { ClickManager } from './managers/ClickManager'
import { EnvironmentManager } from './managers/EnvironmentManager' import { EnvironmentManager } from './managers/EnvironmentManager'
import { HistoryManager } from './managers/HistoryManager' import { HistoryManager } from './managers/HistoryManager'
import { ScribbleManager } from './managers/ScribbleManager'
import { SideEffectManager } from './managers/SideEffectManager' import { SideEffectManager } from './managers/SideEffectManager'
import { SnapManager } from './managers/SnapManager' import { SnapManager } from './managers/SnapManager'
import { TextManager } from './managers/TextManager' import { TextManager } from './managers/TextManager'
@ -263,6 +264,7 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
this.environment = new EnvironmentManager(this) this.environment = new EnvironmentManager(this)
this.scribbles = new ScribbleManager(this)
// Cleanup // Cleanup
@ -677,6 +679,13 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
readonly environment: EnvironmentManager readonly environment: EnvironmentManager
/**
* A manager for the editor's scribbles.
*
* @public
*/
readonly scribbles: ScribbleManager
/** /**
* The current HTML element containing the editor. * The current HTML element containing the editor.
* *

View file

@ -0,0 +1,200 @@
import { TLScribble, Vec2dModel } from '@tldraw/tlschema'
import { Vec2d } from '../../primitives/Vec2d'
import { uniqueId } from '../../utils/uniqueId'
import { Editor } from '../Editor'
import { TLTickEvent } from '../types/event-types'
type ScribbleItem = {
id: string
scribble: TLScribble
timeoutMs: number
delayRemaining: number
prev: null | Vec2dModel
next: null | Vec2dModel
}
/** @public */
export class ScribbleManager {
scribbleItems = new Map<string, ScribbleItem>()
state = 'paused' as 'paused' | 'running'
constructor(private editor: Editor) {}
addScribble = (scribble: Partial<TLScribble>, id = uniqueId()) => {
const item: ScribbleItem = {
id,
scribble: {
id,
size: 20,
color: 'accent',
opacity: 0.8,
delay: 0,
points: [],
shrink: 0.1,
taper: true,
...scribble,
state: 'starting',
},
timeoutMs: 0,
delayRemaining: scribble.delay ?? 0,
prev: null,
next: null,
}
this.scribbleItems.set(id, item)
if (this.state === 'paused') {
this.resume()
}
return item
}
resume() {
this.state = 'running'
this.editor.addListener('tick', this.tick)
}
pause() {
this.editor.removeListener('tick', this.tick)
this.state = 'paused'
}
reset() {
this.editor.updateInstanceState({ scribbles: [] })
this.scribbleItems.clear()
this.pause()
}
/**
* Start stopping the scribble. The scribble won't be removed until its last point is cleared.
*
* @public
*/
stop = (id: ScribbleItem['id']) => {
const item = this.scribbleItems.get(id)
if (!item) throw Error(`Scribble with id ${id} not found`)
item.delayRemaining = Math.min(item.delayRemaining, 200)
item.scribble.state = 'stopping'
return item
}
/**
* Set the scribble's next point.
*
* @param point - The point to add.
* @public
*/
addPoint = (id: ScribbleItem['id'], x: number, y: number) => {
const item = this.scribbleItems.get(id)
if (!item) throw Error(`Scribble with id ${id} not found`)
const { prev } = item
const point = { x, y, z: 0.5 }
if (!prev || Vec2d.Dist(prev, point) >= 1) {
item.next = point
}
return item
}
/**
* Update on each animation frame.
*
* @param elapsed - The number of milliseconds since the last tick.
* @public
*/
tick: TLTickEvent = (elapsed) => {
this.editor.batch(() => {
this.scribbleItems.forEach((item) => {
// let the item get at least eight points before
// switching from starting to active
if (item.scribble.state === 'starting') {
const { next, prev } = item
if (next && next !== prev) {
item.prev = next
item.scribble.points.push(next)
}
if (item.scribble.points.length > 8) {
item.scribble.state = 'active'
}
return
}
if (item.delayRemaining > 0) {
item.delayRemaining = Math.max(0, item.delayRemaining - elapsed)
}
item.timeoutMs += elapsed
if (item.timeoutMs >= 16) {
item.timeoutMs = 0
}
const { delayRemaining, timeoutMs, prev, next, scribble } = item
switch (scribble.state) {
case 'active': {
if (next && next !== prev) {
item.prev = next
scribble.points.push(next)
// If we've run out of delay, then shrink the scribble from the start
if (delayRemaining === 0) {
if (scribble.points.length > 8) {
scribble.points.shift()
}
}
} else {
// While not moving, shrink the scribble from the start
if (timeoutMs === 0) {
if (scribble.points.length > 1) {
scribble.points.shift()
} else {
// Reset the item's delay
item.delayRemaining = scribble.delay
}
}
}
break
}
case 'stopping': {
if (item.delayRemaining === 0) {
if (timeoutMs === 0) {
// If the scribble is down to one point, we're done!
if (scribble.points.length === 1) {
this.scribbleItems.delete(item.id) // Remove the scribble
return
}
if (scribble.shrink) {
// Drop the scribble's size as it shrinks
scribble.size = Math.max(1, scribble.size * (1 - scribble.shrink))
}
// Drop the scribble's first point (its tail)
scribble.points.shift()
}
}
break
}
case 'paused': {
// Nothing to do while paused.
break
}
}
})
// The object here will get frozen into the record, so we need to
// create a copies of the parts that what we'll be mutating later.
this.editor.updateInstanceState({
scribbles: Array.from(this.scribbleItems.values())
.map(({ scribble }) => ({
...scribble,
points: [...scribble.points],
}))
.slice(-5), // limit to three as a minor sanity check
})
// If we've removed all the scribbles, stop ticking
if (this.scribbleItems.size === 0) {
this.pause()
}
})
}
}

View file

@ -85,7 +85,6 @@ import { TLPointerEventName } from '@tldraw/editor';
import { TLRecord } from '@tldraw/editor'; import { TLRecord } from '@tldraw/editor';
import { TLRotationSnapshot } from '@tldraw/editor'; import { TLRotationSnapshot } from '@tldraw/editor';
import { TLSchema } from '@tldraw/editor'; import { TLSchema } from '@tldraw/editor';
import { TLScribble } from '@tldraw/editor';
import { TLSelectionHandle } from '@tldraw/editor'; import { TLSelectionHandle } from '@tldraw/editor';
import { TLShape } from '@tldraw/editor'; import { TLShape } from '@tldraw/editor';
import { TLShapeId } from '@tldraw/editor'; import { TLShapeId } from '@tldraw/editor';
@ -95,7 +94,6 @@ import { TLShapeUtilFlag } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor';
import { TLStoreWithStatus } from '@tldraw/editor'; import { TLStoreWithStatus } from '@tldraw/editor';
import { TLTextShape } from '@tldraw/editor'; import { TLTextShape } from '@tldraw/editor';
import { TLTickEvent } from '@tldraw/editor';
import { TLUnknownShape } from '@tldraw/editor'; import { TLUnknownShape } from '@tldraw/editor';
import { TLVideoShape } from '@tldraw/editor'; import { TLVideoShape } from '@tldraw/editor';
import { UnionValidator } from '@tldraw/editor'; import { UnionValidator } from '@tldraw/editor';

View file

@ -13,7 +13,7 @@ export const TldrawScribble: TLScribbleComponent = ({
const stroke = getStroke(scribble.points, { const stroke = getStroke(scribble.points, {
size: scribble.size / zoom, size: scribble.size / zoom,
start: { taper: true, easing: EASINGS.linear }, start: { taper: scribble.taper, easing: EASINGS.linear },
last: scribble.state === 'stopping', last: scribble.state === 'stopping',
simulatePressure: false, simulatePressure: false,
streamline: 0.32, streamline: 0.32,

View file

@ -1,169 +0,0 @@
import { TLScribble, TLTickEvent, Vec2d, Vec2dModel, VecLike } from '@tldraw/editor'
/** @public */
export class ScribbleManager implements TLScribble {
// Scribble properties
state
points
size
color
opacity
delay
timeoutMs = 0
delayRemaining = 0
// Callbacks
private onUpdate: (scribble: TLScribble) => void
private onComplete: () => void
// Internal state
private prev: VecLike | null = null
private next: VecLike | null = null
constructor(opts: {
onUpdate: (scribble: TLScribble) => void
onComplete: () => void
size?: TLScribble['size']
color?: TLScribble['color']
opacity?: TLScribble['opacity']
delay?: TLScribble['delay']
}) {
const { size = 20, color = 'accent', opacity = 0.8, delay = 0, onComplete, onUpdate } = opts
this.onUpdate = onUpdate
this.onComplete = onComplete
this.size = size
this.color = color
this.delay = delay
this.opacity = opacity
this.points = [] as Vec2dModel[]
this.state = 'starting' as TLScribble['state']
this.prev = null
this.next = null
this.delayRemaining = this.delay
this.resume()
}
resume = () => {
this.state = 'active'
}
pause = () => {
this.state = 'starting'
}
/**
* Start stopping the scribble. The scribble won't be removed until its last point is cleared.
*
* @public
*/
stop = () => {
this.delayRemaining = Math.min(this.delayRemaining, 200)
this.state = 'stopping'
}
/**
* Set the scribble's next point.
*
* @param point - The point to add.
* @public
*/
addPoint = (x: number, y: number) => {
const { prev } = this
const point = { x, y, z: 0.5 }
if (prev && Vec2d.Dist(prev, point) < 1) return
this.next = point
}
/**
* Get the current TLScribble object from the scribble manager.
*
* @public
*/
getScribble(): TLScribble {
return {
state: this.state,
size: this.size,
color: this.color,
opacity: this.opacity,
delay: this.delay,
points: [...this.points],
}
}
private updateScribble() {
this.onUpdate(this.getScribble())
}
tick: TLTickEvent = (elapsed) => {
this.timeoutMs += elapsed
if (this.delayRemaining > 0) {
this.delayRemaining = Math.max(0, this.delayRemaining - elapsed)
}
if (this.timeoutMs >= 16) {
this.timeoutMs = 0
}
const { timeoutMs, state, prev, next, points } = this
switch (state) {
case 'active': {
if (next && next !== prev) {
this.prev = next
points.push(next)
if (this.delayRemaining === 0) {
if (points.length > 8) {
points.shift()
}
}
this.updateScribble()
} else {
// While not moving, shrink the scribble from the start
if (timeoutMs === 0) {
if (points.length > 1) {
points.shift()
this.updateScribble()
} else {
this.delayRemaining = this.delay
}
}
}
break
}
case 'stopping': {
if (this.delayRemaining === 0) {
if (timeoutMs === 0) {
// If the scribble is down to one point, we're done!
if (points.length === 1) {
this.state = 'paused'
this.onComplete()
return
}
// Drop the scribble's size
this.size *= 0.9
// Drop the scribble's first point (its tail)
points.shift()
// otherwise, update the scribble
this.updateScribble()
}
}
break
}
case 'paused': {
// Nothing to do while paused.
break
}
}
}
}

View file

@ -5,17 +5,15 @@ import {
TLFrameShape, TLFrameShape,
TLGroupShape, TLGroupShape,
TLPointerEventInfo, TLPointerEventInfo,
TLScribble,
TLShapeId, TLShapeId,
pointInPolygon, pointInPolygon,
} from '@tldraw/editor' } from '@tldraw/editor'
import { ScribbleManager } from '../../../shapes/shared/ScribbleManager'
export class Erasing extends StateNode { export class Erasing extends StateNode {
static override id = 'erasing' static override id = 'erasing'
private info = {} as TLPointerEventInfo private info = {} as TLPointerEventInfo
private scribble = {} as ScribbleManager private scribbleId = 'id'
private markId = '' private markId = ''
private excludedShapeIds = new Set<TLShapeId>() private excludedShapeIds = new Set<TLShapeId>()
@ -45,41 +43,22 @@ export class Erasing extends StateNode {
.map((shape) => shape.id) .map((shape) => shape.id)
) )
this.startScribble() const scribble = this.editor.scribbles.addScribble({
this.update()
}
private startScribble = () => {
if (this.scribble.tick) {
this.editor.off('tick', this.scribble?.tick)
}
this.scribble = new ScribbleManager({
onUpdate: this.onScribbleUpdate,
onComplete: this.onScribbleComplete,
color: 'muted-1', color: 'muted-1',
size: 12, size: 12,
}) })
this.scribbleId = scribble.id
this.editor.on('tick', this.scribble.tick) this.update()
} }
private pushPointToScribble = () => { private pushPointToScribble = () => {
const { x, y } = this.editor.inputs.currentPagePoint const { x, y } = this.editor.inputs.currentPagePoint
this.scribble.addPoint(x, y) this.editor.scribbles.addPoint(this.scribbleId, x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.editor.updateInstanceState({ scribble })
}
private onScribbleComplete = () => {
this.editor.off('tick', this.scribble.tick)
this.editor.updateInstanceState({ scribble: null })
} }
override onExit = () => { override onExit = () => {
this.scribble.stop() this.editor.scribbles.stop(this.scribbleId)
} }
override onPointerMove = () => { override onPointerMove = () => {

View file

@ -1,18 +1,25 @@
import { StateNode, TLEventHandlers, TLScribble } from '@tldraw/editor' import { StateNode, TLEventHandlers } from '@tldraw/editor'
import { ScribbleManager } from '../../../shapes/shared/ScribbleManager'
export class Lasering extends StateNode { export class Lasering extends StateNode {
static override id = 'lasering' static override id = 'lasering'
scribble = {} as ScribbleManager scribbleId = 'id'
override onEnter = () => { override onEnter = () => {
this.startScribble() const scribble = this.editor.scribbles.addScribble({
color: 'laser',
opacity: 0.7,
size: 4,
delay: 1200,
shrink: 0.05,
taper: true,
})
this.scribbleId = scribble.id
this.pushPointToScribble() this.pushPointToScribble()
} }
override onExit = () => { override onExit = () => {
this.scribble.stop() this.editor.scribbles.stop(this.scribbleId)
} }
override onPointerMove = () => { override onPointerMove = () => {
@ -23,35 +30,9 @@ export class Lasering extends StateNode {
this.complete() this.complete()
} }
private startScribble = () => {
if (this.scribble.tick) {
this.editor.off('tick', this.scribble?.tick)
}
this.scribble = new ScribbleManager({
onUpdate: this.onScribbleUpdate,
onComplete: this.onScribbleComplete,
color: 'laser',
opacity: 0.7,
size: 4,
delay: 1200,
})
this.editor.on('tick', this.scribble.tick)
}
private pushPointToScribble = () => { private pushPointToScribble = () => {
const { x, y } = this.editor.inputs.currentPagePoint const { x, y } = this.editor.inputs.currentPagePoint
this.scribble.addPoint(x, y) this.editor.scribbles.addPoint(this.scribbleId, x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.editor.updateInstanceState({ scribble })
}
private onScribbleComplete = () => {
this.editor.off('tick', this.scribble.tick)
this.editor.updateInstanceState({ scribble: null })
} }
override onCancel: TLEventHandlers['onCancel'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {

View file

@ -5,14 +5,12 @@ import {
TLEventHandlers, TLEventHandlers,
TLFrameShape, TLFrameShape,
TLGroupShape, TLGroupShape,
TLScribble,
TLShape, TLShape,
TLShapeId, TLShapeId,
Vec2d, Vec2d,
intersectLineSegmentPolyline, intersectLineSegmentPolyline,
pointInPolygon, pointInPolygon,
} from '@tldraw/editor' } from '@tldraw/editor'
import { ScribbleManager } from '../../../shapes/shared/ScribbleManager'
export class ScribbleBrushing extends StateNode { export class ScribbleBrushing extends StateNode {
static override id = 'scribble_brushing' static override id = 'scribble_brushing'
@ -21,7 +19,7 @@ export class ScribbleBrushing extends StateNode {
size = 0 size = 0
scribble = {} as ScribbleManager scribbleId = 'id'
initialSelectedShapeIds = new Set<TLShapeId>() initialSelectedShapeIds = new Set<TLShapeId>()
newlySelectedShapeIds = new Set<TLShapeId>() newlySelectedShapeIds = new Set<TLShapeId>()
@ -34,7 +32,13 @@ export class ScribbleBrushing extends StateNode {
this.size = 0 this.size = 0
this.hits.clear() this.hits.clear()
this.startScribble() const scribbleItem = this.editor.scribbles.addScribble({
color: 'selection-stroke',
opacity: 0.32,
size: 12,
})
this.scribbleId = scribbleItem.id
this.updateScribbleSelection(true) this.updateScribbleSelection(true)
@ -44,7 +48,7 @@ export class ScribbleBrushing extends StateNode {
} }
override onExit = () => { override onExit = () => {
this.scribble.stop() this.editor.scribbles.stop(this.scribbleId)
} }
override onPointerMove = () => { override onPointerMove = () => {
@ -75,34 +79,9 @@ export class ScribbleBrushing extends StateNode {
this.complete() this.complete()
} }
private startScribble = () => {
if (this.scribble.tick) {
this.editor.off('tick', this.scribble?.tick)
}
this.scribble = new ScribbleManager({
onUpdate: this.onScribbleUpdate,
onComplete: this.onScribbleComplete,
color: 'selection-stroke',
opacity: 0.32,
size: 12,
})
this.editor.on('tick', this.scribble.tick)
}
private pushPointToScribble = () => { private pushPointToScribble = () => {
const { x, y } = this.editor.inputs.currentPagePoint const { x, y } = this.editor.inputs.currentPagePoint
this.scribble.addPoint(x, y) this.editor.scribbles.addPoint(this.scribbleId, x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.editor.updateInstanceState({ scribble })
}
private onScribbleComplete = () => {
this.editor.off('tick', this.scribble.tick)
this.editor.updateInstanceState({ scribble: null })
} }
private updateScribbleSelection(addPoint: boolean) { private updateScribbleSelection(addPoint: boolean) {

View file

@ -298,14 +298,14 @@ describe('When clicking and dragging', () => {
editor.pointerDown(-100, -100) // outside of any shapes editor.pointerDown(-100, -100) // outside of any shapes
editor.expectPathToBe('root.eraser.pointing') editor.expectPathToBe('root.eraser.pointing')
expect(editor.instanceState.scribble).toBe(null) expect(editor.instanceState.scribbles.length).toBe(0)
editor.pointerMove(50, 50) // inside of box1 editor.pointerMove(50, 50) // inside of box1
editor.expectPathToBe('root.eraser.erasing') editor.expectPathToBe('root.eraser.erasing')
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
@ -331,7 +331,7 @@ describe('When clicking and dragging', () => {
editor.pointerDown(-100, -100) // outside of any shapes editor.pointerDown(-100, -100) // outside of any shapes
editor.pointerMove(50, 50) // inside of box1 editor.pointerMove(50, 50) // inside of box1
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
editor.cancel() editor.cancel()
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
@ -346,7 +346,7 @@ describe('When clicking and dragging', () => {
editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group
editor.pointerMove(280, 280) // still outside of the new group editor.pointerMove(280, 280) // still outside of the new group
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
editor.pointerMove(0, 0) editor.pointerMove(0, 0)
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
@ -361,7 +361,7 @@ describe('When clicking and dragging', () => {
editor.pointerDown(325, 25) // directly on frame1, not its children editor.pointerDown(325, 25) // directly on frame1, not its children
editor.pointerMove(350, 375) // still in the frame, passing through box3 editor.pointerMove(350, 375) // still in the frame, passing through box3
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box3]) expect(editor.erasingShapeIds).toEqual([ids.box3])
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.frame1)).toBeDefined() expect(editor.getShape(ids.frame1)).toBeDefined()
@ -375,7 +375,7 @@ describe('When clicking and dragging', () => {
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
editor.pointerMove(425, 500) // Through the masked part of box3 editor.pointerMove(425, 500) // Through the masked part of box3
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.box3)).toBeDefined() expect(editor.getShape(ids.box3)).toBeDefined()
@ -383,7 +383,7 @@ describe('When clicking and dragging', () => {
editor.pointerMove(375, 0) editor.pointerMove(375, 0)
editor.pointerDown() // Above the not-masked part of box3 editor.pointerDown() // Above the not-masked part of box3
editor.pointerMove(375, 500) // Through the masked part of box3 editor.pointerMove(375, 500) // Through the masked part of box3
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box3]) expect(editor.erasingShapeIds).toEqual([ids.box3])
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.box3)).not.toBeDefined() expect(editor.getShape(ids.box3)).not.toBeDefined()
@ -400,16 +400,16 @@ describe('When clicking and dragging', () => {
it('Starts a scribble on pointer down, updates it on pointer move, stops it on exit', () => { it('Starts a scribble on pointer down, updates it on pointer move, stops it on exit', () => {
editor.setCurrentTool('eraser') editor.setCurrentTool('eraser')
editor.pointerDown(-100, -100) editor.pointerDown(-100, -100)
expect(editor.instanceState.scribble).toBe(null) expect(editor.instanceState.scribbles.length).toBe(0)
editor.pointerMove(50, 50) editor.pointerMove(50, 50)
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
editor.pointerMove(50, 50) editor.pointerMove(50, 50)
editor.pointerMove(51, 50) editor.pointerMove(51, 50)
editor.pointerMove(52, 50) editor.pointerMove(52, 50)
editor.pointerMove(53, 50) editor.pointerMove(53, 50)
editor.pointerUp() editor.pointerUp()
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribbles.length).toBe(1)
}) })
}) })

View file

@ -1040,7 +1040,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented) // (undocumented)
screenBounds: Box2dModel; screenBounds: Box2dModel;
// (undocumented) // (undocumented)
scribble: null | TLScribble; scribbles: TLScribble[];
// (undocumented) // (undocumented)
stylesForNextShape: Record<string, unknown>; stylesForNextShape: Record<string, unknown>;
// (undocumented) // (undocumented)
@ -1107,7 +1107,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
// (undocumented) // (undocumented)
screenBounds: Box2dModel; screenBounds: Box2dModel;
// (undocumented) // (undocumented)
scribble: null | TLScribble; scribbles: TLScribble[];
// (undocumented) // (undocumented)
selectedShapeIds: TLShapeId[]; selectedShapeIds: TLShapeId[];
// (undocumented) // (undocumented)
@ -1155,12 +1155,15 @@ export type TLSchema = StoreSchema<TLRecord, TLStoreProps>;
// @public // @public
export type TLScribble = { export type TLScribble = {
id: string;
points: Vec2dModel[]; points: Vec2dModel[];
size: number; size: number;
color: TLCanvasUiColor; color: TLCanvasUiColor;
opacity: number; opacity: number;
state: SetValue<typeof TL_SCRIBBLE_STATES>; state: SetValue<typeof TL_SCRIBBLE_STATES>;
delay: number; delay: number;
shrink: number;
taper: boolean;
}; };
// @public (undocumented) // @public (undocumented)

View file

@ -7109,22 +7109,22 @@
}, },
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLInstance#scribble:member", "canonicalReference": "@tldraw/tlschema!TLInstance#scribbles:member",
"docComment": "", "docComment": "",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
"text": "scribble: " "text": "scribbles: "
},
{
"kind": "Content",
"text": "null | "
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "TLScribble", "text": "TLScribble",
"canonicalReference": "@tldraw/tlschema!TLScribble:type" "canonicalReference": "@tldraw/tlschema!TLScribble:type"
}, },
{
"kind": "Content",
"text": "[]"
},
{ {
"kind": "Content", "kind": "Content",
"text": ";" "text": ";"
@ -7133,7 +7133,7 @@
"isReadonly": false, "isReadonly": false,
"isOptional": false, "isOptional": false,
"releaseTag": "Public", "releaseTag": "Public",
"name": "scribble", "name": "scribbles",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 3 "endIndex": 3
@ -7915,22 +7915,22 @@
}, },
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLInstancePresence#scribble:member", "canonicalReference": "@tldraw/tlschema!TLInstancePresence#scribbles:member",
"docComment": "", "docComment": "",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
"text": "scribble: " "text": "scribbles: "
},
{
"kind": "Content",
"text": "null | "
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "TLScribble", "text": "TLScribble",
"canonicalReference": "@tldraw/tlschema!TLScribble:type" "canonicalReference": "@tldraw/tlschema!TLScribble:type"
}, },
{
"kind": "Content",
"text": "[]"
},
{ {
"kind": "Content", "kind": "Content",
"text": ";" "text": ";"
@ -7939,7 +7939,7 @@
"isReadonly": false, "isReadonly": false,
"isOptional": false, "isOptional": false,
"releaseTag": "Public", "releaseTag": "Public",
"name": "scribble", "name": "scribbles",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 3 "endIndex": 3
@ -8566,7 +8566,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "{\n points: " "text": "{\n id: string;\n points: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -8602,7 +8602,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ">;\n delay: number;\n}" "text": ">;\n delay: number;\n shrink: number;\n taper: boolean;\n}"
}, },
{ {
"kind": "Content", "kind": "Content",

View file

@ -27,7 +27,7 @@ export const createPresenceStateDerivation =
id: instanceId ?? InstancePresenceRecordType.createId(store.id), id: instanceId ?? InstancePresenceRecordType.createId(store.id),
selectedShapeIds: pageState.selectedShapeIds, selectedShapeIds: pageState.selectedShapeIds,
brush: instance.brush, brush: instance.brush,
scribble: instance.scribble, scribbles: instance.scribbles,
userId: user.id, userId: user.id,
userName: user.name, userName: user.name,
followingUserId: instance.followingUserId, followingUserId: instance.followingUserId,

View file

@ -1607,6 +1607,22 @@ describe('add isHoveringCanvas to TLInstance', () => {
}) })
}) })
describe('add scribbles to TLInstance', () => {
const { up, down } = instanceMigrations.migrators[instanceVersions.AddScribbles]
test('up works as expected', () => {
expect(
up({
scribble: null,
})
).toEqual({ scribbles: [] })
})
test('down works as expected', () => {
expect(down({ scribbles: [] })).toEqual({ scribble: null })
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */ /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) { for (const migrator of allMigrators) {

View file

@ -14,20 +14,26 @@ export const TL_SCRIBBLE_STATES = new Set(['starting', 'paused', 'active', 'stop
* *
* @public */ * @public */
export type TLScribble = { export type TLScribble = {
id: string
points: Vec2dModel[] points: Vec2dModel[]
size: number size: number
color: TLCanvasUiColor color: TLCanvasUiColor
opacity: number opacity: number
state: SetValue<typeof TL_SCRIBBLE_STATES> state: SetValue<typeof TL_SCRIBBLE_STATES>
delay: number delay: number
shrink: number
taper: boolean
} }
/** @internal */ /** @internal */
export const scribbleValidator: T.Validator<TLScribble> = T.object({ export const scribbleValidator: T.Validator<TLScribble> = T.object({
id: T.string,
points: T.arrayOf(vec2dModelValidator), points: T.arrayOf(vec2dModelValidator),
size: T.positiveNumber, size: T.positiveNumber,
color: canvasUiColorTypeValidator, color: canvasUiColorTypeValidator,
opacity: T.number, opacity: T.number,
state: T.setEnum(TL_SCRIBBLE_STATES), state: T.setEnum(TL_SCRIBBLE_STATES),
delay: T.number, delay: T.number,
shrink: T.number,
taper: T.boolean,
}) })

View file

@ -25,7 +25,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
highlightedUserIds: string[] highlightedUserIds: string[]
brush: Box2dModel | null brush: Box2dModel | null
cursor: TLCursor cursor: TLCursor
scribble: TLScribble | null scribbles: TLScribble[]
isFocusMode: boolean isFocusMode: boolean
isDebugMode: boolean isDebugMode: boolean
isToolLocked: boolean isToolLocked: boolean
@ -74,7 +74,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
opacityForNextShape: opacityValidator, opacityForNextShape: opacityValidator,
stylesForNextShape: T.object(stylesForNextShapeValidators), stylesForNextShape: T.object(stylesForNextShapeValidators),
cursor: cursorValidator, cursor: cursorValidator,
scribble: scribbleValidator.nullable(), scribbles: T.arrayOf(scribbleValidator),
isFocusMode: T.boolean, isFocusMode: T.boolean,
isDebugMode: T.boolean, isDebugMode: T.boolean,
isToolLocked: T.boolean, isToolLocked: T.boolean,
@ -108,7 +108,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
opacityForNextShape: 1, opacityForNextShape: 1,
stylesForNextShape: {}, stylesForNextShape: {},
brush: null, brush: null,
scribble: null, scribbles: [],
cursor: { cursor: {
type: 'default', type: 'default',
rotation: 0, rotation: 0,
@ -160,11 +160,12 @@ export const instanceVersions = {
AddLonelyProperties: 19, AddLonelyProperties: 19,
ReadOnlyReadonly: 20, ReadOnlyReadonly: 20,
AddHoveringCanvas: 21, AddHoveringCanvas: 21,
AddScribbles: 22,
} as const } as const
/** @public */ /** @public */
export const instanceMigrations = defineMigrations({ export const instanceMigrations = defineMigrations({
currentVersion: instanceVersions.AddHoveringCanvas, currentVersion: instanceVersions.AddScribbles,
migrators: { migrators: {
[instanceVersions.AddTransparentExportBgs]: { [instanceVersions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => { up: (instance: TLInstance) => {
@ -474,6 +475,17 @@ export const instanceMigrations = defineMigrations({
} }
}, },
}, },
[instanceVersions.AddScribbles]: {
up: ({ scribble: _, ...record }) => {
return {
...record,
scribbles: [],
}
},
down: ({ scribbles: _, ...record }) => {
return { ...record, scribble: null }
},
},
}, },
}) })

View file

@ -19,7 +19,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
selectedShapeIds: TLShapeId[] selectedShapeIds: TLShapeId[]
currentPageId: TLPageId currentPageId: TLPageId
brush: Box2dModel | null brush: Box2dModel | null
scribble: TLScribble | null scribbles: TLScribble[]
screenBounds: Box2dModel screenBounds: Box2dModel
followingUserId: string | null followingUserId: string | null
cursor: { cursor: {
@ -61,7 +61,7 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
selectedShapeIds: T.arrayOf(idValidator<TLShapeId>('shape')), selectedShapeIds: T.arrayOf(idValidator<TLShapeId>('shape')),
currentPageId: idValidator<TLPageId>('page'), currentPageId: idValidator<TLPageId>('page'),
brush: box2dModelValidator.nullable(), brush: box2dModelValidator.nullable(),
scribble: scribbleValidator.nullable(), scribbles: T.arrayOf(scribbleValidator),
chatMessage: T.string, chatMessage: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>, meta: T.jsonValue as T.ObjectValidator<JsonObject>,
}) })
@ -173,7 +173,7 @@ export const InstancePresenceRecordType = createRecordType<TLInstancePresence>(
}, },
selectedShapeIds: [], selectedShapeIds: [],
brush: null, brush: null,
scribble: null, scribbles: [],
chatMessage: '', chatMessage: '',
meta: {}, meta: {},
})) }))