Skip to content
This repository was archived by the owner on Feb 25, 2024. It is now read-only.

Andarist/wheel panning #269

Merged
merged 7 commits into from
Oct 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/beige-points-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'xstate-viz-app': minor
---

A possibility to start panning the canvas by pressing the middle button of a mouse has been added.
235 changes: 65 additions & 170 deletions src/CanvasContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,169 +2,19 @@ import React, { CSSProperties, useEffect, useRef } from 'react';
import { canvasModel, ZoomFactor } from './canvasMachine';
import { useCanvas } from './CanvasContext';
import { useMachine } from '@xstate/react';
import {
assign,
actions,
sendParent,
ContextFrom,
SpecialTargets,
} from 'xstate';
import { actions } from 'xstate';
import { createModel } from 'xstate/lib/model';
import { Point } from './pathUtils';
import { isAcceptingSpaceNatively, isWithPlatformMetaKey } from './utils';
import { useEmbed } from './embedContext';
import {
dragSessionModel,
dragSessionTracker,
DragSession,
PointDelta,
} from './dragSessionTracker';
import { AnyState } from './types';

interface DragSession {
pointerId: number;
point: Point;
}

interface PointDelta {
x: number;
y: number;
}

const dragSessionModel = createModel(
{
session: null as DragSession | null,
ref: null as React.MutableRefObject<HTMLElement> | null,
},
{
events: {
DRAG_SESSION_STARTED: ({ pointerId, point }: DragSession) => ({
pointerId,
point,
}),
DRAG_SESSION_STOPPED: () => ({}),
DRAG_POINT_MOVED: ({ point }: Pick<DragSession, 'point'>) => ({ point }),
},
},
);

const dragSessionTracker = dragSessionModel.createMachine(
{
preserveActionOrder: true,
initial: 'idle',
states: {
idle: {
invoke: {
id: 'dragSessionStartedListener',
src:
({ ref }) =>
(sendBack) => {
const node = ref!.current!;
const listener = (ev: PointerEvent) => {
const isMouseLeftButton = ev.button === 0;
if (isMouseLeftButton) {
sendBack(
dragSessionModel.events.DRAG_SESSION_STARTED({
pointerId: ev.pointerId,
point: {
x: ev.pageX,
y: ev.pageY,
},
}),
);
}
};
node.addEventListener('pointerdown', listener);
return () => node.removeEventListener('pointerdown', listener);
},
},
on: {
DRAG_SESSION_STARTED: {
target: 'active',
actions: actions.forwardTo(SpecialTargets.Parent),
},
},
},
active: {
entry: ['capturePointer', 'setSessionData'],
exit: ['releasePointer', 'clearSessionData'],
invoke: {
id: 'dragSessionListeners',
src:
({ ref, session }) =>
(sendBack) => {
const node = ref!.current!;

const moveListener = (ev: PointerEvent) => {
if (ev.pointerId !== session!.pointerId) {
return;
}
sendBack(
dragSessionModel.events.DRAG_POINT_MOVED({
point: { x: ev.pageX, y: ev.pageY },
}),
);
};
const stopListener = (ev: PointerEvent) => {
if (ev.pointerId !== session!.pointerId) {
return;
}
sendBack(dragSessionModel.events.DRAG_SESSION_STOPPED());
};
node.addEventListener('pointermove', moveListener);
node.addEventListener('pointerup', stopListener);
node.addEventListener('pointercancel', stopListener);

return () => {
node.removeEventListener('pointermove', moveListener);
node.removeEventListener('pointerup', stopListener);
node.removeEventListener('pointercancel', stopListener);
};
},
},
on: {
DRAG_POINT_MOVED: {
actions: ['sendPointDelta', 'updatePoint'],
},
DRAG_SESSION_STOPPED: {
target: 'idle',
actions: actions.forwardTo(SpecialTargets.Parent),
},
},
},
},
},
{
actions: {
capturePointer: ({ ref }, ev: any) =>
ref!.current!.setPointerCapture(ev!.pointerId),
releasePointer: ({ ref, session }) =>
ref!.current!.releasePointerCapture(session!.pointerId),
setSessionData: assign({
session: (ctx, ev: any) => ({
pointerId: ev.pointerId,
point: ev.point,
}),
}),
clearSessionData: assign({
session: null,
}) as any,
updatePoint: assign({
session: (ctx, ev: any) => ({
...ctx.session!,
point: ev.point,
}),
}),
sendPointDelta: sendParent(
(
ctx: ContextFrom<typeof dragSessionModel>,
ev: ReturnType<typeof dragSessionModel.events.DRAG_POINT_MOVED>,
) => ({
type: 'POINTER_MOVED_BY',
delta: {
x: ctx.session!.point.x - ev.point.x,
y: ctx.session!.point.y - ev.point.y,
},
}),
) as any,
},
},
);

