1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15/** 16 * This module provides an extensible, declarative interaction manager for 17 * handling high level mouse and keyboard interactions within an HTML element, 18 * using zones to define areas with different configurations. 19 * 20 * This is typically used on canvas, where we want to create draggable handles, 21 * or area selections, but there are no fine-grained DOM elements we can attach 22 * to. 23 * 24 * It supports: 25 * - Specifying a list of zones, which can specify their own mouse event 26 * handlers. 27 * - Changing the cursor when hovering over a zone. 28 * - High level drag event handlers with customizable drag thresholds, 'while 29 * dragging' cursors and keyboard modifiers. 30 * - Click event handlers, which integrate nicely with drag events (i.e. failed 31 * drag events turn into clicks). 32 * - Mouse wheel events. 33 * 34 * How it works: 35 * 36 * For events that fire on the given target element, the list of zones is 37 * searched from top to bottom until a zone that handles that event is found. 38 * 39 * The list of zones is declarative, and is designed to be updated frequently 40 * i.e. every frame. This means that long running events such as drags can be 41 * can outlive the a single update cycle. Each zone must specify an id which is 42 * a unique string used to link up the new zones with ongoing drag events, and 43 * thus use the new callbacks. This is important as new callbacks might capture 44 * different data. 45 */ 46 47import {removeFalsyValues} from './array_utils'; 48import {DisposableStack} from './disposable_stack'; 49import {bindEventListener, CSSCursor} from './dom_utils'; 50import {Point2D, Rect2D, Size2D, Vector2D} from './geom'; 51 52export interface DragEvent { 53 // The location of the mouse at the start of the drag action. 54 readonly dragStart: Vector2D; 55 56 // The location of the mouse currently. 57 readonly dragCurrent: Vector2D; 58 59 // The amount the mouse has moved by duration the drag. 60 // I.e. currentMousePosition - startingMousePosition 61 readonly dragDelta: Vector2D; 62 63 // The amount the mouse have moved by since the last drag event. 64 readonly deltaSinceLastEvent: Vector2D; 65} 66 67export interface ClickEvent { 68 // Location of the mouse W.R.T the target element (not the current zone). 69 readonly position: Vector2D; 70} 71 72export interface InteractionWheelEvent { 73 // Location of the mouse W.R.T the target element (not the current zone). 74 readonly position: Vector2D; 75 76 // Wheel deltaX directly from the DOM WheelEvent. 77 readonly deltaX: number; 78 79 // Wheel deltaY directly from the DOM WheelEvent. 80 readonly deltaY: number; 81 82 // Whether the ctrl key is held or not. 83 readonly ctrlKey: boolean; 84} 85 86export interface DragConfig { 87 // Optional: Switch to this cursor while dragging. 88 readonly cursorWhileDragging?: CSSCursor; 89 90 // The minimum distance the mouse must move before a drag is triggered. 91 // Default: 0 - drags start instantly. 92 readonly minDistance?: number; 93 94 onDragStart?(e: DragEvent, element: HTMLElement): void; 95 96 // Optional: Called whenever the mouse is moved during a drag event. 97 onDrag?(e: DragEvent, element: HTMLElement): void; 98 99 // Optional: Called when the mouse button is released and the drag is complete. 100 onDragEnd?(e: DragEvent, element: HTMLElement): void; 101} 102 103export interface Zone { 104 // Unique ID for this zone. This is used to coordinate long events such as 105 // drag event callbacks between update cycles. 106 readonly id: string; 107 108 // The area occupied by this zone. 109 readonly area: Point2D & Size2D; 110 111 // Optional: Which cursor to change the mouse to when hovering over this zone. 112 readonly cursor?: CSSCursor; 113 114 // Optional: If present, this keyboard modifier must be held otherwise this 115 // zone is effectively invisible to interactions. 116 readonly keyModifier?: 'shift'; 117 118 // Optional: If present, this zone will respond to drag events. 119 readonly drag?: DragConfig; 120 121 // Optional: If present, this function will be called when this zone is 122 // clicked on. 123 onClick?(e: ClickEvent): void; 124 125 // Optional: If present, this function will be called when the wheel is 126 // scrolled while hovering over this zone. 127 onWheel?(e: InteractionWheelEvent): void; 128} 129 130interface InProgressGesture { 131 readonly zoneId: string; 132 readonly startingMousePosition: Vector2D; 133 currentMousePosition: Vector2D; 134 previouslyNotifiedPosition: Vector2D; 135} 136 137export class ZonedInteractionHandler implements Disposable { 138 private readonly trash = new DisposableStack(); 139 private currentMousePosition?: Point2D; 140 private zones: ReadonlyArray<Zone> = []; 141 private currentGesture?: InProgressGesture; 142 private shiftHeld = false; 143 144 constructor(readonly target: HTMLElement) { 145 this.bindEvent(this.target, 'mousedown', this.onMouseDown.bind(this)); 146 this.bindEvent(document, 'mousemove', this.onMouseMove.bind(this)); 147 this.bindEvent(document, 'mouseup', this.onMouseUp.bind(this)); 148 this.bindEvent(document, 'keydown', this.onKeyDown.bind(this)); 149 this.bindEvent(document, 'keyup', this.onKeyUp.bind(this)); 150 this.bindEvent(this.target, 'wheel', this.handleWheel.bind(this)); 151 } 152 153 [Symbol.dispose](): void { 154 this.trash.dispose(); 155 } 156 157 /** 158 * Update the list of zones and their configurations. Each zone is processed 159 * from the start to the end of the list, so zones which appear earlier in the 160 * list will be chosen before those later in the list. 161 * 162 * Zones can be falsy, which allows the simple conditional zones to be defined 163 * using short circuits, similar to mithril. Falsy zones are simply ignored. 164 * 165 * @param zones - The list of zones to configure interactions areas and their 166 * configurations. 167 */ 168 update(zones: ReadonlyArray<Zone | false | undefined | null>): void { 169 this.zones = removeFalsyValues(zones); 170 this.updateCursor(); 171 } 172 173 // Utility function to bind an event listener to a DOM element and add it to 174 // the trash. 175 private bindEvent<K extends keyof HTMLElementEventMap>( 176 element: EventTarget, 177 event: K, 178 handler: (event: HTMLElementEventMap[K]) => void, 179 ) { 180 this.trash.use(bindEventListener(element, event, handler)); 181 } 182 183 private onMouseDown(e: MouseEvent) { 184 const mousePositionClient = new Vector2D({x: e.clientX, y: e.clientY}); 185 const mouse = mousePositionClient.sub(this.target.getBoundingClientRect()); 186 const zone = this.findZone( 187 (z) => (z.drag || z.onClick) && this.hitTestZone(z, mouse), 188 ); 189 if (zone) { 190 this.currentGesture = { 191 zoneId: zone.id, 192 startingMousePosition: mouse, 193 currentMousePosition: mouse, 194 previouslyNotifiedPosition: mouse, 195 }; 196 this.updateCursor(); 197 } 198 } 199 200 private onMouseMove(e: MouseEvent) { 201 const mousePositionClient = new Vector2D({x: e.clientX, y: e.clientY}); 202 const mousePosition = mousePositionClient.sub( 203 this.target.getBoundingClientRect(), 204 ); 205 this.currentMousePosition = mousePosition; 206 this.updateCursor(); 207 208 const currentDrag = this.currentGesture; 209 if (currentDrag) { 210 currentDrag.currentMousePosition = mousePosition; 211 const delta = currentDrag.startingMousePosition.sub(mousePosition); 212 const dragConfig = this.findZoneById(currentDrag.zoneId)?.drag; 213 if ( 214 dragConfig && 215 delta.manhattanDistance >= (dragConfig?.minDistance ?? 0) 216 ) { 217 dragConfig.onDrag?.( 218 { 219 dragCurrent: mousePosition, 220 dragStart: currentDrag.startingMousePosition, 221 dragDelta: delta, 222 deltaSinceLastEvent: mousePosition.sub( 223 currentDrag.previouslyNotifiedPosition, 224 ), 225 }, 226 this.target, 227 ); 228 currentDrag.previouslyNotifiedPosition = mousePosition; 229 } 230 } 231 } 232 233 private onMouseUp(e: MouseEvent) { 234 const mousePositionClient = new Vector2D({x: e.clientX, y: e.clientY}); 235 const mouse = mousePositionClient.sub(this.target.getBoundingClientRect()); 236 237 const gesture = this.currentGesture; 238 239 if (gesture) { 240 const delta = gesture.startingMousePosition.sub(mouse); 241 const zone = this.findZoneById(gesture.zoneId); 242 if (zone) { 243 if ( 244 zone.drag && 245 delta.manhattanDistance >= (zone.drag?.minDistance ?? 0) 246 ) { 247 this.handleDrag(this.target, gesture, mouse, e, zone.drag); 248 } else { 249 // Check we're still the zone the click was started in 250 if (this.hitTestZone(zone, mouse)) { 251 this.handleClick(this.target, e); 252 } 253 } 254 } 255 256 this.currentGesture = undefined; 257 this.updateCursor(); 258 } 259 } 260 261 private onKeyDown(e: KeyboardEvent) { 262 this.shiftHeld = e.shiftKey; 263 this.updateCursor(); 264 } 265 266 private onKeyUp(e: KeyboardEvent) { 267 this.shiftHeld = e.shiftKey; 268 this.updateCursor(); 269 } 270 271 private handleWheel(e: WheelEvent) { 272 const mousePositionClient = new Vector2D({x: e.clientX, y: e.clientY}); 273 const mouse = mousePositionClient.sub(this.target.getBoundingClientRect()); 274 const zone = this.findZone((z) => z.onWheel && this.hitTestZone(z, mouse)); 275 zone?.onWheel?.({ 276 position: mouse, 277 deltaX: e.deltaX, 278 deltaY: e.deltaY, 279 ctrlKey: e.ctrlKey, 280 }); 281 } 282 283 private handleDrag( 284 element: HTMLElement, 285 currentDrag: InProgressGesture, 286 x: Vector2D, 287 e: MouseEvent, 288 dragConfig: DragConfig, 289 ) { 290 // Update the current position 291 currentDrag.currentMousePosition = x; 292 293 const dragEvent: DragEvent = { 294 dragStart: currentDrag.startingMousePosition, 295 dragCurrent: x, 296 dragDelta: new Vector2D({x: e.movementX, y: e.movementY}), 297 deltaSinceLastEvent: new Vector2D({x: e.movementX, y: e.movementY}), 298 }; 299 300 dragConfig.onDragEnd?.(dragEvent, element); 301 } 302 303 private handleClick(element: HTMLElement, e: MouseEvent) { 304 const mousePositionClient = new Vector2D({x: e.clientX, y: e.clientY}); 305 const mouse = mousePositionClient.sub(element.getBoundingClientRect()); 306 const zone = this.findZone((z) => z.onClick && this.hitTestZone(z, mouse)); 307 zone?.onClick?.({position: mouse}); 308 } 309 310 private updateCursor() { 311 // If a drag is ongoing, use the drag cursor if available 312 const drag = this.currentGesture; 313 if (drag) { 314 const dragDelta = drag.currentMousePosition.sub( 315 drag.startingMousePosition, 316 ); 317 const dragConfig = this.findZoneById(drag.zoneId)?.drag; 318 if ( 319 dragConfig && 320 dragConfig.cursorWhileDragging && 321 dragDelta.manhattanDistance >= (dragConfig.minDistance ?? 0) 322 ) { 323 this.target.style.cursor = dragConfig.cursorWhileDragging; 324 return; 325 } 326 } 327 328 // Otherwise, find the hovered zone and set the cursor 329 const mouse = this.currentMousePosition; 330 const zone = 331 mouse && this.findZone((z) => z.cursor && this.hitTestZone(z, mouse)); 332 this.target.style.cursor = zone?.cursor ?? 'default'; 333 } 334 335 // Find a zone that matches a predicate. 336 private findZone(pred: (z: Zone) => boolean | undefined): Zone | undefined { 337 for (const zone of this.zones) { 338 if (pred(zone)) return zone; 339 } 340 return undefined; 341 } 342 343 // Find a zone by id. 344 private findZoneById(id: string): Zone | undefined { 345 for (const zone of this.zones) { 346 if (zone.id === id) return zone; 347 } 348 return undefined; 349 } 350 351 // Test whether a point hits a zone. 352 private hitTestZone(zone: Zone, x: Point2D): boolean { 353 const rect = Rect2D.fromPointAndSize(zone.area); 354 return rect.containsPoint(x) && (!zone.keyModifier || this.shiftHeld); 355 } 356} 357