UNPKG

tldraw

Version:

A tiny little drawing editor.

200 lines (172 loc) 6.52 kB
import { StateNode, TLCancelEventInfo, TLCompleteEventInfo, tlenv, TLFrameShape, TLPointerEventInfo, TLShape, TLTextShape, } from '@tldraw/editor' import { getTextLabels } from '../../../utils/shapes/shapes' import { renderPlaintextFromRichText } from '../../../utils/text/richText' import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown' import { updateHoveredShapeId } from '../../selection-logic/updateHoveredShapeId' interface EditingShapeInfo { isCreatingTextWhileToolLocked?: boolean } export class EditingShape extends StateNode { static override id = 'editing_shape' hitShapeForPointerUp: TLShape | null = null private info = {} as EditingShapeInfo override onEnter(info: EditingShapeInfo) { const editingShape = this.editor.getEditingShape() if (!editingShape) throw Error('Entered editing state without an editing shape') this.hitShapeForPointerUp = null this.info = info if (info.isCreatingTextWhileToolLocked) { this.parent.setCurrentToolIdMask('text') } updateHoveredShapeId(this.editor) this.editor.select(editingShape) } override onExit() { const { editingShapeId } = this.editor.getCurrentPageState() if (!editingShapeId) return // Clear the editing shape this.editor.setEditingShape(null) updateHoveredShapeId.cancel() if (this.info.isCreatingTextWhileToolLocked) { this.parent.setCurrentToolIdMask(undefined) this.editor.setCurrentTool('text', {}) } } override onPointerMove(info: TLPointerEventInfo) { // In the case where on pointer down we hit a shape's label, we need to check if the user is dragging. // and if they are, we need to transition to translating instead. if (this.hitShapeForPointerUp && this.editor.inputs.isDragging) { if (this.editor.getIsReadonly()) return if (this.hitShapeForPointerUp.isLocked) return this.editor.select(this.hitShapeForPointerUp) this.parent.transition('translating', info) this.hitShapeForPointerUp = null return } switch (info.target) { case 'shape': case 'canvas': { updateHoveredShapeId(this.editor) return } } } override onPointerDown(info: TLPointerEventInfo) { this.hitShapeForPointerUp = null switch (info.target) { // N.B. This bit of logic has a bit of history to it. // There was a PR that got rid of this logic: https://github.com/tldraw/tldraw/pull/4237 // But here we bring it back to help support the new rich text world. // The original issue which is visible in the video attachments in the PR now seem // to have been resolved anyway via some other layer. case 'canvas': { const hitShape = getHitShapeOnCanvasPointerDown(this.editor, true /* hitLabels */) if (hitShape) { this.onPointerDown({ ...info, shape: hitShape, target: 'shape', }) return } break } case 'shape': { const { shape: selectingShape } = info const editingShape = this.editor.getEditingShape() if (!editingShape) { throw Error('Expected an editing shape!') } // for shapes with labels, check to see if the click was inside of the shape's label const geometry = this.editor.getShapeUtil(selectingShape).getGeometry(selectingShape) const textLabels = getTextLabels(geometry) const textLabel = textLabels.length === 1 ? textLabels[0] : undefined // N.B. One nuance here is that we want empty text fields to be removed from the canvas when the user clicks away from them. const isEmptyTextShape = this.editor.isShapeOfType<TLTextShape>(editingShape, 'text') && renderPlaintextFromRichText(this.editor, editingShape.props.richText).trim() === '' if (textLabel && !isEmptyTextShape) { const pointInShapeSpace = this.editor.getPointInShapeSpace( selectingShape, this.editor.inputs.currentPagePoint ) if ( textLabel.bounds.containsPoint(pointInShapeSpace, 0) && textLabel.hitTestPoint(pointInShapeSpace) ) { // it's a hit to the label! if (selectingShape.id === editingShape.id) { // If we clicked on the editing geo / arrow shape's label, do nothing return } else { this.hitShapeForPointerUp = selectingShape this.editor.markHistoryStoppingPoint('editing on pointer up') this.editor.select(selectingShape.id) return } } } else { if (selectingShape.id === editingShape.id) { // If we clicked on a frame, while editing its heading, cancel editing if (this.editor.isShapeOfType<TLFrameShape>(selectingShape, 'frame')) { this.editor.setEditingShape(null) this.parent.transition('idle', info) } // If we clicked on the editing shape (which isn't a shape with a label), do nothing } else { // But if we clicked on a different shape of the same type, transition to pointing_shape instead this.parent.transition('pointing_shape', info) return } return } break } } // still here? Cancel editing and transition back to select idle this.parent.transition('idle', info) // then feed the pointer down event back into the state chart as if it happened in that state this.editor.root.handleEvent(info) } override onPointerUp(info: TLPointerEventInfo) { // If we're not dragging, and it's a hit to the label, begin editing the shape. const hitShape = this.hitShapeForPointerUp if (!hitShape) return this.hitShapeForPointerUp = null // Stay in edit mode to maintain flow of editing. const util = this.editor.getShapeUtil(hitShape) if (hitShape.isLocked) return if (this.editor.getIsReadonly()) { if (!util.canEditInReadonly(hitShape)) { this.parent.transition('pointing_shape', info) return } } this.editor.select(hitShape.id) const currentEditingShape = this.editor.getEditingShape() const isEditToEditAction = currentEditingShape && currentEditingShape.id !== hitShape.id this.editor.setEditingShape(hitShape.id) const isMobile = tlenv.isIos || tlenv.isAndroid if (!isMobile || !isEditToEditAction) { this.editor.emit('place-caret', { shapeId: hitShape.id, point: info.point }) } else if (isMobile && isEditToEditAction) { this.editor.emit('select-all-text', { shapeId: hitShape.id }) } updateHoveredShapeId(this.editor) } override onComplete(info: TLCompleteEventInfo) { this.parent.transition('idle', info) } override onCancel(info: TLCancelEventInfo) { this.parent.transition('idle', info) } }