Skip to content

Commit

Permalink
Permit compound event mappings
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredjj3 committed Jun 22, 2024
1 parent 3cf4cc3 commit a119236
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 52 deletions.
2 changes: 1 addition & 1 deletion site/src/components/EventTypeForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vexml from '@/index';
import { useId, useState } from 'react';

const EVENT_TYPES = ['click', 'hover', 'enter', 'exit'] as const;
const EVENT_TYPES = ['click', 'longpress', 'enter', 'exit'] as const;

export type EventTypeFormProps = {
defaultEventTypes: vexml.EventType[];
Expand Down
2 changes: 1 addition & 1 deletion site/src/components/SourceDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const SourceDisplay = (props: SourceProps) => {
const eventCardId = useId();
const eventCardSelector = '#' + eventCardId.replaceAll(':', '\\:');

const [enabledVexmlEventTypes, setEnabledVexmlEventTypes] = useState<vexml.EventType[]>(['click']);
const [enabledVexmlEventTypes, setEnabledVexmlEventTypes] = useState<vexml.EventType[]>(['click', 'longpress']);

const [logs, setLogs] = useState(new Array<EventLog>());
const nextKey = useNextKey('event-log');
Expand Down
11 changes: 10 additions & 1 deletion site/src/components/Vexml.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ export const Vexml = (props: VexmlProps) => {

const divStyle: React.CSSProperties = {
display: mode === 'svg' ? 'block' : 'none',
userSelect: 'none',
msUserSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
};
const canvasStyle: React.CSSProperties = {
display: mode === 'canvas' ? 'block' : 'none',
userSelect: 'none',
msUserSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
};

const divWidth = useWidth(divContainerRef);
Expand All @@ -52,7 +60,7 @@ export const Vexml = (props: VexmlProps) => {
const handles = new Array<number>();

handles.push(rendering.addEventListener('click', onEvent));
handles.push(rendering.addEventListener('hover', onEvent));
handles.push(rendering.addEventListener('longpress', onEvent));
handles.push(rendering.addEventListener('exit', onEvent));
handles.push(rendering.addEventListener('enter', onEvent));

Expand Down Expand Up @@ -171,6 +179,7 @@ export const Vexml = (props: VexmlProps) => {
element = container.firstElementChild as SVGElement;
// Now that the <svg> is created, we can set the style for screenshots.
element.style.backgroundColor = 'white';
element.style.pointerEvents = 'all';
} else if (container instanceof HTMLCanvasElement) {
// The <canvas> image background is transparent, and there's not much we can do to change that without
// significantly changing the vexml rendering.
Expand Down
2 changes: 2 additions & 0 deletions site/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as vexml from '@/index';

export const VEXML_VERSION = VITE_VEXML_VERSION;

export const LOCAL_STORAGE_VEXML_SOURCES_KEY = 'vexml:sources';
Expand Down
11 changes: 6 additions & 5 deletions src/events/nativebridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type NativeEventOpts<T extends HostElement> = {
[N in NativeEventName<T>]?: AddEventListenerOptions;
};

export type EventMapping<T extends HostElement, V extends string> = {
export type EventMapping<T extends HostElement, V extends string[]> = {
vexml: V;
native: {
[N in NativeEventName<T>]?: EventListener<NativeEvent<T, N>>;
Expand All @@ -36,7 +36,7 @@ export type EventMapping<T extends HostElement, V extends string> = {
*/
export class NativeBridge<V extends string> {
private host: HostElement;
private mappings: EventMapping<HostElement, V>[];
private mappings: EventMapping<HostElement, V[]>[];
private nativeEventTopic: Topic<NativeEventMap<HostElement>>;
private nativeEventOpts: NativeEventOpts<HostElement>;

Expand All @@ -45,7 +45,7 @@ export class NativeBridge<V extends string> {

constructor(opts: {
host: HostElement;
mappings: EventMapping<HostElement, V>[];
mappings: EventMapping<HostElement, V[]>[];
nativeEventTopic: Topic<NativeEventMap<HostElement>>;
nativeEventOpts: NativeEventOpts<HostElement>;
}) {
Expand All @@ -69,7 +69,7 @@ export class NativeBridge<V extends string> {
activate(vexmlEventName: V) {
util.assert(!this.isVexmlEventActive(vexmlEventName), `vexml event is already active: ${vexmlEventName}`);

const mapping = this.mappings.find((m) => m.vexml === vexmlEventName);
const mapping = this.mappings.find((m) => m.vexml.includes(vexmlEventName));
if (!mapping) {
return;
}
Expand Down Expand Up @@ -99,7 +99,7 @@ export class NativeBridge<V extends string> {
deactivate(vexmlEventName: V) {
util.assert(this.isVexmlEventActive(vexmlEventName), `vexml event is already inactive: ${vexmlEventName}`);

const mapping = this.mappings.find((m) => m.vexml === vexmlEventName);
const mapping = this.mappings.find((m) => m.vexml.includes(vexmlEventName));
if (!mapping) {
return;
}
Expand Down Expand Up @@ -137,6 +137,7 @@ export class NativeBridge<V extends string> {
event.type as NativeEventName<HostElement>,
event as NativeEvent<HostElement, NativeEventName<HostElement>>
);
return false;
};

/** Returns whether the vexml event is currently active. */
Expand Down
198 changes: 155 additions & 43 deletions src/rendering/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,44 @@ import * as util from '@/util';
import { InputType } from './types';
import { InteractionModelType } from './interactions';

const LONGPRESS_DURATION_MS = 500;

/** Events that vexml dispatches to listeners. */
export type EventMap = {
click: {
type: 'click';
closestTarget: InteractionModelType | null;
closestTarget: InteractionModelType;
targets: InteractionModelType[];
point: spatial.Point;
native: MouseEvent;
};
hover: {
type: 'hover';
closestTarget: InteractionModelType | null;
targets: InteractionModelType[];
point: spatial.Point;
native: MouseEvent;
native: MouseEvent | TouchEvent;
};
enter: {
type: 'enter';
target: InteractionModelType;
point: spatial.Point;
native: MouseEvent;
native: MouseEvent | TouchEvent;
};
exit: {
type: 'exit';
target: InteractionModelType;
point: spatial.Point;
native: MouseEvent;
native: MouseEvent | TouchEvent;
};
longpress: {
type: 'longpress';
target: InteractionModelType;
point: spatial.Point;
native: MouseEvent | TouchEvent;
};
};

export type EventType = keyof EventMap;

export type AnyEventListener = (event: EventMap[EventType]) => void;
export type ClickEventListener = (event: EventMap['click']) => void;
export type HoverEventListener = (event: EventMap['hover']) => void;
export type EnterEventListener = (event: EventMap['enter']) => void;
export type ExitEventListener = (event: EventMap['exit']) => void;
export type LongpressEventListener = (event: EventMap['longpress']) => void;

export class EventMappingFactory {
private cursor: cursors.PointCursor<InteractionModelType>;
Expand All @@ -52,14 +53,14 @@ export class EventMappingFactory {
this.topic = topic;
}

create(inputType: InputType): events.EventMapping<events.HostElement, keyof EventMap>[] {
create(inputType: InputType): events.EventMapping<events.HostElement, Array<keyof EventMap>>[] {
switch (inputType) {
case 'mouse':
return [this.click(), this.hover(), this.enter(), this.exit()];
return [this.mousePress(), this.mouseEgress()];
case 'touch':
return [this.click()];
return [this.touchPress(), this.touchEgress()];
case 'hybrid':
return [this.click(), this.hover(), this.enter(), this.exit()];
return [...this.create('mouse'), ...this.create('touch')];
case 'auto':
switch (util.device().inputType) {
case 'mouseonly':
Expand All @@ -76,35 +77,55 @@ export class EventMappingFactory {
}
}

private click(): events.EventMapping<events.HostElement, 'click'> {
return {
vexml: 'click',
native: {
click: (event) => {
const { point, targets, closestTarget } = this.cursor.get(event);
this.topic.publish('click', { type: 'click', closestTarget, targets, point, native: event });
},
},
};
}
private mousePress(): events.EventMapping<events.HostElement, ['click', 'longpress']> {
let timeout = 0 as unknown as NodeJS.Timeout;
let isPending = false;
let lastMouseDownInvocation = Symbol();

private hover(): events.EventMapping<events.HostElement, 'hover'> {
return {
vexml: 'hover',
vexml: ['click', 'longpress'],
native: {
mousemove: (event) => {
const { point, targets, closestTarget } = this.cursor.get(event);
this.topic.publish('hover', { type: 'hover', closestTarget, targets, point, native: event });
mousedown: (event) => {
const mouseDownInvocation = Symbol();

const { point, closestTarget } = this.cursor.get(event);
if (!closestTarget) {
return;
}

lastMouseDownInvocation = mouseDownInvocation;
isPending = true;

timeout = setTimeout(() => {
if (lastMouseDownInvocation === mouseDownInvocation) {
this.topic.publish('longpress', { type: 'longpress', target: closestTarget, point, native: event });
}
isPending = false;
}, LONGPRESS_DURATION_MS);
},
mousemove: () => {
clearTimeout(timeout);
isPending = false;
},
mouseup: (event) => {
if (isPending) {
const { point, targets, closestTarget } = this.cursor.get(event);
if (closestTarget) {
this.topic.publish('click', { type: 'click', closestTarget, targets, point, native: event });
}
}
clearTimeout(timeout);
isPending = false;
},
},
};
}

private enter(): events.EventMapping<events.HostElement, 'enter'> {
private mouseEgress(): events.EventMapping<events.HostElement, ['enter', 'exit']> {
let lastEvent: MouseEvent | null = null;

return {
vexml: 'enter',
vexml: ['enter', 'exit'],
native: {
mousemove: (event) => {
lastEvent ??= event;
Expand All @@ -114,6 +135,14 @@ export class EventMappingFactory {

lastEvent = event;

if (before.closestTarget && before.closestTarget !== after.closestTarget) {
this.topic.publish('exit', {
type: 'exit',
target: before.closestTarget,
point: before.point,
native: event,
});
}
if (after.closestTarget && before.closestTarget !== after.closestTarget) {
this.topic.publish('enter', {
type: 'enter',
Expand All @@ -127,28 +156,111 @@ export class EventMappingFactory {
};
}

private exit(): events.EventMapping<events.HostElement, 'exit'> {
let lastEvent: MouseEvent | null = null;
private touchPress(): events.EventMapping<events.HostElement, ['click', 'longpress']> {
let timeout = 0 as unknown as NodeJS.Timeout;
let isPending = false;
let lastTouchStartInvocation = Symbol();

return {
vexml: 'exit',
vexml: ['click', 'longpress'],
native: {
mousemove: (event) => {
lastEvent ??= event;
touchstart: (event) => {
const touchStartInvocation = Symbol();

const before = this.cursor.get(lastEvent);
const after = this.cursor.get(event);
const { point, closestTarget } = this.cursor.get(event.touches[0]);
if (!closestTarget) {
return;
}

lastEvent = event;
lastTouchStartInvocation = touchStartInvocation;
isPending = true;

if (before.closestTarget && before.closestTarget !== after.closestTarget) {
timeout = setTimeout(() => {
if (lastTouchStartInvocation === touchStartInvocation) {
this.topic.publish('longpress', { type: 'longpress', target: closestTarget, point, native: event });
}
isPending = false;
}, LONGPRESS_DURATION_MS);
},
touchmove: () => {
clearTimeout(timeout);
isPending = false;
},
touchend: (event) => {
if (isPending) {
const { point, targets, closestTarget } = this.cursor.get(event.changedTouches[0]);
if (closestTarget) {
this.topic.publish('click', { type: 'click', closestTarget, targets, point, native: event });
}
}
clearTimeout(timeout);
isPending = false;
},
touchcancel: () => {
clearTimeout(timeout);
isPending = false;
},
},
};
}

private touchEgress(): events.EventMapping<events.HostElement, ['enter', 'exit']> {
let lastEnteredEvent: TouchEvent | null = null;

return {
vexml: ['enter', 'exit'],
native: {
touchmove: (event) => {
const before = lastEnteredEvent ? this.cursor.get(lastEnteredEvent.touches[0]) : null;
const after = this.cursor.get(event.touches[0]);

if (before && before.closestTarget && before.closestTarget !== after.closestTarget) {
this.topic.publish('exit', {
type: 'exit',
target: before.closestTarget,
point: before.point,
native: event,
});
}

if (after.closestTarget && before?.closestTarget !== after.closestTarget) {
this.topic.publish('enter', {
type: 'enter',
target: after.closestTarget,
point: after.point,
native: event,
});

lastEnteredEvent = event;
}
},
touchcancel: (event) => {
const before = lastEnteredEvent ? this.cursor.get(lastEnteredEvent.touches[0]) : null;

if (before?.closestTarget) {
this.topic.publish('exit', {
type: 'exit',
target: before.closestTarget,
point: before.point,
native: event,
});

lastEnteredEvent = null;
}
},
touchend: (event) => {
const before = lastEnteredEvent ? this.cursor.get(lastEnteredEvent.touches[0]) : null;

if (before?.closestTarget) {
this.topic.publish('exit', {
type: 'exit',
target: before.closestTarget,
point: before.point,
native: event,
});

lastEnteredEvent = null;
}
},
},
};
Expand Down
Loading

0 comments on commit a119236

Please sign in to comment.