diff --git a/.changeset/beige-points-smash.md b/.changeset/beige-points-smash.md new file mode 100644 index 00000000..36b4e5d5 --- /dev/null +++ b/.changeset/beige-points-smash.md @@ -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. diff --git a/src/CanvasContainer.tsx b/src/CanvasContainer.tsx index 0e3a6fbe..4563c258 100644 --- a/src/CanvasContainer.tsx +++ b/src/CanvasContainer.tsx @@ -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 | null, - }, - { - events: { - DRAG_SESSION_STARTED: ({ pointerId, point }: DragSession) => ({ - pointerId, - point, - }), - DRAG_SESSION_STOPPED: () => ({}), - DRAG_POINT_MOVED: ({ point }: Pick) => ({ 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, - ev: ReturnType, - ) => ({ - 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 | null, @@ -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: () => ({}), @@ -184,6 +36,8 @@ const dragModel = createModel( POINTER_MOVED_BY: ({ delta }: { delta: PointDelta }) => ({ delta, }), + WHEEL_PRESSED: (data: DragSession) => ({ data }), + WHEEL_RELEASED: () => ({}), }, }, ); @@ -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', }, @@ -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: { @@ -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; diff --git a/src/ResizableBox.tsx b/src/ResizableBox.tsx index c5b38b0c..05ff81ee 100644 --- a/src/ResizableBox.tsx +++ b/src/ResizableBox.tsx @@ -1,53 +1,59 @@ import { Box, BoxProps } from '@chakra-ui/react'; import { useMachine } from '@xstate/react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createModel } from 'xstate/lib/model'; -import { Point } from './pathUtils'; +import { assign } from 'xstate'; +import { + dragSessionModel, + dragSessionTracker, + PointDelta, +} from './dragSessionTracker'; +import { Point } from './types'; -const dragDropModel = createModel( +const resizableModel = createModel( { - prevWidth: 0, + ref: null as React.MutableRefObject | null, widthDelta: 0, - dragPoint: { x: 0, y: 0 }, - point: { x: 0, y: 0 }, }, { events: { - 'DRAG.START': (point: Point) => ({ point }), - 'DRAG.MOVE': (point: Point) => ({ point }), - 'DRAG.END': () => ({}), + DRAG_SESSION_STARTED: ({ point }: { point: Point }) => ({ + point, + }), + DRAG_SESSION_STOPPED: () => ({}), + POINTER_MOVED_BY: ({ delta }: { delta: PointDelta }) => ({ + delta, + }), }, }, ); -const dragDropMachine = dragDropModel.createMachine({ +const resizableMachine = resizableModel.createMachine({ + invoke: { + id: 'dragSessionTracker', + src: (ctx) => + dragSessionTracker.withContext({ + ...dragSessionModel.initialContext, + ref: ctx.ref, + }), + }, initial: 'idle', states: { idle: { on: { - 'DRAG.START': { - target: 'dragging', - actions: dragDropModel.assign({ point: (_, e) => e.point }), - }, + DRAG_SESSION_STARTED: 'active', }, }, - dragging: { + active: { on: { - 'DRAG.MOVE': { - actions: dragDropModel.assign({ - dragPoint: (_, e) => e.point, + POINTER_MOVED_BY: { + actions: assign({ widthDelta: (ctx, e) => { - return Math.max(0, ctx.prevWidth + (ctx.point.x - e.point.x)); + return Math.max(0, ctx.widthDelta + e.delta.x); }, }), }, - 'DRAG.END': { - target: 'idle', - actions: dragDropModel.assign({ - point: (ctx) => ctx.dragPoint, - prevWidth: (ctx) => ctx.widthDelta, - }), - }, + DRAG_SESSION_STOPPED: 'idle', }, }, }, @@ -56,7 +62,14 @@ const dragDropMachine = dragDropModel.createMachine({ const ResizeHandle: React.FC<{ onChange: (width: number) => void; }> = ({ onChange }) => { - const [state, send] = useMachine(dragDropMachine); + const ref = useRef(null!); + + const [state] = useMachine( + resizableMachine.withContext({ + ...resizableModel.initialContext, + ref, + }), + ); useEffect(() => { onChange(state.context.widthDelta); @@ -64,6 +77,7 @@ const ResizeHandle: React.FC<{ return ( { - e.stopPropagation(); - e.currentTarget.setPointerCapture(e.pointerId); - send( - dragDropModel.events['DRAG.START']({ x: e.clientX, y: e.clientY }), - ); - }} - onPointerMove={(e) => { - send(dragDropModel.events['DRAG.MOVE']({ x: e.clientX, y: e.clientY })); - }} - onPointerUp={() => { - send(dragDropModel.events['DRAG.END']()); - }} - onPointerCancel={() => { - send(dragDropModel.events['DRAG.END']()); - }} > ); }; diff --git a/src/dragSessionTracker.ts b/src/dragSessionTracker.ts new file mode 100644 index 00000000..aec021b2 --- /dev/null +++ b/src/dragSessionTracker.ts @@ -0,0 +1,175 @@ +import { + assign, + actions, + sendParent, + ContextFrom, + SpecialTargets, +} from 'xstate'; +import { createModel } from 'xstate/lib/model'; +import { Point } from './types'; + +export interface DragSession { + pointerId: number; + point: Point; +} + +export interface PointDelta { + x: number; + y: number; +} + +export const dragSessionModel = createModel( + { + session: null as DragSession | null, + ref: null as React.MutableRefObject | null, + }, + { + events: { + DRAG_SESSION_STARTED: ({ pointerId, point }: DragSession) => ({ + pointerId, + point, + }), + DRAG_SESSION_STOPPED: () => ({}), + DRAG_POINT_MOVED: ({ point }: Pick) => ({ point }), + }, + }, +); + +export const dragSessionTracker = dragSessionModel.createMachine( + { + preserveActionOrder: true, + initial: 'check_session_data', + states: { + check_session_data: { + always: [ + { + cond: (ctx) => !!ctx.session, + target: 'active', + actions: sendParent((ctx) => + dragSessionModel.events.DRAG_SESSION_STARTED(ctx.session!), + ), + }, + 'idle', + ], + }, + 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, session }, ev: any) => + ref!.current!.setPointerCapture(ev!.pointerId || session?.pointerId), + releasePointer: ({ ref, session }) => + ref!.current!.releasePointerCapture(session!.pointerId), + setSessionData: assign({ + session: (ctx, ev: any) => { + if (ev.pointerId && ev.point) + return { + pointerId: ev.pointerId, + point: ev.point, + }; + return ctx.session; + }, + }), + clearSessionData: assign({ + session: null, + }) as any, + updatePoint: assign({ + session: (ctx, ev: any) => ({ + ...ctx.session!, + point: ev.point, + }), + }), + sendPointDelta: sendParent( + ( + ctx: ContextFrom, + ev: ReturnType, + ) => ({ + type: 'POINTER_MOVED_BY', + delta: { + x: ctx.session!.point.x - ev.point.x, + y: ctx.session!.point.y - ev.point.y, + }, + }), + ) as any, + }, + }, +); diff --git a/src/pathUtils.ts b/src/pathUtils.ts index a418a7ec..f05390aa 100644 --- a/src/pathUtils.ts +++ b/src/pathUtils.ts @@ -1,7 +1,6 @@ -export interface Point { - x: number; - y: number; -} +import { Point } from './types'; + +export type { Point }; enum Sides { Top = 'top', diff --git a/src/types.ts b/src/types.ts index eaf9b606..a73dabee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,3 +79,8 @@ export interface ParsedEmbed { export type EmbedContext = | { isEmbedded: false } | ({ isEmbedded: true; originalUrl: string } & ParsedEmbed); + +export interface Point { + x: number; + y: number; +}