• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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