Migration interaction map machinery to XState V5

This commit is contained in:
Frank Noirot
2024-10-28 13:09:21 -04:00
parent 88030afdce
commit 74c9596506
4 changed files with 283 additions and 200 deletions

View File

@ -1,4 +1,4 @@
import { useMachine } from '@xstate/react' import { createActorContext, useMachine } from '@xstate/react'
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants' import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
import { import {
isModifierKey, isModifierKey,
@ -30,161 +30,28 @@ type MachineContext<T extends AnyStateMachine> = {
send: Prop<InterpreterFrom<T>, 'send'> send: Prop<InterpreterFrom<T>, 'send'>
} }
export const InteractionMapMachineContext = createContext( export const InteractionMapMachineContext = createActorContext(
{} as MachineContext<typeof interactionMapMachine> interactionMapMachine
) )
export function InteractionMapMachineProvider({ export const InteractionMapMachineProvider = ({
children, children,
}: React.PropsWithChildren<{}>) { }: {
const [state, send] = useMachine(interactionMapMachine, { children: React.ReactNode
logger: (msg) => { }) => {
console.log(msg) return (
}, <InteractionMapMachineContext.Provider>
actions: { <InteractionMapProviderInner>{children}</InteractionMapProviderInner>
'Add last interaction to sequence': assign({ </InteractionMapMachineContext.Provider>
currentSequence: (context, event) => { )
const newSequence = event.data }
? context.currentSequence
? context.currentSequence.concat(' ', event.data)
: event.data
: context.currentSequence
console.log('newSequence', newSequence) function InteractionMapProviderInner({
return newSequence children,
}, }: {
}), children: React.ReactNode
'Clear sequence': assign({ }) {
currentSequence: () => { const interactionMap = InteractionMapMachineContext.useActorRef()
console.log('clearing sequence')
return ''
},
}),
'Add to interactionMap': assign({
interactionMap: (context, event) => {
const newInteractions: Record<string, InteractionMapItem> =
Object.fromEntries(
Object.entries(event.data.items).map(([name, item]) => [
name,
{
...item,
sequence: normalizeSequence(item.sequence),
},
])
)
const newInteractionMap = {
...context.interactionMap,
[event.data.ownerId]: {
...context.interactionMap[event.data.ownerId],
...newInteractions,
},
}
// console.log('newInteractionMap', newInteractionMap)
return newInteractionMap
},
}),
'Remove from interactionMap': assign({
interactionMap: (context, event) => {
const newInteractionMap = { ...context.interactionMap }
if (event.data instanceof Array) {
event.data.forEach((key) => {
const [ownerId, itemName] = key.split(INTERACTION_MAP_SEPARATOR)
delete newInteractionMap[ownerId][itemName]
})
} else {
delete newInteractionMap[event.data]
}
return newInteractionMap
},
}),
'Merge into overrides': assign({
overrides: (context, event) => {
return {
...context.overrides,
...event.data,
}
},
}),
'Persist keybinding overrides': (context) => {
console.log('Persisting keybinding overrides', context.overrides)
},
},
services: {
'Resolve hotkey by prefix': (context, event) => {
const resolvedInteraction = resolveInteractionEvent(event.data)
// if the key is already a modifier key, skip everything else and reject
if (resolvedInteraction.isModifier) {
// We return an empty string so that we don't clear the currentSequence
return Promise.reject('')
}
// Find all the sequences that start with the current sequence
const searchString =
(context.currentSequence ? context.currentSequence + ' ' : '') +
resolvedInteraction.asString
const sortedInteractions = getSortedInteractionMapSequences(context)
const matches = sortedInteractions.filter(([sequence]) =>
sequence.startsWith(searchString)
)
console.log('matches', {
matches,
sortedInteractions,
searchString,
overrides: context.overrides,
})
// If we have no matches, reject the promise
if (matches.length === 0) {
return Promise.reject()
}
const exactMatches = matches.filter(
([sequence]) => sequence === searchString
)
console.log('exactMatches', exactMatches)
if (!exactMatches.length) {
// We have a prefix match.
// Reject the promise and return the step
// so we can add it to currentSequence
return Promise.reject(resolvedInteraction.asString)
}
// Resolve to just one exact match
const availableExactMatches = exactMatches.filter(
([_, item]) => !item.guard || item.guard(event.data)
)
console.log('availableExactMatches', availableExactMatches)
if (availableExactMatches.length === 0) {
return Promise.reject()
} else {
// return the last-added, available exact match
return Promise.resolve(
availableExactMatches[availableExactMatches.length - 1][1]
)
}
},
'Execute keymap action': async (_context, event) => {
try {
console.log('Executing action', event.data)
event.data.action()
} catch (error) {
console.error(error)
toast.error('There was an error executing the action.')
}
},
},
guards: {
'There are prefix matches': (_context, event) => {
return event.data !== undefined
},
},
})
// Setting up global event listeners // Setting up global event listeners
useEffect(() => { useEffect(() => {
@ -204,7 +71,7 @@ export function InteractionMapMachineProvider({
'true' 'true'
) )
) { ) {
send({ type: 'Fire event', data: event }) interactionMap.send({ type: 'Fire event', data: { event } })
} }
} }
@ -217,9 +84,5 @@ export function InteractionMapMachineProvider({
} }
}, []) }, [])
return ( return children
<InteractionMapMachineContext.Provider value={{ state, send }}>
{children}
</InteractionMapMachineContext.Provider>
)
} }