const dragModel = createModel(
{
ref: null as React.MutableRefObject<HTMLElement> | null,
Expand All @@ -173,7 +23,9 @@ const dragModel = createModel(
events: {
LOCK: () => ({}),
RELEASE: () => ({}),
ENABLE_PANNING: () => ({}),
ENABLE_PANNING: (sessionSeed: DragSession | null = null) => ({
sessionSeed,
}),
DISABLE_PANNING: () => ({}),
ENABLE_PAN_MODE: () => ({}),
DISABLE_PAN_MODE: () => ({}),
Expand All @@ -184,6 +36,8 @@ const dragModel = createModel(
POINTER_MOVED_BY: ({ delta }: { delta: PointDelta }) => ({
delta,
}),
WHEEL_PRESSED: (data: DragSession) => ({ data }),
WHEEL_RELEASED: () => ({}),
},
},
);
Expand Down Expand Up @@ -213,33 +67,43 @@ const dragMachine = dragModel.createMachine(
initial: 'released',
states: {
released: {
invoke: {
src: 'invokeDetectLock',
},
invoke: [
{
src: 'invokeDetectLock',
},
{
src: 'wheelPressListener',
},
],
on: {
LOCK: 'locked',
WHEEL_PRESSED: 'wheelPressed',
},
},
locked: {
entry: actions.raise(
dragModel.events.ENABLE_PANNING(),
) as any,
exit: actions.raise(
dragModel.events.DISABLE_PANNING(),
) as any,
entry: actions.raise(dragModel.events.ENABLE_PANNING()),
exit: actions.raise(dragModel.events.DISABLE_PANNING()),
on: { RELEASE: 'released' },
invoke: {
src: 'invokeDetectRelease',
},
},
wheelPressed: {
entry: actions.raise(((_ctx: any, ev: any) =>
dragModel.events.ENABLE_PANNING(ev.data)) as any),
exit: actions.raise(dragModel.events.DISABLE_PANNING()),
on: {
DRAG_SESSION_STOPPED: 'released',
},
},
},
on: {
ENABLE_PAN_MODE: 'pan',
},
},
pan: {
entry: actions.raise(dragModel.events.ENABLE_PANNING()) as any,
exit: actions.raise(dragModel.events.DISABLE_PANNING()) as any,
entry: actions.raise(dragModel.events.ENABLE_PANNING()),
exit: actions.raise(dragModel.events.DISABLE_PANNING()),
on: {
DISABLE_PAN_MODE: 'lockable',
},
Expand All @@ -259,10 +123,23 @@ const dragMachine = dragModel.createMachine(
exit: 'enableTextSelection',
invoke: {
id: 'dragSessionTracker',
src: (ctx) =>
src: (ctx, ev) =>
dragSessionTracker.withContext({
...dragSessionModel.initialContext,
ref: ctx.ref,
session:
// this is just defensive programming
// this really should receive ENABLE_PANNING at all times as this is the event that is making this state to be entered
// however, raised events are not given to invoke creators so we have to fallback handling WHEEL_PRESSED event
// in reality, because of this issue, ENABLE_PANNING that we can receive here won't ever hold any `sessionSeed` (as that is only coming from the wheel-oriented interaction)
ev.type === 'ENABLE_PANNING'
? ev.sessionSeed
: (
ev as Extract<
typeof ev,
{ type: 'WHEEL_PRESSED' }
>
).data,
}),
},
on: {
Expand Down Expand Up @@ -329,6 +206,24 @@ const dragMachine = dragModel.createMachine(
},
},
services: {
wheelPressListener: (ctx) => (sendBack) => {
const node = ctx.ref!.current!;
const listener = (ev: PointerEvent) => {
if (ev.button === 1) {
sendBack(
dragModel.events.WHEEL_PRESSED({
pointerId: ev.pointerId,
point: {
x: ev.pageX,
y: ev.pageY,
},
}),
);
}
};
node.addEventListener('pointerdown', listener);
return () => node.removeEventListener('pointerdown', listener);
},
invokeDetectLock: () => (sendBack) => {
function keydownListener(e: KeyboardEvent) {
const target = e.target as HTMLElement;
Expand Down
Loading