From dd00f3b230d61d04d347f1acc8531607c1c35ee3 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 26 Oct 2020 18:24:59 +1100 Subject: [PATCH] fix(input): refactor input methods --- src/input.ts | 457 ++++++++++++++++++--------------------------------- 1 file changed, 162 insertions(+), 295 deletions(-) diff --git a/src/input.ts b/src/input.ts index da1e3ab..c92e03e 100644 --- a/src/input.ts +++ b/src/input.ts @@ -2,10 +2,10 @@ // TODO: Add tests for this file -import { BehaviorSubject, Observable, Subject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { BehaviorSubject, fromEvent, merge, Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; import { getViewer, resizeViewers, updateViewer } from './api'; -import { subscription, unsubscribe, unsubscribeWith } from './async'; +import { clearAsyncTimeout, timeout } from './async'; import { calculateCenterFromZoomOffset, coordinatesForElement, @@ -34,16 +34,19 @@ export interface ViewerAction { fn: (_: Event) => void; } /** Mapping of Viewers to the lister actions */ -const _view_actions = new BehaviorSubject>({}); -/** Emitter for view events */ -const _action_emitter = new Subject(); -/** Mapping of custom action hash to viewer */ -const _custom_action_map: HashMap = {}; +const _view_actions = new BehaviorSubject>({}); /** Mapping of custom action hash to viewer */ const _focus_feature_map: HashMap = {}; +/** Whether user is currently performming a pinch */ +let _pinching: boolean = false; +/** Whether user is currently performing a pan */ +let _panning: boolean = false; +/** Starting point of the current panning action */ +let _start_point: Point; +/** Starting distance of the current pinch action */ +let _distance: number; const DEFAULT_ACTION_TYPES = [ - 'dblclick', 'click', 'mousedown', 'mouseup', @@ -55,7 +58,6 @@ const DEFAULT_ACTION_TYPES = [ 'wheel', ]; -let _ignore_actions: string[] = []; let _listening_for_resize = false; export function focusOnFeature(viewer: Viewer) { @@ -79,6 +81,7 @@ export function focusOnFeature(viewer: Viewer) { export function listenForResize() { if (_listening_for_resize) return; window.addEventListener('resize', () => resizeViewers()); + window.addEventListener('blur', () => handlePinchAndPanEnd()); _listening_for_resize = true; } @@ -86,314 +89,178 @@ export function listenForViewActions(viewer: Viewer, actions: string[] = DEFAULT const action_map = _view_actions.getValue(); const element = viewer.element as HTMLElement; if (action_map[viewer.id]) { - for (const action of action_map[viewer.id]) { - element.removeEventListener(action.type, action.fn); - } + action_map[viewer.id].unsubscribe(); } - const action_list = []; - const emitter = _action_emitter; + const action_list: Observable[] = []; + for (const type of actions) { - const action: ViewerAction = { - type, - fn: (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - type === 'touchend' - ? _ignore_actions.includes(type) - ? '' - : emitter.next({ id: viewer.id, type: 'click', event: e }) - : _ignore_actions.includes(type) - ? '' - : emitter.next({ id: viewer.id, type, event: e }); - }, - }; - element.addEventListener(type, action.fn); - action_list.push(action); + action_list.push( + fromEvent(element, type).pipe(map((e) => ({ id: viewer.id, type, event: e }))) + ); } - action_map[viewer.id] = action_list; + action_map[viewer.id] = merge(...action_list).subscribe((details) => { + const { id, type, event } = details; + const e: any = event; + e.preventDefault(); + e.stopPropagation(); + switch (type) { + case 'click': + if (!_panning && !_pinching) { + handleCustomEvents(details); + handleViewClick(id, e); + } + break; + case 'touchstart': + case 'mousedown': + e.touches?.length >= 2 ? handlePinchStart(id, e) : handlePanStart(id, e); + break; + case 'touchmove': + case 'mousemove': + _pinching ? handlePinch(id, e) : _panning ? handlePanning(id, e) : ''; + break; + case 'touchend': + case 'mouseup': + if (!_panning && !_pinching) { + handleCustomEvents(details); + handleViewClick(id, e); + } + handlePinchAndPanEnd(); + break; + case 'mousewheel': + case 'wheel': + handleScrolling(id, e); + break; + } + }); _view_actions.next(action_map); - const view_emitter = emitter.pipe(filter((_) => !!_ && _.id === viewer.id)); - listenForViewClick(viewer, view_emitter as any); - listenForViewDoubleClick(viewer, view_emitter as any); - listenForViewPanStart(viewer, view_emitter as any); - listenForViewPinchStart(viewer, view_emitter as any); - listenForViewScrolling(viewer, view_emitter as any); return; } -export function listenForViewClick(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-click`, - emitter - .pipe( - filter((_) => _.type === 'click'), - map((e) => e.event) - ) - .subscribe((e: any) => { - log('INPUT', 'Resetting zoom level and center position...'); - const view = getViewer(viewer.id); - if (view) { - log('INPUT', `Clicked:`, coordinatesForPoint(viewer, eventToPoint(e))); - } - }) - ); +export function handleViewClick(id: string, event: MouseEvent) { + const view = getViewer(id); + if (view) { + log('INPUT', `Clicked:`, coordinatesForPoint(view, eventToPoint(event))); + } } -export function listenForViewDoubleClick(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-dblclick`, - emitter - .pipe( - filter((_) => _.type === 'dblclick'), - map((e) => e.event) - ) - .subscribe((_) => { - log('INPUT', 'Resetting zoom level and center position...'); - const view = getViewer(viewer.id); - if (view) { - updateViewer(view, { desired_zoom: 1, desired_center: { x: 0.5, y: 0.5 } }); - } - }) - ); +export function handleDoubleClick(id: string) { + log('INPUT', 'Resetting zoom level and center position...'); + const view = getViewer(id); + if (view) { + updateViewer(view, { desired_zoom: 1, desired_center: { x: 0.5, y: 0.5 } }); + } } -export function listenForViewPanStart(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-pan`, - emitter - .pipe( - filter((_) => _.type === 'touchstart' || _.type === 'mousedown'), - map((e) => e.event) - ) - .subscribe((e: any) => { - log('INPUT', 'Starting panning...'); - const view = getViewer(viewer.id); - if (view && !view.options.disable_pan) { - const start_point = eventToPoint(e); - listenForViewPanning(view, emitter, start_point); - listenForViewPanEnd(view, emitter); - } - }) - ); +export function handlePanStart(id: string, event: MouseEvent) { + if (_pinching) return; + log('INPUT', 'Starting panning...'); + const view = getViewer(id); + if (view && !view.options.disable_pan) { + _start_point = eventToPoint(event); + timeout('pan_start', () => (_panning = true), 200); + } } -export function listenForViewPanning( - viewer: Viewer, - emitter: Observable, - start: Point -) { - subscription( - `${viewer.id}-panning`, - emitter - .pipe( - filter((_) => _.type === 'touchmove' || _.type === 'mousemove'), - map((e) => e.event) - ) - .subscribe((e: any) => { - const view = getViewer(viewer.id); - if (view) { - const point = eventToPoint(e); - const diff = Math.abs(point.x - start.x + (point.y - start.y)); - if (!_ignore_actions.includes('click') && diff > 1) { - _ignore_actions.push('click'); - } - const center = { - x: Math.max( - 0, - Math.min( - 1, - (point.x - start.x) / view.box.width / view.desired_zoom + - view.center.x - ) - ), - y: Math.max( - 0, - Math.min( - 1, - (point.y - start.y) / view.box.height / view.desired_zoom + - view.center.y - ) - ), - }; - start = point; - updateViewer(view, { center, desired_center: center }); - } - }) - ); +export function handlePanning(id: string, event: MouseEvent, start: Point = _start_point) { + if (_pinching) return; + _panning = true; + const view = getViewer(id); + if (view) { + const point = eventToPoint(event); + const center = { + x: Math.max( + 0, + Math.min( + 1, + (point.x - start.x) / view.box.width / view.desired_zoom + view.center.x + ) + ), + y: Math.max( + 0, + Math.min( + 1, + (point.y - start.y) / view.box.height / view.desired_zoom + view.center.y + ) + ), + }; + _start_point = point; + updateViewer(view, { center, desired_center: center }); + } } -export function listenForViewPanEnd(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-pan_end`, - emitter.pipe(filter((_) => _.type === 'touchend' || _.type === 'mouseup')).subscribe(() => { - log('INPUT', 'Ending panning...'); - unsubscribe(`${viewer.id}-pan_end`); - unsubscribe(`${viewer.id}-panning`); - setTimeout(() => { - _ignore_actions = _ignore_actions.filter((i) => i !== 'click'); - }, 100); - }) - ); +export function handlePinchStart(id: string, event: TouchEvent) { + log('INPUT', 'Starting pinching...'); + const view = getViewer(id); + _pinching = true; + if (view && !view.options.disable_zoom) { + const points = [ + { x: event.touches[0].clientX, y: event.touches[0].clientY }, + { x: event.touches[1].clientX, y: event.touches[1].clientY }, + ]; + _distance = distanceBetween(points[0], points[1]); + } } -export function listenForViewPinchStart(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-pinch_start`, - emitter - .pipe( - filter((_) => _.type === 'touchstart' && (_.event as any).touches.length === 2), - map((e) => e.event) - ) - .subscribe((e: any) => { - log('INPUT', 'Starting pinching...'); - const view = getViewer(viewer.id); - if (view && !view.options.disable_zoom) { - const points = [ - { x: e.touches[0].clientX, y: e.touches[0].clientY }, - { x: e.touches[1].clientX, y: e.touches[1].clientY }, - ]; - const start_point = { - x: (points[0].x + points[1].x) / 2, - y: (points[0].y + points[1].y) / 2, - }; - const start_dist = distanceBetween(points[0], points[1]); - listenForViewPinching(view, emitter, start_point, start_dist); - listenForViewPinchEnd(view, emitter); - } - }) - ); +export function handlePinch(id: string, event: TouchEvent, distance: number = _distance) { + const view = getViewer(id); + if (view && !view.options.disable_zoom) { + const points = [ + { x: event.touches[0].clientX, y: event.touches[0].clientY }, + { x: event.touches[1].clientX, y: event.touches[1].clientY }, + ]; + const dist = distanceBetween(points[0], points[1]); + const zoom = Math.max(1, Math.min(10, (view.zoom * dist) / distance)); + _distance = dist; + updateViewer(view, { + zoom, + desired_zoom: zoom, + }); + } } -export function listenForViewPinching( - viewer: Viewer, - emitter: Observable, - start: Point, - distance: number -) { - subscription( - `${viewer.id}-pinch`, - emitter - .pipe( - filter((_) => _.type === 'touchmove'), - map((e) => e.event) - ) - .subscribe((e: any) => { - const view = getViewer(viewer.id); - if (view && !view.options.disable_zoom) { - const points = [ - { x: e.touches[0].clientX, y: e.touches[0].clientY }, - { x: e.touches[1].clientX, y: e.touches[1].clientY }, - ]; - const mid_point = { - x: (points[0].x + points[1].x) / 2, - y: (points[0].y + points[1].y) / 2, - }; - const dist = distanceBetween(points[0], points[1]); - const diff = Math.abs(mid_point.x - start.x + (mid_point.y - start.y)); - if (!_ignore_actions.includes('click') && diff > 1) { - _ignore_actions.push('click'); - } - const center = { - x: Math.max( - 0, - Math.min( - 1, - (mid_point.x - start.x) / view.box.width / view.desired_zoom + - view.center.x - ) - ), - y: Math.max( - 0, - Math.min( - 1, - (mid_point.y - start.y) / view.box.height / view.desired_zoom + - view.center.y - ) - ), - }; - const zoom = Math.max(1, Math.min(10, (view.zoom * dist) / distance)); - start = mid_point; - distance = dist; - updateViewer(view, { - center, - desired_center: center, - zoom, - desired_zoom: zoom, - }); - } - }) +export function handlePinchAndPanEnd() { + log('INPUT', 'Ending pinch/pan...'); + clearAsyncTimeout('pan_start'); + timeout( + 'pan_pinch_end', + () => { + _pinching = false; + _panning = false; + }, + 50 ); } -export function listenForViewPinchEnd(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-pinch_end`, - emitter.pipe(filter((_) => _.type === 'touchend' || _.type === 'mouseup')).subscribe(() => { - log('INPUT', 'Ending panning...'); - unsubscribe(`${viewer.id}-pan_end`); - unsubscribe(`${viewer.id}-panning`); - setTimeout(() => { - _ignore_actions = _ignore_actions.filter((i) => i !== 'click'); - }, 100); - }) - ); +export function handleScrolling(id: string, event: WheelEvent) { + const view = getViewer(id); + if (view) { + const delta = event.deltaY >= 0 ? -0.02 : 0.02; + const zoom = Math.min(10, Math.max(1, view.zoom * (1 + delta))); + const box = view.element + ?.querySelector('.svg-viewer__render-container') + ?.getBoundingClientRect(); + const cursor_point = coordinatesForPoint(view, eventToPoint(event), box); + const point = { x: 1 - cursor_point.x, y: 1 - cursor_point.y }; + const center = + zoom === 1 || zoom === 10 || zoom === view.zoom + ? view.center + : calculateCenterFromZoomOffset(1 + delta, point, view.center); + updateViewer(view, { + zoom, + center, + desired_zoom: zoom, + desired_center: center, + }); + } } -export function listenForViewScrolling(viewer: Viewer, emitter: Observable) { - subscription( - `${viewer.id}-scrolling`, - emitter - .pipe( - filter((_) => _.type === 'mousewheel' || _.type === 'wheel'), - map((e) => e.event) - ) - .subscribe((e: any) => { - const event = e as WheelEvent; - event.preventDefault(); // Prevent viewport scrolling - const view = getViewer(viewer.id); - if (view) { - const delta = event.deltaY >= 0 ? -0.02 : 0.02; - const zoom = Math.min(10, Math.max(1, view.zoom * (1 + delta))); - const box = view.element - ?.querySelector('.render-container') - ?.getBoundingClientRect(); - const cursor_point = coordinatesForPoint(view, eventToPoint(e), box); - const point = { x: 1 - cursor_point.x, y: 1 - cursor_point.y }; - const center = - zoom === 1 || zoom === 10 || zoom === view.zoom - ? view.center - : calculateCenterFromZoomOffset(1 + delta, point, view.center); - updateViewer(view, { - zoom, - center, - desired_zoom: zoom, - desired_center: center, - }); - } - }) +export function handleCustomEvents(details: ViewerEvent) { + const { id, type, event } = details; + const viewer = getViewer(id); + if (!viewer || !viewer.actions?.length) return; + const action = viewer.actions.find( + (e) => e.action === type && (e.id === '*' || e.id === (event.target as any)?.id) ); -} - -export function listenForCustomViewActions( - viewer: Viewer, - emitter: Observable = _action_emitter as any -) { - const actions_string = JSON.stringify(viewer.actions); - if (_custom_action_map[viewer.id] !== actions_string) { - unsubscribeWith(`${viewer.id}_`); - for (const action of viewer.actions) { - subscription( - `${viewer.id}_${action.id}-${action.action}`, - emitter.pipe(filter((e) => e && e.type === action.action)).subscribe((e) => { - const el: HTMLElement = e.event.target as any; - if (el.id === action.id || action.id === '*') { - action.callback( - e.event, - coordinatesForPoint(viewer, eventToPoint(e.event as any)) - ); - } - }) - ); - } - } + if (!action) return; + action.callback(event, coordinatesForPoint(viewer, eventToPoint(event as any))); }