[feature] add erase line (#708)

* Add erase line

* Fix erasing small dots

* bump perfect-freehand
This commit is contained in:
Steve Ruiz 2022-06-01 15:21:36 +01:00 committed by GitHub
parent a5e2b55294
commit c126be5c50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 113 additions and 5 deletions

View file

@ -32,7 +32,7 @@
"immer": "^9.0.12", "immer": "^9.0.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nanoid": "^3.1.31", "nanoid": "^3.1.31",
"perfect-freehand": "^1.0.16", "perfect-freehand": "^1.1.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-feather": "^2.0.9", "react-feather": "^2.0.9",

View file

@ -41,6 +41,7 @@
"@tldraw/vec": "^1.7.0", "@tldraw/vec": "^1.7.0",
"@use-gesture/react": "^10.2.14", "@use-gesture/react": "^10.2.14",
"mobx-react-lite": "^3.2.3", "mobx-react-lite": "^3.2.3",
"perfect-freehand": "^1.1.0",
"resize-observer-polyfill": "^1.5.1" "resize-observer-polyfill": "^1.5.1"
}, },
"peerDependencies": { "peerDependencies": {

View file

@ -30,12 +30,14 @@ import { UsersIndicators } from '~components/UsersIndicators'
import { SnapLines } from '~components/SnapLines/SnapLines' import { SnapLines } from '~components/SnapLines/SnapLines'
import { Grid } from '~components/Grid' import { Grid } from '~components/Grid'
import { Overlay } from '~components/Overlay' import { Overlay } from '~components/Overlay'
import { EraseLine } from '~components/EraseLine'
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> { interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
page: TLPage<T, TLBinding> page: TLPage<T, TLBinding>
pageState: TLPageState pageState: TLPageState
assets: TLAssets assets: TLAssets
snapLines?: TLSnapLine[] snapLines?: TLSnapLine[]
eraseLine?: number[][]
grid?: number grid?: number
users?: TLUsers<T> users?: TLUsers<T>
userId?: string userId?: string
@ -64,6 +66,7 @@ export const Canvas = observer(function _Canvas<
pageState, pageState,
assets, assets,
snapLines, snapLines,
eraseLine,
grid, grid,
users, users,
userId, userId,
@ -134,6 +137,7 @@ export const Canvas = observer(function _Canvas<
{users && <Users userId={userId} users={users} />} {users && <Users userId={userId} users={users} />}
</div> </div>
<Overlay camera={pageState.camera}> <Overlay camera={pageState.camera}>
{eraseLine && <EraseLine points={eraseLine} />}
{snapLines && <SnapLines snapLines={snapLines} />} {snapLines && <SnapLines snapLines={snapLines} />}
</Overlay> </Overlay>
</div> </div>

View file

@ -0,0 +1,18 @@
import * as React from 'react'
import { observer } from 'mobx-react-lite'
import getStroke from 'perfect-freehand'
import Utils from '~utils'
export interface UiEraseLintProps {
points: number[][]
}
export type UiEraseLineComponent = (props: UiEraseLintProps) => any | null
export const EraseLine = observer(function EraserLine({ points }: UiEraseLintProps) {
if (points.length === 0) return null
const d = Utils.getSvgPathFromStroke(getStroke(points, { size: 16, start: { taper: true } }))
return <path d={d} className="tl-erase-line" />
})

View file

@ -0,0 +1 @@
export * from './EraseLine'

View file

@ -56,6 +56,10 @@ export interface RendererProps<T extends TLShape, M = any> extends Partial<TLCal
* (optional) The current snap lines to render. * (optional) The current snap lines to render.
*/ */
snapLines?: TLSnapLine[] snapLines?: TLSnapLine[]
/**
* (optional) The current erase line to render.
*/
eraseLine?: number[][]
/** /**
* (optional) The current user's id, used to identify the user. * (optional) The current user's id, used to identify the user.
*/ */
@ -141,6 +145,7 @@ export const Renderer = observer(function _Renderer<
theme, theme,
meta, meta,
snapLines, snapLines,
eraseLine,
grid, grid,
containerRef, containerRef,
performanceMode, performanceMode,
@ -196,6 +201,7 @@ export const Renderer = observer(function _Renderer<
pageState={pageState} pageState={pageState}
assets={assets} assets={assets}
snapLines={snapLines} snapLines={snapLines}
eraseLine={eraseLine}
grid={grid} grid={grid}
users={users} users={users}
userId={userId} userId={userId}

View file

@ -367,6 +367,13 @@ export const TLCSS = css`
.tl-grid-dot { .tl-grid-dot {
fill: var(--tl-grid); fill: var(--tl-grid);
} }
.tl-erase-line {
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
fill: var(--tl-grid);
opacity: 0.32;
}
` `
export function useTLTheme(theme?: Partial<TLTheme>, selector?: string) { export function useTLTheme(theme?: Partial<TLTheme>, selector?: string) {

View file

@ -55,7 +55,7 @@
"@types/lz-string": "^1.3.34", "@types/lz-string": "^1.3.34",
"idb-keyval": "^6.1.0", "idb-keyval": "^6.1.0",
"lz-string": "^1.4.4", "lz-string": "^1.4.4",
"perfect-freehand": "^1.0.16", "perfect-freehand": "^1.1.0",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-hotkey-hook": "^1.0.2", "react-hotkey-hook": "^1.0.2",
"react-hotkeys-hook": "^3.4.4", "react-hotkeys-hook": "^3.4.4",

View file

@ -431,6 +431,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
pageState={pageState} pageState={pageState}
assets={assets} assets={assets}
snapLines={appState.snapLines} snapLines={appState.snapLines}
eraseLine={appState.eraseLine}
grid={GRID_SIZE} grid={GRID_SIZE}
users={room?.users} users={room?.users}
userId={room?.userId} userId={room?.userId}

View file

@ -198,7 +198,7 @@ export class StateManager<T extends Record<string, any>> {
* @param patch The patch to apply. * @param patch The patch to apply.
* @param id (optional) An id for this patch. * @param id (optional) An id for this patch.
*/ */
protected patchState = (patch: Patch<T>, id?: string): this => { patchState = (patch: Patch<T>, id?: string): this => {
this.applyPatch(patch, id) this.applyPatch(patch, id)
if (this.onPatch) { if (this.onPatch) {
this.onPatch(this._state, id) this.onPatch(this._state, id)

View file

@ -4103,6 +4103,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
isToolLocked: false, isToolLocked: false,
isMenuOpen: false, isMenuOpen: false,
isEmptyCanvas: false, isEmptyCanvas: false,
eraseLine: [],
snapLines: [], snapLines: [],
isLoading: false, isLoading: false,
disableAssets: false, disableAssets: false,

View file

@ -22,12 +22,56 @@ export class EraseSession extends BaseSession {
initialSelectedShapes: TDShape[] initialSelectedShapes: TDShape[]
erasableShapes: Set<TDShape> erasableShapes: Set<TDShape>
prevPoint: number[] prevPoint: number[]
prevEraseShapesSize = 0
constructor(app: TldrawApp) { constructor(app: TldrawApp) {
super(app) super(app)
this.prevPoint = [...app.originPoint] this.prevPoint = [...app.originPoint]
this.initialSelectedShapes = this.app.selectedIds.map((id) => this.app.getShape(id)) this.initialSelectedShapes = this.app.selectedIds.map((id) => this.app.getShape(id))
this.erasableShapes = new Set(this.app.shapes.filter((shape) => !shape.isLocked)) this.erasableShapes = new Set(this.app.shapes.filter((shape) => !shape.isLocked))
this.interval = this.loop()
}
interval: any
timestamp1 = 0
timestamp2 = 0
prevErasePoint: number[] = []
loop = () => {
const now = Date.now()
const elapsed1 = now - this.timestamp1
const elapsed2 = now - this.timestamp2
const { eraseLine } = this.app.appState
let next = [...eraseLine]
let didUpdate = false
if (elapsed1 > 16 && this.prevErasePoint !== this.prevPoint) {
didUpdate = true
next = [...eraseLine, this.prevPoint]
this.prevErasePoint = this.prevPoint
}
if (elapsed2 > 32) {
if (next.length > 1) {
didUpdate = true
next.splice(0, Math.ceil(next.length * 0.1))
this.timestamp2 = now
}
}
if (didUpdate) {
this.app.patchState(
{
appState: {
eraseLine: next,
},
},
'eraseline'
)
}
this.interval = requestAnimationFrame(this.loop)
} }
start = (): TldrawPatch | undefined => void null start = (): TldrawPatch | undefined => void null
@ -100,6 +144,12 @@ export class EraseSession extends BaseSession {
this.prevPoint = newPoint this.prevPoint = newPoint
if (erasedShapes.length === this.prevEraseShapesSize) {
return
}
this.prevEraseShapesSize = erasedShapes.length
return { return {
document: { document: {
pages: { pages: {
@ -114,6 +164,8 @@ export class EraseSession extends BaseSession {
cancel = (): TldrawPatch | undefined => { cancel = (): TldrawPatch | undefined => {
const { page } = this.app const { page } = this.app
cancelAnimationFrame(this.interval)
this.erasedShapes.forEach((shape) => { this.erasedShapes.forEach((shape) => {
if (!this.app.getShape(shape.id)) { if (!this.app.getShape(shape.id)) {
this.erasedShapes.delete(shape) this.erasedShapes.delete(shape)
@ -136,12 +188,17 @@ export class EraseSession extends BaseSession {
}, },
}, },
}, },
appState: {
eraseLine: [],
},
} }
} }
complete = (): TldrawPatch | TldrawCommand | undefined => { complete = (): TldrawPatch | TldrawCommand | undefined => {
const { page } = this.app const { page } = this.app
cancelAnimationFrame(this.interval)
this.erasedShapes.forEach((shape) => { this.erasedShapes.forEach((shape) => {
if (!this.app.getShape(shape.id)) { if (!this.app.getShape(shape.id)) {
this.erasedShapes.delete(shape) this.erasedShapes.delete(shape)
@ -217,6 +274,9 @@ export class EraseSession extends BaseSession {
}, },
}, },
}, },
appState: {
eraseLine: [],
},
}, },
after: { after: {
document: { document: {
@ -232,6 +292,9 @@ export class EraseSession extends BaseSession {
}, },
}, },
}, },
appState: {
eraseLine: [],
},
}, },
} }
} }

View file

@ -276,8 +276,8 @@ export class DrawUtil extends TDShapeUtil<T, E> {
const ptB = Vec.sub(B, point) const ptB = Vec.sub(B, point)
const bounds = this.getBounds(shape) const bounds = this.getBounds(shape)
if (points.length <= 2) { if (bounds.width < 8 && bounds.height < 8) {
return Vec.distanceToLineSegment(A, B, shape.point) < 4 return Vec.distanceToLineSegment(A, B, Utils.getBoundsCenter(bounds)) < 5
} }
if (intersectLineSegmentBounds(ptA, ptB, bounds)) { if (intersectLineSegmentBounds(ptA, ptB, bounds)) {

View file

@ -105,6 +105,7 @@ export interface TDSnapshot {
isMenuOpen: boolean isMenuOpen: boolean
status: string status: string
snapLines: TLSnapLine[] snapLines: TLSnapLine[]
eraseLine: number[][]
isLoading: boolean isLoading: boolean
disableAssets: boolean disableAssets: boolean
selectByContain?: boolean selectByContain?: boolean

View file

@ -9029,6 +9029,11 @@ perfect-freehand@^1.0.16:
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.16.tgz#38575ef946ff513b9c94057c763cac003b504020" resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.16.tgz#38575ef946ff513b9c94057c763cac003b504020"
integrity sha512-D4+avUeR8CHSl2vaPbPYX/dNpSMRYO3VOFp7qSSc+LRkSgzQbLATVnXosy7VxtsSHEh1C5t8K8sfmo0zCVnfWQ== integrity sha512-D4+avUeR8CHSl2vaPbPYX/dNpSMRYO3VOFp7qSSc+LRkSgzQbLATVnXosy7VxtsSHEh1C5t8K8sfmo0zCVnfWQ==
perfect-freehand@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.1.0.tgz#65b459f4d6ccceb873e9ac06e07c924761649b32"
integrity sha512-nVWukMN9qlii1dQsQHVvfaNpeOAWVLgTZP6e/tFcU6cWlLo+6YdvfRGBL2u5pU11APlPbHeB0SpMcGA8ZjPgcQ==
picocolors@^1.0.0: picocolors@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"