tldraw
Version:
A tiny little drawing editor.
680 lines (602 loc) • 19.3 kB
text/typescript
import {
Editor,
StateNode,
TLAdjacentDirection,
TLClickEventInfo,
TLKeyboardEventInfo,
TLPointerEventInfo,
TLShape,
Vec,
VecLike,
createShapeId,
debugFlags,
kickoutOccludedShapes,
pointInPolygon,
toRichText,
} from '@tldraw/editor'
import { isOverArrowLabel } from '../../../shapes/arrow/arrowLabel'
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp'
import { updateHoveredShapeId } from '../../selection-logic/updateHoveredShapeId'
import { hasRichText, startEditingShapeWithRichText } from '../selectHelpers'
const SKIPPED_KEYS_FOR_AUTO_EDITING = [
'Delete',
'Backspace',
'[',
']',
'Enter',
' ',
'Shift',
'Tab',
]
export class Idle extends StateNode {
static override id = 'idle'
selectedShapesOnKeyDown: TLShape[] = []
override onEnter() {
this.parent.setCurrentToolIdMask(undefined)
updateHoveredShapeId(this.editor)
this.selectedShapesOnKeyDown = []
this.editor.setCursor({ type: 'default', rotation: 0 })
}
override onExit() {
updateHoveredShapeId.cancel()
}
override onPointerMove() {
updateHoveredShapeId(this.editor)
}
override onPointerDown(info: TLPointerEventInfo) {
switch (info.target) {
case 'canvas': {
// Check to see if we hit any shape under the pointer; if so,
// handle this as a pointer down on the shape instead of the canvas
const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
if (hitShape && !hitShape.isLocked) {
this.onPointerDown({
...info,
shape: hitShape,
target: 'shape',
})
return
}
const selectedShapeIds = this.editor.getSelectedShapeIds()
const onlySelectedShape = this.editor.getOnlySelectedShape()
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
if (
selectedShapeIds.length > 1 ||
(onlySelectedShape &&
!this.editor.getShapeUtil(onlySelectedShape).hideSelectionBoundsBg(onlySelectedShape))
) {
if (isPointInRotatedSelectionBounds(this.editor, currentPagePoint)) {
this.onPointerDown({
...info,
target: 'selection',
})
return
}
}
this.parent.transition('pointing_canvas', info)
break
}
case 'shape': {
const { shape } = info
if (this.editor.isShapeOrAncestorLocked(shape)) {
this.parent.transition('pointing_canvas', info)
break
}
// If we're holding ctrl key, we might select it, or start brushing...
this.parent.transition('pointing_shape', info)
break
}
case 'handle': {
if (this.editor.getIsReadonly()) break
if (this.editor.inputs.getAltKey()) {
this.parent.transition('pointing_shape', info)
} else {
// If we're holding ctrl key, we might select it, or start brushing...
this.parent.transition('pointing_handle', info)
}
break
}
case 'selection': {
switch (info.handle) {
case 'mobile_rotate':
case 'top_left_rotate':
case 'top_right_rotate':
case 'bottom_left_rotate':
case 'bottom_right_rotate': {
if (info.accelKey) {
this.parent.transition('brushing', info)
break
}
this.parent.transition('pointing_rotate_handle', info)
break
}
case 'top':
case 'right':
case 'bottom':
case 'left':
case 'top_left':
case 'top_right':
case 'bottom_left':
case 'bottom_right': {
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (info.ctrlKey && this.editor.canCropShape(onlySelectedShape)) {
this.parent.transition('crop.pointing_crop_handle', info)
} else {
if (info.accelKey) {
this.parent.transition('brushing', info)
break
}
this.parent.transition('pointing_resize_handle', info)
}
break
}
default: {
const hoveredShape = this.editor.getHoveredShape()
if (
hoveredShape &&
!this.editor.getSelectedShapeIds().includes(hoveredShape.id) &&
!hoveredShape.isLocked
) {
this.onPointerDown({
...info,
shape: hoveredShape,
target: 'shape',
})
return
}
this.parent.transition('pointing_selection', info)
}
}
break
}
}
}
override onDoubleClick(info: TLClickEventInfo) {
if (this.editor.inputs.getShiftKey() || info.phase !== 'up') return
// We don't want to double click while toggling shapes
if (info.ctrlKey || info.shiftKey) return
switch (info.target) {
case 'canvas': {
const hoveredShape = this.editor.getHoveredShape()
// todo
// double clicking on the middle of a hollow geo shape without a label, or
// over the label of a hollwo shape that has a label, should start editing
// that shape's label. We can't support "double click anywhere inside"
// of the shape yet because that also creates text shapes, and can produce
// unexpected results when working "inside of" a hollow shape.
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
const hitShape =
hoveredShape && !this.editor.isShapeOfType(hoveredShape, 'group')
? hoveredShape
: (this.editor.getSelectedShapeAtPoint(currentPagePoint) ??
this.editor.getShapeAtPoint(currentPagePoint, {
margin: this.editor.options.hitTestMargin / this.editor.getZoomLevel(),
hitInside: false,
}))
const focusedGroupId = this.editor.getFocusedGroupId()
if (hitShape) {
if (this.editor.isShapeOfType(hitShape, 'group')) {
// Probably select the shape
selectOnCanvasPointerUp(this.editor, info)
return
} else {
const parent = this.editor.getShape(hitShape.parentId)
if (parent && this.editor.isShapeOfType(parent, 'group')) {
// The shape is the direct child of a group. If the group is
// selected, then we can select the shape. If the group is the
// focus layer id, then we can double click into it as usual.
if (focusedGroupId && parent.id === focusedGroupId) {
// noop, double click on the shape as normal below
} else {
// The shape is the child of some group other than our current
// focus layer. We should probably select the group instead.
selectOnCanvasPointerUp(this.editor, info)
return
}
}
}
// double click on the shape. We'll start editing the
// shape if it's editable or else do a double click on
// the canvas.
this.onDoubleClick({
...info,
shape: hitShape,
target: 'shape',
})
return
}
if (!this.editor.inputs.getShiftKey()) {
this.handleDoubleClickOnCanvas(info)
}
break
}
case 'selection': {
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (onlySelectedShape) {
const util = this.editor.getShapeUtil(onlySelectedShape)
const isEdge =
info.handle === 'right' ||
info.handle === 'left' ||
info.handle === 'top' ||
info.handle === 'bottom'
const isCorner =
info.handle === 'top_left' ||
info.handle === 'top_right' ||
info.handle === 'bottom_right' ||
info.handle === 'bottom_left'
if (this.editor.getIsReadonly()) {
// includes readonly check
if (
this.editor.canEditShape(onlySelectedShape, {
type: isCorner
? 'double-click-corner'
: isEdge
? 'double-click-edge'
: 'double-click',
})
) {
this.startEditingShape(onlySelectedShape, info, true /* select all */)
}
break
}
// Test edges for an onDoubleClickEdge handler
if (isEdge) {
const change = util.onDoubleClickEdge?.(onlySelectedShape, info)
if (change) {
this.editor.markHistoryStoppingPoint('double click edge')
this.editor.updateShapes([change])
kickoutOccludedShapes(this.editor, [onlySelectedShape.id])
return
}
}
if (isCorner) {
const change = util.onDoubleClickCorner?.(onlySelectedShape, info)
if (change) {
this.editor.markHistoryStoppingPoint('double click corner')
this.editor.updateShapes([change])
kickoutOccludedShapes(this.editor, [onlySelectedShape.id])
return
}
}
// For corners OR edges but NOT rotation corners
if (this.editor.canCropShape(onlySelectedShape)) {
this.parent.transition('crop', info)
return
}
if (this.editor.canEditShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, info, true /* select all */)
}
}
break
}
case 'shape': {
const { shape } = info
const util = this.editor.getShapeUtil(shape)
// Allow playing videos and embeds
if (shape.type !== 'video' && shape.type !== 'embed' && this.editor.getIsReadonly()) break
if (util.onDoubleClick) {
// Call the shape's double click handler
const change = util.onDoubleClick?.(shape)
if (change) {
this.editor.updateShapes([change])
return
}
}
if (util.canCrop(shape) && !this.editor.isShapeOrAncestorLocked(shape)) {
// crop image etc on double click
this.editor.markHistoryStoppingPoint('select and crop')
this.editor.select(info.shape?.id)
this.parent.transition('crop', info)
return
}
// If the shape can edit, then begin editing
if (this.editor.canEditShape(shape)) {
this.startEditingShape(shape, info, true /* select all */)
} else {
// If the shape's double click handler has not created a change,
// and if the shape cannot edit, then create a text shape and
// begin editing the text shape
this.handleDoubleClickOnCanvas(info)
}
break
}
case 'handle': {
if (this.editor.getIsReadonly()) break
const { shape, handle } = info
const util = this.editor.getShapeUtil(shape)
const changes = util.onDoubleClickHandle?.(shape, handle)
if (changes) {
this.editor.updateShapes([changes])
} else {
// If the shape's double click handler has not created a change,
// and if the shape can edit, then begin editing the shape.
if (this.editor.canEditShape(shape)) {
this.startEditingShape(shape, info, true /* select all */)
}
}
}
}
}
override onRightClick(info: TLPointerEventInfo) {
switch (info.target) {
case 'canvas': {
const hoveredShape = this.editor.getHoveredShape()
const hitShape =
hoveredShape && !this.editor.isShapeOfType(hoveredShape, 'group')
? hoveredShape
: this.editor.getShapeAtPoint(this.editor.inputs.getCurrentPagePoint(), {
margin: this.editor.options.hitTestMargin / this.editor.getZoomLevel(),
hitInside: false,
hitLabels: true,
hitLocked: true,
hitFrameInside: true,
renderingOnly: true,
})
if (hitShape) {
this.onRightClick({
...info,
shape: hitShape,
target: 'shape',
})
return
}
const selectedShapeIds = this.editor.getSelectedShapeIds()
const onlySelectedShape = this.editor.getOnlySelectedShape()
const currentPagePoint = this.editor.inputs.getCurrentPagePoint()
if (
selectedShapeIds.length > 1 ||
(onlySelectedShape &&
!this.editor.getShapeUtil(onlySelectedShape).hideSelectionBoundsBg(onlySelectedShape))
) {
if (isPointInRotatedSelectionBounds(this.editor, currentPagePoint)) {
this.onRightClick({
...info,
target: 'selection',
})
return
}
}
this.editor.selectNone()
break
}
case 'shape': {
const { selectedShapeIds } = this.editor.getCurrentPageState()
const { shape } = info
const targetShape = this.editor.getOutermostSelectableShape(
shape,
(parent) => !selectedShapeIds.includes(parent.id)
)
if (
!selectedShapeIds.includes(targetShape.id) &&
!this.editor.findShapeAncestor(targetShape, (shape) =>
selectedShapeIds.includes(shape.id)
)
) {
this.editor.markHistoryStoppingPoint('selecting shape')
this.editor.setSelectedShapes([targetShape.id])
}
break
}
}
}
override onCancel() {
if (
this.editor.getFocusedGroupId() !== this.editor.getCurrentPageId() &&
this.editor.getSelectedShapeIds().length > 0
) {
this.editor.popFocusedGroupId()
} else {
this.editor.markHistoryStoppingPoint('clearing selection')
this.editor.selectNone()
}
}
override onKeyDown(info: TLKeyboardEventInfo) {
this.selectedShapesOnKeyDown = this.editor.getSelectedShapes()
switch (info.code) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown': {
if (info.accelKey) {
if (info.shiftKey) {
if (info.code === 'ArrowDown') {
this.editor.selectFirstChildShape()
} else if (info.code === 'ArrowUp') {
this.editor.selectParentShape()
}
} else {
this.editor.selectAdjacentShape(
info.code.replace('Arrow', '').toLowerCase() as TLAdjacentDirection
)
}
return
}
this.nudgeSelectedShapes(false)
return
}
}
if (debugFlags['editOnType'].get()) {
// This feature flag lets us start editing a note shape's label when a key is pressed.
// We exclude certain keys to avoid conflicting with modifiers, but there are conflicts
// with other action kbds, hence why this is kept behind a feature flag.
if (!SKIPPED_KEYS_FOR_AUTO_EDITING.includes(info.key) && !info.altKey && !info.ctrlKey) {
// If the only selected shape is editable, then begin editing it
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (
onlySelectedShape &&
// If it's a note shape, then edit on type
this.editor.isShapeOfType(onlySelectedShape, 'note') &&
// If it's not locked or anything
this.editor.canEditShape(onlySelectedShape)
) {
this.startEditingShape(
onlySelectedShape,
{
...info,
target: 'shape',
shape: onlySelectedShape,
},
true /* select all */
)
return
}
}
}
}
override onKeyRepeat(info: TLKeyboardEventInfo) {
switch (info.code) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown': {
if (info.accelKey) {
this.editor.selectAdjacentShape(
info.code.replace('Arrow', '').toLowerCase() as TLAdjacentDirection
)
return
}
this.nudgeSelectedShapes(true)
break
}
case 'Tab': {
const selectedShapes = this.editor.getSelectedShapes()
if (selectedShapes.length && !info.altKey) {
this.editor.selectAdjacentShape(info.shiftKey ? 'prev' : 'next')
}
break
}
}
}
override onKeyUp(info: TLKeyboardEventInfo) {
switch (info.key) {
case 'Enter': {
// Because Enter onKeyDown can happen outside the canvas (but then focus the canvas potentially),
// we need to check if the canvas was initially selecting something before continuing.
if (!this.selectedShapesOnKeyDown.length) return
const selectedShapes = this.editor.getSelectedShapes()
// On enter, if every selected shape is a group, then select all of the children of the groups
if (selectedShapes.every((shape) => this.editor.isShapeOfType(shape, 'group'))) {
this.editor.setSelectedShapes(
selectedShapes.flatMap((shape) => this.editor.getSortedChildIdsForParent(shape.id))
)
return
}
// If the only selected shape is editable, then begin editing it
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (
onlySelectedShape &&
this.editor.canEditShape(onlySelectedShape, { type: 'press_enter' })
) {
this.startEditingShape(
onlySelectedShape,
{
...info,
target: 'shape',
shape: onlySelectedShape,
},
true /* select all */
)
return
}
// If the only selected shape is croppable, then begin cropping it
if (this.editor.canCropShape(onlySelectedShape)) {
this.parent.transition('crop', info)
}
break
}
case 'Tab': {
const selectedShapes = this.editor.getSelectedShapes()
if (selectedShapes.length && !info.altKey) {
this.editor.selectAdjacentShape(info.shiftKey ? 'prev' : 'next')
}
break
}
}
}
private startEditingShape(
shape: TLShape,
info: TLClickEventInfo | (TLKeyboardEventInfo & { target: 'shape'; shape: TLShape }),
shouldSelectAll?: boolean
) {
const { editor } = this
this.editor.markHistoryStoppingPoint('editing shape')
if (hasRichText(shape)) {
startEditingShapeWithRichText(editor, shape, { selectAll: shouldSelectAll })
} else {
editor.setEditingShape(shape)
}
this.parent.transition('editing_shape', info)
}
isOverArrowLabelTest(shape: TLShape | undefined) {
if (!shape) return false
return isOverArrowLabel(this.editor, shape)
}
handleDoubleClickOnCanvas(info: TLClickEventInfo) {
// Create text shape and transition to editing_shape
if (this.editor.getIsReadonly()) return
if (!this.editor.options.createTextOnCanvasDoubleClick) return
this.editor.markHistoryStoppingPoint('creating text shape')
const id = createShapeId()
const { x, y } = this.editor.inputs.getCurrentPagePoint()
// Allow this to trigger the max shapes reached alert
this.editor.createShapes([
{
id,
type: 'text',
x,
y,
props: {
richText: toRichText(''),
autoSize: true,
},
},
])
const shape = this.editor.getShape(id)
if (!shape) return
if (!this.editor.canEditShape(shape)) return
startEditingShapeWithRichText(this.editor, id, { info })
}
private nudgeSelectedShapes(ephemeral = false) {
const {
editor: {
inputs: { keys },
},
} = this
// We want to use the "actual" shift key state,
// not the one that's in the editor.inputs.shiftKey,
// because that one uses a short timeout on release
const shiftKey = keys.has('ShiftLeft')
const delta = new Vec(0, 0)
if (keys.has('ArrowLeft')) delta.x -= 1
if (keys.has('ArrowRight')) delta.x += 1
if (keys.has('ArrowUp')) delta.y -= 1
if (keys.has('ArrowDown')) delta.y += 1
if (delta.equals(new Vec(0, 0))) return
if (!ephemeral) this.editor.markHistoryStoppingPoint('nudge shapes')
const { gridSize } = this.editor.getDocumentSettings()
const step = this.editor.getInstanceState().isGridMode
? shiftKey
? gridSize * GRID_INCREMENT
: gridSize
: shiftKey
? MAJOR_NUDGE_FACTOR
: MINOR_NUDGE_FACTOR
const selectedShapeIds = this.editor.getSelectedShapeIds()
this.editor.nudgeShapes(selectedShapeIds, delta.mul(step))
kickoutOccludedShapes(this.editor, selectedShapeIds)
}
}
export const MAJOR_NUDGE_FACTOR = 10
export const MINOR_NUDGE_FACTOR = 1
export const GRID_INCREMENT = 5
function isPointInRotatedSelectionBounds(editor: Editor, point: VecLike) {
const selectionBounds = editor.getSelectionRotatedPageBounds()
if (!selectionBounds) return false
const selectionRotation = editor.getSelectionRotation()
if (!selectionRotation) return selectionBounds.containsPoint(point)
return pointInPolygon(
point,
selectionBounds.corners.map((c) => Vec.RotWith(c, selectionBounds.point, selectionRotation))
)
}