[feature] add laser pointer (#1412)

This PR adds a laser pointer. It's also available in readonly rooms.

![Kapture 2023-05-18 at 17 00
18](https://github.com/tldraw/tldraw/assets/23072548/4f638dff-8c17-4f9d-8177-4a63a524b7fd)

### Change Type

- [x] `minor` — New Feature

### Test Plan

1. Select the laser pointer tool
2. Draw some lasers.

### Release Notes

- Adds the laser pointer tool.
This commit is contained in:
Steve Ruiz 2023-05-19 12:09:13 +01:00 committed by GitHub
parent 9c28d8a6bd
commit 1eb1f89cd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 357 additions and 35 deletions

View file

@ -32,6 +32,7 @@
--color-text: #fff;
--color-background: #1d1d1d;
--color-accent: #f3c14b;
--color-tint-6: rgb(186, 186, 186);
--shadow-small: 0px 0px 16px -2px rgba(0, 0, 0, 0.52), 0px 0px 4px 0px rgba(0, 0, 0, 0.62);

View file

@ -0,0 +1,6 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.52185 26.4772L7.55602 22.443" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.52166 20.6267L5.88012 19.7974" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.2014 24.1187L9.37213 26.4772" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M27 7.36364L13.1112 21.2524C12.916 21.4477 12.5994 21.4477 12.4041 21.2524L8.7476 17.5959C8.55233 17.4006 8.55233 17.084 8.7476 16.8888L22.6364 3L27 7.36364Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

View file

@ -190,6 +190,7 @@
"tool.asset": "Asset",
"tool.frame": "Frame",
"tool.note": "Note",
"tool.laser": "Laser",
"tool.embed": "Embed",
"tool.text": "Text",
"menu.title": "Menu",

View file

@ -151,6 +151,7 @@ export function getAssetUrlsByImport(opts?: AssetUrlOptions): {
'tool-frame': string
'tool-hand': string
'tool-highlighter': string
'tool-laser': string
'tool-line': string
'tool-media': string
'tool-note': string

View file

@ -162,6 +162,7 @@ import iconsToolEraser from './icons/icon/tool-eraser.svg'
import iconsToolFrame from './icons/icon/tool-frame.svg'
import iconsToolHand from './icons/icon/tool-hand.svg'
import iconsToolHighlighter from './icons/icon/tool-highlighter.svg'
import iconsToolLaser from './icons/icon/tool-laser.svg'
import iconsToolLine from './icons/icon/tool-line.svg'
import iconsToolMedia from './icons/icon/tool-media.svg'
import iconsToolNote from './icons/icon/tool-note.svg'
@ -392,6 +393,7 @@ export function getAssetUrlsByImport(opts) {
'tool-frame': formatAssetUrl(iconsToolFrame, opts),
'tool-hand': formatAssetUrl(iconsToolHand, opts),
'tool-highlighter': formatAssetUrl(iconsToolHighlighter, opts),
'tool-laser': formatAssetUrl(iconsToolLaser, opts),
'tool-line': formatAssetUrl(iconsToolLine, opts),
'tool-media': formatAssetUrl(iconsToolMedia, opts),
'tool-note': formatAssetUrl(iconsToolNote, opts),

View file

@ -151,6 +151,7 @@ export function getAssetUrlsByMetaUrl(opts?: AssetUrlOptions): {
'tool-frame': string
'tool-hand': string
'tool-highlighter': string
'tool-laser': string
'tool-line': string
'tool-media': string
'tool-note': string

View file

@ -498,6 +498,10 @@ export function getAssetUrlsByMetaUrl(opts) {
new URL('./icons/icon/tool-highlighter.svg', import.meta.url).href,
opts
),
'tool-laser': formatAssetUrl(
new URL('./icons/icon/tool-laser.svg', import.meta.url).href,
opts
),
'tool-line': formatAssetUrl(
new URL('./icons/icon/tool-line.svg', import.meta.url).href,
opts

View file

@ -73,6 +73,7 @@
--color-primary: #2f80ed;
--color-warn: #d10b0b;
--color-text: #000000;
--color-laser: #ff0000;
--palette-black: #1d1d1d;
--palette-blue: #4263eb;
--palette-green: #099268;
@ -154,6 +155,7 @@
--color-primary: #2f80ed;
--color-warn: #d10b0b;
--color-text: #f8f9fa;
--color-laser: #ff0000;
--palette-black: #e1e1e1;
--palette-blue: #4156be;
--palette-green: #3b7b5e;

View file

@ -9,6 +9,10 @@ export class ScribbleManager implements TLScribble {
size
color
opacity
delay
timeoutMs = 0
delayRemaining = 0
// Callbacks
private onUpdate: (scribble: TLScribble) => void
@ -24,13 +28,15 @@ export class ScribbleManager implements TLScribble {
size?: TLScribble['size']
color?: TLScribble['color']
opacity?: TLScribble['opacity']
delay?: TLScribble['delay']
}) {
const { size = 20, color = 'accent', opacity = 0.8, onComplete, onUpdate } = opts
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']
@ -38,6 +44,8 @@ export class ScribbleManager implements TLScribble {
this.prev = null
this.next = null
this.delayRemaining = this.delay
this.resume()
}
@ -55,6 +63,7 @@ export class ScribbleManager implements TLScribble {
* @public
*/
stop = () => {
this.delayRemaining = Math.min(this.delayRemaining, 200)
this.state = 'stopping'
}
@ -82,6 +91,7 @@ export class ScribbleManager implements TLScribble {
size: this.size,
color: this.color,
opacity: this.opacity,
delay: this.delay,
points: [...this.points],
}
}
@ -90,10 +100,13 @@ export class ScribbleManager implements TLScribble {
this.onUpdate(this.getScribble())
}
timeoutMs = 0
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
}
@ -106,37 +119,45 @@ export class ScribbleManager implements TLScribble {
this.prev = next
points.push(next)
if (points.length > 8) {
points.shift()
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 && points.length > 1) {
points.shift()
this.updateScribble()
if (timeoutMs === 0) {
if (points.length > 1) {
points.shift()
this.updateScribble()
} else {
this.delayRemaining = this.delay
}
}
}
break
}
case 'stopping': {
if (timeoutMs === 0) {
// If the scribble is down to one point, we're done!
if (points.length === 1) {
this.state = 'paused'
this.onComplete()
return
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()
}
// 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
}

View file

@ -6,6 +6,7 @@ import { TLEraserTool } from './TLEraserTool/TLEraserTool'
import { TLFrameTool } from './TLFrameTool/TLFrameTool'
import { TLGeoTool } from './TLGeoTool/TLGeoTool'
import { TLHandTool } from './TLHandTool/TLHandTool'
import { TLLaserTool } from './TLLaserTool/TLLaserTool'
import { TLLineTool } from './TLLineTool/TLLineTool'
import { TLNoteTool } from './TLNoteTool/TLNoteTool'
import { TLSelectTool } from './TLSelectTool/TLSelectTool'
@ -27,6 +28,7 @@ export class RootState extends StateNode {
TLNoteTool,
TLFrameTool,
TLZoomTool,
TLLaserTool,
]
onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {

View file

@ -32,6 +32,10 @@ export class Erasing extends StateNode {
}
private startScribble = () => {
if (this.scribble.tick) {
this.app.off('tick', this.scribble?.tick)
}
this.scribble = new ScribbleManager({
onUpdate: this.onScribbleUpdate,
onComplete: this.onScribbleComplete,

View file

@ -0,0 +1,14 @@
import { StateNode } from '../StateNode'
import { Idle } from './children/Idle'
import { Lasering } from './children/Lasering'
export class TLLaserTool extends StateNode {
static override id = 'laser'
static initial = 'idle'
static children = () => [Idle, Lasering]
onEnter = () => {
this.app.setCursor({ type: 'cross' })
}
}

View file

@ -0,0 +1,10 @@
import { TLEventHandlers } from '../../../types/event-types'
import { StateNode } from '../../StateNode'
export class Idle extends StateNode {
static override id = 'idle'
onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
this.parent.transition('lasering', info)
}
}

View file

@ -0,0 +1,75 @@
import { TLScribble } from '@tldraw/tlschema'
import { ScribbleManager } from '../../../managers/ScribbleManager'
import { TLEventHandlers } from '../../../types/event-types'
import { StateNode } from '../../StateNode'
export class Lasering extends StateNode {
static override id = 'lasering'
scribble = {} as ScribbleManager
override onEnter = () => {
this.startScribble()
this.pushPointToScribble()
}
override onExit = () => {
this.app.setErasingIds([])
this.scribble.stop()
}
override onPointerMove = () => {
this.pushPointToScribble()
}
override onPointerUp = () => {
this.complete()
}
private startScribble = () => {
if (this.scribble.tick) {
this.app.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.app.on('tick', this.scribble.tick)
}
private pushPointToScribble = () => {
const { x, y } = this.app.inputs.currentPagePoint
this.scribble.addPoint(x, y)
}
private onScribbleUpdate = (scribble: TLScribble) => {
this.app.setScribble(scribble)
}
private onScribbleComplete = () => {
this.app.off('tick', this.scribble.tick)
this.app.setScribble(null)
}
override onCancel: TLEventHandlers['onCancel'] = () => {
this.cancel()
}
override onComplete: TLEventHandlers['onComplete'] = () => {
this.complete()
}
private complete() {
this.parent.transition('idle', {})
}
private cancel() {
this.parent.transition('idle', {})
}
}

View file

@ -8,8 +8,6 @@ import { StateNode } from '../../StateNode'
export class ScribbleBrushing extends StateNode {
static override id = 'scribble_brushing'
static canActivateInReadOnly = true
hits = new Set<TLShapeId>()
size = 0
@ -61,6 +59,10 @@ export class ScribbleBrushing extends StateNode {
}
private startScribble = () => {
if (this.scribble.tick) {
this.app.off('tick', this.scribble?.tick)
}
this.scribble = new ScribbleManager({
onUpdate: this.onScribbleUpdate,
onComplete: this.onScribbleComplete,

View file

@ -25,6 +25,7 @@ export const DefaultScribble: TLScribbleComponent = ({
start: { taper: true, easing: EASINGS.linear },
last: scribble.state === 'stopping',
simulatePressure: false,
streamline: 0.32,
})
)

View file

@ -534,7 +534,7 @@ export const TL_SPLINE_TYPES: Set<"cubic" | "line">;
export const TL_STYLE_TYPES: Set<"align" | "arrowheadEnd" | "arrowheadStart" | "color" | "dash" | "fill" | "font" | "geo" | "icon" | "labelColor" | "opacity" | "size" | "spline" | "verticalAlign">;
// @public (undocumented)
export const TL_UI_COLOR_TYPES: Set<"accent" | "black" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
export const TL_UI_COLOR_TYPES: Set<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
// @public (undocumented)
export interface TLAlignStyle extends TLBaseStyle {
@ -1158,6 +1158,7 @@ export type TLScribble = {
color: TLUiColorType;
opacity: number;
state: SetValue<typeof TL_SCRIBBLE_STATES>;
delay: number;
};
// @public
@ -1369,7 +1370,7 @@ export type TLVideoShapeProps = {
};
// @public (undocumented)
export const uiColorTypeValidator: T.Validator<"accent" | "black" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
export const uiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
// @internal (undocumented)
export const USER_COLORS: string[];

View file

@ -5,6 +5,7 @@ import { imageAssetMigrations } from './assets/TLImageAsset'
import { videoAssetMigrations } from './assets/TLVideoAsset'
import { instanceTypeMigrations } from './records/TLInstance'
import { instancePageStateMigrations } from './records/TLInstancePageState'
import { instancePresenceTypeMigrations } from './records/TLInstancePresence'
import { rootShapeTypeMigrations, TLShape } from './records/TLShape'
import { userDocumentTypeMigrations, userDocumentVersions } from './records/TLUserDocument'
import { userPresenceTypeMigrations } from './records/TLUserPresence'
@ -738,7 +739,129 @@ describe('Removing isReadOnly from user_document', () => {
})
})
/* --- PUT YOU
describe('Adds delay to scribble', () => {
const { up, down } = instanceTypeMigrations.migrators[10]
test('up has no effect when scribble is null', () => {
expect(
up({
scribble: null,
})
).toEqual({ scribble: null })
})
test('up adds the delay property', () => {
expect(
up({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
},
})
).toEqual({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
delay: 0,
},
})
})
test('down has no effect when scribble is null', () => {
expect(down({ scribble: null })).toEqual({ scribble: null })
})
test('removes the delay property', () => {
expect(
down({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
delay: 0,
},
})
).toEqual({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
},
})
})
})
describe('Adds delay to scribble', () => {
const { up, down } = instancePresenceTypeMigrations.migrators[1]
test('up has no effect when scribble is null', () => {
expect(
up({
scribble: null,
})
).toEqual({ scribble: null })
})
test('up adds the delay property', () => {
expect(
up({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
},
})
).toEqual({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
delay: 0,
},
})
})
test('down has no effect when scribble is null', () => {
expect(down({ scribble: null })).toEqual({ scribble: null })
})
test('removes the delay property', () => {
expect(
down({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
delay: 0,
},
})
).toEqual({
scribble: {
points: [{ x: 0, y: 0 }],
size: 4,
color: 'black',
opacity: 1,
state: 'starting',
},
})
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */

View file

@ -105,13 +105,14 @@ const Versions = {
RemoveAlignJustify: 7,
AddZoom: 8,
AddVerticalAlign: 9,
AddScribbleDelay: 10,
} as const
/** @public */
export const instanceTypeMigrations = defineMigrations({
firstVersion: Versions.Initial,
// STEP 2: Update the current version to point to your latest version
currentVersion: Versions.AddVerticalAlign,
currentVersion: Versions.AddScribbleDelay,
// STEP 3: Add an up+down migration for the new version here
migrators: {
[Versions.AddTransparentExportBgs]: {
@ -227,6 +228,21 @@ export const instanceTypeMigrations = defineMigrations({
}
},
},
[Versions.AddScribbleDelay]: {
up: (instance) => {
if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
}
return { ...instance }
},
down: (instance) => {
if (instance.scribble !== null) {
const { delay: _delay, ...rest } = instance.scribble
return { ...instance, scribble: rest }
}
return { ...instance }
},
},
},
})

View file

@ -70,20 +70,36 @@ export const instancePresenceTypeValidator: T.Validator<TLInstancePresence> = T.
// It should be 1 higher than the current version
const Versions = {
Initial: 0,
AddScribbleDelay: 1,
} as const
export const userPresenceTypeMigrations = defineMigrations({
export const instancePresenceTypeMigrations = defineMigrations({
// STEP 2: Update the current version to point to your latest version
currentVersion: Versions.Initial,
firstVersion: Versions.Initial,
currentVersion: Versions.AddScribbleDelay,
migrators: {
// STEP 3: Add an up+down migration for the new version here
[Versions.AddScribbleDelay]: {
up: (instance) => {
if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
}
return { ...instance }
},
down: (instance) => {
if (instance.scribble !== null) {
const { delay: _delay, ...rest } = instance.scribble
return { ...instance, scribble: rest }
}
return { ...instance }
},
},
},
})
/** @public */
export const TLInstancePresence = createRecordType<TLInstancePresence>('instance_presence', {
migrations: userPresenceTypeMigrations,
migrations: instancePresenceTypeMigrations,
validator: instancePresenceTypeValidator,
scope: 'presence',
})

View file

@ -9,6 +9,7 @@ export const TL_UI_COLOR_TYPES = new Set([
'black',
'selection-stroke',
'selection-fill',
'laser',
'muted-1',
] as const)
@ -70,6 +71,7 @@ export type TLScribble = {
color: TLUiColorType
opacity: number
state: SetValue<typeof TL_SCRIBBLE_STATES>
delay: number
}
/** @public */
@ -79,6 +81,7 @@ export const scribbleTypeValidator: T.Validator<TLScribble> = T.object({
color: uiColorTypeValidator,
opacity: T.number,
state: T.setEnum(TL_SCRIBBLE_STATES),
delay: T.number,
})
/** @public */

File diff suppressed because one or more lines are too long

View file

@ -71,6 +71,7 @@ export function ToolbarSchemaProvider({ overrides, children }: ToolbarSchemaProv
toolbarItem(tools.line),
toolbarItem(tools.frame),
toolbarItem(tools.embed),
toolbarItem(tools.laser),
]
if (overrides) {

View file

@ -177,6 +177,17 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
trackEvent('select-tool', { source, id: 'note' })
},
},
{
id: 'laser',
label: 'tool.laser',
readonlyOk: true,
icon: 'tool-laser',
kbd: 'k',
onSelect(source) {
app.setSelectedTool('laser')
trackEvent('select-tool', { source, id: 'laser' })
},
},
{
id: 'embed',
label: 'tool.embed',

View file

@ -194,6 +194,7 @@ export type TLTranslationKey =
| 'tool.asset'
| 'tool.frame'
| 'tool.note'
| 'tool.laser'
| 'tool.embed'
| 'tool.text'
| 'menu.title'

View file

@ -194,6 +194,7 @@ export const DEFAULT_TRANSLATION = {
'tool.asset': 'Asset',
'tool.frame': 'Frame',
'tool.note': 'Note',
'tool.laser': 'Laser',
'tool.embed': 'Embed',
'tool.text': 'Text',
'menu.title': 'Menu',

View file

@ -142,6 +142,7 @@ export type TLUiIconType =
| 'tool-frame'
| 'tool-hand'
| 'tool-highlighter'
| 'tool-laser'
| 'tool-line'
| 'tool-media'
| 'tool-note'
@ -305,6 +306,7 @@ export const TLUiIconTypes = [
'tool-frame',
'tool-hand',
'tool-highlighter',
'tool-laser',
'tool-line',
'tool-media',
'tool-note',