Add tab to Settings dialog to view keyboard shortcuts (#2567)

* Add keyboard custom icon

* Refactor Settings to be more modular

* Add basic keybindings view to settings

* Add more shortcuts

* Add link to see keyboard shortcuts tab

* Little more bottom padding

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Add keybindings to settings search

* Add a playwright test for opening the the keyboard shortcuts

* fmt

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-06-04 13:56:20 -04:00
committed by GitHub
parent 9564890b29
commit e46aca4992
12 changed files with 718 additions and 295 deletions

View File

@ -0,0 +1,87 @@
import {
InteractionMapItem,
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
import { ForwardedRef, forwardRef } from 'react'
import { useLocation } from 'react-router-dom'
interface AllKeybindingsFieldsProps {}
export const AllKeybindingsFields = forwardRef(
(
props: AllKeybindingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
// This is how we will get the interaction map from the context
// in the future whene franknoirot/editable-hotkeys is merged.
// const { state } = useInteractionMapContext()
return (
<div className="relative overflow-y-auto pb-16">
<div ref={scrollRef} className="flex flex-col gap-12">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category, categoryItems]) => (
<div className="flex flex-col gap-4 px-2 pr-4">
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{category}
</h2>
{categoryItems.map((item) => (
<KeybindingField
key={category + '-' + item.name}
category={category}
item={item}
/>
))}
</div>
))}
</div>
</div>
)
}
)
function KeybindingField({
item,
category,
}: {
item: InteractionMapItem
category: string
}) {
const location = useLocation()
return (
<div
className={
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
(location.hash === `#${item.name}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: '')
}
id={item.name}
>
<div>
<h3 className="text-lg font-normal capitalize tracking-wide">
{item.title}
</h3>
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
{item.description}
</p>
</div>
<div className="flex-1 flex flex-wrap justify-end gap-3">
{item.sequence.split(' ').map((chord, i) => (
<kbd
key={`${category}-${item.name}-${chord}-${i}`}
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
>
{chord}
</kbd>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,238 @@
import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
import {
shouldHideSetting,
shouldShowSettingInput,
} from 'lib/settings/settingsUtils'
import { Fragment } from 'react/jsx-runtime'
import { SettingsSection } from './SettingsSection'
import { useLocation, useNavigate } from 'react-router-dom'
import { isTauri } from 'lib/isTauri'
import { ActionButton } from 'components/ActionButton'
import { SettingsFieldInput } from './SettingsFieldInput'
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
import toast from 'react-hot-toast'
import { APP_VERSION } from 'routes/Settings'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
import { paths } from 'lib/paths'
import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { sep } from '@tauri-apps/api/path'
import { ForwardedRef, forwardRef } from 'react'
interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel
isFileSettings: boolean
}
export const AllSettingsFields = forwardRef(
(
{ searchParamTab, isFileSettings }: AllSettingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
const location = useLocation()
const navigate = useNavigate()
const dotDotSlash = useDotDotSlash()
const {
settings: { send, context },
} = useSettingsAuthContext()
const projectPath =
isFileSettings && isTauri()
? decodeURI(
location.pathname
.replace(paths.FILE + '/', '')
.replace(paths.SETTINGS, '')
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
)
: undefined
function restartOnboarding() {
send({
type: `set.app.onboardingStatus`,
data: { level: 'user', value: '' },
})
if (isFileSettings) {
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
} else {
createAndOpenNewProject(navigate)
}
}
return (
<div className="relative overflow-y-auto">
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category, categorySettings]) => (
<Fragment key={category}>
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
{Object.entries(categorySettings)
.filter(
// Filter out settings that don't have a Component or inputType
// or are hidden on the current level or the current platform
(item: [string, Setting<unknown>]) =>
shouldShowSettingInput(item[1], searchParamTab)
)
.map(([settingName, s]) => {
const setting = s as Setting
const parentValue =
setting[setting.getParentLevel(searchParamTab)]
return (
<SettingsSection
title={decamelize(settingName, {
separator: ' ',
})}
id={settingName}
className={
location.hash === `#${settingName}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: ''
}
key={`${category}-${settingName}-${searchParamTab}`}
description={setting.description}
settingHasChanged={
setting[searchParamTab] !== undefined &&
setting[searchParamTab] !==
setting.getFallback(searchParamTab)
}
parentLevel={setting.getParentLevel(searchParamTab)}
onFallback={() =>
send({
type: `set.${category}.${settingName}`,
data: {
level: searchParamTab,
value:
parentValue !== undefined
? parentValue
: setting.getFallback(searchParamTab),
},
} as SetEventTypes)
}
>
<SettingsFieldInput
category={category}
settingName={settingName}
settingsLevel={searchParamTab}
setting={setting}
/>
</SettingsSection>
)
})}
</Fragment>
))}
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
Resets
</h2>
<SettingsSection
title="Onboarding"
description="Replay the onboarding process"
>
<ActionButton
Element="button"
onClick={restartOnboarding}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1',
}}
>
Replay Onboarding
</ActionButton>
</SettingsSection>
<SettingsSection
title="Reset settings"
description={`Restore settings to their default values. Your settings are saved in
${
isTauri()
? ' a file in the app data folder for your OS.'
: " your browser's local storage."
}
`}
>
<div className="flex flex-col items-start gap-4">
{isTauri() && (
<ActionButton
Element="button"
onClick={async () => {
const paths = await getSettingsFolderPaths(
projectPath ? decodeURIComponent(projectPath) : undefined
)
showInFolder(paths[searchParamTab])
}}
iconStart={{
icon: 'folder',
size: 'sm',
className: 'p-1',
}}
>
Show in folder
</ActionButton>
)}
<ActionButton
Element="button"
onClick={async () => {
const defaultDirectory = await getInitialDefaultDir()
send({
type: 'Reset settings',
defaultDirectory,
})
toast.success('Settings restored to default')
}}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1 text-chalkboard-10',
bgClassName: 'bg-destroy-70',
}}
>
Restore default settings
</ActionButton>
</div>
</SettingsSection>
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
About Modeling App
</h2>
<div className="text-sm mb-12">
<p>
{/* This uses a Vite plugin, set in vite.config.ts
to inject the version from package.json */}
App version {APP_VERSION}.{' '}
<a
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"
rel="noopener noreferrer"
>
View release on GitHub
</a>
</p>
<p className="max-w-2xl mt-6">
Don't see the feature you want? Check to see if it's on{' '}
<a
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
>
our roadmap
</a>
, and start a discussion if you don't see it! Your feedback will
help us prioritize what to build next.
</p>
</div>
</div>
</div>
)
}
)

