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:
@ -1042,6 +1042,29 @@ test('Project and user settings can be reset', async ({ page }) => {
|
||||
await expect(page.locator('select[name="app-theme"]')).toHaveValue('system')
|
||||
})
|
||||
|
||||
test('Keyboard shortcuts can be viewed through the help menu', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await page.waitForURL('**/file/**', { waitUntil: 'domcontentloaded' })
|
||||
await page
|
||||
.getByRole('button', { name: 'Start Sketch' })
|
||||
.waitFor({ state: 'visible' })
|
||||
|
||||
// Open the help menu
|
||||
await page.getByRole('button', { name: 'Help', exact: false }).click()
|
||||
|
||||
// Open the keyboard shortcuts
|
||||
await page.getByRole('button', { name: 'Keyboard Shortcuts' }).click()
|
||||
|
||||
// Verify the URL and that you can see a list of shortcuts
|
||||
await expect(page.url()).toContain('?tab=keybindings')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Enter Sketch Mode' })
|
||||
).toBeAttached()
|
||||
})
|
||||
|
||||
test('Click through each onboarding step', async ({ page }) => {
|
||||
const u = await getUtils(page)
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
@ -257,6 +257,14 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
keyboard: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 12V15H13.5M16 12V9M16 12H13.5M4 12V15H6.5M4 12V9M4 12H6.5M4 9V6H6.5M4 9H6.5M16 9V6H13.5M16 9H13.5M6.5 12V15M6.5 12H7.5M6.5 15H13.5M13.5 15V12M13.5 12H12.5M7.5 12V9M7.5 12H10M7.5 9H8.75M7.5 9H6.5M10 12V9M10 12H12.5M10 9H11.25M10 9H8.75M12.5 12V9M12.5 9H13.5M12.5 9H11.25M13.5 9V6M13.5 6H11.25M11.25 9V6M11.25 6H8.75M8.75 9V6M8.75 6H6.5M6.5 9V6"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
line: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
@ -81,6 +81,12 @@ export function HelpMenu(props: React.PropsWithChildren) {
|
||||
>
|
||||
Release notes
|
||||
</HelpMenuItem>
|
||||
<HelpMenuItem
|
||||
as="button"
|
||||
onClick={() => navigate('settings?tab=keybindings')}
|
||||
>
|
||||
Keyboard shortcuts
|
||||
</HelpMenuItem>
|
||||
<HelpMenuItem
|
||||
as="button"
|
||||
onClick={() => {
|
||||
|
87
src/components/Settings/AllKeybindingsFields.tsx
Normal file
87
src/components/Settings/AllKeybindingsFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
238
src/components/Settings/AllSettingsFields.tsx
Normal file
238
src/components/Settings/AllSettingsFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
35
src/components/Settings/KeybindingsSectionsList.tsx
Normal file
35
src/components/Settings/KeybindingsSectionsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
68
src/components/Settings/SettingsSectionsList.tsx
Normal file
68
src/components/Settings/SettingsSectionsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -34,6 +34,15 @@ export function SettingsTabs({
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
)}
|
||||
<RadioGroup.Option value="keybindings">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="keyboard"
|
||||
text="Keybindings"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
182
src/lib/settings/initialKeybindings.ts
Normal file
182
src/lib/settings/initialKeybindings.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
|
||||
export type InteractionMapItem = {
|
||||
name: string
|
||||
sequence: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls both the available names for interaction map categories
|
||||
* and the order in which they are displayed.
|
||||
*/
|
||||
export const interactionMapCategories = [
|
||||
'Sketching',
|
||||
'Modeling',
|
||||
'Command Palette',
|
||||
'Settings',
|
||||
'Panes',
|
||||
'Code Editor',
|
||||
'File Tree',
|
||||
'Miscellaneous',
|
||||
]
|
||||
|
||||
type InteractionMapCategory = (typeof interactionMapCategories)[number]
|
||||
|
||||
/**
|
||||
* A temporary implementation of the interaction map for
|
||||
* display purposes only.
|
||||
* @todo Implement a proper interaction map
|
||||
* that can be edited, saved, and loaded. This is underway in the
|
||||
* franknoirot/editable-hotkeys branch.
|
||||
*/
|
||||
export const interactionMap: Record<
|
||||
InteractionMapCategory,
|
||||
InteractionMapItem[]
|
||||
> = {
|
||||
Settings: [
|
||||
{
|
||||
name: 'toggle-settings',
|
||||
sequence: isTauri() ? 'Meta+,' : 'Shift+Meta+,',
|
||||
title: 'Toggle Settings',
|
||||
description: 'Opens the settings dialog. Always available.',
|
||||
},
|
||||
{
|
||||
name: 'settings-search',
|
||||
sequence: 'Control+.',
|
||||
title: 'Settings Search',
|
||||
description:
|
||||
'Focus the settings search input. Available when settings are open.',
|
||||
},
|
||||
],
|
||||
'Command Palette': [
|
||||
{
|
||||
name: 'toggle-command-palette',
|
||||
sequence: 'Meta+K',
|
||||
title: 'Toggle Command Palette',
|
||||
description: 'Always available. Use Ctrl+/ on Windows/Linux.',
|
||||
},
|
||||
],
|
||||
Panes: [
|
||||
{
|
||||
name: 'toggle-code-pane',
|
||||
sequence: 'Shift+C',
|
||||
title: 'Toggle Code Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-variables-pane',
|
||||
sequence: 'Shift+V',
|
||||
title: 'Toggle Variables Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-logs-pane',
|
||||
sequence: 'Shift+L',
|
||||
title: 'Toggle Logs Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-errors-pane',
|
||||
sequence: 'Shift+E',
|
||||
title: 'Toggle Errors Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
],
|
||||
Sketching: [
|
||||
{
|
||||
name: 'enter-sketch-mode',
|
||||
sequence: 'S',
|
||||
title: 'Enter Sketch Mode',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'unequip-sketch-tool',
|
||||
sequence: 'Escape',
|
||||
title: 'Unequip Sketch Tool',
|
||||
description:
|
||||
'Unequips the current sketch tool. Available while sketching.',
|
||||
},
|
||||
{
|
||||
name: 'exit-sketch-mode',
|
||||
sequence: 'Escape',
|
||||
title: 'Exit Sketch Mode',
|
||||
description: 'Available while sketching, if no sketch tool is equipped.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-line-tool',
|
||||
sequence: 'L',
|
||||
title: 'Toggle Line Tool',
|
||||
description:
|
||||
'Available while sketching, when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-rectangle-tool',
|
||||
sequence: 'R',
|
||||
title: 'Toggle Rectangle Tool',
|
||||
description:
|
||||
'Available while sketching, when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-arc-tool',
|
||||
sequence: 'A',
|
||||
title: 'Toggle Arc Tool',
|
||||
description:
|
||||
'Available while sketching, when not typing in the code editor.',
|
||||
},
|
||||
],
|
||||
Modeling: [
|
||||
{
|
||||
name: 'extrude',
|
||||
sequence: 'E',
|
||||
title: 'Extrude',
|
||||
description:
|
||||
'Available while modeling with either a face selected or an empty selection, when not typing in the code editor.',
|
||||
},
|
||||
],
|
||||
'Code Editor': [
|
||||
{
|
||||
name: 'format-code',
|
||||
sequence: 'Shift+Alt+F',
|
||||
title: 'Format Code',
|
||||
description:
|
||||
'Nicely formats the KCL code in the editor, available when the editor is focused.',
|
||||
},
|
||||
],
|
||||
'File Tree': [
|
||||
{
|
||||
name: 'rename-file',
|
||||
sequence: 'Enter',
|
||||
title: 'Rename File/Folder',
|
||||
description:
|
||||
'Available when a file or folder is selected in the file tree.',
|
||||
},
|
||||
{
|
||||
name: 'delete-file',
|
||||
sequence: 'Meta+Backspace',
|
||||
title: 'Delete File/Folder',
|
||||
description:
|
||||
'Available when a file or folder is selected in the file tree.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts interaction map categories by their order in the
|
||||
* `interactionMapCategories` array.
|
||||
*/
|
||||
export function sortInteractionMapByCategory(
|
||||
[categoryA]: [InteractionMapCategory, InteractionMapItem[]],
|
||||
[categoryB]: [InteractionMapCategory, InteractionMapItem[]]
|
||||
) {
|
||||
return (
|
||||
interactionMapCategories.indexOf(categoryA) -
|
||||
interactionMapCategories.indexOf(categoryB)
|
||||
)
|
||||
}
|
@ -1,28 +1,17 @@
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Fragment, useEffect, useRef } from 'react'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import decamelize from 'decamelize'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { CustomIcon } from 'components/CustomIcon'
|
||||
import {
|
||||
shouldHideSetting,
|
||||
shouldShowSettingInput,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
||||
import { SettingsSearchBar } from 'components/Settings/SettingsSearchBar'
|
||||
import { SettingsTabs } from 'components/Settings/SettingsTabs'
|
||||
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||
import { SettingsFieldInput } from 'components/Settings/SettingsFieldInput'
|
||||
import { SettingsSectionsList } from 'components/Settings/SettingsSectionsList'
|
||||
import { AllSettingsFields } from 'components/Settings/AllSettingsFields'
|
||||
import { AllKeybindingsFields } from 'components/Settings/AllKeybindingsFields'
|
||||
import { KeybindingsSectionsList } from 'components/Settings/KeybindingsSectionsList'
|
||||
|
||||
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
||||
|
||||
@ -33,40 +22,12 @@ export const Settings = () => {
|
||||
const location = useLocation()
|
||||
const isFileSettings = location.pathname.includes(paths.FILE)
|
||||
const searchParamTab =
|
||||
(searchParams.get('tab') as SettingsLevel) ??
|
||||
(searchParams.get('tab') as SettingsLevel | 'keybindings') ??
|
||||
(isFileSettings ? 'project' : 'user')
|
||||
const projectPath =
|
||||
isFileSettings && isTauri()
|
||||
? decodeURI(
|
||||
location.pathname
|
||||
.replace(paths.FILE + '/', '')
|
||||
.replace(paths.SETTINGS, '')
|
||||
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||
)
|
||||
: undefined
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const dotDotSlash = useDotDotSlash()
|
||||
useHotkeys('esc', () => navigate(dotDotSlash()))
|
||||
const {
|
||||
settings: {
|
||||
send,
|
||||
state: { context },
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
function restartOnboarding() {
|
||||
send({
|
||||
type: `set.app.onboardingStatus`,
|
||||
data: { level: 'user', value: '' },
|
||||
})
|
||||
|
||||
if (isFileSettings) {
|
||||
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
|
||||
} else {
|
||||
createAndOpenNewProject(navigate)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to the hash on load if it exists
|
||||
useEffect(() => {
|
||||
@ -137,233 +98,24 @@ export const Settings = () => {
|
||||
gridTemplateRows: '1fr',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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/10 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}
|
||||
{searchParamTab !== 'keybindings' ? (
|
||||
<>
|
||||
<SettingsSectionsList
|
||||
searchParamTab={searchParamTab}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</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>
|
||||
<AllSettingsFields
|
||||
searchParamTab={searchParamTab}
|
||||
isFileSettings={isFileSettings}
|
||||
ref={scrollRef}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeybindingsSectionsList scrollRef={scrollRef} />
|
||||
<AllKeybindingsFields ref={scrollRef} />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
Reference in New Issue
Block a user