tool-with-ui
Steve Ruiz 2024-05-12 17:48:40 +01:00
rodzic cf31e64bd4
commit 9df11cd011
8 zmienionych plików z 258 dodań i 211 usunięć

Wyświetl plik

@ -15,9 +15,10 @@ import {
} from 'tldraw'
import 'tldraw/tldraw.css'
import { SimpleEraserToolUtil } from './SimpleEraserTool/SimpleEraserTool'
import { SimpleSelectToolUtil } from './SimpleSelectTool'
import { SimpleSelectToolUtil } from './SimpleSelectTool/SimpleSelectTool'
const tools: TLToolUtilConstructor<any, any>[] = [SimpleSelectToolUtil, SimpleEraserToolUtil]
const components: TLComponents = {
Scribble: TldrawScribble,
}
@ -46,6 +47,8 @@ export default function NewToolExample() {
{ type: 'geo', x: 200, y: 200 },
{ type: 'geo', x: 400, y: 400 },
{ type: 'text', x: 200, y: 400, props: { text: 'hello' } },
{ type: 'frame', x: 100, y: 600 },
{ type: 'geo', x: 150, y: 625 },
])
}}
>

Wyświetl plik

@ -4,13 +4,12 @@ import {
TLFrameShape,
TLGroupShape,
TLShapeId,
TLToolContext,
ToolUtil,
Vec,
pointInPolygon,
} from 'tldraw'
interface SimpleEraserContext extends TLToolContext {
readonly type: '@simple/eraser'
type SimpleEraserContext = {
state:
| {
name: 'idle'
@ -24,12 +23,21 @@ interface SimpleEraserContext extends TLToolContext {
}
}
export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
type SimpleEraserToolConfig = {
scribbleSize: number
}
export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext, SimpleEraserToolConfig> {
static override type = '@simple/eraser' as const
getDefaultConfig(): SimpleEraserToolConfig {
return {
scribbleSize: 12,
}
}
getDefaultContext(): SimpleEraserContext {
return {
type: '@simple/eraser',
state: { name: 'idle' },
}
}
@ -72,7 +80,7 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
switch (context.state.name) {
case 'idle': {
if (editor.inputs.isPointing) {
if (event.name === 'pointer_down') {
// started pointing
this.setContext({
state: { name: 'pointing' },
@ -90,13 +98,7 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
if (editor.inputs.isDragging || event.name === 'long_press') {
// started dragging
const scribble = editor.scribbles.addScribble({
color: 'muted-1',
size: 12,
})
this.setContext({
state: { name: 'erasing', scribbleId: scribble.id },
})
this.startErasingAfterDragging()
this.updateErasingShapes()
return
}
@ -125,10 +127,11 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
scribbleId: null as string | null,
excludedShapeIds: new Set<TLShapeId>(),
erasingShapeIds: new Set<TLShapeId>(),
prevPoint: new Vec(),
}
private cancel() {
const { editor } = this
const { memo, editor } = this
// Reset the erasing shapes
editor.setErasingShapes([])
@ -137,14 +140,18 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
// Stop the scribble
const context = this.getContext()
if (context.state.name === 'erasing') {
this.editor.scribbles.stop(context.state.scribbleId)
editor.scribbles.stop(context.state.scribbleId)
}
memo.erasingShapeIds.clear()
memo.excludedShapeIds.clear()
memo.scribbleId = null
this.setContext({ state: { name: 'idle' } })
}
private complete() {
const { editor } = this
const { memo, editor } = this
// Delete any shapes that were marked as erasing
const erasingShapeIds = editor.getErasingShapeIds()
@ -156,30 +163,80 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
// Stop the scribble
const context = this.getContext()
if (context.state.name === 'erasing') {
this.editor.scribbles.stop(context.state.scribbleId)
editor.scribbles.stop(context.state.scribbleId)
}
memo.erasingShapeIds.clear()
memo.excludedShapeIds.clear()
memo.scribbleId = null
this.setContext({ state: { name: 'idle' } })
}
private startErasingPointedShapes() {
const { editor, memo } = this
const { originPagePoint } = editor.inputs
const { erasingShapeIds, prevPoint } = memo
editor.mark('erasing')
const zoomLevel = this.editor.getZoomLevel()
const minDist = HIT_TEST_MARGIN / zoomLevel
prevPoint.setTo(originPagePoint)
const minDist = HIT_TEST_MARGIN / editor.getZoomLevel()
// Populate the erasing shape ids working front to back...
const shapes = editor.getCurrentPageShapesSorted()
for (let i = shapes.length - 1; i > -1; i--) {
const shape = shapes[i]
// Look for hit shapes
if (
editor.isPointInShape(shape, originPagePoint, {
hitInside: false,
margin: minDist,
})
) {
const hitShape = editor.getOutermostSelectableShape(shape)
// If we've hit a frame after hitting any other shape, stop here
if (editor.isShapeOfType<TLFrameShape>(hitShape, 'frame') && erasingShapeIds.size > 0) {
break
}
erasingShapeIds.add(hitShape.id)
}
}
editor.setErasingShapes(Array.from(erasingShapeIds))
}
private startErasingAfterDragging() {
const {
editor,
memo: { erasingShapeIds, excludedShapeIds },
} = this
const { originPagePoint } = editor.inputs
const scribble = editor.scribbles.addScribble({
color: 'muted-1',
size: 12,
})
this.setContext({
state: { name: 'erasing', scribbleId: scribble.id },
})
// Clear any erasing shapes from the pointing state
erasingShapeIds.clear()
// Populate the excluded shape ids and the erasing shape ids, working front to back...
excludedShapeIds.clear()
// Populate the excluded shape ids and the erasing shape ids
// working front to back...
const shapes = editor.getCurrentPageShapesSorted()
for (let i = shapes.length - 1; i > -1; i--) {
const shape = shapes[i]
// If the shape is locked, exclude it
if (editor.isShapeOrAncestorLocked(shape)) {
memo.excludedShapeIds.add(shape.id)
excludedShapeIds.add(shape.id)
continue
}
@ -188,35 +245,17 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
editor.isShapeOfType<TLGroupShape>(shape, 'group') ||
editor.isShapeOfType<TLFrameShape>(shape, 'frame')
) {
this.editor.isPointInShape(shape, originPagePoint, {
hitInside: true,
margin: 0,
})
memo.excludedShapeIds.add(shape.id)
if (
editor.isPointInShape(shape, originPagePoint, {
hitInside: true,
margin: 0,
})
) {
excludedShapeIds.add(shape.id)
}
continue
}
// Look for hit shapes
if (
this.editor.isPointInShape(shape, originPagePoint, {
hitInside: false,
margin: minDist,
})
) {
const hitShape = this.editor.getOutermostSelectableShape(shape)
// If we've hit a frame after hitting any other shape, stop here
if (
this.editor.isShapeOfType<TLFrameShape>(hitShape, 'frame') &&
memo.erasingShapeIds.size > 0
) {
break
}
memo.erasingShapeIds.add(hitShape.id)
}
}
this.editor.setErasingShapes(Array.from(memo.erasingShapeIds))
}
private updateErasingShapes() {
@ -225,25 +264,22 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
const context = this.getContext()
if (context.state.name !== 'erasing') return
// Erase
const { excludedShapeIds } = memo
const { excludedShapeIds, erasingShapeIds, prevPoint } = memo
const { scribbleId } = context.state
const zoomLevel = editor.getZoomLevel()
const erasingShapeIds = editor.getErasingShapeIds()
const {
inputs: { currentPagePoint, previousPagePoint },
inputs: { currentPagePoint },
} = editor
// Update scribble
const { x, y } = currentPagePoint
editor.scribbles.addPoint(scribbleId, x, y)
const erasing = new Set<TLShapeId>(erasingShapeIds)
const minDist = HIT_TEST_MARGIN / zoomLevel
const minDist = HIT_TEST_MARGIN / editor.getZoomLevel()
const currentPageShapes = editor.getCurrentPageShapes()
for (const shape of currentPageShapes) {
// Skip groups
if (editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue
// Avoid testing masked shapes, unless the pointer is inside the mask
@ -257,7 +293,7 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
const pageTransform = editor.getShapePageTransform(shape)
if (!geometry || !pageTransform) continue
const pt = pageTransform.clone().invert()
const A = pt.applyToPoint(previousPagePoint)
const A = pt.applyToPoint(prevPoint)
const B = pt.applyToPoint(currentPagePoint)
// If the line segment is entirely above / below / left / right of the shape's bounding box, skip the hit test
@ -272,13 +308,16 @@ export class SimpleEraserToolUtil extends ToolUtil<SimpleEraserContext> {
}
if (geometry.hitTestLineSegment(A, B, minDist)) {
erasing.add(editor.getOutermostSelectableShape(shape).id)
const shapeToErase = editor.getOutermostSelectableShape(shape)
if (excludedShapeIds.has(shapeToErase.id)) continue
erasingShapeIds.add(shapeToErase.id)
}
}
// Remove the hit shapes, except if they're in the list of excluded shapes
// (these excluded shapes will be any frames or groups the pointer was inside of
// when the user started erasing)
this.editor.setErasingShapes(Array.from(erasing).filter((id) => !excludedShapeIds.has(id)))
// Update the prev page point for next segment
prevPoint.setTo(currentPagePoint)
// Remove the hit shapes
editor.setErasingShapes(Array.from(erasingShapeIds))
}
}

Wyświetl plik

@ -10,7 +10,6 @@ import {
SharedStyleMap,
TLEventInfo,
TLShapeId,
TLToolContext,
ToolUtil,
dedupe,
getOwnProperty,
@ -19,8 +18,7 @@ import {
useValue,
} from 'tldraw'
interface SimpleSelectContext extends TLToolContext {
readonly type: '@simple/select'
type SimpleSelectContext = {
state:
| {
name: 'idle'
@ -45,24 +43,26 @@ export class SimpleSelectToolUtil extends ToolUtil<SimpleSelectContext> {
getDefaultContext(): SimpleSelectContext {
return {
type: '@simple/select',
state: { name: 'idle' },
}
}
getDefaultConfig() {
return {}
}
underlay() {
return null
}
overlay() {
const { state } = this.getContext()
if (state.name !== 'brushing') return
return (
<>
<ShapeIndicators />
<HintedShapeIndicator />
<SelectionBrush brush={state.brush} />
{state.name === 'brushing' && <SelectionBrush brush={state.brush} />}
</>
)
}
@ -132,59 +132,62 @@ export class SimpleSelectToolUtil extends ToolUtil<SimpleSelectContext> {
break
}
case 'brushing': {
if (editor.inputs.isDragging) {
if (
event.name === 'pointer_move' ||
// for modifiers
event.name === 'key_down' ||
event.name === 'key_up'
) {
const { originPagePoint, currentPagePoint } = editor.inputs
const box = Box.FromPoints([originPagePoint, currentPagePoint])
// update the box in the context
this.setContext({
state: {
name: 'brushing',
brush: box.toJson(),
},
})
const hitIds = new Set<TLShapeId>()
// If we're holding shift, add the initial selected ids to the hitIds set
if (editor.inputs.shiftKey) {
for (const id of memo.initialSelectedIds) {
hitIds.add(id)
}
}
// Test the rest of the shapes on the page (broad phase only for simplifity)
for (const shape of editor.getCurrentPageShapes()) {
if (hitIds.has(shape.id)) continue
const pageBounds = editor.getShapePageBounds(shape.id)
if (!pageBounds) continue
if (box.collides(pageBounds)) {
hitIds.add(shape.id)
}
}
// If the selected ids have changed, update the selection
const currentSelectedIds = editor.getSelectedShapeIds()
if (
currentSelectedIds.length !== hitIds.size ||
currentSelectedIds.some((id) => !hitIds.has(id))
) {
editor.setSelectedShapes(Array.from(hitIds))
}
}
} else {
if (!editor.inputs.isPointing) {
// Stopped pointing
this.setContext({
state: {
name: 'idle',
},
})
return
}
if (
event.name === 'pointer_move' ||
// for modifiers
event.name === 'key_down' ||
event.name === 'key_up'
) {
const { originPagePoint, currentPagePoint } = editor.inputs
const box = Box.FromPoints([originPagePoint, currentPagePoint])
// update the box in the context
this.setContext({
state: {
name: 'brushing',
brush: box.toJson(),
},
})
const hitIds = new Set<TLShapeId>()
// If we're holding shift, add the initial selected ids to the hitIds set
if (editor.inputs.shiftKey) {
for (const id of memo.initialSelectedIds) {
hitIds.add(id)
}
}
// Test the rest of the shapes on the page (broad phase only for simplifity)
for (const shape of editor.getCurrentPageShapes()) {
if (hitIds.has(shape.id)) continue
const pageBounds = editor.getShapePageBounds(shape.id)
if (!pageBounds) continue
if (box.collides(pageBounds)) {
hitIds.add(shape.id)
}
}
// If the selected ids have changed, update the selection
const currentSelectedIds = editor.getSelectedShapeIds()
if (
currentSelectedIds.length !== hitIds.size ||
currentSelectedIds.some((id) => !hitIds.has(id))
) {
editor.setSelectedShapes(Array.from(hitIds))
}
}
break
}
}
@ -235,17 +238,16 @@ function ShapeIndicators() {
// todo: move to tldraw selected ids wrappe
const prev = rPreviousSelectedShapeIds.current
const next = new Set<TLShapeId>()
if (!editor.getInstanceState().isChangingStyle) {
const instanceState = editor.getInstanceState()
if (!instanceState.isChangingStyle) {
const selected = editor.getSelectedShapeIds()
for (const id of selected) {
next.add(id)
}
if (editor.isInAny('select.idle', 'select.editing_shape')) {
const instanceState = editor.getInstanceState()
if (instanceState.isHoveringCanvas && !instanceState.isCoarsePointer) {
const hovered = editor.getHoveredShapeId()
if (hovered) next.add(hovered)
}
if (instanceState.isHoveringCanvas && !instanceState.isCoarsePointer) {
const hovered = editor.getHoveredShapeId()
if (hovered) next.add(hovered)
}
}

Wyświetl plik

@ -188,8 +188,10 @@ export { BaseBoxShapeTool } from './lib/editor/tools/BaseBoxShapeTool/BaseBoxSha
export { StateNode, type TLStateNodeConstructor } from './lib/editor/tools/StateNode'
export {
ToolUtil,
type TLToolConfig,
type TLToolContext,
type TLToolUtilConstructor,
type TLToolUtilConstructorWithConfig,
} from './lib/editor/tools/ToolUtil'
export {
useSvgExportContext,

Wyświetl plik

@ -529,20 +529,20 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
)
}
function SelectionForegroundWrapper() {
const editor = useEditor()
const selectionRotation = useValue('selection rotation', () => editor.getSelectionRotation(), [
editor,
])
const selectionBounds = useValue(
'selection bounds',
() => editor.getSelectionRotatedPageBounds(),
[editor]
)
const { SelectionForeground } = useEditorComponents()
if (!selectionBounds || !SelectionForeground) return null
return <SelectionForeground bounds={selectionBounds} rotation={selectionRotation} />
}
// function SelectionForegroundWrapper() {
// const editor = useEditor()
// const selectionRotation = useValue('selection rotation', () => editor.getSelectionRotation(), [
// editor,
// ])
// const selectionBounds = useValue(
// 'selection bounds',
// () => editor.getSelectionRotatedPageBounds(),
// [editor]
// )
// const { SelectionForeground } = useEditorComponents()
// if (!selectionBounds || !SelectionForeground) return null
// return <SelectionForeground bounds={selectionBounds} rotation={selectionRotation} />
// }
function SelectionBackgroundWrapper() {
const editor = useEditor()

Wyświetl plik

@ -132,7 +132,7 @@ import { TextManager } from './managers/TextManager'
import { TickManager } from './managers/TickManager'
import { UserPreferencesManager } from './managers/UserPreferencesManager'
import { ShapeUtil, TLResizeMode, TLShapeUtilConstructor } from './shapes/ShapeUtil'
import { TLToolUtilConstructor, ToolUtil } from './tools/ToolUtil'
import { TLToolUtilConstructor, TLToolUtilConstructorWithConfig, ToolUtil } from './tools/ToolUtil'
import { TLContent } from './types/clipboard-types'
import { TLEventMap } from './types/emit-types'
import {
@ -178,7 +178,7 @@ export interface TLEditorOptions {
/**
* An array of tools to use in the editor. These will be used to handle events and manage user interactions in the editor.
*/
tools: readonly TLToolUtilConstructor<any>[]
tools: readonly (TLToolUtilConstructor<any, any> | TLToolUtilConstructorWithConfig<any, any>)[]
/**
* An array of bindings to use in the editor. These will be used to create and manage bindings in the editor.
*/
@ -279,9 +279,20 @@ export class Editor extends EventEmitter<TLEventMap> {
const toolMap: Record<string, ToolUtil<any>> = {}
for (const Tool of [...tools]) {
const tool = new Tool(this)
toolMap[Tool.type] = tool
for (const _tool of [...tools]) {
let ToolConstructor: TLToolUtilConstructor<any, any>
let config: object
if (Array.isArray(_tool)) {
ToolConstructor = _tool[0]
config = _tool[1]
} else {
ToolConstructor = _tool
config = {}
}
const tool = new ToolConstructor(this, config)
toolMap[ToolConstructor.type] = tool
tool.setContext(tool.getDefaultContext())
}
@ -1020,7 +1031,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
isIn(path: string): boolean {
isIn(_path: string): boolean {
return false
}
@ -1039,52 +1050,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return paths.some((path) => this.isIn(path))
}
/**
* Set the selected tool.
*
* @example
* ```ts
* editor.setCurrentTool('hand')
* editor.setCurrentTool('hand', { date: Date.now() })
* ```
*
* @param id - The id of the tool to select.
* @param info - Arbitrary data to pass along into the transition.
*
* @public
*/
setCurrentTool(id: string, info = {}): this {
const current = this.getCurrentTool()
const next = this.getTool(id)
if (current !== next) {
current.onExit(info)
console.log('setting', id)
this._currentToolId.set(id)
current.onEnter(info)
}
return this
}
// /**
// * The current selected tool.
// *
// * @public
// */
// @computed getCurrentTool(): StateNode {
// return this.root.getCurrent()!
// }
// /**
// * The id of the current selected tool.
// *
// * @public
// */
// @computed getCurrentToolId(): string {
// const currentTool = this.getCurrentTool()
// if (!currentTool) return ''
// return currentTool.getCurrentToolIdMask() ?? currentTool.id
// }
/* ---------------- Document Settings --------------- */
/**
@ -1928,6 +1893,31 @@ export class Editor extends EventEmitter<TLEventMap> {
return this._tools.get()[id] as T
}
/**
* Set the selected tool.
*
* @example
* ```ts
* editor.setCurrentTool('hand')
* editor.setCurrentTool('hand', { date: Date.now() })
* ```
*
* @param id - The id of the tool to select.
* @param info - Arbitrary data to pass along into the transition.
*
* @public
*/
setCurrentTool(id: string, info = {}): this {
const current = this.getCurrentTool()
const next = this.getTool(id)
if (current !== next) {
current.onExit(info)
this._currentToolId.set(id)
current.onEnter(info)
}
return this
}
/* --------------------- Camera --------------------- */
/** @internal */

Wyświetl plik

@ -4,21 +4,29 @@ import { ReadonlySharedStyleMap } from '../../utils/SharedStylesMap'
import { Editor } from '../Editor'
import { TLEventInfo } from '../types/event-types'
export interface TLToolContext {
type: string
}
export abstract class ToolUtil<Context extends object, Config extends object = object> {
constructor(
public editor: Editor,
config: Partial<Config> = {}
) {
this.config = { ...this.getDefaultConfig?.(), ...config }
}
export abstract class ToolUtil<T extends TLToolContext> {
constructor(public editor: Editor) {}
static type: string
static type: TLToolContext['type']
public readonly config: Partial<Config>
/**
* The tool's default context, set when the tool is first registered in the Editor.
*/
abstract getDefaultContext(): T
abstract getDefaultConfig?(): Config
private _context = atom<T>('tool context', {} as T)
/**
* The tool's default context, set when the tool is first registered in the Editor.
*/
abstract getDefaultContext(): Context
private _context = atom<Context>('tool context', {} as Context)
/**
* Get the tool's context.
@ -32,7 +40,7 @@ export abstract class ToolUtil<T extends TLToolContext> {
*
* @param context - A partial of the tool's context.
*/
setContext(context: Partial<T>) {
setContext(context: Partial<Context>) {
this._context.set({ ...this._context.__unsafe__getWithoutCapture(), ...context })
}
@ -74,10 +82,12 @@ export abstract class ToolUtil<T extends TLToolContext> {
}
/** @public */
export interface TLToolUtilConstructor<
T extends TLToolContext,
U extends ToolUtil<T> = ToolUtil<T>,
> {
new (editor: Editor): U
type: T['type']
export interface TLToolUtilConstructor<T extends object, Q extends object> {
new (editor: Editor, config: Q): ToolUtil<T, Q>
type: string
}
export type TLToolUtilConstructorWithConfig<T extends object, Q extends object> = [
TLToolUtilConstructor<T, Q>,
Q,
]

Wyświetl plik

@ -60,13 +60,14 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
const isCreating = useValue(
'is creating this shape',
() => {
const resizingState = this.editor.getStateDescendant('select.resizing')
if (!resizingState) return false
if (!resizingState.getIsActive()) return false
const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
?.info
if (!info) return false
return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id
return false
// const resizingState = this.editor.getStateDescendant('select.resizing')
// if (!resizingState) return false
// if (!resizingState.getIsActive()) return false
// const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
// ?.info
// if (!info) return false
// return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id
},
[shape.id]
)