UNPKG

@itwin/core-frontend

Version:
1,071 lines • 81.9 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module Tools */ import { AbandonedError, assert, BeEvent, BeTimePoint, IModelStatus, Logger } from "@itwin/core-bentley"; import { Matrix3d, Point2d, Point3d, Transform } from "@itwin/core-geometry"; import { Easing, NpcCenter } from "@itwin/core-common"; import { TentativeOrAccuSnap } from "../AccuSnap"; import { FrontendLoggerCategory } from "../common/FrontendLoggerCategory"; import { IModelApp } from "../IModelApp"; import { linePlaneIntersect } from "../LinePlaneIntersect"; import { MessageBoxIconType, MessageBoxType } from "../NotificationManager"; import { IconSprites } from "../Sprites"; import { DynamicsContext } from "../ViewContext"; import { ScreenViewport } from "../Viewport"; import { ViewStatus } from "../ViewStatus"; import { PrimitiveTool } from "./PrimitiveTool"; import { BeButton, BeButtonEvent, BeButtonState, BeModifierKeys, BeTouchEvent, BeWheelEvent, CoordinateLockOverrides, CoordSource, EventHandled, InputSource, } from "./Tool"; import { ToolSettings } from "./ToolSettings"; /** * @public * @extensions */ export var StartOrResume; (function (StartOrResume) { StartOrResume[StartOrResume["Start"] = 1] = "Start"; StartOrResume[StartOrResume["Resume"] = 2] = "Resume"; })(StartOrResume || (StartOrResume = {})); /** * @public * @extensions */ export var ManipulatorToolEvent; (function (ManipulatorToolEvent) { ManipulatorToolEvent[ManipulatorToolEvent["Start"] = 1] = "Start"; ManipulatorToolEvent[ManipulatorToolEvent["Stop"] = 2] = "Stop"; ManipulatorToolEvent[ManipulatorToolEvent["Suspend"] = 3] = "Suspend"; ManipulatorToolEvent[ManipulatorToolEvent["Unsuspend"] = 4] = "Unsuspend"; })(ManipulatorToolEvent || (ManipulatorToolEvent = {})); /** Maintains the state of tool settings properties for the current session. * @see [[ToolAdmin.toolSettingsState]] to access the state for the current session. * @public */ export class ToolSettingsState { /** Retrieve saved tool settings DialogItemValue by property name. */ getInitialToolSettingValue(toolId, propertyName) { const key = `${toolId}:${propertyName}`; const savedValue = window.sessionStorage.getItem(key); if (null !== savedValue) { return JSON.parse(savedValue); } return undefined; } /** Retrieve an array of DialogPropertyItem with the values latest values that were used in the session. */ getInitialToolSettingValues(toolId, propertyNames) { const initializedProperties = []; let propertyValue; propertyNames.forEach((propertyName) => { propertyValue = this.getInitialToolSettingValue(toolId, propertyName); if (propertyValue) initializedProperties.push({ value: propertyValue, propertyName }); }); return initializedProperties.length ? initializedProperties : undefined; } /** Save single tool settings value to session storage. */ saveToolSettingProperty(toolId, item) { const key = `${toolId}:${item.propertyName}`; const objectAsString = JSON.stringify(item.value); window.sessionStorage.setItem(key, objectAsString); } /** Save an array of tool settings values to session storage */ saveToolSettingProperties(toolId, tsProps) { tsProps.forEach((item) => this.saveToolSettingProperty(toolId, item)); } } /** @internal */ export class ToolState { coordLockOvr = CoordinateLockOverrides.None; locateCircleOn = false; setFrom(other) { this.coordLockOvr = other.coordLockOvr; this.locateCircleOn = other.locateCircleOn; } clone() { const val = new ToolState(); val.setFrom(this); return val; } } /** @internal */ export class SuspendedToolState { _toolState; _accuSnapState; _locateOptions; _viewCursor; _inDynamics; _shuttingDown = false; constructor() { const { toolAdmin, viewManager, accuSnap, locateManager } = IModelApp; toolAdmin.setIncompatibleViewportCursor(true); // Don't save this this._toolState = toolAdmin.toolState.clone(); this._accuSnapState = accuSnap.toolState.clone(); this._locateOptions = locateManager.options.clone(); this._viewCursor = viewManager.cursor; this._inDynamics = viewManager.inDynamicsMode; if (this._inDynamics) viewManager.endDynamicsMode(); } stop() { if (this._shuttingDown) return; const { toolAdmin, viewManager, accuSnap, locateManager } = IModelApp; toolAdmin.setIncompatibleViewportCursor(true); // Don't restore this toolAdmin.toolState.setFrom(this._toolState); accuSnap.toolState.setFrom(this._accuSnapState); locateManager.options.setFrom(this._locateOptions); viewManager.setViewCursor(this._viewCursor); if (this._inDynamics) viewManager.beginDynamicsMode(); else viewManager.endDynamicsMode(); } } /** @internal */ export class CurrentInputState { _rawPoint = new Point3d(); _point = new Point3d(); _viewPoint = new Point3d(); qualifiers = BeModifierKeys.None; viewport; button = [new BeButtonState(), new BeButtonState(), new BeButtonState()]; lastButton = BeButton.Data; inputSource = InputSource.Unknown; lastMotion = new Point2d(); lastMotionEvent; lastWheelEvent; lastTouchStart; touchTapTimer; touchTapCount; get rawPoint() { return this._rawPoint; } set rawPoint(pt) { this._rawPoint.setFrom(pt); } get point() { return this._point; } set point(pt) { this._point.setFrom(pt); } get viewPoint() { return this._viewPoint; } set viewPoint(pt) { this._viewPoint.setFrom(pt); } get isShiftDown() { return 0 !== (this.qualifiers & BeModifierKeys.Shift); } get isControlDown() { return 0 !== (this.qualifiers & BeModifierKeys.Control); } get isAltDown() { return 0 !== (this.qualifiers & BeModifierKeys.Alt); } isDragging(button) { return this.button[button].isDragging; } onStartDrag(button) { this.button[button].isDragging = true; } onInstallTool() { this.clearKeyQualifiers(); this.lastWheelEvent = this.lastMotionEvent = undefined; this.lastTouchStart = this.touchTapTimer = this.touchTapCount = undefined; } clearKeyQualifiers() { this.qualifiers = BeModifierKeys.None; } clearViewport(vp) { if (vp === this.viewport) this.viewport = undefined; } isAnyDragging() { return this.button.some((button) => button.isDragging); } setKeyQualifier(qual, down) { this.qualifiers = down ? (this.qualifiers | qual) : (this.qualifiers & (~qual)); } setKeyQualifiers(ev) { this.setKeyQualifier(BeModifierKeys.Shift, ev.shiftKey); this.setKeyQualifier(BeModifierKeys.Control, ev.ctrlKey); this.setKeyQualifier(BeModifierKeys.Alt, ev.altKey); } onMotion(pt2d) { this.lastMotion.x = pt2d.x; this.lastMotion.y = pt2d.y; } changeButtonToDownPoint(ev) { ev.point = this.button[ev.button].downUorPt; ev.rawPoint = this.button[ev.button].downRawPt; if (ev.viewport) ev.viewPoint = ev.viewport.worldToView(ev.rawPoint); } updateDownPoint(ev) { this.button[ev.button].downUorPt = ev.point; } onButtonDown(button) { let isDoubleClick = false; const now = Date.now(); const vp = this.viewport; if (undefined !== vp) { const viewPt = vp.worldToView(this.button[button].downRawPt); const center = vp.npcToView(NpcCenter); viewPt.z = center.z; isDoubleClick = ((now - this.button[button].downTime) < ToolSettings.doubleClickTimeout.milliseconds) && (viewPt.distance(this.viewPoint) < vp.pixelsFromInches(ToolSettings.doubleClickToleranceInches)); } this.button[button].init(this.point, this.rawPoint, now, true, isDoubleClick, false, this.inputSource); this.lastButton = button; } onButtonUp(button) { this.button[button].isDown = false; this.button[button].isDragging = false; this.lastButton = button; } toEvent(ev, useSnap) { let coordsFrom = CoordSource.User; const point = this.point.clone(); let viewport = this.viewport; if (useSnap) { const snap = TentativeOrAccuSnap.getCurrentSnap(false); if (snap) { coordsFrom = snap.isHot ? CoordSource.ElemSnap : CoordSource.User; point.setFrom(snap.isPointAdjusted ? snap.adjustedPoint : snap.getPoint()); // NOTE: adjustedPoint can be set by adjustSnapPoint even when not hot... viewport = snap.viewport; } else if (IModelApp.tentativePoint.isActive) { coordsFrom = CoordSource.TentativePoint; point.setFrom(IModelApp.tentativePoint.getPoint()); viewport = IModelApp.tentativePoint.viewport; } } const buttonState = this.button[this.lastButton]; ev.init({ point, rawPoint: this.rawPoint, viewPoint: this.viewPoint, viewport, coordsFrom, keyModifiers: this.qualifiers, button: this.lastButton, isDown: buttonState.isDown, isDoubleClick: buttonState.isDoubleClick, isDragging: buttonState.isDragging, inputSource: this.inputSource, }); } adjustLastDataPoint(ev) { const state = this.button[BeButton.Data]; state.downUorPt = ev.point; state.downRawPt = ev.point; this.viewport = ev.viewport; } toEventFromLastDataPoint(ev) { const state = this.button[BeButton.Data]; const point = state.downUorPt; const rawPoint = state.downRawPt; const viewPoint = this.viewport ? this.viewport.worldToView(rawPoint) : Point3d.create(); // BeButtonEvent is invalid when viewport is undefined ev.init({ point, rawPoint, viewPoint, viewport: this.viewport, coordsFrom: CoordSource.User, keyModifiers: this.qualifiers, button: BeButton.Data, isDown: state.isDown, isDoubleClick: state.isDoubleClick, isDragging: state.isDragging, inputSource: state.inputSource, }); } fromPoint(vp, pt, source) { this.viewport = vp; this._viewPoint.x = pt.x; this._viewPoint.y = pt.y; this._viewPoint.z = vp.npcToView(NpcCenter).z; vp.viewToWorld(this._viewPoint, this._rawPoint); this.point = this._rawPoint; this.inputSource = source; } fromButton(vp, pt, source, applyLocks) { this.fromPoint(vp, pt, source); // NOTE: Using the hit point on the element is preferable to ignoring a snap that is not "hot" completely if (TentativeOrAccuSnap.getCurrentSnap(false)) { if (applyLocks) IModelApp.toolAdmin.adjustSnapPoint(); return; } IModelApp.toolAdmin.adjustPoint(this._point, vp, true, applyLocks); } isStartDrag(button) { // First make sure we aren't already dragging any button if (this.isAnyDragging()) return false; const state = this.button[button]; if (!state.isDown) return false; if ((Date.now() - state.downTime) <= ToolSettings.startDragDelay.milliseconds) return false; const vp = this.viewport; if (undefined === vp) return false; const viewPt = vp.worldToView(state.downRawPt); const deltaX = Math.abs(this._viewPoint.x - viewPt.x); const deltaY = Math.abs(this._viewPoint.y - viewPt.y); return ((deltaX + deltaY) > vp.pixelsFromInches(ToolSettings.startDragDistanceInches)); } } /** Controls the operation of [[Tool]]s, administering the current [[ViewTool]], [[PrimitiveTool]], and [[IdleTool]] and forwarding events to the appropriate tool. * @see [[IModelApp.toolAdmin]] to access the session's `ToolAdmin`. * @public * @extensions */ export class ToolAdmin { markupView; /** @internal */ currentInputState = new CurrentInputState(); /** @internal */ toolState = new ToolState(); /** Maintains the state of tool settings properties for the current session. */ toolSettingsState = new ToolSettingsState(); _canvasDecoration; _suspendedByViewTool; _suspendedByInputCollector; _viewTool; _primitiveTool; _idleTool; _inputCollector; _saveCursor; _saveLocateCircle = false; _defaultToolId = "Select"; _defaultToolArgs; _lastHandledMotionTime; _mouseMoveOverTimeout; _editCommandHandler; /** The name of the [[PrimitiveTool]] to use as the default tool. * Defaults to "Select", referring to [[SelectionTool]]. * @note An empty string signifies no default tool allowing more events to be handled by [[idleTool]]. * @see [[startDefaultTool]] to activate the default tool. * @see [[defaultToolArgs]] to supply arguments when starting the tool. */ get defaultToolId() { return this._defaultToolId; } set defaultToolId(toolId) { this._defaultToolId = toolId; } /** The arguments supplied to the default [[Tool]]'s [[Tool.run]] method from [[startDefaultTool]]. * @see [[defaultToolId]] to configure the default tool. */ get defaultToolArgs() { return this._defaultToolArgs; } set defaultToolArgs(args) { this._defaultToolArgs = args; } /** Apply operations such as transform, copy or delete to all members of an assembly. */ assemblyLock = false; /** If Grid Lock is on, project data points to grid. */ gridLock = false; /** If ACS Snap Lock is on, project snap points to the ACS plane. */ acsPlaneSnapLock = false; /** If ACS Plane Lock is on, standard view rotations are relative to the ACS instead of global. */ acsContextLock = false; /** Options for how uncaught exceptions should be handled by [[ToolAdmin.exceptionHandler]]. */ static exceptionOptions = { /** Log exception to Logger. */ log: true, /** Show an alert box explaining that a problem happened. */ alertBox: true, /** Include the "gory details" (e.g. stack trace) in the alert box. */ details: true, /** break into debugger (only works if debugger is already opened) */ launchDebugger: true, }; /** A function that catches otherwise-uncaught exceptions occurring inside ToolAdmin.eventLoop. * To customize the behavior of this function, modify [[ToolAdmin.exceptionOptions]]. * To replace it within your own handler, simply assign to it, e.g.: * ```ts * ToolAdmin.exceptionHandler = (exception: any): Promise<any> => { * ... your implementation here * } * ``` */ static async exceptionHandler(exception) { const opts = ToolAdmin.exceptionOptions; const msg = undefined !== exception.stack ? exception.stack : exception.toString(); if (opts.log) Logger.logError(`${FrontendLoggerCategory.Package}.unhandledException`, msg); if (opts.launchDebugger) // this does nothing if the debugger window is not already opened debugger; // eslint-disable-line no-debugger if (!opts.alertBox) return; let out = `<h2>${IModelApp.localization.getLocalizedString("iModelJs:Errors.ReloadPage")}</h2>`; if (opts.details) { out += `<h3>${IModelApp.localization.getLocalizedString("iModelJs:Errors.Details")}</h3><h4>`; msg.split("\n").forEach((line) => out += `${line}<br>`); out += "</h4>"; } const div = document.createElement("div"); div.innerHTML = out; return IModelApp.notifications.openMessageBox(MessageBoxType.MediumAlert, div, MessageBoxIconType.Critical); } static _removals = []; /** The registered handler method that will update the UI with any property value changes. * @internal */ _toolSettingsChangeHandler = undefined; /** Returns the handler registered by the UI layer that allows it to display property changes made by the active Tool. */ get toolSettingsChangeHandler() { return this._toolSettingsChangeHandler; } set toolSettingsChangeHandler(handler) { this._toolSettingsChangeHandler = handler; } /** The registered handler method that will inform the UI to reload tool setting with properties from active tool. * @internal */ _reloadToolSettingsHandler = undefined; /** Returns the handler registered by the UI layer that allows it to display property changes made by the active Tool. */ get reloadToolSettingsHandler() { return this._reloadToolSettingsHandler; } set reloadToolSettingsHandler(handler) { this._reloadToolSettingsHandler = handler; } /** The registered handler method that will trigger UI Sync processing. * @internal */ _toolSyncUiEventDispatcher = undefined; /** Returns the handler registered by the UI layer that will trigger UiSyncEvent processing that informs UI component to refresh their state. */ get toolSyncUiEventDispatcher() { return this._toolSyncUiEventDispatcher; } set toolSyncUiEventDispatcher(handler) { this._toolSyncUiEventDispatcher = handler; } /** Handler for keyboard events. */ static _keyEventHandler = (ev) => { if (!ev.repeat) // we don't want repeated keyboard events. If we keep them they interfere with replacing mouse motion events, since they come as a stream. ToolAdmin.addEvent(ev); }; /** @internal */ onInitialized() { if (typeof document === "undefined") return; // if document isn't defined, we're probably running in a test environment. At any rate, we can't have interactive tools. this._idleTool = IModelApp.tools.create("Idle"); ["keydown", "keyup"].forEach((type) => { document.addEventListener(type, ToolAdmin._keyEventHandler, false); ToolAdmin._removals.push(() => document.removeEventListener(type, ToolAdmin._keyEventHandler, false)); }); ToolAdmin._removals.push(() => window.onfocus = null); } /** @internal */ onShutDown() { this.clearMotionPromises(); this._idleTool = undefined; IconSprites.emptyAll(); // clear cache of icon sprites ToolAdmin._removals.forEach((remove) => remove()); ToolAdmin._removals.length = 0; } /** Get the ScreenViewport where the cursor is currently, if any. */ get cursorView() { return this.currentInputState.viewport; } /** Called from ViewManager.dropViewport to prevent tools from continuing to operate on the dropped viewport. * @internal */ forgetViewport(vp) { // Ignore pending motion promises on fulfillment. this.clearMotionPromises(); // make sure tools don't think the cursor is still in this viewport. this.onMouseLeave(vp); // Invalidate last motion if for this viewport... if (this.currentInputState.lastMotionEvent?.viewport === vp) this.currentInputState.lastMotionEvent = undefined; // Remove any events associated with this viewport. ToolAdmin._toolEvents = ToolAdmin._toolEvents.filter((ev) => ev.vp !== vp); } getMousePosition(event) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return event.vp.mousePosFromEvent(event.ev); } getMouseMovement(event) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return event.vp.mouseMovementFromEvent(event.ev); } getMouseButton(button) { switch (button) { case 1 /* MouseButton.Middle */: return BeButton.Middle; case 2 /* MouseButton.Right */: return BeButton.Reset; default: return BeButton.Data; } } async onMouseButton(event, isDown) { const ev = event.ev; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const vp = event.vp; const pos = this.getMousePosition(event); const button = this.getMouseButton(ev.button); this.currentInputState.setKeyQualifiers(ev); return isDown ? this.onButtonDown(vp, pos, button, InputSource.Mouse) : this.onButtonUp(vp, pos, button, InputSource.Mouse); } async onWheel(event) { const ev = event.ev; const vp = event.vp; if (undefined === vp || this.filterViewport(vp)) return EventHandled.Yes; const current = this.currentInputState; current.setKeyQualifiers(ev); if (ev.deltaY === 0) return EventHandled.No; let delta; switch (ev.deltaMode) { case ev.DOM_DELTA_LINE: delta = -ev.deltaY * ToolSettings.wheelLineFactor; // 40 break; case ev.DOM_DELTA_PAGE: delta = -ev.deltaY * ToolSettings.wheelPageFactor; // 120; break; default: // DOM_DELTA_PIXEL: delta = -ev.deltaY; break; } const pt2d = this.getMousePosition(event); vp.setAnimator(); current.fromButton(vp, pt2d, InputSource.Mouse, true); const wheelEvent = new BeWheelEvent(); wheelEvent.wheelDelta = delta; current.toEvent(wheelEvent, true); const overlayHit = this.pickCanvasDecoration(wheelEvent); if (undefined !== overlayHit && undefined !== overlayHit.onWheel && overlayHit.onWheel(wheelEvent)) return EventHandled.Yes; const tool = this.activeTool; if (undefined === tool || EventHandled.Yes !== await tool.onMouseWheel(wheelEvent) && vp !== this.markupView) return this.idleTool.onMouseWheel(wheelEvent); return EventHandled.Yes; } async sendTapEvent(touchEv) { const vp = touchEv.viewport; if (undefined !== vp) vp.setAnimator(); const overlayHit = this.pickCanvasDecoration(touchEv); if (undefined !== overlayHit && undefined !== overlayHit.onMouseButton && overlayHit.onMouseButton(touchEv)) return EventHandled.Yes; if (await IModelApp.accuSnap.onTouchTap(touchEv)) return EventHandled.Yes; const tool = this.activeTool; if (undefined !== tool && EventHandled.Yes === await tool.onTouchTap(touchEv)) return EventHandled.Yes; return this.idleTool.onTouchTap(touchEv); } async doubleTapTimeout() { const current = this.currentInputState; if (undefined === current.touchTapTimer) return; const touchEv = current.lastTouchStart; const numTouches = (undefined !== current.lastTouchStart ? current.lastTouchStart.touchCount : 0); const numTaps = (undefined !== current.touchTapCount ? current.touchTapCount : 0); current.touchTapTimer = current.touchTapCount = current.lastTouchStart = undefined; if (undefined === touchEv || 0 > numTouches || 0 > numTaps) return; touchEv.tapCount = numTaps; await this.sendTapEvent(touchEv); } async onTouch(event) { const touchEvent = event.ev; const vp = event.vp; if (undefined === vp || this.filterViewport(vp)) return; const ev = new BeTouchEvent({ touchEvent }); const current = this.currentInputState; const pos = BeTouchEvent.getTouchListCentroid(0 !== touchEvent.targetTouches.length ? touchEvent.targetTouches : touchEvent.changedTouches, vp); switch (touchEvent.type) { case "touchstart": if (touchEvent.changedTouches.length === touchEvent.targetTouches.length) vp.setAnimator(); // Clear viewport animator on start of new touch input (first contact point added)... current.setKeyQualifiers(touchEvent); break; case "touchend": current.setKeyQualifiers(touchEvent); break; } current.fromButton(vp, undefined !== pos ? pos : Point2d.createZero(), InputSource.Touch, true); current.toEvent(ev, false); const tool = this.activeTool; switch (touchEvent.type) { case "touchstart": { current.lastTouchStart = ev; IModelApp.accuSnap.onTouchStart(ev); if (undefined !== tool) await tool.onTouchStart(ev); return; } case "touchend": { IModelApp.accuSnap.onTouchEnd(ev); if (undefined !== tool) { await tool.onTouchEnd(ev); if (0 === ev.touchCount) await tool.onTouchComplete(ev); } if (undefined === current.lastTouchStart) return; if (ev.touchEvent.timeStamp - current.lastTouchStart.touchEvent.timeStamp > (2.0 * ToolSettings.doubleTapTimeout.milliseconds)) return; // Too much time has passed from touchstart to be considered a tap... // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < ev.touchEvent.changedTouches.length; i++) { const currTouch = ev.touchEvent.changedTouches[i]; const startTouch = BeTouchEvent.findTouchById(current.lastTouchStart.touchEvent.targetTouches, currTouch.identifier); if (undefined !== startTouch) { const currPt = BeTouchEvent.getTouchPosition(currTouch, vp); const startPt = BeTouchEvent.getTouchPosition(startTouch, vp); if (currPt.distance(startPt) < vp.pixelsFromInches(ToolSettings.touchMoveDistanceInches)) continue; // Hasn't moved appreciably.... } current.lastTouchStart = undefined; // Not a tap... return; } if (0 !== ev.touchCount || undefined === current.lastTouchStart) return; // All fingers off, defer processing tap until we've waited long enough to detect double tap... if (undefined === current.touchTapTimer) { current.touchTapTimer = Date.now(); current.touchTapCount = 1; // NOTE: We cannot await the executeAfter call below, because that prevents any other // taps from being processed, which makes it impossible for double tap to happen. // eslint-disable-next-line @typescript-eslint/unbound-method void ToolSettings.doubleTapTimeout.executeAfter(this.doubleTapTimeout, this); } else if (undefined !== current.touchTapCount) { current.touchTapCount++; } return; } case "touchcancel": { current.lastTouchStart = undefined; IModelApp.accuSnap.onTouchCancel(ev); if (undefined !== tool) await tool.onTouchCancel(ev); return; } case "touchmove": { if (!IModelApp.accuSnap.onTouchMove(ev) && undefined !== tool) await tool.onTouchMove(ev); if (undefined === current.lastTouchStart) return; if (ev.touchEvent.timeStamp - current.lastTouchStart.touchEvent.timeStamp < ToolSettings.touchMoveDelay.milliseconds) return; // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < ev.touchEvent.changedTouches.length; ++i) { const currTouch = ev.touchEvent.changedTouches[i]; const startTouch = BeTouchEvent.findTouchById(current.lastTouchStart.touchEvent.targetTouches, currTouch.identifier); if (undefined === startTouch) continue; const currPt = BeTouchEvent.getTouchPosition(currTouch, vp); const startPt = BeTouchEvent.getTouchPosition(startTouch, vp); if (currPt.distance(startPt) < vp.pixelsFromInches(ToolSettings.touchMoveDistanceInches)) continue; // Hasn't moved appreciably.... const touchStart = current.lastTouchStart; current.lastTouchStart = undefined; if (IModelApp.accuSnap.onTouchMoveStart(ev, touchStart)) return; if (undefined === tool || EventHandled.Yes !== await tool.onTouchMoveStart(ev, touchStart)) await this.idleTool.onTouchMoveStart(ev, touchStart); return; } return; } } } /** A first-in-first-out queue of ToolEvents. */ static _toolEvents = []; static tryReplace(ev, vp) { if (ToolAdmin._toolEvents.length < 1) return false; const last = ToolAdmin._toolEvents[ToolAdmin._toolEvents.length - 1]; const lastType = last.ev.type; if (lastType !== ev.type || (lastType !== "mousemove" && lastType !== "touchmove")) return false; // only mousemove and touchmove can replace previous last.ev = ev; // sequential moves are not important. Replace the previous one with this one. last.vp = vp; return true; } /** @internal */ static getNextEvent() { if (ToolAdmin._toolEvents.length > 1) // if there is more than one event, we're going to need another animation frame to process it. IModelApp.requestNextAnimation(); return ToolAdmin._toolEvents.shift(); // pull first event from the queue } /** Called from HTML event listeners. Events are processed in the order they're received in ToolAdmin.eventLoop * @internal */ static addEvent(ev, vp) { // Don't add events to queue if event loop hasn't been started to process them... if (!IModelApp.isEventLoopStarted) return; if (!ToolAdmin.tryReplace(ev, vp)) // see if this event replaces the last event in the queue this._toolEvents.push({ ev, vp }); // otherwise put it at the end of the queue. IModelApp.requestNextAnimation(); // wake up event loop, if } /** Process the next event in the event queue, if any. */ async processNextEvent() { const event = ToolAdmin.getNextEvent(); // pull first event from the queue if (undefined === event) return; // nothing in queue switch (event.ev.type) { case "mousedown": return this.onMouseButton(event, true); case "mouseup": return this.onMouseButton(event, false); case "mousemove": return this.onMouseMove(event); case "mouseover": return this.onMouseEnter(event); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion case "mouseout": return this.onMouseLeave(event.vp); case "wheel": return this.onWheel(event); case "keydown": return this.onKeyTransition(event, true); case "keyup": return this.onKeyTransition(event, false); case "touchstart": return this.onTouch(event); case "touchend": return this.onTouch(event); case "touchcancel": return this.onTouch(event); case "touchmove": return this.onTouch(event); } } _processingEvent = false; /** * Process a single event, plus timer events. Don't start work on new events if the previous one has not finished. * @internal */ async processEvent() { if (this._processingEvent) return; // we're still working on the previous event. try { this._processingEvent = true; // we can't allow any further event processing until the current event completes. await this.processNextEvent(); } catch (exception) { await ToolAdmin.exceptionHandler(exception); // we don't attempt to exit here } finally { this._processingEvent = false; // this event is now finished. Allow processing next time through. } } /** The idleTool handles events that are not otherwise processed. */ get idleTool() { assert(undefined !== this._idleTool); return this._idleTool; } set idleTool(idleTool) { this._idleTool = idleTool; } /** Return true to filter (ignore) events to the given viewport */ filterViewport(vp) { if (undefined === vp || vp.isDisposed) return true; const tool = this.activeTool; return (undefined !== tool ? !tool.isCompatibleViewport(vp, false) : false); } /** @internal */ async onInstallTool(tool) { this.currentInputState.onInstallTool(); return tool.onInstall(); } /** @internal */ async onPostInstallTool(tool) { return tool.onPostInstall(); } get viewTool() { return this._viewTool; } get primitiveTool() { return this._primitiveTool; } /** The currently active InteractiveTool. May be ViewTool, InputCollector, PrimitiveTool, undefined - in that priority order. */ get activeTool() { return this._viewTool ? this._viewTool : (this._inputCollector ? this._inputCollector : this._primitiveTool); // NOTE: Viewing tools suspend input collectors as well as primitives } /** The current tool. May be ViewTool, InputCollector, PrimitiveTool, or IdleTool - in that priority order. */ get currentTool() { return this.activeTool ? this.activeTool : this.idleTool; } /** Allow applications to inhibit specific tooltips, such as for maps. */ wantToolTip(_hit) { return true; } /** Ask the current tool to provide tooltip contents for the supplied HitDetail. */ async getToolTip(hit) { return this.currentTool.getToolTip(hit); } /** * Event raised whenever the active tool changes. This includes PrimitiveTool, ViewTool, and InputCollector. * @param newTool The newly activated tool */ activeToolChanged = new BeEvent(); /** * Event raised by tools that support edit manipulators like the SelectTool. * @param tool The current tool */ manipulatorToolEvent = new BeEvent(); async onMouseEnter(event) { const vp = event.vp; const current = this.currentInputState; current.viewport = vp; // Detect if drag was active and button was released outside the view... const tool = this.activeTool; if (undefined === tool) return; const buttonMask = event.ev.buttons; let cancelDrag = false; current.button.forEach((button, buttonNum) => { if (button.isDragging && !(buttonMask & (1 << buttonNum))) { button.isDragging = button.isDown = false; cancelDrag = true; } }); if (cancelDrag) await tool.onReinitialize(); } /** @internal */ onMouseLeave(vp) { if (this._mouseMoveOverTimeout !== undefined) clearTimeout(this._mouseMoveOverTimeout); IModelApp.accuSnap.clear(); this.currentInputState.clearViewport(vp); this.setCanvasDecoration(vp); vp.invalidateDecorations(); // stop drawing locate circle... } /** @internal */ updateDynamics(ev, useLastData, adjustPoint) { if (undefined === this.activeTool) return; if (undefined === ev) { ev = new BeButtonEvent(); if (useLastData) this.fillEventFromLastDataButton(ev); else this.fillEventFromCursorLocation(ev); if (adjustPoint && undefined !== ev.viewport) { // Use ev.rawPoint for cursor location when not snapped as ev.point gets adjusted in fromButton... const snap = TentativeOrAccuSnap.getCurrentSnap(false); if (undefined !== snap) { // Account for changes to locks, reset and re-adjust snap point... snap.adjustedPoint.setFrom(snap.getPoint()); this.adjustSnapPoint(); ev.point.setFrom(snap.isPointAdjusted ? snap.adjustedPoint : snap.getPoint()); } else { ev.point.setFrom(IModelApp.tentativePoint.isActive ? IModelApp.tentativePoint.getPoint() : ev.rawPoint); this.adjustPoint(ev.point, ev.viewport); } IModelApp.accuDraw.onMotion(ev); } } if (undefined === ev.viewport) return; // Support tools requesting async information in onMouseMotion for use in decorate or onDynamicFrame... const toolPromise = this._toolMotionPromise = this.activeTool.onMouseMotion(ev); const tool = this.activeTool; const vp = ev.viewport; const motion = ev; toolPromise.then(() => { if (undefined === this._toolMotionPromise) return; // Only early return if canceled, result from a previous motion is preferable to showing nothing... this.currentInputState.lastMotionEvent = motion; // Save to use for simulation motion... // Update decorations when dynamics are inactive... if (!IModelApp.viewManager.inDynamicsMode) { vp.invalidateDecorations(); return; } // Update dynamics and decorations only after motion... const context = new DynamicsContext(vp); tool.onDynamicFrame(motion, context); context.changeDynamics(); }).catch((_) => { }); } async sendEndDragEvent(ev) { let tool = this.activeTool; if (undefined !== tool) { if (!tool.isValidLocation(ev, true)) tool = undefined; else if (tool.receivedDownEvent) tool.receivedDownEvent = false; else tool = undefined; } // Don't send tool end drag event if it didn't get the start drag event if (undefined === tool || EventHandled.Yes !== await tool.onMouseEndDrag(ev)) return this.idleTool.onMouseEndDrag(ev); } setCanvasDecoration(vp, dec, ev) { if (dec === this._canvasDecoration) return; if (this._canvasDecoration && this._canvasDecoration.onMouseLeave) this._canvasDecoration.onMouseLeave(); this._canvasDecoration = dec; if (ev && dec && dec.onMouseEnter) dec.onMouseEnter(ev); vp.canvas.style.cursor = dec ? (dec.decorationCursor ? dec.decorationCursor : "pointer") : IModelApp.viewManager.cursor; vp.invalidateDecorations(); } pickCanvasDecoration(ev) { const vp = ev.viewport; if (undefined === vp) return undefined; const decoration = (undefined === this.viewTool) ? vp.pickCanvasDecoration(ev.viewPoint) : undefined; this.setCanvasDecoration(vp, decoration, ev); return decoration; } /** Current request for locate/snap */ _snapMotionPromise; /** Current request for active tool motion event */ _toolMotionPromise; clearMotionPromises() { this._snapMotionPromise = this._toolMotionPromise = undefined; } async forceOnMotionSnap(ev) { // Make sure that we fire the motion snap event correctly this._lastHandledMotionTime = undefined; return this.onMotionSnap(ev); } async onMotionSnap(ev) { try { await this.onMotionSnapOrSkip(ev); return true; } catch (error) { if (error instanceof AbandonedError) return false; // expected, not a problem. Just ignore this motion and return. throw error; // unknown error } } // Call accuSnap.onMotion async onMotionSnapOrSkip(ev) { if (this.shouldSkipOnMotionSnap()) return; await IModelApp.accuSnap.onMotion(ev); this._lastHandledMotionTime = BeTimePoint.now(); } // Should the current onMotionSnap event be skipped to avoid unnecessary ReadPixel calls? shouldSkipOnMotionSnap() { if (this._lastHandledMotionTime === undefined) return false; const now = BeTimePoint.now(); const msSinceLastCall = now.milliseconds - this._lastHandledMotionTime.milliseconds; const delay = 1000 / ToolSettings.maxOnMotionSnapCallPerSecond; return msSinceLastCall < delay; } async onStartDrag(ev, tool) { if (undefined !== tool && EventHandled.Yes === await tool.onMouseStartDrag(ev)) return EventHandled.Yes; // Pass start drag event to idle tool if active tool doesn't explicitly handle it return this.idleTool.onMouseStartDrag(ev); } async onMotion(vp, pt2d, inputSource, forceStartDrag = false, movement) { const current = this.currentInputState; current.onMotion(pt2d); if (this.filterViewport(vp)) { this.setIncompatibleViewportCursor(false); return; } // Detect when the motion stops by setting a timeout if (this._mouseMoveOverTimeout !== undefined) clearTimeout(this._mouseMoveOverTimeout); // If a previous timeout was up, it is cancelled: the movement is not over yet const ev = new BeButtonEvent(); current.fromPoint(vp, pt2d, inputSource); current.toEvent(ev, false); const overlayHit = this.pickCanvasDecoration(ev); if (undefined !== overlayHit) { if (overlayHit.onMouseMove) overlayHit.onMouseMove(ev); if (undefined === overlayHit.propagateMouseMove || !overlayHit.propagateMouseMove(ev)) return; // we're inside a pickable decoration that doesn't want event sent to tool } this._mouseMoveOverTimeout = setTimeout(async () => { await this.onMotionEnd(vp, pt2d, inputSource); await processMotion(); }, 100); const processMotion = async () => { // Update event to account for AccuSnap adjustments... current.fromButton(vp, pt2d, inputSource, true); current.toEvent(ev, true); ev.movement = movement; IModelApp.accuDraw.onMotion(ev); let tool = this.activeTool; const isValidLocation = (undefined !== tool ? tool.isValidLocation(ev, false) : true); this.setIncompatibleViewportCursor(isValidLocation); if (forceStartDrag || current.isStartDrag(ev.button)) { current.onStartDrag(ev.button); current.changeButtonToDownPoint(ev); ev.isDragging = true; if (undefined !== tool) { if (!isValidLocation) tool = undefined; else if (forceStartDrag) tool.receivedDownEvent = true; else if (!tool.receivedDownEvent) tool = undefined; } await this.onStartDrag(ev, tool); return; } this.updateDynamics(ev); }; const snapPromise = this._snapMotionPromise = this.onMotionSnap(ev); /** When forceStartDrag is true, make sure we don't return a fulfilled promise until we've processed the motion so callers can await it. * The .then below happens AFTER this method returns its (fulfilled) promise so we can't use that. */ if (forceStartDrag) { await snapPromise; return processMotion(); } if (this.isLocateCircleOn) vp.invalidateDecorations(); snapPromise.then(async (snapOk) => { if (!snapOk || snapPromise !== this._snapMotionPromise) return; return processMotion(); }).catch((_) => { }); } // Called when we detect that the motion stopped async onMotionEnd(vp, pos, inputSource) { const current = this.currentInputState; const ev = new BeButtonEvent(); current.fromPoint(vp, pos, inputSource); current.toEvent(ev, false); await this.forceOnMotionSnap(ev); } async onMouseMove(event) { const vp = event.vp; if (undefined === vp) return; const pos = this.getMousePosition(event); const mov = this.getMouseMovement(event); // Sometimes the mouse goes down in a view, but we lose focus while its down so we never receive the up event. // That makes it look like the motion is a drag. Fix that by clearing the "isDown" based on the buttons member of the MouseEvent. const buttonMask = event.ev.buttons; if (!(buttonMask & 1)) this.currentInputState.button[BeButton.Data].isDown = false; return this.onMotion(vp, pos, InputSource.Mouse, false, mov); } adjustPointToACS(pointActive, vp, perpendicular) { // The "I don't want ACS lock" flag can be set by tools to override the default behavior if (0 !== (this.toolState.coordLockOvr & CoordinateLockOverrides.ACS)) return; let viewZRoot; // Lock to the construction plane if (vp.view.is3d() && vp.view.isCameraOn) viewZRoot = vp.view.camera.eye.vectorTo(pointActive); else viewZRoot = vp.rotation.getRow(2); const auxOriginRoot = vp.getAuxCoordOrigin(); const auxRMatrixRoot = vp.getAuxCoordRotation(); let auxNormalRoot = auxRMatrixRoot.getRow(2); // If ACS xy plane is perpendicular to view and not snapping, project to closest xz or yz plane instead if (auxNormalRoot.isPerpendicularTo(viewZRoot) && !TentativeOrAccuSnap.isHot) { const auxXRoot = auxRMatrixRoot.getRow(0); const auxYRoot = auxRMatrixRoot.getRow(1); auxNormalRoot = (Math.abs(auxXRoot.dotProduct(viewZRoot)) > Math.abs(auxYRoot.dotProduct(viewZRoot))) ? auxXRoot : auxYRoot; } linePlaneIntersect(pointActive, pointActive, viewZRoot, auxOriginRoot, auxNormalRoot, perpendicular); } adjustPointToGrid(pointActive, vp) { // The "I don't want grid lock" flag can be set by tools to override the default behavior if (!this.gridLock || 0 !== (this.toolState.coordLockOvr & CoordinateLockOverrides.Grid)) return; vp.pointToGrid(pointActive); } adjustPoint(pointActive, vp, projectToACS = true, applyLocks = true) { if (Math.abs(pointActive.z) < 1.0e-7) pointActive.z = 0.0; // remove Z fuzz introduced by active depth when near 0 let handled = false; if (applyLocks && !(IModelApp.tentativePoint.isActive || IModelApp.accuSnap.isHot)) handled = IModelApp.accuDraw.adjustPoint(pointActive, vp, false); // NOTE: We don't need to support axis lock, it is worthless if you have AccuDraw if (!handled && vp.isPointAdjustmentRequired) { if (applyLocks) this.adjustPointToGrid(pointActive, vp); if (projectToACS) this.adjustPointToACS(pointActive, vp, false); } else if (applyLocks) { const savePoint = pointActive.clone(); this.adjustPointToGrid(pointActive, vp); // if grid lock changes point, resend point to accudraw if (handled && !pointActive.isExactEqual(savePoint)) IModelApp.accuDraw.adjustPoint(pointActive, vp, false); } if (Math.abs(pointActive.z) < 1.0e-7) pointActive.z = 0.0; } adjustSnapPoint(perpendicular = true) { const snap = TentativeOrAccuSnap.getCurrentSnap(false); if (!snap) return; const vp = snap.viewport; const isHot = snap.isHot; const point = snap.getPoint().clone(); const savePt = point.clone(); if (!isHot) // Want point adjusted to grid for a hit that isn't hot this.adjustPointToGrid(point, vp); if (!IModelApp.accuDraw.adjustPoint(point, vp, isHot)) { if (vp.isSnapAdjustmentRequired) this.adjustPointToACS(point, vp, perpendicular || IModelApp.accuDraw.isActive); } if (!point.isExactEqual(savePt)) snap.adjustedPoint.setFrom(point); } /** Application sub-classes can override this method to intercept button events before they are sent to the active tool. * An example use for this event would be to implement a shift + right-click or right-press menu. * @return true if event was handled and should not propagate to the active tool. */ onPreButtonEvent(_ev) { return false; } /** @internal */ async sendButtonEvent(ev) { const overlayHit = this.pickCanvasDecoration(ev); if (undefined !== overlayHit && undefined !== overlayHit.onMouseButton && overlayHit.onMouseButton(ev)) return; if (this.onPreButtonEvent(ev)) return; if (IModelApp.accuSnap.onPreB