import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text'; import { ConnectorUtils, OverlayIdentifier, } from '@blocksuite/affine-block-surface'; import { focusTextModel } from '@blocksuite/affine-components/rich-text'; import type { EdgelessTextBlockModel, FrameBlockModel, NoteBlockModel, } from '@blocksuite/affine-model'; import { ConnectorElementModel, GroupElementModel, MindmapElementModel, ShapeElementModel, TextElementModel, } from '@blocksuite/affine-model'; import { FeatureFlagService, TelemetryProvider, } from '@blocksuite/affine-shared/services'; import { clamp, handleNativeRangeAtPoint, resetNativeSelection, } from '@blocksuite/affine-shared/utils'; import type { PointerEventState } from '@blocksuite/block-std'; import { BaseTool, getTopElements, type GfxBlockElementModel, GfxExtensionIdentifier, type GfxModel, type GfxPrimitiveElementModel, isGfxGroupCompatibleModel, type PointTestOptions, } from '@blocksuite/block-std/gfx'; import type { IVec } from '@blocksuite/global/utils'; import { Bound, DisposableGroup, getCommonBoundWithRotation, last, noop, Vec, } from '@blocksuite/global/utils'; import { effect } from '@preact/signals-core'; import { isSingleMindMapNode } from '../../../_common/edgeless/mindmap/index.js'; import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; import type { EdgelessFrameManager, FrameOverlay } from '../frame-manager.js'; import { prepareCloneData } from '../utils/clone-utils.js'; import { calPanDelta } from '../utils/panning-utils.js'; import { isCanvasElement, isEdgelessTextBlock, isFrameBlock, isNoteBlock, } from '../utils/query.js'; import type { EdgelessSnapManager } from '../utils/snap-manager.js'; import { addText, mountConnectorLabelEditor, mountFrameTitleEditor, mountGroupTitleEditor, mountShapeTextEditor, mountTextElementEditor, } from '../utils/text.js'; import { fitToScreen } from '../utils/viewport.js'; import { CanvasElementEventExt } from './default-tool-ext/event-ext.js'; import type { DefaultToolExt } from './default-tool-ext/ext.js'; import { DefaultModeDragType } from './default-tool-ext/ext.js'; import { MindMapExt } from './default-tool-ext/mind-map-ext/mind-map-ext.js'; export class DefaultTool extends BaseTool { static override toolName: string = 'default'; private _accumulateDelta: IVec = [0, 0]; private _alignBound = new Bound(); private _autoPanTimer: number | null = null; private readonly _clearDisposable = () => { if (this._disposables) { this._disposables.dispose(); this._disposables = null; } }; private readonly _clearSelectingState = () => { this._stopAutoPanning(); this._clearDisposable(); this._wheeling = false; }; private _disposables: DisposableGroup | null = null; private _extHandlers: { dragStart?: (evt: PointerEventState) => void; dragMove?: (evt: PointerEventState) => void; dragEnd?: (evt: PointerEventState) => void; }[] = []; private _exts: DefaultToolExt[] = []; private _hoveredFrame: FrameBlockModel | null = null; // Do not select the text, when click again after activating the note. private _isDoubleClickedOnMask = false; private _lock = false; private readonly _panViewport = (delta: IVec) => { this._accumulateDelta[0] += delta[0]; this._accumulateDelta[1] += delta[1]; this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]); }; private readonly _pendingUpdates = new Map< GfxBlockElementModel | GfxPrimitiveElementModel, Partial >(); private _rafId: number | null = null; private _selectedBounds: Bound[] = []; // For moving the connector label private _selectedConnector: ConnectorElementModel | null = null; private _selectedConnectorLabelBounds: Bound | null = null; private _selectionRectTransition: null | { w: number; h: number; startX: number; startY: number; endX: number; endY: number; } = null; private readonly _startAutoPanning = (delta: IVec) => { this._panViewport(delta); this._updateSelectingState(delta); this._stopAutoPanning(); this._autoPanTimer = window.setInterval(() => { this._panViewport(delta); this._updateSelectingState(delta); }, 30); }; private readonly _stopAutoPanning = () => { if (this._autoPanTimer) { clearTimeout(this._autoPanTimer); this._autoPanTimer = null; } }; private _toBeMoved: GfxModel[] = []; private readonly _updateSelectingState = (delta: IVec = [0, 0]) => { const { gfx } = this; if (gfx.keyboard.spaceKey$.peek() && this._selectionRectTransition) { /* Move the selection if space is pressed */ const curDraggingViewArea = this.controller.draggingViewArea$.peek(); const { w, h, startX, startY, endX, endY } = this._selectionRectTransition; const { endX: lastX, endY: lastY } = curDraggingViewArea; const dx = lastX + delta[0] - endX + this._accumulateDelta[0]; const dy = lastY + delta[1] - endY + this._accumulateDelta[1]; this.controller.draggingViewArea$.value = { ...curDraggingViewArea, x: Math.min(startX + dx, lastX), y: Math.min(startY + dy, lastY), w, h, startX: startX + dx, startY: startY + dy, }; } else { const curDraggingArea = this.controller.draggingViewArea$.peek(); const newStartX = curDraggingArea.startX - delta[0]; const newStartY = curDraggingArea.startY - delta[1]; this.controller.draggingViewArea$.value = { ...curDraggingArea, startX: newStartX, startY: newStartY, x: Math.min(newStartX, curDraggingArea.endX), y: Math.min(newStartY, curDraggingArea.endY), w: Math.abs(curDraggingArea.endX - newStartX), h: Math.abs(curDraggingArea.endY - newStartY), }; } const { x, y, w, h } = this.controller.draggingArea$.peek(); const bound = new Bound(x, y, w, h); let elements = gfx.getElementsByBound(bound).filter(el => { if (isFrameBlock(el)) { return el.childElements.length === 0 || bound.contains(el.elementBound); } if (el instanceof MindmapElementModel) { return bound.contains(el.elementBound); } return true; }); elements = getTopElements(elements).filter(el => !el.isLocked()); const set = new Set( gfx.keyboard.shiftKey$.peek() ? [...elements, ...gfx.selection.selectedElements] : elements ); this.edgelessSelectionManager.set({ elements: Array.from(set).map(element => element.id), editing: false, }); }; private _wheeling = false; dragType = DefaultModeDragType.None; enableHover = true; private get _edgeless(): EdgelessRootBlockComponent | null { const block = this.std.view.getBlock(this.doc.root!.id); return (block as EdgelessRootBlockComponent) ?? null; } private get _frameMgr() { return this.std.get( GfxExtensionIdentifier('frame-manager') ) as EdgelessFrameManager; } private get _supportedExts() { return this._exts.filter(ext => ext.supportedDragTypes.includes(this.dragType) ); } /** * Get the end position of the dragging area in the model coordinate */ get dragLastPos() { const { endX, endY } = this.controller.draggingArea$.peek(); return [endX, endY] as IVec; } /** * Get the start position of the dragging area in the model coordinate */ get dragStartPos() { const { startX, startY } = this.controller.draggingArea$.peek(); return [startX, startY] as IVec; } get edgelessSelectionManager() { return this.gfx.selection; } private get frameOverlay() { return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; } get snapOverlay() { return this.std.get( OverlayIdentifier('snap-manager') ) as EdgelessSnapManager; } private _addEmptyParagraphBlock( block: NoteBlockModel | EdgelessTextBlockModel ) { const blockId = this.doc.addBlock( 'affine:paragraph', { type: 'text' }, block.id ); if (blockId) { focusTextModel(this.std, blockId); } } private async _cloneContent() { this._lock = true; if (!this._edgeless) return; const clipboardController = this._edgeless?.clipboardController; const snapshot = prepareCloneData(this._toBeMoved, this.std); const bound = getCommonBoundWithRotation(this._toBeMoved); const { canvasElements, blockModels } = await clipboardController.createElementsFromClipboardData( snapshot, bound.center ); this._toBeMoved = [...canvasElements, ...blockModels]; this.edgelessSelectionManager.set({ elements: this._toBeMoved.map(e => e.id), editing: false, }); } private _determineDragType(e: PointerEventState): DefaultModeDragType { const { x, y } = e; // Is dragging started from current selected rect if (this.edgelessSelectionManager.isInSelectedRect(x, y)) { if (this.edgelessSelectionManager.selectedElements.length === 1) { let selected = this.edgelessSelectionManager.selectedElements[0]; // double check const currentSelected = this._pick(x, y); if ( !isFrameBlock(selected) && !(selected instanceof GroupElementModel) && currentSelected && currentSelected !== selected ) { selected = currentSelected; this.edgelessSelectionManager.set({ elements: [selected.id], editing: false, }); } if ( isCanvasElement(selected) && ConnectorUtils.isConnectorWithLabel(selected) && (selected as ConnectorElementModel).labelIncludesPoint( this.gfx.viewport.toModelCoord(x, y) ) ) { this._selectedConnector = selected as ConnectorElementModel; this._selectedConnectorLabelBounds = Bound.fromXYWH( this._selectedConnector.labelXYWH! ); return DefaultModeDragType.ConnectorLabelMoving; } } return this.edgelessSelectionManager.editing ? DefaultModeDragType.NativeEditing : DefaultModeDragType.ContentMoving; } else { const selected = this._pick(x, y); if (selected) { this.edgelessSelectionManager.set({ elements: [selected.id], editing: false, }); if ( isCanvasElement(selected) && ConnectorUtils.isConnectorWithLabel(selected) && (selected as ConnectorElementModel).labelIncludesPoint( this.gfx.viewport.toModelCoord(x, y) ) ) { this._selectedConnector = selected as ConnectorElementModel; this._selectedConnectorLabelBounds = Bound.fromXYWH( this._selectedConnector.labelXYWH! ); return DefaultModeDragType.ConnectorLabelMoving; } return DefaultModeDragType.ContentMoving; } else { return DefaultModeDragType.Selecting; } } } private _filterConnectedConnector() { this._toBeMoved = this._toBeMoved.filter(ele => { // eslint-disable-next-line sonarjs/no-collapsible-if if ( ele instanceof ConnectorElementModel && ele.source?.id && ele.target?.id ) { if ( this._toBeMoved.some(e => e.id === ele.source.id) && this._toBeMoved.some(e => e.id === ele.target.id) ) { return false; } } return true; }); } private _isDraggable(element: GfxModel) { return !( element instanceof ConnectorElementModel && !ConnectorUtils.isConnectorAndBindingsAllSelected( element, this._toBeMoved ) ); } private _moveContent( [dx, dy]: IVec, alignBound: Bound, shifted?: boolean, shouldClone?: boolean ) { alignBound.x += dx; alignBound.y += dy; const alignRst = this.snapOverlay.align(alignBound); const delta = [dx + alignRst.dx, dy + alignRst.dy]; if (shifted) { const angle = Math.abs(Math.atan2(delta[1], delta[0])); const direction = angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'x' : 'y'; delta[direction === 'x' ? 1 : 0] = 0; } this._toBeMoved.forEach((element, index) => { const isGraphicElement = isCanvasElement(element); if (isGraphicElement && !this._isDraggable(element)) return; let bound = this._selectedBounds[index]; if (shouldClone) bound = bound.clone(); bound.x += delta[0]; bound.y += delta[1]; if (isGraphicElement) { if (!this._lock) { this._lock = true; this.doc.captureSync(); } if (element instanceof ConnectorElementModel) { element.moveTo(bound); } } this._scheduleUpdate(element, { xywh: bound.serialize(), }); }); this._hoveredFrame = this._frameMgr.getFrameFromPoint( this.dragLastPos, this._toBeMoved.filter(ele => isFrameBlock(ele)) ); this._hoveredFrame && !this._hoveredFrame.isLocked() ? this.frameOverlay.highlight(this._hoveredFrame) : this.frameOverlay.clear(); } private _moveLabel(delta: IVec) { const connector = this._selectedConnector; let bounds = this._selectedConnectorLabelBounds; if (!connector || !bounds) return; bounds = bounds.clone(); const center = connector.getNearestPoint( Vec.add(bounds.center, delta) as IVec ); const distance = connector.getOffsetDistanceByPoint(center as IVec); bounds.center = center; this.gfx.updateElement(connector, { labelXYWH: bounds.toXYWH(), labelOffset: { distance, }, }); } private _pick(x: number, y: number, options?: PointTestOptions) { const modelPos = this.gfx.viewport.toModelCoord(x, y); const tryGetLockedAncestor = (e: GfxModel | null) => { if (e?.isLockedByAncestor()) { return e.groups.findLast(group => group.isLocked()); } return e; }; const frameByPickingTitle = last( this.gfx .getElementByPoint(modelPos[0], modelPos[1], { ...options, all: true, }) .filter( el => isFrameBlock(el) && el.externalBound?.isPointInBound(modelPos) ) ); if (frameByPickingTitle) return tryGetLockedAncestor(frameByPickingTitle); const result = this.gfx.getElementInGroup( modelPos[0], modelPos[1], options ); if (result instanceof MindmapElementModel) { const picked = this.gfx.getElementByPoint(modelPos[0], modelPos[1], { ...((options ?? {}) as PointTestOptions), all: true, }); let pickedIdx = picked.length - 1; while (pickedIdx >= 0) { const element = picked[pickedIdx]; if (element === result) { pickedIdx -= 1; continue; } break; } return tryGetLockedAncestor(picked[pickedIdx]) ?? null; } // if the frame has title, it only can be picked by clicking the title if (isFrameBlock(result) && result.externalXYWH) { return null; } return tryGetLockedAncestor(result); } private _scheduleUpdate( element: GfxBlockElementModel | GfxPrimitiveElementModel, updates: Partial ) { this._pendingUpdates.set(element, updates); if (this._rafId !== null) return; this._rafId = requestAnimationFrame(() => { this._pendingUpdates.forEach((updates, element) => { this.gfx.updateElement(element, updates); }); this._pendingUpdates.clear(); this._rafId = null; }); } private initializeDragState( dragType: DefaultModeDragType, event: PointerEventState ) { this.dragType = dragType; if ( (this._toBeMoved.length && this._toBeMoved.every( ele => !(ele.group instanceof MindmapElementModel) )) || (isSingleMindMapNode(this._toBeMoved) && this._toBeMoved[0].id === (this._toBeMoved[0].group as MindmapElementModel).tree.id) ) { const mindmap = this._toBeMoved[0].group as MindmapElementModel; this._alignBound = this.snapOverlay.setupAlignables(this._toBeMoved, [ mindmap, ...(mindmap?.childElements || []), ]); } this._clearDisposable(); this._disposables = new DisposableGroup(); const ctx = { movedElements: this._toBeMoved, dragType, event, }; this._extHandlers = this._supportedExts.map(ext => ext.initDrag(ctx)); this._selectedBounds = this._toBeMoved.map(element => Bound.deserialize(element.xywh) ); // If the drag type is selecting, set up the dragging area disposable group // If the viewport updates when dragging, should update the dragging area and selection if (this.dragType === DefaultModeDragType.Selecting) { this._disposables.add( this.gfx.viewport.viewportUpdated.on(() => { if ( this.dragType === DefaultModeDragType.Selecting && this.controller.dragging$.peek() && !this._autoPanTimer ) { this._updateSelectingState(); } }) ); return; } if (this.dragType === DefaultModeDragType.ContentMoving) { this._disposables.add( this.gfx.viewport.viewportMoved.on(delta => { if ( this.dragType === DefaultModeDragType.ContentMoving && this.controller.dragging$.peek() && !this._autoPanTimer ) { if ( this._toBeMoved.every(ele => { return !this._isDraggable(ele); }) ) { return; } if (!this._wheeling) { this._wheeling = true; this._selectedBounds = this._toBeMoved.map(element => Bound.deserialize(element.xywh) ); } this._alignBound = this.snapOverlay.setupAlignables( this._toBeMoved ); this._moveContent(delta, this._alignBound); } }) ); return; } } override activate(_: Record): void { if (this.gfx.selection.lastSurfaceSelections.length) { this.gfx.selection.set(this.gfx.selection.lastSurfaceSelections); } } override click(e: PointerEventState) { if (this.doc.readonly) return; const selected = this._pick(e.x, e.y, { ignoreTransparent: true, }); if (selected) { const { selectedIds, surfaceSelections } = this.edgelessSelectionManager; const editing = surfaceSelections[0]?.editing ?? false; // click active canvas text, edgeless text block and note block if ( selectedIds.length === 1 && selectedIds[0] === selected.id && editing ) { // edgeless text block and note block if ( (isNoteBlock(selected) || isEdgelessTextBlock(selected)) && selected.children.length === 0 ) { this._addEmptyParagraphBlock(selected); } // canvas text return; } // click non-active edgeless text block and note block, and then enter editing if ( !selected.isLocked() && !e.keys.shift && selectedIds.length === 1 && (isNoteBlock(selected) || isEdgelessTextBlock(selected)) && ((selectedIds[0] === selected.id && !editing) || (editing && selectedIds[0] !== selected.id)) ) { // issue #1809 // If the previously selected element is a noteBlock and is in an active state, // then the currently clicked noteBlock should also be in an active state when selected. this.edgelessSelectionManager.set({ elements: [selected.id], editing: true, }); this._edgeless?.updateComplete .then(() => { // check if block has children blocks, if not, add a paragraph block and focus on it if (selected.children.length === 0) { this._addEmptyParagraphBlock(selected); } else { const block = this.std.host.view.getBlock(selected.id); if (block) { const rect = block .querySelector('.affine-block-children-container')! .getBoundingClientRect(); const offsetY = 8 * this.gfx.viewport.zoom; const offsetX = 2 * this.gfx.viewport.zoom; const x = clamp( e.raw.clientX, rect.left + offsetX, rect.right - offsetX ); const y = clamp( e.raw.clientY, rect.top + offsetY, rect.bottom - offsetY ); handleNativeRangeAtPoint(x, y); } else { handleNativeRangeAtPoint(e.raw.clientX, e.raw.clientY); } } }) .catch(console.error); return; } this.edgelessSelectionManager.set({ // hold shift key to multi select or de-select element elements: e.keys.shift ? this.edgelessSelectionManager.has(selected.id) ? selectedIds.filter(id => id !== selected.id) : [...selectedIds, selected.id] : [selected.id], editing: false, }); } else if (!e.keys.shift) { this.edgelessSelectionManager.clear(); resetNativeSelection(null); } this._isDoubleClickedOnMask = false; this._supportedExts.forEach(ext => ext.click?.(e)); } override deactivate() { this._stopAutoPanning(); this._clearDisposable(); this._accumulateDelta = [0, 0]; noop(); } override doubleClick(e: PointerEventState) { if (this.doc.readonly) { const viewport = this.gfx.viewport; if (viewport.zoom === 1) { // Fit to Screen fitToScreen( [...this.gfx.layer.blocks, ...this.gfx.layer.canvasElements], this.gfx.viewport ); } else { // Zoom to 100% and Center const [x, y] = viewport.toModelCoord(e.x, e.y); viewport.setViewport(1, [x, y], true); } return; } const selected = this._pick(e.x, e.y, { hitThreshold: 10, }); if (!this._edgeless) { return; } if (!selected) { const textFlag = this.doc .get(FeatureFlagService) .getFlag('enable_edgeless_text'); if (textFlag) { const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); this.std.command.exec(insertEdgelessTextCommand, { x, y }); } else { addText(this._edgeless, e); } this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { control: 'canvas:dbclick', page: 'whiteboard editor', module: 'toolbar', segment: 'toolbar', type: 'text', }); return; } else { if (selected.isLocked()) return; const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); if (selected instanceof TextElementModel) { mountTextElementEditor(selected, this._edgeless, { x, y, }); return; } if (selected instanceof ShapeElementModel) { mountShapeTextEditor(selected, this._edgeless); return; } if (selected instanceof ConnectorElementModel) { mountConnectorLabelEditor(selected, this._edgeless, [x, y]); return; } if (isFrameBlock(selected)) { mountFrameTitleEditor(selected, this._edgeless); return; } if (selected instanceof GroupElementModel) { mountGroupTitleEditor(selected, this._edgeless); return; } } this._supportedExts.forEach(ext => ext.click?.(e)); if ( e.raw.target && e.raw.target instanceof HTMLElement && e.raw.target.classList.contains('affine-note-mask') ) { this.click(e); this._isDoubleClickedOnMask = true; return; } } override dragEnd(e: PointerEventState) { this._extHandlers.forEach(handler => handler.dragEnd?.(e)); this._toBeMoved.forEach(el => { this.doc.transact(() => { el.pop('xywh'); }); if (el instanceof ConnectorElementModel) { el.pop('labelXYWH'); } }); { const frameManager = this._frameMgr; const toBeMovedTopElements = getTopElements( this._toBeMoved.map(el => el.group instanceof MindmapElementModel ? el.group : el ) ); if (this._hoveredFrame) { frameManager.addElementsToFrame( this._hoveredFrame, toBeMovedTopElements ); } else { // only apply to root nodes of trees toBeMovedTopElements.forEach(element => frameManager.removeFromParentFrame(element) ); } } if (this._lock) { this.doc.captureSync(); this._lock = false; } if (this.edgelessSelectionManager.editing) return; this._selectedBounds = []; this.snapOverlay.cleanupAlignables(); this.frameOverlay.clear(); this._toBeMoved = []; this._selectedConnector = null; this._selectedConnectorLabelBounds = null; this._clearSelectingState(); this.dragType = DefaultModeDragType.None; } override dragMove(e: PointerEventState) { const { viewport } = this.gfx; switch (this.dragType) { case DefaultModeDragType.Selecting: { // Record the last drag pointer position for auto panning and view port updating this._updateSelectingState(); const moveDelta = calPanDelta(viewport, e); if (moveDelta) { this._startAutoPanning(moveDelta); } else { this._stopAutoPanning(); } break; } case DefaultModeDragType.AltCloning: case DefaultModeDragType.ContentMoving: { if ( this._toBeMoved.length && this._toBeMoved.every(ele => { return !this._isDraggable(ele); }) ) { return; } if (this._wheeling) { this._wheeling = false; } const dx = this.dragLastPos[0] - this.dragStartPos[0]; const dy = this.dragLastPos[1] - this.dragStartPos[1]; const alignBound = this._alignBound.clone(); const shifted = e.keys.shift || this.gfx.keyboard.shiftKey$.peek(); this._moveContent([dx, dy], alignBound, shifted, true); this._extHandlers.forEach(handler => handler.dragMove?.(e)); break; } case DefaultModeDragType.ConnectorLabelMoving: { const dx = this.dragLastPos[0] - this.dragStartPos[0]; const dy = this.dragLastPos[1] - this.dragStartPos[1]; this._moveLabel([dx, dy]); break; } case DefaultModeDragType.NativeEditing: { // TODO reset if drag out of note break; } } } // eslint-disable-next-line @typescript-eslint/no-misused-promises override async dragStart(e: PointerEventState) { if (this.edgelessSelectionManager.editing) return; // Determine the drag type based on the current state and event let dragType = this._determineDragType(e); const elements = this.edgelessSelectionManager.selectedElements; if (elements.some(e => e.isLocked())) return; const toBeMoved = new Set(elements); elements.forEach(element => { if (isGfxGroupCompatibleModel(element)) { element.descendantElements.forEach(ele => { toBeMoved.add(ele); }); } }); this._toBeMoved = Array.from(toBeMoved); // If alt key is pressed and content is moving, clone the content if (e.keys.alt && dragType === DefaultModeDragType.ContentMoving) { dragType = DefaultModeDragType.AltCloning; await this._cloneContent(); } this._filterConnectedConnector(); // Connector needs to be updated first this._toBeMoved.sort((a, _) => a instanceof ConnectorElementModel ? -1 : 1 ); // Set up drag state this.initializeDragState(dragType, e); // stash the state this._toBeMoved.forEach(ele => { ele.stash('xywh'); if (ele instanceof ConnectorElementModel) { ele.stash('labelXYWH'); } }); this._extHandlers.forEach(handler => handler.dragStart?.(e)); } override mounted() { this.disposable.add( effect(() => { const pressed = this.gfx.keyboard.spaceKey$.value; if (pressed) { const currentDraggingArea = this.controller.draggingViewArea$.peek(); this._selectionRectTransition = { w: currentDraggingArea.w, h: currentDraggingArea.h, startX: currentDraggingArea.startX, startY: currentDraggingArea.startY, endX: currentDraggingArea.endX, endY: currentDraggingArea.endY, }; } else { this._selectionRectTransition = null; } }) ); this._exts = [MindMapExt, CanvasElementEventExt].map( constructor => new constructor(this) ); this._exts.forEach(ext => ext.mounted()); } override pointerDown(e: PointerEventState): void { this._supportedExts.forEach(ext => ext.pointerDown(e)); } override pointerMove(e: PointerEventState) { const hovered = this._pick(e.x, e.y, { hitThreshold: 10, }); if ( isFrameBlock(hovered) && hovered.externalBound?.isPointInBound( this.gfx.viewport.toModelCoord(e.x, e.y) ) ) { this.frameOverlay.highlight(hovered); } else { this.frameOverlay.clear(); } this._supportedExts.forEach(ext => ext.pointerMove(e)); } override pointerUp(e: PointerEventState) { this._supportedExts.forEach(ext => ext.pointerUp(e)); } override tripleClick() { if (this._isDoubleClickedOnMask) return; } override unmounted(): void { this._exts.forEach(ext => ext.unmounted()); } } declare module '@blocksuite/block-std/gfx' { interface GfxToolsMap { default: DefaultTool; } }