Skip to content

Commit

Permalink
feat: trigger contextual men on control-click or long press in the ma…
Browse files Browse the repository at this point in the history
…thfield
  • Loading branch information
arnog committed Nov 18, 2023
1 parent 211a48e commit 158b8b5
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 37 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
toggling the virtual keyboard, changing the mathfield mode (math, text)
and inserting and editing matrixes.

### Improvements

- The tooltip above the virtual keyboard toggle (and the menu glyph) now
only appears after a delay.

## 0.96.2 (2023-11-16)

Expand Down
37 changes: 34 additions & 3 deletions src/editor-mathfield/mathfield-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ import {
validateOrigin,
} from './utils';

import { onPointerDown, offsetFromPoint } from './pointer-input';
import {
onPointerDown,
offsetFromPoint,
stopTrackingPointer,
} from './pointer-input';

import { ModeEditor } from './mode-editor';
import { getLatexGroupBody } from './mode-editor-latex';
Expand Down Expand Up @@ -121,6 +125,8 @@ import {
updateEnvironmentPopover,
} from 'editor/environment-popover';
import { Menu } from 'ui/menu/menu';
import { onContextMenu } from 'ui/menu/context-menu';
import { keyboardModifiersFromEvent } from 'ui/events/utils';

const DEFAULT_KEYBOARD_TOGGLE_GLYPH = `<svg style="width: 21px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" role="img" aria-label="${localize(
'tooltip.toggle virtual keyboard'
Expand Down Expand Up @@ -155,7 +161,7 @@ export class _Mathfield implements Mathfield, KeyboardDelegateInterface {
readonly host: HTMLElement | undefined;

field: HTMLElement;
fieldContent: HTMLElement;
fieldContent: HTMLElement; // set in render() function
readonly ariaLiveText: HTMLElement;
// readonly accessibleMathML: HTMLElement;

Expand Down Expand Up @@ -391,6 +397,15 @@ If you are using Vue, this may be because you are using the runtime-only build o

this._menu = new Menu(getDefaultMenuItems(this));

// Listen for contextmenu events on the field
this.field.addEventListener('contextmenu', this, {
signal: this.eventController.signal,
});
// Listen to keydown (for context menu shortcut)
this.field.addEventListener('keydown', this, {
signal: this.eventController.signal,
});

const menuToggle =
this.element!.querySelector<HTMLElement>('[part=menu-toggle]')!;
menuToggle?.addEventListener(
Expand All @@ -405,6 +420,7 @@ If you are using Vue, this may be because you are using the runtime-only build o
x: menuToggle.offsetLeft,
y: menuToggle.offsetHeight + menuToggle.offsetTop,
},
keyboardModifiers: keyboardModifiersFromEvent(ev),
onDismiss: () => this.element!.classList.remove('tracking'),
});
ev.preventDefault();
Expand Down Expand Up @@ -451,7 +467,7 @@ If you are using Vue, this may be because you are using the runtime-only build o
// The mathfield container is initially set with a visibility of hidden
// to minimize flashing during construction.
element
.querySelector<HTMLElement>('.ML__container')!
.querySelector<HTMLElement>('[part=container')!
.style.removeProperty('visibility');

// Now start recording potentially undoable actions
Expand Down Expand Up @@ -735,6 +751,21 @@ If you are using Vue, this may be because you are using the runtime-only build o

case 'pointerdown':
onPointerDown(this, evt as PointerEvent);
onContextMenu(
evt,
this.element!.querySelector<HTMLElement>('[part=container')!,
this._menu,
() => stopTrackingPointer(this)
);
break;

case 'contextmenu':
onContextMenu(
evt,
this.element!.querySelector<HTMLElement>('[part=container')!,
this._menu,
() => stopTrackingPointer(this)
);
break;

case 'virtual-keyboard-toggle':
Expand Down
65 changes: 35 additions & 30 deletions src/editor-mathfield/pointer-input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { on, off, getAtomBounds, Rect } from './utils';
import { getAtomBounds, Rect } from './utils';
import type { _Mathfield } from './mathfield-private';
import { requestUpdate } from './render';
import { Offset } from '../public/mathfield';
Expand All @@ -8,6 +8,8 @@ import { selectGroup } from '../editor-model/commands-select';

let gLastTap: { x: number; y: number; time: number } | null = null;
let gTapCount = 0;
let gTrackingEventController: AbortController | undefined = undefined;
let gPointerCaptureId: number | undefined;

function isPointerEvent(evt: Event | null): evt is PointerEvent {
return (
Expand All @@ -17,6 +19,15 @@ function isPointerEvent(evt: Event | null): evt is PointerEvent {
);
}

export function stopTrackingPointer(mathfield: _Mathfield): void {
gTrackingEventController?.abort();
gTrackingEventController = undefined;
if (typeof gPointerCaptureId === 'number') {
mathfield.field!.releasePointerCapture(gPointerCaptureId);
gPointerCaptureId = undefined;
}
}

export function onPointerDown(mathfield: _Mathfield, evt: PointerEvent): void {
//Reset the atom bounds cache
mathfield.atomBoundsCache = new Map<string, Rect>();
Expand All @@ -41,19 +52,9 @@ export function onPointerDown(mathfield: _Mathfield, evt: PointerEvent): void {
if (scrollLeft) field.scroll({ top: 0, left: field.scrollLeft - 16 });
else if (scrollRight) field.scroll({ top: 0, left: field.scrollLeft + 16 });
}, 32);
function endPointerTracking(evt: null | PointerEvent | MouseEvent): void {
if ('PointerEvent' in window) {
off(field, 'pointermove', onPointerMove);
off(
field,
'pointerup pointercancel',
endPointerTracking as EventListener
);
if (isPointerEvent(evt)) field.releasePointerCapture(evt.pointerId);
} else {
off(window, 'mousemove', onPointerMove);
off(window, 'mouseup blur', endPointerTracking as EventListener);
}

function endPointerTracking(): void {
stopTrackingPointer(mathfield);

trackingPointer = false;
clearInterval(scrollInterval);
Expand All @@ -64,7 +65,7 @@ export function onPointerDown(mathfield: _Mathfield, evt: PointerEvent): void {
function onPointerMove(evt: PointerEvent | MouseEvent): void {
// If we've somehow lost focus, end tracking
if (!that.hasFocus()) {
endPointerTracking(null);
endPointerTracking();
return;
}

Expand Down Expand Up @@ -106,10 +107,8 @@ export function onPointerDown(mathfield: _Mathfield, evt: PointerEvent): void {
}

if (trackingWords) selectGroup(that.model);

// Prevent synthetic mouseMove event when this is a touch event
evt.preventDefault();
evt.stopPropagation();
// Note: do not prevent default, as we need to track
// the pointer to prevent long press if the pointer has moved
}

// Calculate the tap count
Expand Down Expand Up @@ -177,7 +176,7 @@ export function onPointerDown(mathfield: _Mathfield, evt: PointerEvent): void {
// for double-click, triple-click, etc...
// (note that `evt.detail` is not set when using pointerEvent)
if (evt.detail === 3 || gTapCount > 2) {
endPointerTracking(evt);
endPointerTracking();
if (evt.detail === 3 || gTapCount === 3) {
// This is a triple-click
mathfield.model.selection = {
Expand All @@ -187,18 +186,24 @@ export function onPointerDown(mathfield: _Mathfield, evt: PointerEvent): void {
}
} else if (!trackingPointer) {
trackingPointer = true;
if (!gTrackingEventController)
gTrackingEventController = new AbortController();
const options = { signal: gTrackingEventController.signal };
if ('PointerEvent' in window) {
on(field, 'pointermove', onPointerMove);
on(
field,
'pointerup pointercancel',
endPointerTracking as EventListener
);
if (isPointerEvent(evt)) field.setPointerCapture(evt.pointerId);
field.addEventListener('pointermove', onPointerMove, options);
field.addEventListener('pointerup', endPointerTracking, options);
field.addEventListener('pointercancel', endPointerTracking, options);
if (isPointerEvent(evt)) {
gPointerCaptureId = evt.pointerId;
field.setPointerCapture(gPointerCaptureId);
}
} else {
on(window, 'blur', endPointerTracking as EventListener);
on(window, 'mousemove', onPointerMove);
on(window, 'mouseup', endPointerTracking as EventListener);
// @ts-ignore
window.addEventListener('blur', endPointerTracking, options);
// @ts-ignore
window.addEventListener('mousemove', onPointerMove, options);
// @ts-ignore
window.addEventListener('mouseup', endPointerTracking, options);
}

if (evt.detail === 2 || gTapCount === 2) {
Expand Down
59 changes: 59 additions & 0 deletions src/ui/events/longpress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { distance } from 'ui/geometry/utils';
import { eventLocation } from './utils';

// We use a class to encapsulate the state that needs to be tracked and,
// more importantly, to avoid memory leaks by using the `handleEvent()` hook
// to ensure proper disposal of event handlers
export class LongPressDetector {
static DELAY = 300; // In ms
static DISTANCE = 10; // In pixels

private readonly startPoint?: { x: number; y: number };
private lastPoint?: { x: number; y: number };

private timer = 0;

constructor(triggerEvent: Event, onLongPress: () => void) {
const location = eventLocation(triggerEvent);
if (!location) return;

this.startPoint = location;
this.lastPoint = location;

this.timer = setTimeout(() => {
this.dispose();
const delta = distance(this.lastPoint!, this.startPoint!);
if (delta < LongPressDetector.DISTANCE) onLongPress();
}, LongPressDetector.DELAY);
for (const evt of ['pointermove', 'pointerup', 'pointercancel'])
window.addEventListener(evt, this, { passive: true });
}

dispose(): void {
clearTimeout(this.timer);
this.timer = 0;

for (const evt of ['pointermove', 'pointerup', 'pointercancel'])
window.removeEventListener(evt, this);
}

handleEvent(event: Event): void {
if (event.type === 'pointerup' || event.type === 'pointercancel') {
this.dispose();
event.stopPropagation();
} else if (event.type === 'pointermove') {
const location = eventLocation(event);
if (location) {
this.lastPoint = location;
event.stopPropagation();
}
}
}
}

export function onLongPress(
triggerEvent: Event,
onLongPress: () => void
): LongPressDetector {
return new LongPressDetector(triggerEvent, onLongPress);
}
7 changes: 7 additions & 0 deletions src/ui/geometry/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,10 @@ export function fitInViewport(
element.style.height = `${Math.round(height).toString()}px`;
element.style.width = `${Math.round(width).toString()}px`;
}

export function distance(
p1: { x: number; y: number },
p2: { x: number; y: number }
): number {
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
}
77 changes: 77 additions & 0 deletions src/ui/menu/context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { eventLocation, keyboardModifiersFromEvent } from 'ui/events/utils';
import { Menu } from './menu';
import { onLongPress } from 'ui/events/longpress';

export function onContextMenu(
event: Event,
target: Element,
menu: Menu,
onTrigger?: () => void
): boolean {
//
// The context menu gesture (right-click, control-click, etc..)
// was triggered
//
if (event.type === 'contextmenu') {
const evt = event as MouseEvent;
onTrigger?.();
menu.show({
parent: target,
location: { x: Math.round(evt.clientX), y: Math.round(evt.clientY) },
keyboardModifiers: keyboardModifiersFromEvent(evt),
});
event.preventDefault();
event.stopPropagation();
return true;
}

//
// The context menu keyboard shortcut (shift+F10) was triggered
//
if (event.type === 'keydown') {
const evt = event as KeyboardEvent;
if (evt.code === 'ContextMenu' || (evt.code === 'F10' && evt.shiftKey)) {
// Shift+F10 = contextual menu
// Get the center of the parent
const bounds = target?.getBoundingClientRect();
if (bounds) {
onTrigger?.();
menu.show({
parent: target,
location: {
x: Math.round(bounds.left + bounds.width / 2),
y: Math.round(bounds.top + bounds.height / 2),
},
keyboardModifiers: keyboardModifiersFromEvent(evt),
});
event.preventDefault();
event.stopPropagation();
return true;
}
}
}

//
// This might be a long press...
//
if (event.type === 'pointerdown') {
// Are we inside the target element?
let eventTarget = event.target as HTMLElement;
while (eventTarget && target !== eventTarget)
eventTarget = eventTarget.parentNode as HTMLElement;
if (!eventTarget) return false;

const pt = eventLocation(event);
const keyboardModifiers = keyboardModifiersFromEvent(event);
onLongPress(event, () => {
if (menu.state !== 'closed') return;
onTrigger?.();
menu.show({ parent: target, location: pt, keyboardModifiers });
// // @ts-ignore
// if (menu.state === 'open') menu.state = 'modal';
});
return true;
}

return false;
}
2 changes: 1 addition & 1 deletion src/ui/menu/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class MenuItem implements MenuItemInterface {
}

if (this.ariaLabel) li.setAttribute('aria-label', this.ariaLabel);
if (this.ariaDetails) li.setAttribute('aria-label', this.ariaDetails);
if (this.ariaDetails) li.setAttribute('aria-details', this.ariaDetails);

if (!this.enabled) li.setAttribute('aria-disabled', 'true');
else {
Expand Down
Loading

0 comments on commit 158b8b5

Please sign in to comment.