[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;
z: number;
};
readonly scribbles: ScribbleManager;
select(...shapes: TLShape[] | TLShapeId[]): this;
selectAll(): this;
get selectedShapeIds(): TLShapeId[];

View file

@ -15254,6 +15254,37 @@
"isAbstract": false,
"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",
"canonicalReference": "@tldraw/editor!Editor#select:member(1)",

View file

@ -145,13 +145,24 @@ function GridWrapper() {
function ScribbleWrapper() {
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 { 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() {

View file

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

View file

@ -105,6 +105,7 @@ import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage
import { ClickManager } from './managers/ClickManager'
import { EnvironmentManager } from './managers/EnvironmentManager'
import { HistoryManager } from './managers/HistoryManager'
import { ScribbleManager } from './managers/ScribbleManager'
import { SideEffectManager } from './managers/SideEffectManager'
import { SnapManager } from './managers/SnapManager'
import { TextManager } from './managers/TextManager'
@ -263,6 +264,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
this.environment = new EnvironmentManager(this)
this.scribbles = new ScribbleManager(this)
// Cleanup
@ -677,6 +679,13 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
readonly environment: EnvironmentManager
/**
* A manager for the editor's scribbles.
*
* @public
*/
readonly scribbles: ScribbleManager
/**
* 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 { TLRotationSnapshot } from '@tldraw/editor';
import { TLSchema } from '@tldraw/editor';
import { TLScribble } from '@tldraw/editor';
import { TLSelectionHandle } from '@tldraw/editor';
import { TLShape } from '@tldraw/editor';
import { TLShapeId } from '@tldraw/editor';
@ -95,7 +94,6 @@ import { TLShapeUtilFlag } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor';
import { TLStoreWithStatus } from '@tldraw/editor';
import { TLTextShape } from '@tldraw/editor';
import { TLTickEvent } from '@tldraw/editor';
import { TLUnknownShape } from '@tldraw/editor';
import { TLVideoShape } from '@tldraw/editor';
import { UnionValidator } from '@tldraw/editor';

View file

@ -13,7 +13,7 @@ export const TldrawScribble: TLScribbleComponent = ({
const stroke = getStroke(scribble.points, {
size: scribble.size / zoom,
start: { taper: true, easing: EASINGS.linear },
start: { taper: scribble.taper, easing: EASINGS.linear },
last: scribble.state === 'stopping',
simulatePressure: false,
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,
TLGroupShape,
TLPointerEventInfo,
TLScribble,
TLShapeId,
pointInPolygon,
} from '@tldraw/editor'
import { ScribbleManager } from '../../../shapes/shared/ScribbleManager'
export class Erasing extends StateNode {
static override id = 'erasing'
private info = {} as TLPointerEventInfo
private scribble = {} as ScribbleManager
private scribbleId = 'id'
private markId = ''
private excludedShapeIds = new Set<TLShapeId>()
@ -45,41 +43,22 @@ export class Erasing extends StateNode {
.map((shape) => shape.id)
)
this.startScribble()
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,
const scribble = this.editor.scribbles.addScribble({
color: 'muted-1',
size: 12,
})
this.scribbleId = scribble.id
this.editor.on('tick', this.scribble.tick)
this.update()
}
private pushPointToScribble = () => {
const { x, y } = this.editor.inputs.currentPagePoint
this.scribble.addPoint(x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.editor.updateInstanceState({ scribble })
}
private onScribbleComplete = () => {
this.editor.off('tick', this.scribble.tick)
this.editor.updateInstanceState({ scribble: null })
this.editor.scribbles.addPoint(this.scribbleId, x, y)
}
override onExit = () => {
this.scribble.stop()
this.editor.scribbles.stop(this.scribbleId)
}
override onPointerMove = () => {

View file

@ -1,18 +1,25 @@
import { StateNode, TLEventHandlers, TLScribble } from '@tldraw/editor'
import { ScribbleManager } from '../../../shapes/shared/ScribbleManager'
import { StateNode, TLEventHandlers } from '@tldraw/editor'
export class Lasering extends StateNode {
static override id = 'lasering'
scribble = {} as ScribbleManager
scribbleId = 'id'
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()
}
override onExit = () => {
this.scribble.stop()
this.editor.scribbles.stop(this.scribbleId)
}
override onPointerMove = () => {
@ -23,35 +30,9 @@ export class Lasering extends StateNode {
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 = () => {
const { x, y } = this.editor.inputs.currentPagePoint
this.scribble.addPoint(x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.editor.updateInstanceState({ scribble })
}
private onScribbleComplete = () => {
this.editor.off('tick', this.scribble.tick)
this.editor.updateInstanceState({ scribble: null })
this.editor.scribbles.addPoint(this.scribbleId, x, y)
}
override onCancel: TLEventHandlers['onCancel'] = () => {

View file

@ -5,14 +5,12 @@ import {
TLEventHandlers,
TLFrameShape,
TLGroupShape,
TLScribble,
TLShape,
TLShapeId,
Vec2d,
intersectLineSegmentPolyline,
pointInPolygon,
} from '@tldraw/editor'
import { ScribbleManager } from '../../../shapes/shared/ScribbleManager'
export class ScribbleBrushing extends StateNode {
static override id = 'scribble_brushing'
@ -21,7 +19,7 @@ export class ScribbleBrushing extends StateNode {
size = 0
scribble = {} as ScribbleManager
scribbleId = 'id'
initialSelectedShapeIds = new Set<TLShapeId>()
newlySelectedShapeIds = new Set<TLShapeId>()
@ -34,7 +32,13 @@ export class ScribbleBrushing extends StateNode {
this.size = 0
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)
@ -44,7 +48,7 @@ export class ScribbleBrushing extends StateNode {
}
override onExit = () => {
this.scribble.stop()
this.editor.scribbles.stop(this.scribbleId)
}
override onPointerMove = () => {
@ -75,34 +79,9 @@ export class ScribbleBrushing extends StateNode {
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 = () => {
const { x, y } = this.editor.inputs.currentPagePoint
this.scribble.addPoint(x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.editor.updateInstanceState({ scribble })
}
private onScribbleComplete = () => {
this.editor.off('tick', this.scribble.tick)
this.editor.updateInstanceState({ scribble: null })
this.editor.scribbles.addPoint(this.scribbleId, x, y)
}
private updateScribbleSelection(addPoint: boolean) {

View file

@ -298,14 +298,14 @@ describe('When clicking and dragging', () => {
editor.pointerDown(-100, -100) // outside of any shapes
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.expectPathToBe('root.eraser.erasing')
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box1])
@ -331,7 +331,7 @@ describe('When clicking and dragging', () => {
editor.pointerDown(-100, -100) // outside of any shapes
editor.pointerMove(50, 50) // inside of box1
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box1])
editor.cancel()
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.pointerMove(280, 280) // still outside of the new group
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([])
editor.pointerMove(0, 0)
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.pointerMove(350, 375) // still in the frame, passing through box3
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([ids.box3])
editor.pointerUp()
expect(editor.getShape(ids.frame1)).toBeDefined()
@ -375,7 +375,7 @@ describe('When clicking and dragging', () => {
expect(editor.erasingShapeIds).toEqual([])
editor.pointerMove(425, 500) // Through the masked part of box3
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.instanceState.scribbles.length).toBe(1)
expect(editor.erasingShapeIds).toEqual([])
editor.pointerUp()
expect(editor.getShape(ids.box3)).toBeDefined()
@ -383,7 +383,7 @@ describe('When clicking and dragging', () => {
editor.pointerMove(375, 0)
editor.pointerDown() // Above the not-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])
editor.pointerUp()
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', () => {
editor.setCurrentTool('eraser')
editor.pointerDown(-100, -100)
expect(editor.instanceState.scribble).toBe(null)
expect(editor.instanceState.scribbles.length).toBe(0)
editor.pointerMove(50, 50)
jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.instanceState.scribbles.length).toBe(1)
editor.pointerMove(50, 50)
editor.pointerMove(51, 50)
editor.pointerMove(52, 50)
editor.pointerMove(53, 50)
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)
screenBounds: Box2dModel;
// (undocumented)
scribble: null | TLScribble;
scribbles: TLScribble[];
// (undocumented)
stylesForNextShape: Record<string, unknown>;
// (undocumented)
@ -1107,7 +1107,7 @@ export interface TLInstancePresence extends BaseRecord<'instance_presence', TLIn
// (undocumented)
screenBounds: Box2dModel;
// (undocumented)
scribble: null | TLScribble;
scribbles: TLScribble[];
// (undocumented)
selectedShapeIds: TLShapeId[];
// (undocumented)
@ -1155,12 +1155,15 @@ export type TLSchema = StoreSchema<TLRecord, TLStoreProps>;
// @public
export type TLScribble = {
id: string;
points: Vec2dModel[];
size: number;
color: TLCanvasUiColor;
opacity: number;
state: SetValue<typeof TL_SCRIBBLE_STATES>;
delay: number;
shrink: number;
taper: boolean;
};
// @public (undocumented)

View file

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

View file

@ -27,7 +27,7 @@ export const createPresenceStateDerivation =
id: instanceId ?? InstancePresenceRecordType.createId(store.id),
selectedShapeIds: pageState.selectedShapeIds,
brush: instance.brush,
scribble: instance.scribble,
scribbles: instance.scribbles,
userId: user.id,
userName: user.name,
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 --- */
for (const migrator of allMigrators) {

View file

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

View file

@ -25,7 +25,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
highlightedUserIds: string[]
brush: Box2dModel | null
cursor: TLCursor
scribble: TLScribble | null
scribbles: TLScribble[]
isFocusMode: boolean
isDebugMode: boolean
isToolLocked: boolean
@ -74,7 +74,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
opacityForNextShape: opacityValidator,
stylesForNextShape: T.object(stylesForNextShapeValidators),
cursor: cursorValidator,
scribble: scribbleValidator.nullable(),
scribbles: T.arrayOf(scribbleValidator),
isFocusMode: T.boolean,
isDebugMode: T.boolean,
isToolLocked: T.boolean,
@ -108,7 +108,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
opacityForNextShape: 1,
stylesForNextShape: {},
brush: null,
scribble: null,
scribbles: [],
cursor: {
type: 'default',
rotation: 0,
@ -160,11 +160,12 @@ export const instanceVersions = {
AddLonelyProperties: 19,
ReadOnlyReadonly: 20,
AddHoveringCanvas: 21,
AddScribbles: 22,
} as const
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: instanceVersions.AddHoveringCanvas,
currentVersion: instanceVersions.AddScribbles,
migrators: {
[instanceVersions.AddTransparentExportBgs]: {
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[]
currentPageId: TLPageId
brush: Box2dModel | null
scribble: TLScribble | null
scribbles: TLScribble[]
screenBounds: Box2dModel
followingUserId: string | null
cursor: {
@ -61,7 +61,7 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
selectedShapeIds: T.arrayOf(idValidator<TLShapeId>('shape')),
currentPageId: idValidator<TLPageId>('page'),
brush: box2dModelValidator.nullable(),
scribble: scribbleValidator.nullable(),
scribbles: T.arrayOf(scribbleValidator),
chatMessage: T.string,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
@ -173,7 +173,7 @@ export const InstancePresenceRecordType = createRecordType<TLInstancePresence>(
},
selectedShapeIds: [],
brush: null,
scribble: null,
scribbles: [],
chatMessage: '',
meta: {},
}))