kopia lustrzana https://github.com/Tldraw/Tldraw
576 wiersze
16 KiB
TypeScript
576 wiersze
16 KiB
TypeScript
import {
|
|
Editor,
|
|
HIT_TEST_MARGIN,
|
|
StateNode,
|
|
TLClickEventInfo,
|
|
TLEventHandlers,
|
|
TLGroupShape,
|
|
TLKeyboardEventInfo,
|
|
TLShape,
|
|
TLTextShape,
|
|
Vec2d,
|
|
VecLike,
|
|
createShapeId,
|
|
pointInPolygon,
|
|
} from '@tldraw/editor'
|
|
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
|
|
import { getShouldEnterCropMode } from '../../selection-logic/getShouldEnterCropModeOnPointerDown'
|
|
import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp'
|
|
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
|
|
|
|
export class Idle extends StateNode {
|
|
static override id = 'idle'
|
|
|
|
override onEnter = () => {
|
|
this.parent.setCurrentToolIdMask(undefined)
|
|
updateHoveredId(this.editor)
|
|
this.editor.updateInstanceState(
|
|
{ cursor: { type: 'default', rotation: 0 } },
|
|
{ ephemeral: true }
|
|
)
|
|
}
|
|
|
|
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
|
updateHoveredId(this.editor)
|
|
}
|
|
|
|
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
|
|
if (this.editor.getIsMenuOpen()) return
|
|
|
|
const shouldEnterCropMode = info.ctrlKey && getShouldEnterCropMode(this.editor)
|
|
|
|
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': {
|
|
// 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) {
|
|
this.onPointerDown({
|
|
...info,
|
|
shape: hitShape,
|
|
target: 'shape',
|
|
})
|
|
return
|
|
}
|
|
|
|
const selectedShapeIds = this.editor.getSelectedShapeIds()
|
|
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
|
const {
|
|
inputs: { currentPagePoint },
|
|
} = this.editor
|
|
|
|
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': {
|
|
if (this.editor.isShapeOrAncestorLocked(info.shape)) {
|
|
this.parent.transition('pointing_canvas', info)
|
|
break
|
|
}
|
|
this.parent.transition('pointing_shape', info)
|
|
break
|
|
}
|
|
case 'handle': {
|
|
if (this.editor.getInstanceState().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: {
|
|
const hoveredShape = this.editor.getHoveredShape()
|
|
if (hoveredShape && !this.editor.getSelectedShapeIds().includes(hoveredShape.id)) {
|
|
this.onPointerDown({
|
|
...info,
|
|
shape: hoveredShape,
|
|
target: 'shape',
|
|
})
|
|
return
|
|
}
|
|
|
|
this.parent.transition('pointing_selection', info)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
override onDoubleClick: TLEventHandlers['onDoubleClick'] = (info) => {
|
|
if (this.editor.inputs.shiftKey || info.phase !== 'up') 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 product
|
|
// unexpected results when working "inside of" a hollow shape.
|
|
|
|
const hitShape =
|
|
hoveredShape && !this.editor.isShapeOfType<TLGroupShape>(hoveredShape, 'group')
|
|
? hoveredShape
|
|
: this.editor.getSelectedShapeAtPoint(this.editor.inputs.currentPagePoint) ??
|
|
this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, {
|
|
margin: HIT_TEST_MARGIN / this.editor.getZoomLevel(),
|
|
hitInside: false,
|
|
})
|
|
|
|
const focusedGroupId = this.editor.getFocusedGroupId()
|
|
|
|
if (hitShape) {
|
|
if (this.editor.isShapeOfType<TLGroupShape>(hitShape, 'group')) {
|
|
// Probably select the shape
|
|
selectOnCanvasPointerUp(this.editor)
|
|
return
|
|
} else {
|
|
const parent = this.editor.getShape(hitShape.parentId)
|
|
if (parent && this.editor.isShapeOfType<TLGroupShape>(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)
|
|
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.shiftKey) {
|
|
this.handleDoubleClickOnCanvas(info)
|
|
}
|
|
break
|
|
}
|
|
case 'selection': {
|
|
if (this.editor.getInstanceState().isReadonly) break
|
|
|
|
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
|
|
|
if (onlySelectedShape) {
|
|
const util = this.editor.getShapeUtil(onlySelectedShape)
|
|
|
|
if (!this.canInteractWithShapeInReadOnly(onlySelectedShape)) {
|
|
return
|
|
}
|
|
|
|
// 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.getInstanceState().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.handleDoubleClickOnCanvas(info)
|
|
}
|
|
break
|
|
}
|
|
case 'handle': {
|
|
if (this.editor.getInstanceState().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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override onRightClick: TLEventHandlers['onRightClick'] = (info) => {
|
|
switch (info.target) {
|
|
case 'canvas': {
|
|
const hoveredShape = this.editor.getHoveredShape()
|
|
const hitShape =
|
|
hoveredShape && !this.editor.isShapeOfType<TLGroupShape>(hoveredShape, 'group')
|
|
? hoveredShape
|
|
: this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, {
|
|
margin: HIT_TEST_MARGIN / this.editor.getZoomLevel(),
|
|
hitInside: false,
|
|
hitLabels: true,
|
|
hitFrameInside: false,
|
|
renderingOnly: true,
|
|
})
|
|
|
|
if (hitShape) {
|
|
this.onRightClick({
|
|
...info,
|
|
shape: hitShape,
|
|
target: 'shape',
|
|
})
|
|
return
|
|
}
|
|
|
|
const selectedShapeIds = this.editor.getSelectedShapeIds()
|
|
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
|
const {
|
|
inputs: { currentPagePoint },
|
|
} = this.editor
|
|
|
|
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.mark('selecting shape')
|
|
this.editor.setSelectedShapes([targetShape.id])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
override onCancel: TLEventHandlers['onCancel'] = () => {
|
|
if (
|
|
this.editor.getFocusedGroupId() !== this.editor.getCurrentPageId() &&
|
|
this.editor.getSelectedShapeIds().length > 0
|
|
) {
|
|
this.editor.popFocusedGroupId()
|
|
} else {
|
|
this.editor.mark('clearing selection')
|
|
this.editor.selectNone()
|
|
}
|
|
}
|
|
|
|
override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
|
|
switch (info.code) {
|
|
case 'ArrowLeft':
|
|
case 'ArrowRight':
|
|
case 'ArrowUp':
|
|
case 'ArrowDown': {
|
|
this.nudgeSelectedShapes(false)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
override onKeyRepeat: TLEventHandlers['onKeyDown'] = (info) => {
|
|
switch (info.code) {
|
|
case 'ArrowLeft':
|
|
case 'ArrowRight':
|
|
case 'ArrowUp':
|
|
case 'ArrowDown': {
|
|
this.nudgeSelectedShapes(true)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
override onKeyUp = (info: TLKeyboardEventInfo) => {
|
|
switch (info.code) {
|
|
case 'Enter': {
|
|
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<TLGroupShape>(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.shouldStartEditingShape(onlySelectedShape)) {
|
|
this.startEditingShape(onlySelectedShape, {
|
|
...info,
|
|
target: 'shape',
|
|
shape: onlySelectedShape,
|
|
})
|
|
return
|
|
}
|
|
|
|
// If the only selected shape is croppable, then begin cropping it
|
|
if (getShouldEnterCropMode(this.editor)) {
|
|
this.parent.transition('crop', info)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private shouldStartEditingShape(
|
|
shape: TLShape | null = this.editor.getOnlySelectedShape()
|
|
): boolean {
|
|
if (!shape) return false
|
|
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return false
|
|
if (!this.canInteractWithShapeInReadOnly(shape)) return false
|
|
return this.editor.getShapeUtil(shape).canEdit(shape)
|
|
}
|
|
|
|
private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) {
|
|
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return
|
|
this.editor.mark('editing shape')
|
|
this.editor.setEditingShape(shape.id)
|
|
this.parent.transition('editing_shape', info)
|
|
}
|
|
|
|
isDarwin = window.navigator.userAgent.toLowerCase().indexOf('mac') > -1
|
|
|
|
handleDoubleClickOnCanvas(info: TLClickEventInfo) {
|
|
// Create text shape and transition to editing_shape
|
|
if (this.editor.getInstanceState().isReadonly) return
|
|
|
|
this.editor.mark('creating text shape')
|
|
|
|
const id = createShapeId()
|
|
|
|
const { x, y } = this.editor.inputs.currentPagePoint
|
|
|
|
this.editor.createShapes<TLTextShape>([
|
|
{
|
|
id,
|
|
type: 'text',
|
|
x,
|
|
y,
|
|
props: {
|
|
text: '',
|
|
autoSize: true,
|
|
},
|
|
},
|
|
])
|
|
|
|
const shape = this.editor.getShape(id)
|
|
if (!shape) return
|
|
|
|
const util = this.editor.getShapeUtil(shape)
|
|
if (this.editor.getInstanceState().isReadonly) {
|
|
if (!util.canEditInReadOnly(shape)) {
|
|
return
|
|
}
|
|
}
|
|
|
|
this.editor.setEditingShape(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')
|
|
|
|
const { gridSize } = this.editor.getDocumentSettings()
|
|
|
|
const step = this.editor.getInstanceState().isGridMode
|
|
? shiftKey
|
|
? gridSize * GRID_INCREMENT
|
|
: gridSize
|
|
: shiftKey
|
|
? MAJOR_NUDGE_FACTOR
|
|
: MINOR_NUDGE_FACTOR
|
|
|
|
this.editor.nudgeShapes(this.editor.getSelectedShapeIds(), delta.mul(step))
|
|
}
|
|
|
|
private canInteractWithShapeInReadOnly(shape: TLShape) {
|
|
if (!this.editor.getInstanceState().isReadonly) return true
|
|
const util = this.editor.getShapeUtil(shape)
|
|
if (util.canEditInReadOnly(shape)) return true
|
|
return false
|
|
}
|
|
}
|
|
|
|
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) => Vec2d.RotWith(c, selectionBounds.point, selectionRotation))
|
|
)
|
|
}
|