UNPKG

tldraw

Version:

A tiny little drawing editor.

680 lines (602 loc) • 19.3 kB
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)) ) }