[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:
parent
1c2611aab6
commit
19645b771d
20 changed files with 372 additions and 311 deletions
|
@ -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[];
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 && scribbles.length ? (
|
||||
<>
|
||||
{scribbles.map((scribble) => (
|
||||
<CollaboratorScribble
|
||||
key={userId + '_scribble_' + scribble.id}
|
||||
className="tl-collaborator__scribble"
|
||||
key={userId + '_scribble'}
|
||||
scribble={scribble}
|
||||
color={color}
|
||||
zoom={zoomLevel}
|
||||
opacity={scribble.color === 'laser' ? 0.5 : 0.1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{CollaboratorShapeIndicator &&
|
||||
selectedShapeIds.map((shapeId) => (
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
200
packages/editor/src/lib/editor/managers/ScribbleManager.ts
Normal file
200
packages/editor/src/lib/editor/managers/ScribbleManager.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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'] = () => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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 }
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -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: {},
|
||||
}))
|
||||
|
|
Loading…
Reference in a new issue