Tldraw/packages/editor/src/lib/app/tools/SelectTool/children/Idle.ts

443 wiersze
11 KiB
TypeScript

import { Vec2d } from '@tldraw/primitives'
import { TLGeoShape, TLShape, createShapeId } from '@tldraw/tlschema'
import { debugFlags } from '../../../../utils/debug-flags'
import {
TLClickEventInfo,
TLEventHandlers,
TLKeyboardEventInfo,
TLPointerEventInfo,
} from '../../../types/event-types'
import { StateNode } from '../../StateNode'
export class Idle extends StateNode {
static override id = 'idle'
isDarwin = window.navigator.userAgent.toLowerCase().indexOf('mac') > -1
onPointerEnter: TLEventHandlers['onPointerEnter'] = (info) => {
switch (info.target) {
case 'canvas': {
// noop
break
}
case 'shape': {
const { selectedIds, focusLayerId } = this.editor
const hoveringShape = this.editor.getOutermostSelectableShape(
info.shape,
(parent) => !selectedIds.includes(parent.id)
)
if (hoveringShape.id !== focusLayerId) {
this.editor.setHoveredId(hoveringShape.id)
}
// Custom cursor debugging!
// Change the cursor to the type specified by the shape's text label
if (debugFlags.debugCursors.value) {
if (hoveringShape.type !== 'geo') break
const cursorType = (hoveringShape as TLGeoShape).props.text
try {
this.editor.setCursor({ type: cursorType })
} catch (e) {
console.error(`Cursor type not recognized: '${cursorType}'`)
this.editor.setCursor({ type: 'default' })
}
}
break
}
}
}
onPointerLeave: TLEventHandlers['onPointerEnter'] = (info) => {
switch (info.target) {
case 'shape': {
this.editor.setHoveredId(null)
break
}
}
}
onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
if (this.editor.isMenuOpen) return
const shouldEnterCropMode = this.shouldEnterCropMode(info, true)
if (info.ctrlKey && !shouldEnterCropMode) {
// On Mac, you can right click using the Control keys + Click.
if (info.target === 'shape' && this.isDarwin && this.editor.inputs.keys.has('ControlLeft')) {
if (!this.editor.isShapeOrAncestorLocked(info.shape)) {
this.parent.transition('pointing_shape', info)
return
}
}
this.parent.transition('brushing', info)
return
}
switch (info.target) {
case 'canvas': {
this.parent.transition('pointing_canvas', info)
break
}
case 'shape': {
if (this.editor.isShapeOrAncestorLocked(info.shape)) break
this.parent.transition('pointing_shape', info)
break
}
case 'handle': {
if (this.editor.isReadOnly) break
if (this.editor.inputs.altKey) {
this.parent.transition('pointing_shape', info)
} else {
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': {
this.parent.transition('pointing_rotate_handle', info)
break
}
case 'top':
case 'right':
case 'bottom':
case 'left': {
if (shouldEnterCropMode) {
this.parent.transition('pointing_crop_handle', info)
} else {
this.parent.transition('pointing_resize_handle', info)
}
break
}
case 'top_left':
case 'top_right':
case 'bottom_left':
case 'bottom_right': {
if (shouldEnterCropMode) {
this.parent.transition('pointing_crop_handle', info)
} else {
this.parent.transition('pointing_resize_handle', info)
}
break
}
default: {
this.parent.transition('pointing_selection', info)
}
}
break
}
}
}
onDoubleClick: TLEventHandlers['onDoubleClick'] = (info) => {
if (info.phase !== 'up') return
switch (info.target) {
case 'canvas': {
// Create text shape and transition to editing_shape
if (this.editor.isReadOnly) break
this.createTextShapeAtPoint(info)
break
}
case 'selection': {
if (this.editor.isReadOnly) break
const { onlySelectedShape } = this.editor
if (onlySelectedShape) {
const util = this.editor.getShapeUtil(onlySelectedShape)
// Test edges for an onDoubleClickEdge handler
if (
info.handle === 'right' ||
info.handle === 'left' ||
info.handle === 'top' ||
info.handle === 'bottom'
) {
const change = util.onDoubleClickEdge?.(onlySelectedShape)
if (change) {
this.editor.mark('double click edge')
this.editor.updateShapes([change])
return
}
}
// For corners OR edges
if (
util.canCrop(onlySelectedShape) &&
!this.editor.isShapeOrAncestorLocked(onlySelectedShape)
) {
this.parent.transition('crop', info)
return
}
if (this.shouldStartEditingShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, info)
}
}
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.isReadOnly) break
if (util.onDoubleClick) {
// Call the shape's double click handler
const change = util.onDoubleClick?.(shape)
if (change) {
this.editor.updateShapes([change])
return
} else if (util.canCrop(shape) && !this.editor.isShapeOrAncestorLocked(shape)) {
// crop on double click
this.editor.mark('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.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info)
} 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.createTextShapeAtPoint(info)
}
break
}
case 'handle': {
if (this.editor.isReadOnly) 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.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info)
}
}
}
}
}
onRightClick: TLEventHandlers['onRightClick'] = (info) => {
switch (info.target) {
case 'canvas': {
this.editor.selectNone()
break
}
case 'shape': {
const { selectedIds } = this.editor.pageState
const { shape } = info
const targetShape = this.editor.getOutermostSelectableShape(
shape,
(parent) => !this.editor.isSelected(parent.id)
)
if (!selectedIds.includes(targetShape.id)) {
this.editor.mark('selecting shape')
this.editor.setSelectedIds([targetShape.id])
}
break
}
}
}
onEnter = () => {
this.editor.setHoveredId(null)
this.editor.setCursor({ type: 'default' })
}
onCancel: TLEventHandlers['onCancel'] = () => {
if (
this.editor.focusLayerId !== this.editor.currentPageId &&
this.editor.selectedIds.length > 0
) {
this.editor.popFocusLayer()
} else {
this.editor.mark('clearing selection')
this.editor.selectNone()
}
}
onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
switch (info.code) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown': {
this.nudgeSelectedShapes(false)
break
}
}
}
onKeyRepeat: TLEventHandlers['onKeyDown'] = (info) => {
switch (info.code) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown': {
this.nudgeSelectedShapes(true)
break
}
}
}
onKeyUp = (info: TLKeyboardEventInfo) => {
if (this.editor.isReadOnly) {
switch (info.code) {
case 'Enter': {
if (this.shouldStartEditingShape() && this.editor.onlySelectedShape) {
this.startEditingShape(this.editor.onlySelectedShape, {
...info,
target: 'shape',
shape: this.editor.onlySelectedShape,
})
return
}
break
}
}
} else {
switch (info.code) {
case 'Enter': {
const { selectedShapes } = this.editor
if (selectedShapes.every((shape) => shape.type === 'group')) {
this.editor.setSelectedIds(
selectedShapes.flatMap((shape) => this.editor.getSortedChildIds(shape.id))
)
return
}
if (this.shouldStartEditingShape() && this.editor.onlySelectedShape) {
this.startEditingShape(this.editor.onlySelectedShape, {
...info,
target: 'shape',
shape: this.editor.onlySelectedShape,
})
return
}
if (this.shouldEnterCropMode(info, false)) {
this.parent.transition('crop', info)
}
break
}
}
}
}
private shouldStartEditingShape(shape: TLShape | null = this.editor.onlySelectedShape): boolean {
if (!shape) return false
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return false
const util = this.editor.getShapeUtil(shape)
return util.canEdit(shape)
}
private shouldEnterCropMode(
info: TLPointerEventInfo | TLKeyboardEventInfo,
withCtrlKey: boolean
): boolean {
const singleShape = this.editor.onlySelectedShape
if (!singleShape) return false
if (this.editor.isShapeOrAncestorLocked(singleShape)) return false
const shapeUtil = this.editor.getShapeUtil(singleShape)
// Should the Ctrl key be pressed to enter crop mode
if (withCtrlKey) {
return shapeUtil.canCrop(singleShape) && info.ctrlKey
} else {
return shapeUtil.canCrop(singleShape)
}
}
private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) {
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return
this.editor.mark('editing shape')
this.editor.setEditingId(shape.id)
this.parent.transition('editing_shape', info)
}
private createTextShapeAtPoint(info: TLClickEventInfo) {
this.editor.mark('creating text shape')
const id = createShapeId()
const { x, y } = this.editor.inputs.currentPagePoint
this.editor.createShapes([
{
id,
type: 'text',
x,
y,
props: {
text: '',
autoSize: true,
},
},
])
const shape = this.editor.getShapeById(id)
if (!shape) return
const bounds = this.editor.getBounds(shape)
this.editor.updateShapes([
{
id,
type: 'text',
x: shape.x - bounds.width / 2,
y: shape.y - bounds.height / 2,
},
])
this.editor.setEditingId(id)
this.editor.select(id)
this.parent.transition('editing_shape', 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 Vec2d(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 Vec2d(0, 0))) return
if (!ephemeral) this.editor.mark('nudge shapes')
this.editor.nudgeShapes(this.editor.selectedIds, delta, shiftKey)
}
}