Less churny registration using an object instead of array

This commit is contained in:
Frank Noirot
2024-08-02 00:37:13 -04:00
parent 19688a8f5f
commit b6ec308a4a
8 changed files with 198 additions and 108 deletions

View File

@ -1,4 +1,4 @@
import { useRef, useMemo, memo } from 'react'
import { useRef, useMemo, memo, useCallback } from 'react'
import { isCursorInSketchCommandRange } from 'lang/util'
import { engineCommandManager, kclManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext'
@ -23,6 +23,7 @@ import {
} from 'lib/toolbar'
import { useKclContext } from 'lang/KclProvider'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
import { InteractionSequence } from 'components/Settings/AllKeybindingsFields'
export function Toolbar({
className = '',
@ -253,7 +254,7 @@ export function Toolbar({
* It contains a tooltip with the title, description, and links
* and a hotkey listener
*/
const ToolbarItemContents = memo(function ToolbarItemContents({
function ToolbarItemContents({
itemConfig,
configCallbackProps,
}: {
@ -261,32 +262,35 @@ const ToolbarItemContents = memo(function ToolbarItemContents({
configCallbackProps: ToolbarItemCallbackProps
}) {
const { state: interactionMapState } = useInteractionMapContext()
const resolvedSequence =
interactionMapState.context.overrides[
`${KEYBINDING_CATEGORIES.MODELING}.${itemConfig.id}`
] ||
(itemConfig.hotkey instanceof Array
? itemConfig.hotkey[0]
: itemConfig.hotkey) ||
''
const resolvedSequence = useMemo(
() =>
interactionMapState.context.overrides[
`${KEYBINDING_CATEGORIES.MODELING}.${itemConfig.id}`
] ||
(itemConfig.hotkey instanceof Array
? itemConfig.hotkey[0]
: itemConfig.hotkey) ||
'',
[interactionMapState.context.overrides, itemConfig.id, itemConfig.hotkey]
)
useInteractionMap(
[
{
KEYBINDING_CATEGORIES.MODELING,
{
[itemConfig.id]: {
name: itemConfig.id,
title: itemConfig.title,
sequence: resolvedSequence,
action: () => itemConfig.onClick(configCallbackProps),
guard: () =>
!(
itemConfig.status === 'available' &&
!!itemConfig.hotkey &&
!itemConfig.disabled &&
!itemConfig.disableHotkey
),
itemConfig.status === 'available' &&
!!itemConfig.hotkey &&
!itemConfig.disabled &&
!itemConfig.disableHotkey,
ownerId: KEYBINDING_CATEGORIES.MODELING,
},
],
[configCallbackProps.modelingSend, configCallbackProps.commandBarSend],
KEYBINDING_CATEGORIES.MODELING
},
[itemConfig.disabled, itemConfig.disableHotkey, resolvedSequence]
)
return (
@ -310,7 +314,10 @@ const ToolbarItemContents = memo(function ToolbarItemContents({
{itemConfig.title}
</span>
{itemConfig.status === 'available' && resolvedSequence ? (
<kbd className="flex-none hotkey">{resolvedSequence}</kbd>
<InteractionSequence
sequence={resolvedSequence}
className="flex-nowrap !gap-1"
/>
) : itemConfig.status === 'kcl-only' ? (
<>
<span className="text-wrap font-sans flex-0 text-chalkboard-70 dark:text-chalkboard-40">
@ -359,4 +366,4 @@ const ToolbarItemContents = memo(function ToolbarItemContents({
</Tooltip>
</>
)
})
}

View File

@ -25,8 +25,9 @@ export const CommandBar = () => {
}, [pathname])
useInteractionMap(
[
{
KEYBINDING_CATEGORIES.COMMAND_BAR,
{
toggle: {
name: 'toggle',
title: 'Toggle Command Bar',
sequence: 'meta+k g RightButton+shift',
@ -38,8 +39,9 @@ export const CommandBar = () => {
})
},
guard: () => true,
ownerId: KEYBINDING_CATEGORIES.COMMAND_BAR,
},
{
close: {
name: 'close',
title: 'Close Command Bar',
sequence: 'esc',
@ -47,10 +49,10 @@ export const CommandBar = () => {
commandBarSend({ type: 'Close' })
},
guard: () => !commandBarState.matches('Closed'),
ownerId: KEYBINDING_CATEGORIES.COMMAND_BAR,
},
],
[commandBarState, commandBarSend],
KEYBINDING_CATEGORIES.COMMAND_BAR
},
[commandBarState, commandBarSend]
)
function stepBack() {

View File

@ -8,9 +8,12 @@ import {
sortKeys,
} from 'lib/keyboard'
import {
InteractionMapItem,
MouseButtonName,
getSortedInteractionMapSequences,
interactionMapMachine,
makeOverrideKey,
normalizeSequence,
} from 'machines/interactionMapMachine'
import { createContext, useEffect } from 'react'
import toast from 'react-hot-toast'
@ -59,42 +62,41 @@ export function InteractionMapMachineProvider({
}),
'Add to interactionMap': assign({
interactionMap: (context, event) => {
// normalize any interaction sequences to be sorted
const normalizedInteractions = event.data.map((item) => ({
...item,
sequence: (
context.overrides[makeOverrideKey(item)] || item.sequence
const newInteractions: Record<string, InteractionMapItem> =
Object.fromEntries(
Object.entries(event.data.items).map(([name, item]) => [
name,
{
...item,
sequence: normalizeSequence(item.sequence),
},
])
)
.split(' ')
.map((step) =>
step
.split(INTERACTION_MAP_SEPARATOR)
.sort(sortKeys)
.map(mapKey)
.join(INTERACTION_MAP_SEPARATOR)
)
.join(' '),
}))
// Add the new items to the interactionMap and sort by sequence
// making it faster to search for a sequence
const newInteractionMap = [
const newInteractionMap = {
...context.interactionMap,
...normalizedInteractions,
].sort((a, b) => a.sequence.localeCompare(b.sequence))
[event.data.ownerId]: {
...context.interactionMap[event.data.ownerId],
...newInteractions,
},
}
console.log('newInteractionMap', newInteractionMap)
// console.log('newInteractionMap', newInteractionMap)
return newInteractionMap
},
}),
'Remove from interactionMap': assign({
interactionMap: (context, event) => {
// Filter out any items that have an ownerId that matches event.data
return [
...context.interactionMap.filter(
(item) => item.ownerId !== event.data
),
]
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({
@ -123,16 +125,15 @@ export function InteractionMapMachineProvider({
const searchString =
(context.currentSequence ? context.currentSequence + ' ' : '') +
resolvedInteraction.asString
const sortedInteractions = getSortedInteractionMapSequences(context)
const matches = context.interactionMap.filter((item) =>
(
context.overrides[makeOverrideKey(item)] || item.sequence
).startsWith(searchString)
const matches = sortedInteractions.filter(([sequence]) =>
sequence.startsWith(searchString)
)
console.log('matches', {
matches,
interactionMap: context.interactionMap,
sortedInteractions,
searchString,
overrides: context.overrides,
})
@ -143,9 +144,7 @@ export function InteractionMapMachineProvider({
}
const exactMatches = matches.filter(
(item) =>
(context.overrides[makeOverrideKey(item)] || item.sequence) ===
searchString
([sequence]) => sequence === searchString
)
console.log('exactMatches', exactMatches)
if (!exactMatches.length) {
@ -157,7 +156,7 @@ export function InteractionMapMachineProvider({
// Resolve to just one exact match
const availableExactMatches = exactMatches.filter(
(item) => !item.guard || item.guard(event.data)
([_, item]) => !item.guard || item.guard(event.data)
)
console.log('availableExactMatches', availableExactMatches)
@ -166,7 +165,7 @@ export function InteractionMapMachineProvider({
} else {
// return the last-added, available exact match
return Promise.resolve(
availableExactMatches[availableExactMatches.length - 1]
availableExactMatches[availableExactMatches.length - 1][1]
)
}
},

View File

@ -67,18 +67,6 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
[sidebarPanes, showDebugPanel.current]
)
useInteractionMap(
filteredPanes.map((pane) => ({
name: pane.id,
action: () => togglePane(pane.id),
keybinding: pane.keybinding,
title: `Toggle ${pane.title} pane`,
sequence: pane.keybinding,
})),
[filteredPanes, context.store?.openPanes],
KEYBINDING_CATEGORIES.USER_INTERFACE
)
const paneBadgeMap: Record<SidebarType, number | boolean> = useMemo(() => {
return filteredPanes.reduce((acc, pane) => {
if (pane.showBadge) {
@ -102,6 +90,24 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
[context.store?.openPanes, send]
)
useInteractionMap(
KEYBINDING_CATEGORIES.USER_INTERFACE,
Object.fromEntries(
filteredPanes.map((pane) => [
pane.id,
{
name: pane.id,
action: () => togglePane(pane.id),
keybinding: pane.keybinding,
title: `Toggle ${pane.title} pane`,
sequence: pane.keybinding,
ownerId: KEYBINDING_CATEGORIES.USER_INTERFACE,
},
])
),
[filteredPanes, context.store?.openPanes]
)
return (
<Resizable
className={`group flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}

View File

@ -1,5 +1,5 @@
import { ActionIcon } from 'components/ActionIcon'
import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
import { resolveInteractionEvent } from 'lib/keyboard'
import {
@ -13,11 +13,42 @@ export function AllKeybindingsFields() {
return (
<div className="relative overflow-y-auto">
<div className="flex flex-col gap-4 px-2">
{state.context.interactionMap.map((item) => (
{Object.entries(state.context.interactionMap).map(
([category, categoryItems]) => (
<KeybindingSection
key={category}
category={category}
items={categoryItems}
/>
)
)}
</div>
</div>
)
}
function KeybindingSection({
category,
items,
...props
}: HTMLProps<HTMLDivElement> & {
category: string
items: Record<string, InteractionMapItem>
}) {
return (
<section {...props}>
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
<div className="flex flex-col my-2 gap-2">
{Object.entries(items).map(([_, item]) => (
<KeybindingField key={item.ownerId + '-' + item.name} item={item} />
))}
</div>
</div>
</section>
)
}
@ -116,7 +147,7 @@ function KeybindingField({ item }: { item: InteractionMapItem }) {
return isEditing ? (
<form
key={item.ownerId + '-' + item.name}
className="flex gap-2 justify-between items-center"
className="group flex gap-2 justify-between items-center"
onSubmit={handleSubmit}
>
<h3>{item.title}</h3>
@ -134,7 +165,7 @@ function KeybindingField({ item }: { item: InteractionMapItem }) {
) : (
<div
key={item.ownerId + '-' + item.name}
className="flex gap-2 justify-between items-center"
className="group flex gap-2 justify-between items-center"
>
<h3>{item.title}</h3>
<InteractionSequence
@ -145,7 +176,7 @@ function KeybindingField({ item }: { item: InteractionMapItem }) {
/>
<button
ref={submitRef}
className="p-0 m-0"
className="invisible group-focus:visible group-hover:visible p-0 m-0 [&:not(:hover)]:border-transparent"
onClick={() => setIsEditing(true)}
>
<CustomIcon name="sketch" className="w-5 h-5" />

View File

@ -1,40 +1,41 @@
import { InteractionMapItem } from 'machines/interactionMapMachine'
import { useEffect, useMemo } from 'react'
import { useInteractionMapContext } from './useInteractionMapContext'
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
/**
* Custom hook to add an interaction map to the interaction map machine
* from within a component, and remove it when the component unmounts.
* @param interactionMap - An array of interaction map items. You don't need to provide an `ownerId` property, as it will be added automatically.
* @param deps - Any dependencies that should trigger a resetting of the interaction map when they change.
* @param mapId - An optional ID for the interaction map. If not provided, a random UUID will be generated.
*/
export function useInteractionMap(
interactionMap: Omit<InteractionMapItem, 'ownerId'>[],
deps: any[],
mapId?: string
/** An ID for the interaction map set. */
ownerId: string,
/** A set of iteraction map items to add */
items: Record<string, InteractionMapItem>,
/** Any dependencies that should invalidate the items */
deps: any[]
) {
const interactionMachine = useInteractionMapContext()
const mapIdMemoized = useMemo<string>(
() => mapId || crypto.randomUUID(),
[mapId]
)
const interactionMapMemoized = useMemo<InteractionMapItem[]>(
() => interactionMap.map((item) => ({ ...item, ownerId: mapIdMemoized })),
deps
const memoizedItems = useMemo(() => items, deps)
const itemKeys = Object.keys(memoizedItems).map(
(key) => `${ownerId}${INTERACTION_MAP_SEPARATOR}${key}`
)
useEffect(() => {
interactionMachine.send({
type: 'Add to interaction map',
data: interactionMapMemoized,
data: {
ownerId,
items: memoizedItems,
},
})
return () => {
interactionMachine.send({
type: 'Remove from interaction map',
data: mapIdMemoized,
data: itemKeys,
})
}
}, [interactionMapMemoized, mapIdMemoized])
}, [memoizedItems])
}

View File

@ -275,7 +275,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
status: 'available',
title: 'Exit sketch',
showTitle: true,
hotkey: 'Esc',
hotkey: 'escape',
description: 'Exit the current sketch',
links: [],
},
@ -296,7 +296,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
state.matches('Sketch.Rectangle tool.Awaiting second corner'),
title: 'Line',
hotkey: (state) =>
state.matches('Sketch.Line tool') ? ['Esc', 'L'] : 'L',
state.matches('Sketch.Line tool') ? ['escape', 'L'] : 'L',
description: 'Start drawing straight lines',
links: [],
isActive: (state) => state.matches('Sketch.Line tool'),
@ -320,7 +320,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
!state.matches('Sketch.Tangential arc to'),
title: 'Tangential Arc',
hotkey: (state) =>
state.matches('Sketch.Tangential arc to') ? ['Esc', 'A'] : 'A',
state.matches('Sketch.Tangential arc to') ? ['escape', 'A'] : 'A',
description: 'Start drawing an arc tangent to the current segment',
links: [],
isActive: (state) => state.matches('Sketch.Tangential arc to'),
@ -400,7 +400,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
!state.matches('Sketch.Rectangle tool'),
title: 'Corner rectangle',
hotkey: (state) =>
state.matches('Sketch.Rectangle tool') ? ['Esc', 'R'] : 'R',
state.matches('Sketch.Rectangle tool') ? ['escape', 'R'] : 'R',
description: 'Start drawing a rectangle',
links: [],
isActive: (state) => state.matches('Sketch.Rectangle tool'),

View File

@ -1,4 +1,7 @@
import { createMachine } from 'xstate'
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
import { mapKey, sortKeys } from 'lib/keyboard'
import { interactionMapCategories } from 'lib/settings/initialKeybindings'
import { ContextFrom, createMachine } from 'xstate'
export type MouseButtonName = `${'Left' | 'Middle' | 'Right'}Button`
@ -15,11 +18,18 @@ export function makeOverrideKey(interactionMapItem: InteractionMapItem) {
return `${interactionMapItem.ownerId}.${interactionMapItem.name}`
}
export type InteractionMap = {
[key: (typeof interactionMapCategories)[number]]: Record<
string,
InteractionMapItem
>
}
export const interactionMapMachine = createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAzAE4AbADoATBIAcAdk0AWCQFYJmiRMMAaEAE9EAWgCMN7-t0JbRNdS0tDM29dbQBfGNc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5k5RFxaVV5RWVVDQRow30TU29rbrsrb1cPBB8-AKDtXytg7R0TOITqgVTKGnpGLH0AGUJYTFReKFKmKqSV0mYAMUIsYrAijEkZJBAmpVTWxDmbfTMI7wmEyWYGGbThYZaUJGKw2SxwmwWSJmRYgRL8FJCdIbLL6XKwYgAGyKZAAFsR0ABrMBuZgQUhgfS8ArEan6O4E4lgAASFOpbgAQm4AAp3EqENTPRoKD6kL4IbzeTSaLreMyWXS6RWaXRmOaQhB2Aw2KyGawOEyInWo9E1VbYzJMPFwIkk8lUmnMbDlLbUQk4dAlJjCdkurm8j2CkViiVS17vFqvNreCSArq6Yw6iLaRyGGwGqK-bRzUKGbyGM0mYuGG3LTFpdaOrb413Fd38r1YH36P0BoNYEMc1sR-lC0VgcWS7wvOQyxOgZNOfxWeHRDPzMsGgFdPSObrKsxwywo+Jouu1B2bfQAUTUYDwAFdMGR+aJaA8wBg6QymagWWywDvR9MAAaRpN9MlSONZ2aT4k0QMIzH0YJvGCGYbGCVNdANTQ4X0DVUzsCswW6WJT1tC4GwyK9b3vJ9ilfdYPy-b0nV7QNg30QC6NA8CaEg0hoLeOc4IXBCQQCGxjDVawKz1eEt0tdMHDMboU20M0zBPJZznrNZqKyZgAFVqAgANikKb1CAgOAhITUT1EQbUVRBA9gRsEw1SGdwvHLExkLLCR1yCzVyxPU9UGIGz4FeCi9MvLJpVguV4NGbyRl8fRyx1XCTDBQxNH+MJa10i9GyvXZ9k-I4TiwM4MXnYTkpUVL-iQwwTFwzROvhM1bC3DTVSBXQHFsHVtBsEqGvtcrcRbLkyT5GkktlFqxIVY9fiCzyzXUk1DGwnyEGVfxyxzCxAhsXROu8Ka7SxWanVo4CGL499HnQFbGraY9FJVUJgQ1cJUP+CRLDiOIgA */
context: {
interactionMap: [] as InteractionMapItem[],
overrides: {} as { [key: string]: string },
interactionMap: {} as InteractionMap,
overrides: {} as Record<string, string>,
currentSequence: '' as string,
},
predictableActionArguments: true,
@ -29,11 +39,16 @@ export const interactionMapMachine = createMachine({
events: {} as
| {
type: 'Add to interaction map'
data: InteractionMapItem[]
data: {
ownerId: string
items: {
[key: string]: InteractionMapItem
}
}
}
| {
type: 'Remove from interaction map'
data: string
data: string | string[]
}
| { type: 'Fire event'; data: MouseEvent | KeyboardEvent }
| { type: 'Execute keymap action'; data: InteractionMapItem }
@ -129,3 +144,32 @@ export const interactionMapMachine = createMachine({
},
},
})
export function getSortedInteractionMapSequences(
context: ContextFrom<typeof interactionMapMachine>
) {
return Object.values(context.interactionMap)
.flatMap((items) =>
Object.entries(items).map(
([_, item]) =>
[context.overrides[makeOverrideKey(item)] || item.sequence, item] as [
string,
InteractionMapItem
]
)
)
.sort((a, b) => a[0].localeCompare(b[0]))
}
export function normalizeSequence(sequence: string) {
return sequence
.split(' ')
.map((step) =>
step
.split(INTERACTION_MAP_SEPARATOR)
.sort(sortKeys)
.map(mapKey)
.join(INTERACTION_MAP_SEPARATOR)
)
.join(' ')
}