View File

@ -0,0 +1,35 @@
import {
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
interface KeybindingSectionsListProps {
scrollRef: React.RefObject<HTMLDivElement>
}
export function KeybindingsSectionsList({
scrollRef,
}: KeybindingSectionsListProps) {
return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{category}
</button>
))}
</div>
)
}

View File

@ -3,11 +3,23 @@ import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize'
import Fuse from 'fuse.js'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { interactionMap } from 'lib/settings/initialKeybindings'
import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom'
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'
export type SettingsSearchItem = {
name: string
displayName: string
description: string
category: string
level: ExtendedSettingsLevel
}
export function SettingsSearchBar() {
const inputRef = useRef<HTMLInputElement>(null)
useHotkeys(
@ -21,29 +33,40 @@ export function SettingsSearchBar() {
const navigate = useNavigate()
const [query, setQuery] = useState('')
const { settings } = useSettingsAuthContext()
const settingsAsSearchable = useMemo(
() =>
Object.entries(settings.state.context).flatMap(
const settingsAsSearchable: SettingsSearchItem[] = useMemo(
() => [
...Object.entries(settings.state.context).flatMap(
([category, categorySettings]) =>
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
const s = setting as Setting
return ['project', 'user']
return (['project', 'user'] satisfies SettingsLevel[])
.filter((l) => s.hideOnLevel !== l)
.map((l) => ({
category: decamelize(category, { separator: ' ' }),
settingName: settingName,
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
setting: s,
level: l,
name: settingName,
description: s.description ?? '',
displayName: decamelize(settingName, { separator: ' ' }),
level: l as ExtendedSettingsLevel,
}))
})
),
...Object.entries(interactionMap).flatMap(
([category, categoryKeybindings]) =>
categoryKeybindings.map((keybinding) => ({
name: keybinding.name,
displayName: keybinding.title,
description: keybinding.description,
category: category,
level: 'keybindings' as ExtendedSettingsLevel,
}))
),
],
[settings.state.context]
)
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
const fuse = new Fuse(settingsAsSearchable, {
keys: ['category', 'settingNameDisplay', 'setting.description'],
keys: ['category', 'displayName', 'description'],
includeScore: true,
})
@ -52,16 +75,8 @@ export function SettingsSearchBar() {
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
}, [query])
function handleSelection({
level,
settingName,
}: {
category: string
settingName: string
setting: Setting<unknown>
level: string
}) {
navigate(`?tab=${level}#${settingName}`)
function handleSelection({ level, name }: SettingsSearchItem) {
navigate(`?tab=${level}#${name}`)
}
return (
@ -87,18 +102,18 @@ export function SettingsSearchBar() {
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
{searchResults?.map((option) => (
<Combobox.Option
key={`${option.category}-${option.settingName}-${option.level}`}
key={`${option.category}-${option.name}-${option.level}`}
value={option}
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
>
<p className="flex-grow text-base capitalize m-0 leading-none">
{option.level} ·{' '}
{decamelize(option.category, { separator: ' ' })} ·{' '}
{option.settingNameDisplay}
{option.displayName}
</p>
{option.setting.description && (
{option.description && (
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
{option.setting.description}
{option.description}
</p>
)}
</Combobox.Option>

View File

@ -0,0 +1,68 @@
import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { shouldHideSetting } from 'lib/settings/settingsUtils'
interface SettingsSectionsListProps {
searchParamTab: SettingsLevel
scrollRef: React.RefObject<HTMLDivElement>
}
export function SettingsSectionsList({
searchParamTab,
scrollRef,
}: SettingsSectionsListProps) {
const {
settings: { context },
} = useSettingsAuthContext()
return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting: Setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{decamelize(category, { separator: ' ' })}
</button>
))}
<button
onClick={() =>
scrollRef.current?.querySelector(`#settings-resets`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
Resets
</button>
<button
onClick={() =>
scrollRef.current?.querySelector(`#settings-about`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
About
</button>
</div>
)
}

View File

@ -34,6 +34,15 @@ export function SettingsTabs({
)}
</RadioGroup.Option>
)}
<RadioGroup.Option value="keybindings">
{({ checked }) => (
<SettingsTabButton
checked={checked}
icon="keyboard"
text="Keybindings"
/>
)}
</RadioGroup.Option>
</RadioGroup>
)
}