[feature] add erase line (#708)
* Add erase line * Fix erasing small dots * bump perfect-freehand
This commit is contained in:
parent
a5e2b55294
commit
c126be5c50
15 changed files with 113 additions and 5 deletions
|
@ -32,7 +32,7 @@
|
|||
"immer": "^9.0.12",
|
||||
"lodash": "^4.17.21",
|
||||
"nanoid": "^3.1.31",
|
||||
"perfect-freehand": "^1.0.16",
|
||||
"perfect-freehand": "^1.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-feather": "^2.0.9",
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
"@tldraw/vec": "^1.7.0",
|
||||
"@use-gesture/react": "^10.2.14",
|
||||
"mobx-react-lite": "^3.2.3",
|
||||
"perfect-freehand": "^1.1.0",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
@ -30,12 +30,14 @@ import { UsersIndicators } from '~components/UsersIndicators'
|
|||
import { SnapLines } from '~components/SnapLines/SnapLines'
|
||||
import { Grid } from '~components/Grid'
|
||||
import { Overlay } from '~components/Overlay'
|
||||
import { EraseLine } from '~components/EraseLine'
|
||||
|
||||
interface CanvasProps<T extends TLShape, M extends Record<string, unknown>> {
|
||||
page: TLPage<T, TLBinding>
|
||||
pageState: TLPageState
|
||||
assets: TLAssets
|
||||
snapLines?: TLSnapLine[]
|
||||
eraseLine?: number[][]
|
||||
grid?: number
|
||||
users?: TLUsers<T>
|
||||
userId?: string
|
||||
|
@ -64,6 +66,7 @@ export const Canvas = observer(function _Canvas<
|
|||
pageState,
|
||||
assets,
|
||||
snapLines,
|
||||
eraseLine,
|
||||
grid,
|
||||
users,
|
||||
userId,
|
||||
|
@ -134,6 +137,7 @@ export const Canvas = observer(function _Canvas<
|
|||
{users && <Users userId={userId} users={users} />}
|
||||
</div>
|
||||
<Overlay camera={pageState.camera}>
|
||||
{eraseLine && <EraseLine points={eraseLine} />}
|
||||
{snapLines && <SnapLines snapLines={snapLines} />}
|
||||
</Overlay>
|
||||
</div>
|
||||
|
|
18
packages/core/src/components/EraseLine/EraseLine.tsx
Normal file
18
packages/core/src/components/EraseLine/EraseLine.tsx
Normal 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" />
|
||||
})
|
1
packages/core/src/components/EraseLine/index.ts
Normal file
1
packages/core/src/components/EraseLine/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './EraseLine'
|
|
@ -56,6 +56,10 @@ export interface RendererProps<T extends TLShape, M = any> extends Partial<TLCal
|
|||
* (optional) The current snap lines to render.
|
||||
*/
|
||||
snapLines?: TLSnapLine[]
|
||||
/**
|
||||
* (optional) The current erase line to render.
|
||||
*/
|
||||
eraseLine?: number[][]
|
||||
/**
|
||||
* (optional) The current user's id, used to identify the user.
|
||||
*/
|
||||
|
@ -141,6 +145,7 @@ export const Renderer = observer(function _Renderer<
|
|||
theme,
|
||||
meta,
|
||||
snapLines,
|
||||
eraseLine,
|
||||
grid,
|
||||
containerRef,
|
||||
performanceMode,
|
||||
|
@ -196,6 +201,7 @@ export const Renderer = observer(function _Renderer<
|
|||
pageState={pageState}
|
||||
assets={assets}
|
||||
snapLines={snapLines}
|
||||
eraseLine={eraseLine}
|
||||
grid={grid}
|
||||
users={users}
|
||||
userId={userId}
|
||||
|
|
|
@ -367,6 +367,13 @@ export const TLCSS = css`
|
|||
.tl-grid-dot {
|
||||
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) {
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
"@types/lz-string": "^1.3.34",
|
||||
"idb-keyval": "^6.1.0",
|
||||
"lz-string": "^1.4.4",
|
||||
"perfect-freehand": "^1.0.16",
|
||||
"perfect-freehand": "^1.1.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hotkey-hook": "^1.0.2",
|
||||
"react-hotkeys-hook": "^3.4.4",
|
||||
|
|
|
@ -431,6 +431,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
|
|||
pageState={pageState}
|
||||
assets={assets}
|
||||
snapLines={appState.snapLines}
|
||||
eraseLine={appState.eraseLine}
|
||||
grid={GRID_SIZE}
|
||||
users={room?.users}
|
||||
userId={room?.userId}
|
||||
|
|
|
@ -198,7 +198,7 @@ export class StateManager<T extends Record<string, any>> {
|
|||
* @param patch The patch to apply.
|
||||
* @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)
|
||||
if (this.onPatch) {
|
||||
this.onPatch(this._state, id)
|
||||
|
|
|
@ -4103,6 +4103,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
|
|||
isToolLocked: false,
|
||||
isMenuOpen: false,
|
||||
isEmptyCanvas: false,
|
||||
eraseLine: [],
|
||||
snapLines: [],
|
||||
isLoading: false,
|
||||
disableAssets: false,
|
||||
|
|
|
@ -22,12 +22,56 @@ export class EraseSession extends BaseSession {
|
|||
initialSelectedShapes: TDShape[]
|
||||
erasableShapes: Set<TDShape>
|
||||
prevPoint: number[]
|
||||
prevEraseShapesSize = 0
|
||||
|
||||
constructor(app: TldrawApp) {
|
||||
super(app)
|
||||
this.prevPoint = [...app.originPoint]
|
||||
this.initialSelectedShapes = this.app.selectedIds.map((id) => this.app.getShape(id))
|
||||
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
|
||||
|
@ -100,6 +144,12 @@ export class EraseSession extends BaseSession {
|
|||
|
||||
this.prevPoint = newPoint
|
||||
|
||||
if (erasedShapes.length === this.prevEraseShapesSize) {
|
||||
return
|
||||
}
|
||||
|
||||
this.prevEraseShapesSize = erasedShapes.length
|
||||
|
||||
return {
|
||||
document: {
|
||||
pages: {
|
||||
|
@ -114,6 +164,8 @@ export class EraseSession extends BaseSession {
|
|||
cancel = (): TldrawPatch | undefined => {
|
||||
const { page } = this.app
|
||||
|
||||
cancelAnimationFrame(this.interval)
|
||||
|
||||
this.erasedShapes.forEach((shape) => {
|
||||
if (!this.app.getShape(shape.id)) {
|
||||
this.erasedShapes.delete(shape)
|
||||
|
@ -136,12 +188,17 @@ export class EraseSession extends BaseSession {
|
|||
},
|
||||
},
|
||||
},
|
||||
appState: {
|
||||
eraseLine: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
complete = (): TldrawPatch | TldrawCommand | undefined => {
|
||||
const { page } = this.app
|
||||
|
||||
cancelAnimationFrame(this.interval)
|
||||
|
||||
this.erasedShapes.forEach((shape) => {
|
||||
if (!this.app.getShape(shape.id)) {
|
||||
this.erasedShapes.delete(shape)
|
||||
|
@ -217,6 +274,9 @@ export class EraseSession extends BaseSession {
|
|||
},
|
||||
},
|
||||
},
|
||||
appState: {
|
||||
eraseLine: [],
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
|
@ -232,6 +292,9 @@ export class EraseSession extends BaseSession {
|
|||
},
|
||||
},
|
||||
},
|
||||
appState: {
|
||||
eraseLine: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -276,8 +276,8 @@ export class DrawUtil extends TDShapeUtil<T, E> {
|
|||
const ptB = Vec.sub(B, point)
|
||||
const bounds = this.getBounds(shape)
|
||||
|
||||
if (points.length <= 2) {
|
||||
return Vec.distanceToLineSegment(A, B, shape.point) < 4
|
||||
if (bounds.width < 8 && bounds.height < 8) {
|
||||
return Vec.distanceToLineSegment(A, B, Utils.getBoundsCenter(bounds)) < 5
|
||||
}
|
||||
|
||||
if (intersectLineSegmentBounds(ptA, ptB, bounds)) {
|
||||
|
|
|
@ -105,6 +105,7 @@ export interface TDSnapshot {
|
|||
isMenuOpen: boolean
|
||||
status: string
|
||||
snapLines: TLSnapLine[]
|
||||
eraseLine: number[][]
|
||||
isLoading: boolean
|
||||
disableAssets: boolean
|
||||
selectByContain?: boolean
|
||||
|
|
|
@ -9029,6 +9029,11 @@ perfect-freehand@^1.0.16:
|
|||
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.16.tgz#38575ef946ff513b9c94057c763cac003b504020"
|
||||
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:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||
|
|
Loading…
Reference in a new issue