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 {
isModifierKey,
@ -30,161 +30,28 @@ type MachineContext<T extends AnyStateMachine> = {
send: Prop<InterpreterFrom<T>, 'send'>
}
export const InteractionMapMachineContext = createContext(
{} as MachineContext<typeof interactionMapMachine>
export const InteractionMapMachineContext = createActorContext(
interactionMapMachine
)
export function InteractionMapMachineProvider({
export const InteractionMapMachineProvider = ({
children,
}: React.PropsWithChildren<{}>) {
const [state, send] = useMachine(interactionMapMachine, {
logger: (msg) => {
console.log(msg)
},
actions: {
'Add last interaction to sequence': assign({
currentSequence: (context, event) => {
const newSequence = event.data
? context.currentSequence
? context.currentSequence.concat(' ', event.data)
: event.data
: context.currentSequence
}: {
children: React.ReactNode
}) => {
return (
<InteractionMapMachineContext.Provider>
<InteractionMapProviderInner>{children}</InteractionMapProviderInner>
</InteractionMapMachineContext.Provider>
)
}
console.log('newSequence', newSequence)
return newSequence
},
}),
'Clear sequence': assign({
currentSequence: () => {
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
},
},
})
function InteractionMapProviderInner({
children,
}: {
children: React.ReactNode
}) {
const interactionMap = InteractionMapMachineContext.useActorRef()
// Setting up global event listeners
useEffect(() => {
@ -204,7 +71,7 @@ export function InteractionMapMachineProvider({
'true'
)
) {
send({ type: 'Fire event', data: event })
interactionMap.send({ type: 'Fire event', data: { event } })
}
}
@ -217,9 +84,5 @@ export function InteractionMapMachineProvider({
}
}, [])
return (
<InteractionMapMachineContext.Provider value={{ state, send }}>
{children}
</InteractionMapMachineContext.Provider>
)
return children
}

View File

@ -1,6 +1,11 @@
import { InteractionMapMachineContext } from 'components/InteractionMapMachineProvider'
import { useContext } from 'react'
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
}
export type InteractionEvent = MouseEvent | KeyboardEvent
export function resolveInteractionEvent(
event: MouseEvent | KeyboardEvent
event: InteractionEvent
): ResolveKeymapEvent {
// First, determine if this is a key or mouse event
const action =

View File

@ -1,7 +1,13 @@
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 { 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`
@ -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 */
context: {
interactionMap: {} as InteractionMap,
overrides: {} as Record<string, string>,
currentSequence: '' as string,
types: {
context: {} as InteractionMapContext,
events: {} as InteractionMapEvents,
},
predictableActionArguments: true,
preserveActionOrder: true,
tsTypes: {} as import('./interactionMapMachine.typegen').Typegen0,
schema: {
events: {} as
| {
type: 'Add to interaction map'
data: {
ownerId: string
items: {
[key: string]: InteractionMapItem
}
}
actions: {
'Add last interaction to sequence': assign({
currentSequence: ({ context, event }) => {
if (event.type !== 'xstate.error.actor.resolveHotkeyByPrefix') {
return context.currentSequence
}
| {
type: 'Remove from interaction map'
data: string | string[]
const newSequence = event.data
? context.currentSequence
? 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 }
| { type: 'Execute keymap action'; data: InteractionMapItem }
| { type: 'Update prefix matrix' }
| { type: 'Add last interaction to sequence' }
| { type: 'Clear sequence' }
| { type: 'Update overrides'; data: { [key: string]: string } }
| { type: 'Resolve hotkey by prefix'; data: MouseEvent | KeyboardEvent }
| { type: 'done.invoke.resolveHotkeyByPrefix'; data: InteractionMapItem }
| {
type: 'error.platform.resolveHotkeyByPrefix'
data: string | undefined
},
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 }) => {
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',
initial: 'Listening for interaction',
@ -78,6 +273,19 @@ export const interactionMapMachine = createMachine({
'Resolve hotkey': {
invoke: {
id: 'resolveHotkeyByPrefix',
input: ({ context, event }) => {
if (event.type === 'Fire event') {
return {
context,
data: event.data.event,
}
} else {
return {
context,
data: {} as InteractionEvent,
}
}
},
onDone: {
target: 'Execute keymap event',
},
@ -87,7 +295,7 @@ export const interactionMapMachine = createMachine({
actions: {
type: 'Add last interaction to sequence',
},
cond: {
guard: {
type: 'There are prefix matches',
},
},
@ -98,7 +306,7 @@ export const interactionMapMachine = createMachine({
},
},
],
src: 'Resolve hotkey by prefix',
src: 'resolveHotkeyByPrefix',
},
},
'Execute keymap event': {
@ -107,6 +315,12 @@ export const interactionMapMachine = createMachine({
},
invoke: {
id: 'executeKeymapAction',
input: ({ event }) => {
if (event.type !== 'xstate.done.actor.resolveHotkeyByPrefix') {
return {} as InteractionMapItem
}
return event.output
},
onDone: {
target: 'Listening for interaction',
},
@ -129,7 +343,7 @@ export const interactionMapMachine = createMachine({
'Remove from interaction map': {
target: '#Interaction Map Actor',
internal: true,
reenter: false,
actions: [
{
type: 'Remove from interactionMap',
@ -139,7 +353,7 @@ export const interactionMapMachine = createMachine({
'Update overrides': {
target: '#Interaction Map Actor',
internal: true,
reenter: false,
actions: ['Merge into overrides', 'Persist keybinding overrides'],
},
},