View File

@ -1,6 +1,11 @@
import { InteractionMapMachineContext } from 'components/InteractionMapMachineProvider' import { InteractionMapMachineContext } from 'components/InteractionMapMachineProvider'
import { useContext } from 'react'
export const useInteractionMapContext = () => { export const useInteractionMapContext = () => {
return useContext(InteractionMapMachineContext) const interactionMapActor = InteractionMapMachineContext.useActorRef()
const interactionMapState = InteractionMapMachineContext.useSelector((state) => state)
return {
actor: interactionMapActor,
send: interactionMapActor.send,
state: interactionMapState,
}
} }

View File

@ -69,8 +69,9 @@ type ResolveKeymapEvent = {
asString: string asString: string
} }
export type InteractionEvent = MouseEvent | KeyboardEvent
export function resolveInteractionEvent( export function resolveInteractionEvent(
event: MouseEvent | KeyboardEvent event: InteractionEvent
): ResolveKeymapEvent { ): ResolveKeymapEvent {
// First, determine if this is a key or mouse event // First, determine if this is a key or mouse event
const action = const action =

View File

@ -1,7 +1,13 @@
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants' import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
import { mapKey, sortKeys } from 'lib/keyboard' import {
InteractionEvent,
mapKey,
resolveInteractionEvent,
sortKeys,
} from 'lib/keyboard'
import { interactionMapCategories } from 'lib/settings/initialKeybindings' import { interactionMapCategories } from 'lib/settings/initialKeybindings'
import { ContextFrom, createMachine } from 'xstate' import toast from 'react-hot-toast'
import { assign, ContextFrom, createMachine, fromPromise, setup } from 'xstate'
export type MouseButtonName = `${'Left' | 'Middle' | 'Right'}Button` export type MouseButtonName = `${'Left' | 'Middle' | 'Right'}Button`
@ -25,43 +31,232 @@ export type InteractionMap = {
> >
} }
export const interactionMapMachine = createMachine({ export type InteractionMapContext = {
interactionMap: InteractionMap
overrides: Record<string, string>
currentSequence: string
}
export type InteractionMapEvents =
| {
type: 'Add to interaction map'
data: {
ownerId: string
items: {
[key: string]: InteractionMapItem
}
}
}
| {
type: 'Remove from interaction map'
data: string | string[]
}
| {
type: 'Update overrides'
data: Record<string, string>
}
| {
type: 'Fire event'
data: {
event: KeyboardEvent | MouseEvent
}
}
| {
type: 'Add last interaction to sequence'
}
| {
type: 'Clear sequence'
}
| {
type: 'xstate.done.actor.resolveHotkeyByPrefix'
output: InteractionMapItem
}
| {
type: 'xstate.error.actor.resolveHotkeyByPrefix'
data: string | undefined
}
| {
type: 'xstate.done.actor.executeKeymapAction'
}
| {
type: 'xstate.error.actor.executeKeymapAction'
}
export const interactionMapMachine = setup({
/** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAzAE4AbADoATBIAcAdk0AWCQFYJmiRMMAaEAE9EAWgCMN7-t0JbRNdS0tDM29dbQBfGNc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5k5RFxaVV5RWVVDQRow30TU29rbrsrb1cPBB8-AKDtXytg7R0TOITqgVTKGnpGLH0AGUJYTFReKFKmKqSV0mYAMUIsYrAijEkZJBAmpVTWxDmbfTMI7wmEyWYGGbThYZaUJGKw2SxwmwWSJmRYgRL8FJCdIbLL6XKwYgAGyKZAAFsR0ABrMBuZgQUhgfS8ArEan6O4E4lgAASFOpbgAQm4AAp3EqENTPRoKD6kL4IbzeTSaLreMyWXS6RWaXRmOaQhB2Aw2KyGawOEyInWo9E1VbYzJMPFwIkk8lUmnMbDlLbUQk4dAlJjCdkurm8j2CkViiVS17vFqvNreCSArq6Yw6iLaRyGGwGqK-bRzUKGbyGM0mYuGG3LTFpdaOrb413Fd38r1YH36P0BoNYEMc1sR-lC0VgcWS7wvOQyxOgZNOfxWeHRDPzMsGgFdPSObrKsxwywo+Jouu1B2bfQAUTUYDwAFdMGR+aJaA8wBg6QymagWWywDvR9MAAaRpN9MlSONZ2aT4k0QMIzH0YJvGCGYbGCVNdANTQ4X0DVUzsCswW6WJT1tC4GwyK9b3vJ9ilfdYPy-b0nV7QNg30QC6NA8CaEg0hoLeOc4IXBCQQCGxjDVawKz1eEt0tdMHDMboU20M0zBPJZznrNZqKyZgAFVqAgANikKb1CAgOAhITUT1EQbUVRBA9gRsEw1SGdwvHLExkLLCR1yCzVyxPU9UGIGz4FeCi9MvLJpVguV4NGbyRl8fRyx1XCTDBQxNH+MJa10i9GyvXZ9k-I4TiwM4MXnYTkpUVL-iQwwTFwzROvhM1bC3DTVSBXQHFsHVtBsEqGvtcrcRbLkyT5GkktlFqxIVY9fiCzyzXUk1DGwnyEGVfxyxzCxAhsXROu8Ka7SxWanVo4CGL499HnQFbGraY9FJVUJgQ1cJUP+CRLDiOIgA */ /** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAzAE4AbADoATBIAcAdk0AWCQFYJmiRMMAaEAE9EAWgCMN7-t0JbRNdS0tDM29dbQBfGNc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5k5RFxaVV5RWVVDQRow30TU29rbrsrb1cPBB8-AKDtXytg7R0TOITqgVTKGnpGLH0AGUJYTFReKFKmKqSV0mYAMUIsYrAijEkZJBAmpVTWxDmbfTMI7wmEyWYGGbThYZaUJGKw2SxwmwWSJmRYgRL8FJCdIbLL6XKwYgAGyKZAAFsR0ABrMBuZgQUhgfS8ArEan6O4E4lgAASFOpbgAQm4AAp3EqENTPRoKD6kL4IbzeTSaLreMyWXS6RWaXRmOaQhB2Aw2KyGawOEyInWo9E1VbYzJMPFwIkk8lUmnMbDlLbUQk4dAlJjCdkurm8j2CkViiVS17vFqvNreCSArq6Yw6iLaRyGGwGqK-bRzUKGbyGM0mYuGG3LTFpdaOrb413Fd38r1YH36P0BoNYEMc1sR-lC0VgcWS7wvOQyxOgZNOfxWeHRDPzMsGgFdPSObrKsxwywo+Jouu1B2bfQAUTUYDwAFdMGR+aJaA8wBg6QymagWWywDvR9MAAaRpN9MlSONZ2aT4k0QMIzH0YJvGCGYbGCVNdANTQ4X0DVUzsCswW6WJT1tC4GwyK9b3vJ9ilfdYPy-b0nV7QNg30QC6NA8CaEg0hoLeOc4IXBCQQCGxjDVawKz1eEt0tdMHDMboU20M0zBPJZznrNZqKyZgAFVqAgANikKb1CAgOAhITUT1EQbUVRBA9gRsEw1SGdwvHLExkLLCR1yCzVyxPU9UGIGz4FeCi9MvLJpVguV4NGbyRl8fRyx1XCTDBQxNH+MJa10i9GyvXZ9k-I4TiwM4MXnYTkpUVL-iQwwTFwzROvhM1bC3DTVSBXQHFsHVtBsEqGvtcrcRbLkyT5GkktlFqxIVY9fiCzyzXUk1DGwnyEGVfxyxzCxAhsXROu8Ka7SxWanVo4CGL499HnQFbGraY9FJVUJgQ1cJUP+CRLDiOIgA */
context: { types: {
interactionMap: {} as InteractionMap, context: {} as InteractionMapContext,
overrides: {} as Record<string, string>, events: {} as InteractionMapEvents,
currentSequence: '' as string,
}, },
predictableActionArguments: true, actions: {
preserveActionOrder: true, 'Add last interaction to sequence': assign({
tsTypes: {} as import('./interactionMapMachine.typegen').Typegen0, currentSequence: ({ context, event }) => {
schema: { if (event.type !== 'xstate.error.actor.resolveHotkeyByPrefix') {
events: {} as return context.currentSequence
| {
type: 'Add to interaction map'
data: {
ownerId: string
items: {
[key: string]: InteractionMapItem
}
}
} }
| { const newSequence = event.data
type: 'Remove from interaction map' ? context.currentSequence
data: string | string[] ? context.currentSequence.concat(' ', event.data)
: event.data
: context.currentSequence
console.log('newSequence', newSequence)
return newSequence
},
}),
'Clear sequence': assign({
currentSequence: () => {
console.log('clearing sequence')
return ''
},
}),
'Add to interactionMap': assign({
interactionMap: ({ context, event }) => {
if (event.type !== 'Add to interaction map') {
return context.interactionMap
} }
| { type: 'Fire event'; data: MouseEvent | KeyboardEvent } const newInteractions: Record<string, InteractionMapItem> =
| { type: 'Execute keymap action'; data: InteractionMapItem } Object.fromEntries(
| { type: 'Update prefix matrix' } Object.entries(event.data.items).map(([name, item]) => [
| { type: 'Add last interaction to sequence' } name,
| { type: 'Clear sequence' } {
| { type: 'Update overrides'; data: { [key: string]: string } } ...item,
| { type: 'Resolve hotkey by prefix'; data: MouseEvent | KeyboardEvent } sequence: normalizeSequence(item.sequence),
| { type: 'done.invoke.resolveHotkeyByPrefix'; data: InteractionMapItem } },
| { ])
type: 'error.platform.resolveHotkeyByPrefix' )
data: string | undefined
}, const newInteractionMap = {
...context.interactionMap,
[event.data.ownerId]: {
...context.interactionMap[event.data.ownerId],
...newInteractions,
},
}
// console.log('newInteractionMap', newInteractionMap)
return newInteractionMap
},
}),
'Remove from interactionMap': assign({
interactionMap: ({ context, event }) => {
if (event.type !== 'Remove from interaction map') {
return context.interactionMap
}
const newInteractionMap = { ...context.interactionMap }
if (event.data instanceof Array) {
event.data.forEach((key) => {
const [ownerId, itemName] = key.split(INTERACTION_MAP_SEPARATOR)
delete newInteractionMap[ownerId][itemName]
})
} else {
delete newInteractionMap[event.data]
}
return newInteractionMap
},
}),
'Merge into overrides': assign({
overrides: ({ context, event }) => {
if (event.type !== 'Update overrides') {
return context.overrides
}
return {
...context.overrides,
...event.data,
}
},
}),
'Persist keybinding overrides': ({context}) => {
console.log('Persisting keybinding overrides', context.overrides)
},
},
actors: {
resolveHotkeyByPrefix: fromPromise(
({
input: { context, data },
}: {
input: { context: InteractionMapContext; data: InteractionEvent }
}) => {
return new Promise<InteractionMapItem>((resolve, reject) => {
const resolvedInteraction = resolveInteractionEvent(data)
// if the key is already a modifier key, skip everything else and reject
if (resolvedInteraction.isModifier) {
// We return an empty string so that we don't clear the currentSequence
reject('')
}
// Find all the sequences that start with the current sequence
const searchString =
(context.currentSequence ? context.currentSequence + ' ' : '') +
resolvedInteraction.asString
const sortedInteractions = getSortedInteractionMapSequences(context)
const matches = sortedInteractions.filter(([sequence]) =>
sequence.startsWith(searchString)
)
console.log('matches', {
matches,
sortedInteractions,
searchString,
overrides: context.overrides,
})
// If we have no matches, reject the promise
if (matches.length === 0) {
reject()
}
const exactMatches = matches.filter(
([sequence]) => sequence === searchString
)
console.log('exactMatches', exactMatches)
if (!exactMatches.length) {
// We have a prefix match.
// Reject the promise and return the step
// so we can add it to currentSequence
reject(resolvedInteraction.asString)
}
// Resolve to just one exact match
const availableExactMatches = exactMatches.filter(
([_, item]) => !item.guard || item.guard(data)
)
console.log('availableExactMatches', availableExactMatches)
if (availableExactMatches.length === 0) {
reject()
} else {
// return the last-added, available exact match
resolve(
availableExactMatches[availableExactMatches.length - 1][1]
)
}
})
}
),
'Execute keymap action': fromPromise(async ({ input }: { input: InteractionMapItem}) => {
try {
console.log('Executing action', input)
input.action()
} catch (error) {
console.error(error)
toast.error('There was an error executing the action.')
}
}),
},
guards: {
'There are prefix matches': ({ event }) => {
return event.type === 'xstate.error.actor.resolveHotkeyByPrefix' && event.data !== undefined
},
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAHBICsAOgCcANgMBGTUc2nrAFgDMOgDQgAnogBM7g3rtGbAdjMjdx1TIzsDHQBfKOc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5E5RFxaVV5RWVVDQR-TXc9U3d-HR1wmy6zG2c3BFM7Gz0JdyMJCM0Iu08jGLjKgWTKGnpGFgBVaggcTDJ87CxCCDhJGSQQBqVk5sRAzT0bCU0DfzmdOwlzJoRoguqsQPF+EkhKkdhk9AAZQiwTCoXhQYpMCoJDakZgAMUIWEKYAKGBu9QUj1IzwQBgcen83RC-n8NiM7IGwIQvUmZncEmM4T8tjBEKqmxh6SYemysGIABsCmQABbEdAAazALmYEFIYD0vDyxE1eiJcsVYAAEmrNS4AEIuAAKRKKhDU5LuDyadxa1jsdj0Vh6fwiwbCXJM-j07msbXcmh0rJsbNF6yhKW2UqwMrgCqVqo1WuY52l1HlxyKTGEptzFuthftTpdbo9ckp3tALQMEiMhhmOm6wRMXScrkQRlMEgZ9gkn2s3wiplT2PTWzSuxz5vzNqLJezZYrVZrW6tO8bzrArvdplubcaTx9IOmph8yYnbJ02h7pi5bI6iYcRNzH9KwbGXSFqklDcAFE1DAPAAFcTltURaBJMAMB1PUDVQI0TTAODEMwABpLVUPSZJW3udsH07RBkyjAwrG+b4BlCdxhjHbkJj0HRvhMPpIgsOxwPFaFMxgwikMKFDtnQzC9z0A90ErLBqwI+DpNIlxyPTKivVo9R6ICQxmMCVlTHYzjRhsTQoyFfw-G+PivGiMFUGIK54DuMUcQzdcMgpe9qUfBAAFofy4uxrAZX4wiWf1EtEvy11haVEWRDC0QxLAsQgwyDJCujWnpSyegBUxjAWUcbLpHw2gnTQbD6PojDctYV0giS4VlPNCgLW0gqpFRQvGMxA0nSzZiMRy-ncLk5ujdw7HaAVQnGbpktXKC4VgzTkLIuTSXQIaOyMhAAl-VkfBmWc2o+WdTBTGIoiAA */
context: {
interactionMap: {},
overrides: {},
currentSequence: '',
}, },
id: 'Interaction Map Actor', id: 'Interaction Map Actor',
initial: 'Listening for interaction', initial: 'Listening for interaction',
@ -78,6 +273,19 @@ export const interactionMapMachine = createMachine({
'Resolve hotkey': { 'Resolve hotkey': {
invoke: { invoke: {
id: 'resolveHotkeyByPrefix', id: 'resolveHotkeyByPrefix',
input: ({ context, event }) => {
if (event.type === 'Fire event') {
return {
context,
data: event.data.event,
}
} else {
return {
context,
data: {} as InteractionEvent,
}
}
},
onDone: { onDone: {
target: 'Execute keymap event', target: 'Execute keymap event',
}, },
@ -87,7 +295,7 @@ export const interactionMapMachine = createMachine({
actions: { actions: {
type: 'Add last interaction to sequence', type: 'Add last interaction to sequence',
}, },
cond: { guard: {
type: 'There are prefix matches', type: 'There are prefix matches',
}, },
}, },
@ -98,7 +306,7 @@ export const interactionMapMachine = createMachine({
}, },
}, },
], ],
src: 'Resolve hotkey by prefix', src: 'resolveHotkeyByPrefix',
}, },
}, },
'Execute keymap event': { 'Execute keymap event': {
@ -107,6 +315,12 @@ export const interactionMapMachine = createMachine({
}, },
invoke: { invoke: {
id: 'executeKeymapAction', id: 'executeKeymapAction',
input: ({ event }) => {
if (event.type !== 'xstate.done.actor.resolveHotkeyByPrefix') {
return {} as InteractionMapItem
}
return event.output
},
onDone: { onDone: {
target: 'Listening for interaction', target: 'Listening for interaction',
}, },
@ -129,7 +343,7 @@ export const interactionMapMachine = createMachine({
'Remove from interaction map': { 'Remove from interaction map': {
target: '#Interaction Map Actor', target: '#Interaction Map Actor',
internal: true, reenter: false,
actions: [ actions: [
{ {
type: 'Remove from interactionMap', type: 'Remove from interactionMap',
@ -139,7 +353,7 @@ export const interactionMapMachine = createMachine({
'Update overrides': { 'Update overrides': {
target: '#Interaction Map Actor', target: '#Interaction Map Actor',
internal: true, reenter: false,
actions: ['Merge into overrides', 'Persist keybinding overrides'], actions: ['Merge into overrides', 'Persist keybinding overrides'],
}, },
}, },