import { Group2d, HIT_TEST_MARGIN, StateNode, TLArrowShape, TLEventHandlers, TLGeoShape, TLPointerEventInfo, TLShape, } from '@bigbluebutton/editor' export class PointingShape extends StateNode { static override id = 'pointing_shape' hitShape = {} as TLShape hitShapeForPointerUp = {} as TLShape didSelectOnEnter = false override onEnter = (info: TLPointerEventInfo & { target: 'shape' }) => { const selectedShapeIds = this.editor.getSelectedShapeIds() const selectionBounds = this.editor.getSelectionRotatedPageBounds() const focusedGroupId = this.editor.getFocusedGroupId() const { inputs: { currentPagePoint, shiftKey, altKey }, } = this.editor this.hitShape = info.shape const outermostSelectingShape = this.editor.getOutermostSelectableShape(info.shape) if ( // If the shape has an onClick handler this.editor.getShapeUtil(info.shape).onClick || // ...or if the shape is the focused layer (e.g. group) outermostSelectingShape.id === focusedGroupId || // ...or if the shape is within the selection selectedShapeIds.includes(outermostSelectingShape.id) || this.editor.isAncestorSelected(outermostSelectingShape.id) || // ...or if the current point is NOT within the selection bounds (selectedShapeIds.length > 1 && selectionBounds?.containsPoint(currentPagePoint)) ) { // We won't select the shape on enter, though we might select it on pointer up! this.didSelectOnEnter = false this.hitShapeForPointerUp = outermostSelectingShape return } this.didSelectOnEnter = true if (shiftKey && !altKey) { this.editor.cancelDoubleClick() if (!selectedShapeIds.includes(outermostSelectingShape.id)) { this.editor.mark('shift selecting shape') this.editor.setSelectedShapes([...selectedShapeIds, outermostSelectingShape.id]) } } else { this.editor.mark('selecting shape') this.editor.setSelectedShapes([outermostSelectingShape.id]) } } override onPointerUp: TLEventHandlers['onPointerUp'] = (info) => { const selectedShapeIds = this.editor.getSelectedShapeIds() const focusedGroupId = this.editor.getFocusedGroupId() const zoomLevel = this.editor.getZoomLevel() const { inputs: { currentPagePoint, shiftKey }, } = this.editor const hitShape = this.editor.getShapeAtPoint(currentPagePoint, { margin: HIT_TEST_MARGIN / zoomLevel, hitInside: true, renderingOnly: true, }) ?? this.hitShape const selectingShape = hitShape ? this.editor.getOutermostSelectableShape(hitShape) : this.hitShapeForPointerUp if (selectingShape) { // If the selecting shape has a click handler, call it instead of selecting the shape const util = this.editor.getShapeUtil(selectingShape) if (util.onClick) { const change = util.onClick?.(selectingShape) if (change) { this.editor.mark('shape on click') this.editor.updateShapes([change]) this.parent.transition('idle', info) return } } if (selectingShape.id === focusedGroupId) { if (selectedShapeIds.length > 0) { this.editor.mark('clearing shape ids') this.editor.setSelectedShapes([]) } else { this.editor.popFocusedGroupId() } this.parent.transition('idle', info) return } } if (!this.didSelectOnEnter) { // if the shape has an ancestor which is a focusable layer and it is not focused but it is selected // then we should focus the layer and select the shape const outermostSelectableShape = this.editor.getOutermostSelectableShape( hitShape, // if a group is selected, we want to stop before reaching that group // so we can drill down into the group (parent) => !selectedShapeIds.includes(parent.id) ) // If the outermost shape is selected, then either select or deselect the SELECTING shape if (selectedShapeIds.includes(outermostSelectableShape.id)) { // same shape, so deselect it if shift is pressed, otherwise deselect all others if (shiftKey) { this.editor.mark('deselecting on pointer up') this.editor.deselect(selectingShape) } else { if (selectedShapeIds.includes(selectingShape.id)) { // todo // if the shape is editable and we're inside of an editable part of that shape, e.g. the label of a geo shape, // then we would want to begin editing the shape. At the moment we're relying on the shape label's onPointerUp // handler to do this logic, and prevent the regular pointer up event, so we won't be here in that case. // ! tldraw hack // if the shape is a geo shape, and we're inside of the label, then we want to begin editing the label if ( selectedShapeIds.length === 1 && (this.editor.isShapeOfType(selectingShape, 'geo') || this.editor.isShapeOfType(selectingShape, 'arrow')) ) { const geometry = this.editor.getShapeGeometry(selectingShape) const labelGeometry = (geometry as Group2d).children[1] if (labelGeometry) { const pointInShapeSpace = this.editor.getPointInShapeSpace( selectingShape, currentPagePoint ) if ( labelGeometry.bounds.containsPoint(pointInShapeSpace, 0) && labelGeometry.hitTestPoint(pointInShapeSpace) ) { this.editor.batch(() => { this.editor.mark('editing on pointer up') this.editor.select(selectingShape.id) const util = this.editor.getShapeUtil(selectingShape) if (this.editor.getInstanceState().isReadonly) { if (!util.canEditInReadOnly(selectingShape)) { return } } this.editor.setEditingShape(selectingShape.id) this.editor.setCurrentTool('select.editing_shape') }) return } } } // We just want to select the single shape from the selection this.editor.mark('selecting on pointer up') this.editor.select(selectingShape.id) } else { this.editor.mark('selecting on pointer up') this.editor.select(selectingShape) } } } else if (shiftKey) { // Different shape, so we are drilling down into a group with shift key held. // Deselect any ancestors and add the target shape to the selection const ancestors = this.editor.getShapeAncestors(outermostSelectableShape) this.editor.mark('shift deselecting on pointer up') this.editor.setSelectedShapes([ ...this.editor.getSelectedShapeIds().filter((id) => !ancestors.find((a) => a.id === id)), outermostSelectableShape.id, ]) } else { this.editor.mark('selecting on pointer up') // different shape and we are drilling down, but no shift held so just select it straight up this.editor.setSelectedShapes([outermostSelectableShape.id]) } } this.parent.transition('idle', info) } override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { if (this.editor.getInstanceState().isReadonly) return this.parent.transition('translating', info) } } override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } override onComplete: TLEventHandlers['onComplete'] = () => { this.cancel() } override onInterrupt = () => { this.cancel() } private cancel() { this.parent.transition('idle') } }