diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index d0418bf..19da2a1 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -169,6 +169,7 @@ import { createServerInstall, provideServerInstall } from '@/providers/server-in import { setupProviders } from '@/providers/setup' import { setupAuthProvider } from '@/providers/setup/auth' import { setupLoadingStateProvider } from '@/providers/setup/loading-state' +import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' import { useError } from '@/store/error.js' import { useTheming } from '@/store/state' @@ -184,6 +185,8 @@ const onSchematicWorkshopPage = computed(() => route.path === '/lab/schematic-pr const isSchematicFile = (path: string) => /\.(litematic|schematic|schem)$/i.test(path) const APP_LEFT_NAV_WIDTH = '4rem' +setupKeyboardShortcuts() + const discoverContentPath = computed(() => { const projectType = route.params.projectType if ( diff --git a/apps/app-frontend/src/components/ui/settings/KeybindSettings.vue b/apps/app-frontend/src/components/ui/settings/KeybindSettings.vue new file mode 100644 index 0000000..d5fe9b1 --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/KeybindSettings.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts b/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts index 3fa7b51..08077a2 100644 --- a/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts +++ b/apps/app-frontend/src/components/ui/settings/settings-category-definitions.ts @@ -3,6 +3,7 @@ import type { MessageDescriptor } from '@modrinth/ui' export type SettingsCategoryId = | 'interface' | 'home-navigation' + | 'shortcut-settings' | 'language-translation' | 'ai' | 'java-performance' @@ -45,6 +46,15 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [ group: 'launcher', onboardingId: 'settings-tab-home-navigation', }, + { + id: 'shortcut-settings', + name: { + id: 'app.settings.tabs.shortcut-settings', + defaultMessage: 'Keyboard shortcuts', + }, + group: 'launcher', + onboardingId: 'settings-tab-shortcut-settings', + }, { id: 'language-translation', name: { diff --git a/apps/app-frontend/src/components/ui/settings/settings-registry.ts b/apps/app-frontend/src/components/ui/settings/settings-registry.ts index 4708a1e..24de1dc 100644 --- a/apps/app-frontend/src/components/ui/settings/settings-registry.ts +++ b/apps/app-frontend/src/components/ui/settings/settings-registry.ts @@ -7,6 +7,7 @@ import { GameIcon, GaugeIcon, InfoIcon, + KeyboardIcon, LanguagesIcon, LayoutTemplateIcon, PaintbrushIcon, @@ -49,6 +50,10 @@ const categoryContent: Record import('./HomeNavigationSettings.vue')), }, + 'shortcut-settings': { + icon: KeyboardIcon, + content: defineAsyncComponent(() => import('./KeybindSettings.vue')), + }, 'language-translation': { icon: LanguagesIcon, content: defineAsyncComponent(() => import('./LanguageTranslationSettings.vue')), diff --git a/apps/app-frontend/src/composables/useKeyboardShortcuts.ts b/apps/app-frontend/src/composables/useKeyboardShortcuts.ts new file mode 100644 index 0000000..57af8e4 --- /dev/null +++ b/apps/app-frontend/src/composables/useKeyboardShortcuts.ts @@ -0,0 +1,215 @@ +import { + type KeyBinding, + bindingMatchesKeyboardEvent, + bindingMatchesMouseEvent, + bindingMatchesWheelEvent, + useModalStack, +} from '@modrinth/ui' +import { computed, onUnmounted } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +import { useNetworkStatus } from '@/composables/useNetworkStatus' +import { + SCROLL_ACTIONS, + SHORTCUT_ACTIONS, + type ShortcutAction, +} from '@/helpers/shortcut-actions' +import { resolveAllBindings } from '@/helpers/shortcut-bindings' +import { getNavShortcutEnabled } from '@/helpers/nav-shortcut-state' +import { getQuickScrollEnabled } from '@/helpers/scroll-top-state' +import { useTheming } from '@/store/theme' + +/** Editing surfaces keep these keys for themselves. */ +function isEditableTarget(target: EventTarget | null) { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) +} + +function scrollsAtOwnLevel(element: Element) { + const { overflowY } = getComputedStyle(element) + return /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight +} + +/** A container that scrolls the page itself rather than a widget on it. */ +function isPageScroller(element: Element) { + return scrollsAtOwnLevel(element) && element.clientHeight >= window.innerHeight / 2 +} + +/** Nearest scrolling ancestor, whatever its size. */ +function nearestScrollContainer(target: EventTarget | null) { + let element = target instanceof Element ? target : null + while (element) { + if (scrollsAtOwnLevel(element)) return element + element = element.parentElement + } + return null +} + +/** + * The page's own scroller. Most pages scroll inside `.app-viewport`, but some + * screens (settings, consoles, studios) turn that element's scrolling off and + * host a full-height container inside it, so fall back to whatever fills the + * middle of the viewport. + */ +function resolvePageScroller() { + const viewport = document.querySelector('.app-viewport') + if (!viewport) return null + if (isPageScroller(viewport)) return viewport + + const bounds = viewport.getBoundingClientRect() + let element: Element | null = document.elementFromPoint( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + ) + while (element) { + if (isPageScroller(element)) return element + element = element.parentElement + } + + return viewport +} + +export function setupKeyboardShortcuts() { + const themeStore = useTheming() + const { hasModal } = useModalStack() + const route = useRoute() + const router = useRouter() + const { offline } = useNetworkStatus() + + themeStore.quickScrollEnabled = getQuickScrollEnabled() + for (const shortcut of SHORTCUT_ACTIONS) { + if (shortcut.group === 'nav') themeStore[shortcut.enabledField] = getNavShortcutEnabled(shortcut.id) + } + themeStore.shortcutBindings = resolveAllBindings() + + /** The combination an action answers to now: recorded if there is one, default otherwise. */ + function bindingFor(action: ShortcutAction): KeyBinding { + return themeStore.shortcutBindings[action.id] ?? action.defaultBinding + } + + /** Whether an action is switched on and reachable in the current state. */ + function shortcutIsAvailable(action: ShortcutAction): boolean { + if (!themeStore[action.enabledField]) return false + return !action.unavailable?.({ + worldsTabEnabled: themeStore.featureFlags.worlds_tab, + offline: offline.value, + }) + } + + function findShortcut( + event: E, + matches: (binding: KeyBinding, event: E) => boolean, + { includeDisabled = false }: { includeDisabled?: boolean } = {}, + ): ShortcutAction | null { + if (hasModal.value) return null + if (isEditableTarget(event.target)) return null + + for (const action of SHORTCUT_ACTIONS) { + if (!includeDisabled && !shortcutIsAvailable(action)) continue + if (matches(bindingFor(action), event)) return action + } + + return null + } + + /** Does what an action is for. Scrolling actions move the page's own scroller. */ + function runShortcut(action: ShortcutAction, event: Event) { + if (action.target) { + router.push(action.target(route)) + return + } + + const focusedContainer = nearestScrollContainer(event.target) + if (focusedContainer && !isPageScroller(focusedContainer)) return + + const scroller = resolvePageScroller() + if (!scroller) return + + action.applyScroll?.(scroller) + } + + function handleScrollShortcutKey(event: KeyboardEvent) { + if (event.isComposing) return + + const match = findShortcut(event, bindingMatchesKeyboardEvent, { includeDisabled: true }) + if (match?.group !== 'scroll') return + + const focusedContainer = nearestScrollContainer(event.target) + if (focusedContainer && !isPageScroller(focusedContainer)) return + + const scroller = resolvePageScroller() + if (!scroller) return + + event.preventDefault() + if (!shortcutIsAvailable(match)) return + + match.applyScroll?.(scroller) + } + + function handleNavShortcutKey(event: KeyboardEvent) { + if (event.isComposing) return + + const match = findShortcut(event, bindingMatchesKeyboardEvent) + if (match?.group !== 'nav') return + + event.preventDefault() + runShortcut(match, event) + } + + const hasPointerBindings = computed(() => + SHORTCUT_ACTIONS.some((action) => bindingFor(action).device === 'mouse'), + ) + + function handleShortcutMouse(event: MouseEvent) { + if (!hasPointerBindings.value) return + + const match = findShortcut(event, bindingMatchesMouseEvent) + if (!match) return + + event.preventDefault() + event.stopPropagation() + runShortcut(match, event) + } + + function handleShortcutWheel(event: WheelEvent) { + if (!hasPointerBindings.value) return + + const match = findShortcut(event, bindingMatchesWheelEvent) + if (!match) return + + event.preventDefault() + event.stopPropagation() + runShortcut(match, event) + } + + function handleGlobalKeydown(event: KeyboardEvent) { + const key = event.key.toLowerCase() + const isFindShortcut = key === 'f' && (event.ctrlKey || event.metaKey) + const isBlockedDevtoolsShortcut = event.key === 'F12' && !themeStore.devMode + + if (isFindShortcut || isBlockedDevtoolsShortcut) { + event.preventDefault() + event.stopPropagation() + } + + handleScrollShortcutKey(event) + handleNavShortcutKey(event) + } + + window.addEventListener('keydown', handleGlobalKeydown, true) + window.addEventListener('mousedown', handleShortcutMouse, true) + // Not passive: a bound wheel direction has to keep the page from scrolling. + window.addEventListener('wheel', handleShortcutWheel, { capture: true, passive: false }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleGlobalKeydown, true) + window.removeEventListener('mousedown', handleShortcutMouse, true) + window.removeEventListener('wheel', handleShortcutWheel, { capture: true, passive: false }) + }) + + return { bindingFor, shortcutIsAvailable, SCROLL_ACTIONS } +} \ No newline at end of file diff --git a/apps/app-frontend/src/helpers/nav-shortcut-state.ts b/apps/app-frontend/src/helpers/nav-shortcut-state.ts new file mode 100644 index 0000000..141d199 --- /dev/null +++ b/apps/app-frontend/src/helpers/nav-shortcut-state.ts @@ -0,0 +1,14 @@ +const NAV_SHORTCUT_STORAGE_PREFIX = 'starlight-shortcut-nav-' + +/** + * Whether a navigation shortcut is enabled. Shortcuts are off by default; + * each one is turned on individually from the Shortcut settings page. + */ +export function getNavShortcutEnabled(id: string): boolean { + const value = localStorage.getItem(NAV_SHORTCUT_STORAGE_PREFIX + id) + return value === 'true' +} + +export function setNavShortcutEnabled(id: string, enabled: boolean) { + localStorage.setItem(NAV_SHORTCUT_STORAGE_PREFIX + id, String(enabled)) +} diff --git a/apps/app-frontend/src/helpers/scroll-top-state.ts b/apps/app-frontend/src/helpers/scroll-top-state.ts new file mode 100644 index 0000000..1c24a54 --- /dev/null +++ b/apps/app-frontend/src/helpers/scroll-top-state.ts @@ -0,0 +1,20 @@ +const SHOW_SCROLL_TOP_STORAGE_KEY = 'starlight-show-scroll-top' +const QUICK_SCROLL_ENABLED_STORAGE_KEY = 'starlight-quick-scroll-enabled' + +export function getShowScrollTop(): boolean { + const value = localStorage.getItem(SHOW_SCROLL_TOP_STORAGE_KEY) + return value !== 'false' +} + +export function setShowScrollTop(show: boolean) { + localStorage.setItem(SHOW_SCROLL_TOP_STORAGE_KEY, String(show)) +} + +export function getQuickScrollEnabled(): boolean { + const value = localStorage.getItem(QUICK_SCROLL_ENABLED_STORAGE_KEY) + return value === 'true' +} + +export function setQuickScrollEnabled(enabled: boolean) { + localStorage.setItem(QUICK_SCROLL_ENABLED_STORAGE_KEY, String(enabled)) +} diff --git a/apps/app-frontend/src/helpers/shortcut-actions.ts b/apps/app-frontend/src/helpers/shortcut-actions.ts new file mode 100644 index 0000000..a58506a --- /dev/null +++ b/apps/app-frontend/src/helpers/shortcut-actions.ts @@ -0,0 +1,309 @@ +import { defineMessages, type KeyBinding, type MessageDescriptor } from '@modrinth/ui' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { getLastBrowseContentProjectType, isBrowseContentProjectType } from '@/helpers/settings' + +/** Which half of the settings page an action belongs to. */ +export type ShortcutGroupId = 'scroll' | 'nav' + +/** Theme store fields that switch an action on. */ +export type ShortcutEnabledField = + | 'quickScrollEnabled' + | 'shortcutNavHome' + | 'shortcutNavWorlds' + | 'shortcutNavDiscover' + | 'shortcutNavSkins' + | 'shortcutNavMultiplayer' + | 'shortcutNavLibrary' + | 'shortcutNavLab' + | 'shortcutNavDownloads' + | 'shortcutNavCreate' + | 'shortcutNavSettings' + +export interface ShortcutContext { + worldsTabEnabled: boolean + offline: boolean +} + +export interface ShortcutAction { + /** Stable id, used for the binding, the enable flag and the settings row. */ + id: string + group: ShortcutGroupId + enabledField: ShortcutEnabledField + defaultBinding: KeyBinding + label: MessageDescriptor + description: MessageDescriptor + /** Navigation actions know where they lead. */ + target?: (route: RouteLocationNormalizedLoaded) => string + /** Navigation actions that are not reachable right now. */ + unavailable?: (context: ShortcutContext) => boolean + /** Scrolling actions move the page's own scroller. */ + applyScroll?: (scroller: HTMLElement) => void +} + +/** + * Declared here rather than assembled from the action ids, so the extractor can + * see them; an id built at runtime is one a translation pass would drop. + */ +const messages = defineMessages({ + scrollTop: { + id: 'app.shortcut-settings.home-action', + defaultMessage: 'Scroll to the top', + }, + scrollTopDescription: { + id: 'app.shortcut-settings.home-action-description', + defaultMessage: 'Moves the page you are reading to the top.', + }, + scrollBottom: { + id: 'app.shortcut-settings.end-action', + defaultMessage: 'Scroll to the bottom', + }, + scrollBottomDescription: { + id: 'app.shortcut-settings.end-action-description', + defaultMessage: 'Moves the page you are reading to the bottom.', + }, + scrollUp: { + id: 'app.shortcut-settings.page-up-action', + defaultMessage: 'Scroll up one screen', + }, + scrollUpDescription: { + id: 'app.shortcut-settings.page-up-action-description', + defaultMessage: 'Moves the page you are reading up by one screen.', + }, + scrollDown: { + id: 'app.shortcut-settings.page-down-action', + defaultMessage: 'Scroll down one screen', + }, + scrollDownDescription: { + id: 'app.shortcut-settings.page-down-action-description', + defaultMessage: 'Moves the page you are reading down by one screen.', + }, + navHome: { id: 'app.shortcut-settings.nav-home', defaultMessage: 'Home' }, + navHomeDescription: { + id: 'app.shortcut-settings.nav-home-description', + defaultMessage: 'Jump to the Home page.', + }, + navWorlds: { id: 'app.shortcut-settings.nav-worlds', defaultMessage: 'Worlds' }, + navWorldsDescription: { + id: 'app.shortcut-settings.nav-worlds-description', + defaultMessage: 'Jump to your worlds.', + }, + navDiscover: { + id: 'app.shortcut-settings.nav-discover', + defaultMessage: 'Discover content', + }, + navDiscoverDescription: { + id: 'app.shortcut-settings.nav-discover-description', + defaultMessage: 'Jump to browsing content.', + }, + navSkins: { id: 'app.shortcut-settings.nav-skins', defaultMessage: 'Skin selector' }, + navSkinsDescription: { + id: 'app.shortcut-settings.nav-skins-description', + defaultMessage: 'Jump to the skin selector.', + }, + navMultiplayer: { id: 'app.shortcut-settings.nav-multiplayer', defaultMessage: 'Multiplayer' }, + navMultiplayerDescription: { + id: 'app.shortcut-settings.nav-multiplayer-description', + defaultMessage: 'Jump to multiplayer.', + }, + navLibrary: { id: 'app.shortcut-settings.nav-library', defaultMessage: 'Library' }, + navLibraryDescription: { + id: 'app.shortcut-settings.nav-library-description', + defaultMessage: 'Jump to your library.', + }, + navLab: { id: 'app.shortcut-settings.nav-lab', defaultMessage: 'Lab' }, + navLabDescription: { + id: 'app.shortcut-settings.nav-lab-description', + defaultMessage: 'Jump to the Lab.', + }, + navDownloads: { id: 'app.shortcut-settings.nav-downloads', defaultMessage: 'Downloads' }, + navDownloadsDescription: { + id: 'app.shortcut-settings.nav-downloads-description', + defaultMessage: 'Jump to downloads.', + }, + navCreate: { id: 'app.shortcut-settings.nav-create', defaultMessage: 'Create new instance' }, + navCreateDescription: { + id: 'app.shortcut-settings.nav-create-description', + defaultMessage: 'Jump to creating a new instance.', + }, + navSettings: { id: 'app.shortcut-settings.nav-settings', defaultMessage: 'Settings' }, + navSettingsDescription: { + id: 'app.shortcut-settings.nav-settings-description', + defaultMessage: 'Jump to the settings.', + }, +}) + +function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding { + return { device: 'keyboard', code, mod, alt, shift } +} + +/** + * Resolves the Discover content target from the current route, so the nav + * button and the keyboard shortcut share one source of truth. + */ +export function discoverContentTarget(route: RouteLocationNormalizedLoaded): string { + const projectType = route.params.projectType + if ( + !route.query.i && + !route.query.sid && + !route.query.wid && + typeof projectType === 'string' && + isBrowseContentProjectType(projectType) + ) { + return `/browse/${projectType}` + } + + return `/browse/${getLastBrowseContentProjectType()}` +} + +/** + * Every shortcut the launcher answers to. The binding here is the one a fresh + * install uses; a recorded binding is stored separately and wins over it. + */ +export const SHORTCUT_ACTIONS: ShortcutAction[] = [ + { + id: 'shortcutScrollTop', + group: 'scroll', + enabledField: 'quickScrollEnabled', + defaultBinding: keyboard('Home'), + label: messages.scrollTop, + description: messages.scrollTopDescription, + applyScroll: (scroller) => scroller.scrollTo({ top: 0, behavior: 'smooth' }), + }, + { + id: 'shortcutScrollBottom', + group: 'scroll', + enabledField: 'quickScrollEnabled', + defaultBinding: keyboard('End'), + label: messages.scrollBottom, + description: messages.scrollBottomDescription, + applyScroll: (scroller) => + scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }), + }, + { + id: 'shortcutScrollUp', + group: 'scroll', + enabledField: 'quickScrollEnabled', + defaultBinding: keyboard('PageUp'), + label: messages.scrollUp, + description: messages.scrollUpDescription, + applyScroll: (scroller) => { + // Immediate scrolling keeps rapid key repeats responsive. + scroller.scrollTop = Math.max(0, scroller.scrollTop - scroller.clientHeight * 0.9) + }, + }, + { + id: 'shortcutScrollDown', + group: 'scroll', + enabledField: 'quickScrollEnabled', + defaultBinding: keyboard('PageDown'), + label: messages.scrollDown, + description: messages.scrollDownDescription, + applyScroll: (scroller) => { + scroller.scrollTop = Math.min( + scroller.scrollHeight, + scroller.scrollTop + scroller.clientHeight * 0.9, + ) + }, + }, + { + id: 'shortcutNavHome', + group: 'nav', + enabledField: 'shortcutNavHome', + defaultBinding: keyboard('Digit1', true), + label: messages.navHome, + description: messages.navHomeDescription, + target: () => '/', + }, + { + id: 'shortcutNavWorlds', + group: 'nav', + enabledField: 'shortcutNavWorlds', + defaultBinding: keyboard('Digit2', true), + label: messages.navWorlds, + description: messages.navWorldsDescription, + target: () => '/worlds', + unavailable: (context) => !context.worldsTabEnabled, + }, + { + id: 'shortcutNavDiscover', + group: 'nav', + enabledField: 'shortcutNavDiscover', + defaultBinding: keyboard('Digit3', true), + label: messages.navDiscover, + description: messages.navDiscoverDescription, + target: (route) => discoverContentTarget(route), + unavailable: (context) => context.offline, + }, + { + id: 'shortcutNavSkins', + group: 'nav', + enabledField: 'shortcutNavSkins', + defaultBinding: keyboard('Digit4', true), + label: messages.navSkins, + description: messages.navSkinsDescription, + target: () => '/skins', + }, + { + id: 'shortcutNavMultiplayer', + group: 'nav', + enabledField: 'shortcutNavMultiplayer', + defaultBinding: keyboard('Digit5', true), + label: messages.navMultiplayer, + description: messages.navMultiplayerDescription, + target: () => '/multiplayer', + }, + { + id: 'shortcutNavLibrary', + group: 'nav', + enabledField: 'shortcutNavLibrary', + defaultBinding: keyboard('Digit6', true), + label: messages.navLibrary, + description: messages.navLibraryDescription, + target: () => '/library', + }, + { + id: 'shortcutNavLab', + group: 'nav', + enabledField: 'shortcutNavLab', + defaultBinding: keyboard('Digit7', true), + label: messages.navLab, + description: messages.navLabDescription, + target: () => '/lab', + }, + { + id: 'shortcutNavDownloads', + group: 'nav', + enabledField: 'shortcutNavDownloads', + defaultBinding: keyboard('Digit8', true), + label: messages.navDownloads, + description: messages.navDownloadsDescription, + target: () => '/downloads', + }, + { + id: 'shortcutNavCreate', + group: 'nav', + enabledField: 'shortcutNavCreate', + defaultBinding: keyboard('Digit9', true), + label: messages.navCreate, + description: messages.navCreateDescription, + target: () => '/create', + unavailable: (context) => context.offline, + }, + { + id: 'shortcutNavSettings', + group: 'nav', + enabledField: 'shortcutNavSettings', + defaultBinding: keyboard('Comma', true), + label: messages.navSettings, + description: messages.navSettingsDescription, + target: () => '/settings', + }, +] + +export const SCROLL_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'scroll') +export const NAV_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'nav') + +export function shortcutAction(id: string): ShortcutAction | undefined { + return SHORTCUT_ACTIONS.find((action) => action.id === id) +} diff --git a/apps/app-frontend/src/helpers/shortcut-bindings.ts b/apps/app-frontend/src/helpers/shortcut-bindings.ts new file mode 100644 index 0000000..86eeea3 --- /dev/null +++ b/apps/app-frontend/src/helpers/shortcut-bindings.ts @@ -0,0 +1,120 @@ +import type { BindingConflict, KeyBinding } from '@modrinth/ui' + +import { SHORTCUT_ACTIONS, shortcutAction } from '@/helpers/shortcut-actions' + +const BINDING_STORAGE_PREFIX = 'starlight-shortcut-binding-' + +/** + * Combinations the shell or the operating system answers to before the launcher + * sees them. Saved shortcuts may not take these over. + */ +export const RESERVED_BINDINGS: BindingConflict[] = [ + { + id: 'system-find', + label: 'Find in page (Ctrl/Cmd + F)', + binding: keyboard('KeyF', true), + reserved: true, + }, + { + id: 'system-devtools', + label: 'Developer tools (F12)', + binding: keyboard('F12'), + reserved: true, + }, + { + id: 'system-close', + label: 'Close window (Ctrl/Cmd + W)', + binding: keyboard('KeyW', true), + reserved: true, + }, + { + id: 'system-quit', + label: 'Quit (Ctrl/Cmd + Q)', + binding: keyboard('KeyQ', true), + reserved: true, + }, + { + id: 'system-reload', + label: 'Reload (Ctrl/Cmd + R)', + binding: keyboard('KeyR', true), + reserved: true, + }, + { id: 'system-refresh', label: 'Refresh (F5)', binding: keyboard('F5'), reserved: true }, + { + id: 'system-exit', + label: 'Close window (Alt + F4)', + binding: keyboard('F4', false, true), + reserved: true, + }, +] + +function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding { + return { device: 'keyboard', code, mod, alt, shift } +} + +function isKeyBinding(value: unknown): value is KeyBinding { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + (candidate.device === 'keyboard' || candidate.device === 'mouse') && + typeof candidate.code === 'string' && + candidate.code.length > 0 && + typeof candidate.mod === 'boolean' && + typeof candidate.shift === 'boolean' && + typeof candidate.alt === 'boolean' + ) +} + +/** The combination recorded for an action, if it has one. */ +export function readStoredBinding(id: string): KeyBinding | null { + const raw = localStorage.getItem(BINDING_STORAGE_PREFIX + id) + if (!raw) return null + + try { + const parsed = JSON.parse(raw) + return isKeyBinding(parsed) ? parsed : null + } catch { + // A value that cannot be read is treated as unset, so the action keeps + // working with its default rather than becoming unreachable. + return null + } +} + +export function storeBinding(id: string, binding: KeyBinding) { + localStorage.setItem(BINDING_STORAGE_PREFIX + id, JSON.stringify(binding)) +} + +/** Drops the recorded combination, putting the action back on its default. */ +export function clearStoredBinding(id: string) { + if (readStoredBinding(id)) localStorage.removeItem(BINDING_STORAGE_PREFIX + id) +} + +/** The combination an action answers to now: the recorded one, or its default. */ +export function resolveBinding(id: string): KeyBinding | null { + const action = shortcutAction(id) + if (!action) return null + return readStoredBinding(id) ?? action.defaultBinding +} + +export function resolveAllBindings(): Record { + const bindings: Record = {} + for (const action of SHORTCUT_ACTIONS) { + bindings[action.id] = resolveBinding(action.id) ?? action.defaultBinding + } + return bindings +} + +/** + * Everything a new combination would collide with: the other actions, plus the + * combinations the shell owns. `label` is what the dialog names in the error. + */ +export function buildConflicts(exceptId: string, label: (id: string) => string): BindingConflict[] { + const conflicts = SHORTCUT_ACTIONS.filter((action) => action.id !== exceptId).flatMap( + (action) => { + const binding = resolveBinding(action.id) + return binding ? [{ id: action.id, label: label(action.id), binding }] : [] + }, + ) + + return [...conflicts, ...RESERVED_BINDINGS] +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 4c576dc..5589109 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11652,5 +11652,40 @@ "app.log-share.ai.tool-result": {"message": "Tool result received"}, "app.log-share.ai.limit": {"message": "Reached tool loop limit"}, "app.log-share.ai.working": {"message": "Analyzing with LogAgent…"}, - "app.log-share.ai.failed": {"message": "LogAgent analysis failed: {message}"} + "app.log-share.ai.failed": {"message": "LogAgent analysis failed: {message}"}, + "app.shortcut-settings.change-shortcut": {"message": "Change the shortcut for {action}"}, + "app.shortcut-settings.enable": {"message": "Enable quick scrolling"}, + "app.shortcut-settings.end-action": {"message": "Scroll to the bottom"}, + "app.shortcut-settings.end-action-description": {"message": "Moves the page you are reading to the bottom."}, + "app.shortcut-settings.home-action": {"message": "Scroll to the top"}, + "app.shortcut-settings.home-action-description": {"message": "Moves the page you are reading to the top."}, + "app.shortcut-settings.nav-create": {"message": "Create new instance"}, + "app.shortcut-settings.nav-create-description": {"message": "Jump to creating a new instance."}, + "app.shortcut-settings.nav-description": {"message": "Jump to a menu item with its shortcut. Each shortcut is off until enabled."}, + "app.shortcut-settings.nav-discover": {"message": "Discover content"}, + "app.shortcut-settings.nav-discover-description": {"message": "Jump to browsing content."}, + "app.shortcut-settings.nav-downloads": {"message": "Downloads"}, + "app.shortcut-settings.nav-downloads-description": {"message": "Jump to downloads."}, + "app.shortcut-settings.nav-home": {"message": "Home"}, + "app.shortcut-settings.nav-home-description": {"message": "Jump to the Home page."}, + "app.shortcut-settings.nav-lab": {"message": "Lab"}, + "app.shortcut-settings.nav-lab-description": {"message": "Jump to the Lab."}, + "app.shortcut-settings.nav-library": {"message": "Library"}, + "app.shortcut-settings.nav-library-description": {"message": "Jump to your library."}, + "app.shortcut-settings.nav-multiplayer": {"message": "Multiplayer"}, + "app.shortcut-settings.nav-multiplayer-description": {"message": "Jump to multiplayer."}, + "app.shortcut-settings.nav-settings": {"message": "Settings"}, + "app.shortcut-settings.nav-settings-description": {"message": "Jump to the settings."}, + "app.shortcut-settings.nav-skins": {"message": "Skin selector"}, + "app.shortcut-settings.nav-skins-description": {"message": "Jump to the skin selector."}, + "app.shortcut-settings.nav-title": {"message": "Navigation shortcuts"}, + "app.shortcut-settings.nav-worlds": {"message": "Worlds"}, + "app.shortcut-settings.nav-worlds-description": {"message": "Jump to your worlds."}, + "app.shortcut-settings.page-down-action": {"message": "Scroll down one screen"}, + "app.shortcut-settings.page-down-action-description": {"message": "Moves the page you are reading down by one screen."}, + "app.shortcut-settings.page-up-action": {"message": "Scroll up one screen"}, + "app.shortcut-settings.page-up-action-description": {"message": "Moves the page you are reading up by one screen."}, + "app.shortcut-settings.scroll-description": {"message": "Move through long pages with the keyboard."}, + "app.shortcut-settings.scroll-title": {"message": "Quick scrolling"}, + "app.settings.tabs.shortcut-settings": {"message": "Keyboard shortcuts"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index ea424a7..5cc97b3 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11483,5 +11483,40 @@ "app.log-share.ai.tool-result": {"message": "已收到工具结果"}, "app.log-share.ai.limit": {"message": "已达到工具调用上限"}, "app.log-share.ai.working": {"message": "正在使用 LogAgent 分析…"}, - "app.log-share.ai.failed": {"message": "LogAgent 分析失败:{message}"} + "app.log-share.ai.failed": {"message": "LogAgent 分析失败:{message}"}, + "app.shortcut-settings.change-shortcut": {"message": "更改「{action}」的快捷键"}, + "app.shortcut-settings.enable": {"message": "启用快捷滚动"}, + "app.shortcut-settings.end-action": {"message": "滚动到页面底部"}, + "app.shortcut-settings.end-action-description": {"message": "将当前页面移动到底部。"}, + "app.shortcut-settings.home-action": {"message": "滚动到页面顶部"}, + "app.shortcut-settings.home-action-description": {"message": "将当前页面移动到顶部。"}, + "app.shortcut-settings.nav-create": {"message": "新建实例"}, + "app.shortcut-settings.nav-create-description": {"message": "前往新建实例。"}, + "app.shortcut-settings.nav-description": {"message": "使用各自的快捷键跳转到对应菜单项。每个快捷键默认关闭,需逐个启用。"}, + "app.shortcut-settings.nav-discover": {"message": "发现内容"}, + "app.shortcut-settings.nav-discover-description": {"message": "前往浏览内容。"}, + "app.shortcut-settings.nav-downloads": {"message": "下载"}, + "app.shortcut-settings.nav-downloads-description": {"message": "前往下载。"}, + "app.shortcut-settings.nav-home": {"message": "首页"}, + "app.shortcut-settings.nav-home-description": {"message": "前往首页。"}, + "app.shortcut-settings.nav-lab": {"message": "实验室"}, + "app.shortcut-settings.nav-lab-description": {"message": "前往实验室。"}, + "app.shortcut-settings.nav-library": {"message": "内容库"}, + "app.shortcut-settings.nav-library-description": {"message": "前往内容库。"}, + "app.shortcut-settings.nav-multiplayer": {"message": "多人游戏"}, + "app.shortcut-settings.nav-multiplayer-description": {"message": "前往多人游戏。"}, + "app.shortcut-settings.nav-settings": {"message": "设置"}, + "app.shortcut-settings.nav-settings-description": {"message": "前往设置。"}, + "app.shortcut-settings.nav-skins": {"message": "皮肤选择器"}, + "app.shortcut-settings.nav-skins-description": {"message": "前往皮肤选择器。"}, + "app.shortcut-settings.nav-title": {"message": "导航快捷键"}, + "app.shortcut-settings.nav-worlds": {"message": "世界"}, + "app.shortcut-settings.nav-worlds-description": {"message": "前往你的世界。"}, + "app.shortcut-settings.page-down-action": {"message": "向下滚动一屏"}, + "app.shortcut-settings.page-down-action-description": {"message": "将当前页面向下移动一屏。"}, + "app.shortcut-settings.page-up-action": {"message": "向上滚动一屏"}, + "app.shortcut-settings.page-up-action-description": {"message": "将当前页面向上移动一屏。"}, + "app.shortcut-settings.scroll-description": {"message": "使用键盘在长页面中快速滚动。"}, + "app.shortcut-settings.scroll-title": {"message": "快速滚动"}, + "app.settings.tabs.shortcut-settings": {"message": "键盘快捷键"} } diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts index ed2ba47..19cf521 100644 --- a/apps/app-frontend/src/store/theme.ts +++ b/apps/app-frontend/src/store/theme.ts @@ -6,6 +6,18 @@ import { resolveFontFamily, } from '@/helpers/font-family.ts' +/** + * Mirrors `KeyBinding` from `@modrinth/ui`. Declared here so this module stays + * loadable without the UI package. + */ +export interface ShortcutBinding { + device: 'keyboard' | 'mouse' + code: string + mod: boolean + shift: boolean + alt: boolean +} + let systemThemeMq: MediaQueryList | null = null export const DEFAULT_FEATURE_FLAGS = { @@ -135,8 +147,30 @@ export type ThemeStore = { minimalHomeInstanceId: string | null closeBehavior: CloseBehavior + /** Whether the floating "back to top" button is shown on scrollable pages. */ + showScrollTop: boolean + /** Whether quick scrolling shortcuts (Home/End/PageUp/PageDown) are active. */ + quickScrollEnabled: boolean + /** Per-nav-item quick jump shortcuts (Ctrl/Cmd + number), off by default. */ + shortcutNavHome: boolean + shortcutNavWorlds: boolean + shortcutNavDiscover: boolean + shortcutNavSkins: boolean + shortcutNavMultiplayer: boolean + shortcutNavLibrary: boolean + shortcutNavLab: boolean + shortcutNavDownloads: boolean + shortcutNavCreate: boolean + shortcutNavSettings: boolean + + /** + * The combination every shortcut answers to, keyed by action id. Kept here so + * pages re-render when one is recorded; the value itself lives in local + * storage, next to the enable flags. + */ + shortcutBindings: Record + devMode: boolean - featureFlags: FeatureFlags uiFont: string | null monoFont: string | null @@ -162,6 +196,22 @@ export const DEFAULT_THEME_STORE: ThemeStore = { minimalHomeInstanceId: null, closeBehavior: 'ask', + showScrollTop: true, + quickScrollEnabled: false, + + shortcutNavHome: false, + shortcutNavWorlds: false, + shortcutNavDiscover: false, + shortcutNavSkins: false, + shortcutNavMultiplayer: false, + shortcutNavLibrary: false, + shortcutNavLab: false, + shortcutNavDownloads: false, + shortcutNavCreate: false, + shortcutNavSettings: false, + + shortcutBindings: {}, + devMode: false, featureFlags: DEFAULT_FEATURE_FLAGS, diff --git a/packages/app-lib/src/api/ai.rs b/packages/app-lib/src/api/ai.rs index 89625c2..3ac2532 100644 --- a/packages/app-lib/src/api/ai.rs +++ b/packages/app-lib/src/api/ai.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use crate::{ErrorKind, State}; -const CATALOG_SOURCE: &str = "LobeHub 906b10e03029648655e0257bda4f785a9e0973f0"; +const CATALOG_SOURCE: &str = "LobeHub 6c7a6fb033e443bc27bf34cbd5509ab3db7f22f3"; const KEYRING_PREFIX: &str = "ai-provider"; const OAUTH_CREDENTIAL: &str = "oauth"; const API_KEY_CREDENTIAL: &str = "api-key"; diff --git a/packages/app-lib/src/api/lobehub_text_models.json b/packages/app-lib/src/api/lobehub_text_models.json index 75109f6..99574ee 100644 --- a/packages/app-lib/src/api/lobehub_text_models.json +++ b/packages/app-lib/src/api/lobehub_text_models.json @@ -1,5 +1,5 @@ { - "source": "LobeHub 906b10e03029648655e0257bda4f785a9e0973f0 model-bank chat models", + "source": "LobeHub 6c7a6fb033e443bc27bf34cbd5509ab3db7f22f3 model-bank chat models", "providers": { "ai21": [ { @@ -203,6 +203,16 @@ } ], "aihubmix": [ + { + "id": "gpt-6-sol", + "name": "GPT-6 Sol", + "enabled": true + }, + { + "id": "gpt-6-luna", + "name": "GPT-6 Luna", + "enabled": true + }, { "id": "gpt-5.5", "name": "GPT-5.5", @@ -343,6 +353,11 @@ "name": "ChatGPT-4o", "enabled": false }, + { + "id": "grok-4.7", + "name": "Grok 4.7", + "enabled": true + }, { "id": "grok-4-20-non-reasoning", "name": "Grok 4.20 (Non-Reasoning)", @@ -368,6 +383,11 @@ "name": "Grok 3 Mini", "enabled": false }, + { + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "enabled": true + }, { "id": "claude-opus-4-7", "name": "Claude Opus 4.7", @@ -383,11 +403,6 @@ "name": "Claude Opus 4.5", "enabled": false }, - { - "id": "claude-opus-4-1-20250805", - "name": "Claude Opus 4.1", - "enabled": false - }, { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", @@ -403,6 +418,21 @@ "name": "Claude Haiku 4.5", "enabled": true }, + { + "id": "mimo-v2.6-pro", + "name": "MiMo-V2.6 Pro", + "enabled": true + }, + { + "id": "mimo-v2.6-pro-ultraspeed", + "name": "MiMo-V2.6 Pro UltraSpeed", + "enabled": true + }, + { + "id": "mimo-v2.6-flash", + "name": "MiMo-V2.6 Flash", + "enabled": true + }, { "id": "DeepSeek-V3.1-Think", "name": "DeepSeek V3.1 (Think)", @@ -542,11 +572,6 @@ "name": "Ring-2.6-1T", "enabled": true }, - { - "id": "Ring-2.5-1T", - "name": "Ring-2.5-1T", - "enabled": false - }, { "id": "Ling-3.0-flash", "name": "Ling-3.0-flash", @@ -561,14 +586,14 @@ "id": "Ling-2.6-flash", "name": "Ling-2.6-flash", "enabled": false - }, - { - "id": "Ling-2.5-1T", - "name": "Ling-2.5-1T", - "enabled": false } ], "anthropic": [ + { + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "enabled": true + }, { "id": "claude-fable-5-1", "name": "Claude Fable 5.1", @@ -623,14 +648,24 @@ "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "enabled": true - }, - { - "id": "claude-opus-4-1-20250805", - "name": "Claude Opus 4.1", - "enabled": false } ], "azure": [ + { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "enabled": true + }, + { + "id": "gpt-6-sol", + "name": "GPT-6 Sol", + "enabled": true + }, + { + "id": "gpt-6-luna", + "name": "GPT-6 Luna", + "enabled": true + }, { "id": "gpt-5.4", "name": "GPT-5.4", @@ -914,6 +949,11 @@ } ], "bedrock": [ + { + "id": "global.anthropic.claude-opus-5-5", + "name": "Claude Opus 5.5", + "enabled": true + }, { "id": "global.anthropic.claude-opus-4-7", "name": "Claude Opus 4.7", @@ -1030,14 +1070,29 @@ "id": "gpt-oss-120b", "name": "GPT OSS 120B", "enabled": false - }, - { - "id": "zai-glm-4.7", - "name": "GLM-4.7", - "enabled": false } ], "chatgpt": [ + { + "id": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "enabled": true + }, + { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "enabled": true + }, + { + "id": "gpt-6-sol", + "name": "GPT-6 Sol", + "enabled": true + }, + { + "id": "gpt-6-luna", + "name": "GPT-6 Luna", + "enabled": true + }, { "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", @@ -1056,7 +1111,7 @@ { "id": "gpt-5.5", "name": "GPT-5.5", - "enabled": true + "enabled": false } ], "cloudflare": [ @@ -1883,10 +1938,15 @@ "name": "Gemini Flash-Lite Latest", "enabled": false }, + { + "id": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "enabled": true + }, { "id": "gemini-3.7-flash", "name": "Gemini 3.7 Flash", - "enabled": true + "enabled": false }, { "id": "gemini-3.6-flash", @@ -1972,26 +2032,6 @@ "id": "gemini-2.5-flash-lite", "name": "Gemini 2.5 Flash-Lite", "enabled": false - }, - { - "id": "gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "enabled": false - }, - { - "id": "gemini-2.0-flash-001", - "name": "Gemini 2.0 Flash 001", - "enabled": false - }, - { - "id": "gemini-2.0-flash-lite", - "name": "Gemini 2.0 Flash-Lite", - "enabled": false - }, - { - "id": "gemini-2.0-flash-lite-001", - "name": "Gemini 2.0 Flash-Lite 001", - "enabled": false } ], "groq": [ @@ -2005,10 +2045,15 @@ "name": "Compound Mini", "enabled": false }, + { + "id": "qwen/qwen3.8-27b", + "name": "Qwen3.8 27B", + "enabled": true + }, { "id": "qwen/qwen3.6-27b", "name": "Qwen3.6 27B", - "enabled": true + "enabled": false }, { "id": "minimaxai/minimax-m2.7", @@ -2025,16 +2070,6 @@ "name": "GPT OSS 20B", "enabled": false }, - { - "id": "llama-3.1-8b-instant", - "name": "Llama 3.1 8B Instant", - "enabled": false - }, - { - "id": "llama-3.3-70b-versatile", - "name": "Llama 3.3 70B Versatile", - "enabled": false - }, { "id": "meta-llama/llama-prompt-guard-2-22m", "name": "Llama Prompt Guard 2 22M", @@ -3575,13 +3610,13 @@ ], "mistral": [ { - "id": "mistral-medium-3.5", - "name": "Mistral Medium 3.5", - "enabled": true + "id": "zai-glm-5-2", + "name": "Z.ai GLM 5.2", + "enabled": false }, { - "id": "devstral-2512", - "name": "Devstral 2", + "id": "mistral-medium-3.5", + "name": "Mistral Medium 3.5", "enabled": true }, { @@ -3589,61 +3624,21 @@ "name": "Devstral Small 2", "enabled": false }, - { - "id": "mistral-medium-2508", - "name": "Mistral Medium 3.1", - "enabled": false - }, - { - "id": "magistral-medium-2509", - "name": "Magistral Medium 1.2", - "enabled": false - }, - { - "id": "magistral-small-2509", - "name": "Magistral Small 1.2", - "enabled": false - }, - { - "id": "open-mistral-nemo", - "name": "Mistral Nemo", - "enabled": false - }, { "id": "mistral-small-2603", "name": "Mistral Small 4", "enabled": false }, - { - "id": "mistral-small-2506", - "name": "Mistral Small 3.2", - "enabled": false - }, { "id": "mistral-large-2512", "name": "Mistral Large 3", "enabled": true }, - { - "id": "mistral-large-2411", - "name": "Mistral Large 2.1", - "enabled": false - }, { "id": "codestral-latest", "name": "Codestral 2508", "enabled": false }, - { - "id": "pixtral-large-latest", - "name": "Pixtral Large", - "enabled": false - }, - { - "id": "pixtral-12b-2409", - "name": "Pixtral 12B", - "enabled": false - }, { "id": "ministral-3b-latest", "name": "Ministral 3B", @@ -3653,16 +3648,6 @@ "id": "ministral-8b-latest", "name": "Ministral 8B", "enabled": false - }, - { - "id": "open-codestral-mamba", - "name": "Codestral Mamba", - "enabled": false - }, - { - "id": "labs-leanstral-2603", - "name": "Leanstral", - "enabled": false } ], "modelscope": [ @@ -3676,30 +3661,10 @@ "name": "Qwen3 Next 80B A3B Instruct", "enabled": false }, - { - "id": "deepseek-ai/DeepSeek-V3.2", - "name": "DeepSeek V3.2", - "enabled": true - }, - { - "id": "deepseek-ai/DeepSeek-V3.2-Exp", - "name": "DeepSeek V3.2 Exp", - "enabled": false - }, - { - "id": "deepseek-ai/DeepSeek-V3.1", - "name": "DeepSeek V3.1", - "enabled": false - }, { "id": "Qwen/Qwen3-235B-A22B", "name": "Qwen3 235B A22B", "enabled": false - }, - { - "id": "Qwen/Qwen3-32B", - "name": "Qwen3 32B", - "enabled": false } ], "moonshot": [ @@ -3722,41 +3687,6 @@ "id": "kimi-k2.6", "name": "Kimi K2.6", "enabled": false - }, - { - "id": "kimi-k2.5", - "name": "Kimi K2.5", - "enabled": false - }, - { - "id": "moonshot-v1-8k", - "name": "Moonshot V1 8K", - "enabled": false - }, - { - "id": "moonshot-v1-32k", - "name": "Moonshot V1 32K", - "enabled": false - }, - { - "id": "moonshot-v1-128k", - "name": "Moonshot V1 128K", - "enabled": false - }, - { - "id": "moonshot-v1-8k-vision-preview", - "name": "Moonshot V1 8K Vision Preview", - "enabled": false - }, - { - "id": "moonshot-v1-32k-vision-preview", - "name": "Moonshot V1 32K Vision Preview", - "enabled": false - }, - { - "id": "moonshot-v1-128k-vision-preview", - "name": "Moonshot V1 128K Vision Preview", - "enabled": false } ], "nebius": [ @@ -4424,16 +4354,6 @@ "name": "Qwen3.5 397B A17B", "enabled": false }, - { - "id": "minimax-m2.5", - "name": "MiniMax M2.5", - "enabled": false - }, - { - "id": "kimi-k2.5", - "name": "Kimi K2.5", - "enabled": false - }, { "id": "gpt-oss:20b", "name": "GPT-OSS 20B", @@ -4451,6 +4371,26 @@ } ], "openai": [ + { + "id": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "enabled": true + }, + { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "enabled": true + }, + { + "id": "gpt-6-sol", + "name": "GPT-6 Sol", + "enabled": true + }, + { + "id": "gpt-6-luna", + "name": "GPT-6 Luna", + "enabled": true + }, { "id": "chat-latest", "name": "Chat Latest", @@ -4474,7 +4414,7 @@ { "id": "gpt-5.5", "name": "GPT-5.5", - "enabled": true + "enabled": false }, { "id": "gpt-5.5-pro", @@ -4484,7 +4424,7 @@ { "id": "gpt-5.4", "name": "GPT-5.4", - "enabled": true + "enabled": false }, { "id": "gpt-5.4-pro", @@ -4494,7 +4434,7 @@ { "id": "gpt-5.4-mini", "name": "GPT-5.4 mini", - "enabled": true + "enabled": false }, { "id": "gpt-5.4-nano", @@ -4708,6 +4648,16 @@ "name": "MiniMax M3", "enabled": false }, + { + "id": "muse-spark-1.2-contributor", + "name": "Muse Spark 1.2 Contributor", + "enabled": false + }, + { + "id": "muse-spark-1.3-contributor", + "name": "Muse Spark 1.3 Contributor", + "enabled": true + }, { "id": "qwen3.6-plus", "name": "Qwen3.6 Plus", @@ -4912,6 +4862,41 @@ "name": "Auto (best for prompt)", "enabled": true }, + { + "id": "anthropic/claude-opus-5.5", + "name": "Claude Opus 5.5", + "enabled": true + }, + { + "id": "openai/gpt-6-sol", + "name": "GPT-6 Sol", + "enabled": true + }, + { + "id": "openai/gpt-6-luna", + "name": "GPT-6 Luna", + "enabled": true + }, + { + "id": "x-ai/grok-4.7", + "name": "Grok 4.7", + "enabled": true + }, + { + "id": "xiaomi/mimo-v2.6-pro", + "name": "MiMo-V2.6 Pro", + "enabled": true + }, + { + "id": "xiaomi/mimo-v2.6-pro-ultraspeed", + "name": "MiMo-V2.6 Pro UltraSpeed", + "enabled": true + }, + { + "id": "xiaomi/mimo-v2.6-flash", + "name": "MiMo-V2.6 Flash", + "enabled": true + }, { "id": "deepseek/deepseek-chat-v3.1", "name": "DeepSeek V3.1", @@ -5260,6 +5245,26 @@ } ], "qwen": [ + { + "id": "deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "enabled": false + }, + { + "id": "deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731", + "enabled": false + }, + { + "id": "qwen3.8-max-0902", + "name": "Qwen3.8 Max 0902", + "enabled": true + }, + { + "id": "qwen3.8-max", + "name": "Qwen3.8 Max", + "enabled": true + }, { "id": "kimi-k2.6", "name": "Kimi K2.6", @@ -5490,10 +5495,15 @@ "name": "QwQ Plus", "enabled": false }, + { + "id": "qwen3.8-flash", + "name": "Qwen3.8 Flash", + "enabled": true + }, { "id": "qwen3.7-flash", "name": "Qwen3.7 Flash", - "enabled": true + "enabled": false }, { "id": "qwen3.6-flash", @@ -5802,75 +5812,40 @@ } ], "sambanova": [ + { + "id": "MiniMax-M2.7", + "name": "MiniMax M2.7", + "enabled": false + }, + { + "id": "DeepSeek-V3.1", + "name": "DeepSeek V3.1", + "enabled": false + }, { "id": "Meta-Llama-3.3-70B-Instruct", "name": "Meta Llama 3.3 70B Instruct", - "enabled": true - }, - { - "id": "Meta-Llama-3.2-1B-Instruct", - "name": "Meta Llama 3.2 1B Instruct", "enabled": false }, { - "id": "Meta-Llama-3.2-3B-Instruct", - "name": "Meta Llama 3.2 3B Instruct", - "enabled": false - }, - { - "id": "Llama-3.2-11B-Vision-Instruct", - "name": "Meta Llama 3.2 11B Vision Instruct", + "id": "gpt-oss-120b", + "name": "GPT OSS 120B", "enabled": true }, { - "id": "Llama-3.2-90B-Vision-Instruct", - "name": "Meta Llama 3.2 90B Vision Instruct", + "id": "MiniMax-M3", + "name": "MiniMax M3", "enabled": true }, { - "id": "Meta-Llama-3.1-8B-Instruct", - "name": "Meta Llama 3.1 8B Instruct", + "id": "DeepSeek-V3.2", + "name": "DeepSeek V3.2", "enabled": false }, { - "id": "Meta-Llama-3.1-70B-Instruct", - "name": "Meta Llama 3.1 70B Instruct", + "id": "gemma-4-31B-it", + "name": "Gemma 4 31B IT", "enabled": false - }, - { - "id": "Meta-Llama-3.1-405B-Instruct", - "name": "Meta Llama 3.1 405B Instruct", - "enabled": false - }, - { - "id": "Llama-3.1-Tulu-3-405B", - "name": "Llama 3.1 Tulu 3 405B", - "enabled": false - }, - { - "id": "DeepSeek-R1", - "name": "DeepSeek R1", - "enabled": false - }, - { - "id": "DeepSeek-R1-Distill-Llama-70B", - "name": "DeepSeek R1 Distill Llama 70B", - "enabled": true - }, - { - "id": "QwQ-32B-Preview", - "name": "QwQ 32B Preview", - "enabled": true - }, - { - "id": "Qwen2.5-72B-Instruct", - "name": "Qwen2.5 72B Instruct", - "enabled": true - }, - { - "id": "Qwen2.5-Coder-32B-Instruct", - "name": "Qwen2.5 Coder 32B Instruct", - "enabled": true } ], "search1api": [ @@ -6262,6 +6237,11 @@ } ], "stepfun": [ + { + "id": "step-5-preview", + "name": "Step 5 Preview", + "enabled": true + }, { "id": "step-3.7-flash", "name": "Step 3.7 Flash", @@ -6407,6 +6387,16 @@ } ], "supergrok": [ + { + "id": "grok-4.7", + "name": "Grok 4.7", + "enabled": true + }, + { + "id": "grok-4.6", + "name": "Grok 4.6", + "enabled": true + }, { "id": "grok-4.5", "name": "Grok 4.5", @@ -7166,10 +7156,15 @@ } ], "vertexai": [ + { + "id": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "enabled": true + }, { "id": "gemini-3.7-flash", "name": "Gemini 3.7 Flash", - "enabled": true + "enabled": false }, { "id": "gemini-3.6-flash", @@ -7345,20 +7340,30 @@ "name": "Doubao Seed 2.1 Turbo", "enabled": true }, + { + "id": "deepseek-v4-pro-ga", + "name": "DeepSeek V4 Pro 0813", + "enabled": false + }, + { + "id": "deepseek-v4-flash-ga", + "name": "DeepSeek V4 Flash 0731", + "enabled": false + }, { "id": "glm-5-2", "name": "GLM-5.2", - "enabled": true + "enabled": false }, { "id": "deepseek-v4-pro", "name": "DeepSeek V4 Pro", - "enabled": true + "enabled": false }, { "id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash", - "enabled": true + "enabled": false }, { "id": "doubao-seed-2.0-pro", @@ -7701,6 +7706,16 @@ } ], "xai": [ + { + "id": "grok-4.7", + "name": "Grok 4.7", + "enabled": true + }, + { + "id": "grok-4.6", + "name": "Grok 4.6", + "enabled": true + }, { "id": "grok-4.5", "name": "Grok 4.5", @@ -7728,6 +7743,21 @@ } ], "xiaomimimo": [ + { + "id": "mimo-v2.6-pro", + "name": "MiMo-V2.6 Pro", + "enabled": true + }, + { + "id": "mimo-v2.6-pro-ultraspeed", + "name": "MiMo-V2.6 Pro UltraSpeed", + "enabled": true + }, + { + "id": "mimo-v2.6-flash", + "name": "MiMo-V2.6 Flash", + "enabled": true + }, { "id": "mimo-v2.5-pro", "name": "MiMo-V2.5 Pro", diff --git a/packages/ui/src/components/base/KeybindingChips.vue b/packages/ui/src/components/base/KeybindingChips.vue new file mode 100644 index 0000000..5fc9e56 --- /dev/null +++ b/packages/ui/src/components/base/KeybindingChips.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index 250489c..3901909 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -118,3 +118,4 @@ export { default as TimeFramePicker } from './TimeFramePicker.vue' export { default as Timeline } from './Timeline.vue' export { default as Toggle } from './Toggle.vue' export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue' +export { default as KeybindingChips } from './KeybindingChips.vue' diff --git a/packages/ui/src/components/modal/KeybindingChoiceTree.vue b/packages/ui/src/components/modal/KeybindingChoiceTree.vue new file mode 100644 index 0000000..9e75ab9 --- /dev/null +++ b/packages/ui/src/components/modal/KeybindingChoiceTree.vue @@ -0,0 +1,256 @@ + + + diff --git a/packages/ui/src/components/modal/KeybindingModal.vue b/packages/ui/src/components/modal/KeybindingModal.vue new file mode 100644 index 0000000..48e4a29 --- /dev/null +++ b/packages/ui/src/components/modal/KeybindingModal.vue @@ -0,0 +1,574 @@ + + + + + diff --git a/packages/ui/src/components/modal/index.ts b/packages/ui/src/components/modal/index.ts index dc36321..3ddce21 100644 --- a/packages/ui/src/components/modal/index.ts +++ b/packages/ui/src/components/modal/index.ts @@ -8,3 +8,4 @@ export { default as ShareModal } from './ShareModal.vue' export type { Tab as TabbedModalTab } from './TabbedModal.vue' export { default as TabbedModal } from './TabbedModal.vue' export { default as UploadProgressModal } from './UploadProgressModal.vue' +export { default as KeybindingModal } from './KeybindingModal.vue' diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index f66970b..a8a72e7 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -30,3 +30,4 @@ export * from './use-loading-state-core' export * from './use-ready-state' export * from './use-server-image' export * from './virtual-scroll' +export * from './use-keybinding-labels' diff --git a/packages/ui/src/composables/use-keybinding-labels.ts b/packages/ui/src/composables/use-keybinding-labels.ts new file mode 100644 index 0000000..c2cbf6a --- /dev/null +++ b/packages/ui/src/composables/use-keybinding-labels.ts @@ -0,0 +1,54 @@ +import { computed } from 'vue' + +import { + bindingParts, + bindingText, + type KeyBinding, + type KeybindingLabels, +} from '../utils/keybinding' +import { MOUSE_CODE_MESSAGES } from '../utils/keybinding-catalog' +import { defineMessages, useVIntl } from './i18n' + +const messages = defineMessages({ + mod: { id: 'ui.keybinding-modal.mod', defaultMessage: 'Ctrl/Cmd' }, + shift: { id: 'ui.keybinding-modal.shift', defaultMessage: 'Shift' }, + alt: { id: 'ui.keybinding-modal.alt', defaultMessage: 'Alt' }, +}) + +/** + * The names a combination is built from, in the reader's language. Shared so a + * shortcut reads the same wherever it is shown. + */ +export function useKeybindingLabels() { + const { formatMessage } = useVIntl() + + return computed(() => ({ + mod: formatMessage(messages.mod), + shift: formatMessage(messages.shift), + alt: formatMessage(messages.alt), + mouseButton: Object.fromEntries( + Object.entries(MOUSE_CODE_MESSAGES).map(([code, message]) => [code, formatMessage(message)]), + ), + wheelUp: formatMessage(MOUSE_CODE_MESSAGES.WheelUp), + wheelDown: formatMessage(MOUSE_CODE_MESSAGES.WheelDown), + })) +} + +/** Splits a combination into the pieces a key cap is drawn for. */ +export function useBindingParts(binding: () => KeyBinding | null) { + const labels = useKeybindingLabels() + + return computed(() => { + const value = binding() + return value ? bindingParts(value, labels.value) : [] + }) +} + +export function useBindingText(binding: () => KeyBinding | null) { + const labels = useKeybindingLabels() + + return computed(() => { + const value = binding() + return value ? bindingText(value, labels.value) : '' + }) +} diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 6634624..783cded 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -4861,5 +4861,49 @@ }, "skin.preview.armor.unequipped": { "defaultMessage": "Unequipped" - } + }, + "ui.keybinding-modal.alt": {"defaultMessage": "Alt"}, + "ui.keybinding-modal.cancel": {"defaultMessage": "Cancel"}, + "ui.keybinding-modal.capture-hint": {"defaultMessage": "Then press the keys you want this shortcut to use."}, + "ui.keybinding-modal.capture-title": {"defaultMessage": "Click here to record"}, + "ui.keybinding-modal.current": {"defaultMessage": "Current shortcut"}, + "ui.keybinding-modal.device-keyboard": {"defaultMessage": "Keyboard"}, + "ui.keybinding-modal.device-mouse": {"defaultMessage": "Mouse"}, + "ui.keybinding-modal.group-digits": {"defaultMessage": "Digits"}, + "ui.keybinding-modal.group-function": {"defaultMessage": "Function keys"}, + "ui.keybinding-modal.group-letters": {"defaultMessage": "Letters"}, + "ui.keybinding-modal.group-mouse-buttons": {"defaultMessage": "Buttons"}, + "ui.keybinding-modal.group-navigation": {"defaultMessage": "Navigation keys"}, + "ui.keybinding-modal.group-punctuation": {"defaultMessage": "Punctuation"}, + "ui.keybinding-modal.group-wheel": {"defaultMessage": "Wheel"}, + "ui.keybinding-modal.issue-conflict": {"defaultMessage": "Already used by {label}."}, + "ui.keybinding-modal.issue-needs-modifier": {"defaultMessage": "Hold Ctrl/Cmd, Alt or Shift as well, or pick a function or navigation key."}, + "ui.keybinding-modal.issue-reserved": {"defaultMessage": "{label} is reserved by the launcher."}, + "ui.keybinding-modal.listening-hint": {"defaultMessage": "Press the keys you want to use."}, + "ui.keybinding-modal.listening-title": {"defaultMessage": "Listening…"}, + "ui.keybinding-modal.mismatch": {"defaultMessage": "That was a different combination, so recording started again."}, + "ui.keybinding-modal.mod": {"defaultMessage": "Ctrl/Cmd"}, + "ui.keybinding-modal.modifiers": {"defaultMessage": "Modifiers"}, + "ui.keybinding-modal.mouse-back": {"defaultMessage": "Back button"}, + "ui.keybinding-modal.mouse-forward": {"defaultMessage": "Forward button"}, + "ui.keybinding-modal.mouse-left": {"defaultMessage": "Left click"}, + "ui.keybinding-modal.mouse-middle": {"defaultMessage": "Middle click"}, + "ui.keybinding-modal.mouse-right": {"defaultMessage": "Right click"}, + "ui.keybinding-modal.nothing-captured": {"defaultMessage": "No key was recorded."}, + "ui.keybinding-modal.restore-default": {"defaultMessage": "Restore default"}, + "ui.keybinding-modal.save": {"defaultMessage": "Save"}, + "ui.keybinding-modal.shift": {"defaultMessage": "Shift"}, + "ui.keybinding-modal.special-hint": {"defaultMessage": "Mouse buttons, the wheel and combinations with them can only be picked here."}, + "ui.keybinding-modal.title": {"defaultMessage": "Set a shortcut"}, + "ui.keybinding-modal.title-for": {"defaultMessage": "Shortcut for {action}"}, + "ui.keybinding-modal.tree-label": {"defaultMessage": "Inputs a shortcut can use"}, + "ui.keybinding-modal.unset": {"defaultMessage": "Not set"}, + "ui.keybinding-modal.use-capture": {"defaultMessage": "Record a combination instead"}, + "ui.keybinding-modal.use-special": {"defaultMessage": "Pick an input another way"}, + "ui.keybinding-modal.verified-hint": {"defaultMessage": "Confirmed. Save to use this shortcut."}, + "ui.keybinding-modal.verified-title": {"defaultMessage": "Confirmed"}, + "ui.keybinding-modal.verifying-hint": {"defaultMessage": "This checks the combination was recorded correctly."}, + "ui.keybinding-modal.verifying-title": {"defaultMessage": "Press it again to confirm"}, + "ui.keybinding-modal.wheel-down": {"defaultMessage": "Wheel down"}, + "ui.keybinding-modal.wheel-up": {"defaultMessage": "Wheel up"} } diff --git a/packages/ui/src/locales/zh-CN/index.json b/packages/ui/src/locales/zh-CN/index.json index 80ae032..f2f4496 100644 --- a/packages/ui/src/locales/zh-CN/index.json +++ b/packages/ui/src/locales/zh-CN/index.json @@ -4882,5 +4882,49 @@ }, "skin.preview.armor.unequipped": { "message": "未装备" - } + }, + "ui.keybinding-modal.alt": {"defaultMessage": "Alt"}, + "ui.keybinding-modal.cancel": {"defaultMessage": "取消"}, + "ui.keybinding-modal.capture-hint": {"defaultMessage": "然后按下你想让这个快捷键使用的按键。"}, + "ui.keybinding-modal.capture-title": {"defaultMessage": "点击此处开始录制"}, + "ui.keybinding-modal.current": {"defaultMessage": "当前快捷键"}, + "ui.keybinding-modal.device-keyboard": {"defaultMessage": "键盘"}, + "ui.keybinding-modal.device-mouse": {"defaultMessage": "鼠标"}, + "ui.keybinding-modal.group-digits": {"defaultMessage": "数字键"}, + "ui.keybinding-modal.group-function": {"defaultMessage": "功能键"}, + "ui.keybinding-modal.group-letters": {"defaultMessage": "字母键"}, + "ui.keybinding-modal.group-mouse-buttons": {"defaultMessage": "鼠标键"}, + "ui.keybinding-modal.group-navigation": {"defaultMessage": "导航键"}, + "ui.keybinding-modal.group-punctuation": {"defaultMessage": "符号键"}, + "ui.keybinding-modal.group-wheel": {"defaultMessage": "滚轮"}, + "ui.keybinding-modal.issue-conflict": {"defaultMessage": "已被「{label}」占用。"}, + "ui.keybinding-modal.issue-needs-modifier": {"defaultMessage": "请同时按住 Ctrl/Cmd、Alt 或 Shift,或改选功能键、导航键。"}, + "ui.keybinding-modal.issue-reserved": {"defaultMessage": "「{label}」由启动器保留。"}, + "ui.keybinding-modal.listening-hint": {"defaultMessage": "请按下你想使用的按键。"}, + "ui.keybinding-modal.listening-title": {"defaultMessage": "正在监听…"}, + "ui.keybinding-modal.mismatch": {"defaultMessage": "组合不一致,已重新开始录制。"}, + "ui.keybinding-modal.mod": {"defaultMessage": "Ctrl/Cmd"}, + "ui.keybinding-modal.modifiers": {"defaultMessage": "修饰键"}, + "ui.keybinding-modal.mouse-back": {"defaultMessage": "后退键"}, + "ui.keybinding-modal.mouse-forward": {"defaultMessage": "前进键"}, + "ui.keybinding-modal.mouse-left": {"defaultMessage": "鼠标左键"}, + "ui.keybinding-modal.mouse-middle": {"defaultMessage": "鼠标中键"}, + "ui.keybinding-modal.mouse-right": {"defaultMessage": "鼠标右键"}, + "ui.keybinding-modal.nothing-captured": {"defaultMessage": "没有录制到按键。"}, + "ui.keybinding-modal.restore-default": {"defaultMessage": "恢复默认"}, + "ui.keybinding-modal.save": {"defaultMessage": "保存"}, + "ui.keybinding-modal.shift": {"defaultMessage": "Shift"}, + "ui.keybinding-modal.special-hint": {"defaultMessage": "鼠标键、滚轮以及与它们组合的按键只能在这里选择。"}, + "ui.keybinding-modal.title": {"defaultMessage": "设置快捷键"}, + "ui.keybinding-modal.title-for": {"defaultMessage": "设置「{action}」的快捷键"}, + "ui.keybinding-modal.tree-label": {"defaultMessage": "快捷键可用的输入"}, + "ui.keybinding-modal.unset": {"defaultMessage": "未设置"}, + "ui.keybinding-modal.use-capture": {"defaultMessage": "改用按键录制"}, + "ui.keybinding-modal.use-special": {"defaultMessage": "改用特殊方式选择"}, + "ui.keybinding-modal.verified-hint": {"defaultMessage": "已确认,保存后生效。"}, + "ui.keybinding-modal.verified-title": {"defaultMessage": "已确认"}, + "ui.keybinding-modal.verifying-hint": {"defaultMessage": "用于确认组合是否被正确录制。"}, + "ui.keybinding-modal.verifying-title": {"defaultMessage": "请再按一次以确认"}, + "ui.keybinding-modal.wheel-down": {"defaultMessage": "滚轮向下"}, + "ui.keybinding-modal.wheel-up": {"defaultMessage": "滚轮向上"} } diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index e7c1598..6646bda 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -16,3 +16,5 @@ export * from './truncate' export * from './version-compatibility' export * from './vue-children' export * from './webgl/skin-rendering' +export * from './keybinding' +export * from './keybinding-catalog' diff --git a/packages/ui/src/utils/keybinding-catalog.ts b/packages/ui/src/utils/keybinding-catalog.ts new file mode 100644 index 0000000..902c9e5 --- /dev/null +++ b/packages/ui/src/utils/keybinding-catalog.ts @@ -0,0 +1,149 @@ +import { defineMessages } from '../composables/i18n' +import type { KeyBindingDevice } from './keybinding' + +/** Structurally identical to the i18n `MessageDescriptor`, without the dependency. */ +export interface CatalogMessage { + id: string + defaultMessage: string +} + +export interface KeybindingCatalogItem { + device: KeyBindingDevice + code: string + /** Set for inputs whose name has to be translated; key caps read the same everywhere. */ + label?: CatalogMessage +} + +export interface KeybindingCatalogGroup { + id: string + label: CatalogMessage + items: KeybindingCatalogItem[] +} + +export interface KeybindingCatalogDevice { + id: KeyBindingDevice + label: CatalogMessage + groups: KeybindingCatalogGroup[] +} + +const messages = defineMessages({ + mouseLeft: { id: 'ui.keybinding-modal.mouse-left', defaultMessage: 'Left click' }, + mouseMiddle: { id: 'ui.keybinding-modal.mouse-middle', defaultMessage: 'Middle click' }, + mouseRight: { id: 'ui.keybinding-modal.mouse-right', defaultMessage: 'Right click' }, + mouseBack: { id: 'ui.keybinding-modal.mouse-back', defaultMessage: 'Back button' }, + mouseForward: { id: 'ui.keybinding-modal.mouse-forward', defaultMessage: 'Forward button' }, + wheelUp: { id: 'ui.keybinding-modal.wheel-up', defaultMessage: 'Wheel up' }, + wheelDown: { id: 'ui.keybinding-modal.wheel-down', defaultMessage: 'Wheel down' }, + deviceKeyboard: { id: 'ui.keybinding-modal.device-keyboard', defaultMessage: 'Keyboard' }, + deviceMouse: { id: 'ui.keybinding-modal.device-mouse', defaultMessage: 'Mouse' }, + groupLetters: { id: 'ui.keybinding-modal.group-letters', defaultMessage: 'Letters' }, + groupDigits: { id: 'ui.keybinding-modal.group-digits', defaultMessage: 'Digits' }, + groupFunction: { id: 'ui.keybinding-modal.group-function', defaultMessage: 'Function keys' }, + groupNavigation: { + id: 'ui.keybinding-modal.group-navigation', + defaultMessage: 'Navigation keys', + }, + groupPunctuation: { + id: 'ui.keybinding-modal.group-punctuation', + defaultMessage: 'Punctuation', + }, + groupMouseButtons: { + id: 'ui.keybinding-modal.group-mouse-buttons', + defaultMessage: 'Buttons', + }, + groupWheel: { id: 'ui.keybinding-modal.group-wheel', defaultMessage: 'Wheel' }, +}) + +/** + * Names for the pointer inputs. Shared by the picker tree and by the chips that + * render a saved combination, so both read the same way. + */ +export const MOUSE_CODE_MESSAGES: Record = { + Mouse0: messages.mouseLeft, + Mouse1: messages.mouseMiddle, + Mouse2: messages.mouseRight, + Mouse3: messages.mouseBack, + Mouse4: messages.mouseForward, + WheelUp: messages.wheelUp, + WheelDown: messages.wheelDown, +} + +const LETTER_CODES: KeybindingCatalogItem[] = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + .split('') + .map((letter) => ({ device: 'keyboard', code: `Key${letter}` })) + +const DIGIT_CODES: KeybindingCatalogItem[] = '0123456789' + .split('') + .map((digit) => ({ device: 'keyboard', code: `Digit${digit}` })) + +const FUNCTION_CODES: KeybindingCatalogItem[] = Array.from({ length: 12 }, (_, index) => ({ + device: 'keyboard', + code: `F${index + 1}`, +})) + +const NAVIGATION_CODES: KeybindingCatalogItem[] = [ + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Insert', + 'Delete', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', +].map((code) => ({ device: 'keyboard', code })) + +const PUNCTUATION_CODES: KeybindingCatalogItem[] = [ + 'Comma', + 'Period', + 'Slash', + 'Semicolon', + 'Quote', + 'BracketLeft', + 'BracketRight', + 'Backslash', + 'Minus', + 'Equal', + 'Backquote', + 'Space', +].map((code) => ({ device: 'keyboard', code })) + +const MOUSE_ITEMS: KeybindingCatalogItem[] = Object.entries(MOUSE_CODE_MESSAGES).map( + ([code, label]) => ({ device: 'mouse', code, label }), +) + +/** + * Everything a shortcut can be attached to, grouped for the picker. Inputs that + * cannot be reproduced by pressing them - mouse buttons, the wheel, and any + * combination of those with the keyboard - are only reachable from here. + */ +export const KEYBINDING_CATALOG: KeybindingCatalogDevice[] = [ + { + id: 'keyboard', + label: messages.deviceKeyboard, + groups: [ + { id: 'letters', label: messages.groupLetters, items: LETTER_CODES }, + { id: 'digits', label: messages.groupDigits, items: DIGIT_CODES }, + { id: 'function', label: messages.groupFunction, items: FUNCTION_CODES }, + { id: 'navigation', label: messages.groupNavigation, items: NAVIGATION_CODES }, + { id: 'punctuation', label: messages.groupPunctuation, items: PUNCTUATION_CODES }, + ], + }, + { + id: 'mouse', + label: messages.deviceMouse, + groups: [ + { + id: 'buttons', + label: messages.groupMouseButtons, + items: MOUSE_ITEMS.filter((item) => item.code.startsWith('Mouse')), + }, + { + id: 'wheel', + label: messages.groupWheel, + items: MOUSE_ITEMS.filter((item) => item.code.startsWith('Wheel')), + }, + ], + }, +] diff --git a/packages/ui/src/utils/keybinding.ts b/packages/ui/src/utils/keybinding.ts new file mode 100644 index 0000000..ee43ed3 --- /dev/null +++ b/packages/ui/src/utils/keybinding.ts @@ -0,0 +1,229 @@ +/** + * The device a binding was recorded from. Keyboard bindings are matched against + * `KeyboardEvent`, mouse bindings against `MouseEvent` and `WheelEvent`. + */ +export type KeyBindingDevice = 'keyboard' | 'mouse' + +/** + * A single shortcut. `mod` is the primary modifier held down: the launcher has + * always treated Ctrl and Cmd as one and the same, so a binding recorded on one + * platform keeps working on the other. + */ +export interface KeyBinding { + device: KeyBindingDevice + /** A `KeyboardEvent.code`, or one of the mouse codes below. */ + code: string + mod: boolean + shift: boolean + alt: boolean +} + +/** `MouseEvent.button`, in the order browsers report it. */ +export const MOUSE_BUTTON_CODES = ['Mouse0', 'Mouse1', 'Mouse2', 'Mouse3', 'Mouse4'] as const +export const WHEEL_CODES = ['WheelUp', 'WheelDown'] as const + +const MODIFIER_CODES = new Set([ + 'ControlLeft', + 'ControlRight', + 'ShiftLeft', + 'ShiftRight', + 'AltLeft', + 'AltRight', + 'MetaLeft', + 'MetaRight', + 'CapsLock', +]) + +/** + * Codes that may be bound on their own. Anything else has to carry a modifier, + * so a shortcut cannot swallow ordinary typing or list navigation. + */ +const MODIFIER_FREE_CODES = new Set([ + 'Home', + 'End', + 'PageUp', + 'PageDown', + ...Array.from({ length: 24 }, (_, index) => `F${index + 1}`), + ...MOUSE_BUTTON_CODES.filter((code) => code !== 'Mouse0'), + ...WHEEL_CODES, +]) + +/** + * Codes that always need a modifier, whatever else is allowed. A bare left + * click is how the whole interface is operated, so it can never be a shortcut. + */ +const MODIFIER_REQUIRED_CODES = new Set(['Mouse0']) + +/** A combination someone else already answers to. */ +export interface BindingConflict { + id: string + label: string + binding: KeyBinding + /** The shell or the operating system owns it rather than another action. */ + reserved?: boolean +} + +export type BindingIssue = + { kind: 'conflict'; label: string; reserved: boolean } | { kind: 'needs-modifier' } + +export interface KeybindingLabels { + mod: string + shift: string + alt: string + /** Keyed by mouse code, e.g. `Mouse0`. */ + mouseButton: Record + wheelUp: string + wheelDown: string +} + +export function isModifierCode(code: string): boolean { + return MODIFIER_CODES.has(code) +} + +export function isMouseCode(code: string): boolean { + return code.startsWith('Mouse') || (WHEEL_CODES as readonly string[]).includes(code) +} + +/** + * Canonical form used to compare bindings, so two ways of recording the same + * combination still collide. + */ +export function bindingKey(binding: KeyBinding): string { + const modifiers = [binding.mod && 'mod', binding.alt && 'alt', binding.shift && 'shift'] + .filter(Boolean) + .join('+') + return `${binding.device}|${binding.code}|${modifiers}` +} + +export function bindingsEqual(a: KeyBinding, b: KeyBinding): boolean { + return bindingKey(a) === bindingKey(b) +} + +function eventModifiers(event: MouseEvent | KeyboardEvent | WheelEvent) { + return { + mod: event.ctrlKey || event.metaKey, + shift: event.shiftKey, + alt: event.altKey, + } +} + +/** + * The combination an event represents, or null while only modifiers are held - + * a lone Ctrl is not a shortcut. + */ +export function bindingFromKeyboardEvent(event: KeyboardEvent): KeyBinding | null { + if (event.repeat || isModifierCode(event.code)) return null + return { device: 'keyboard', code: event.code, ...eventModifiers(event) } +} + +export function bindingFromMouseEvent(event: MouseEvent): KeyBinding | null { + const code = `Mouse${event.button}` + if (!(MOUSE_BUTTON_CODES as readonly string[]).includes(code)) return null + return { device: 'mouse', code, ...eventModifiers(event) } +} + +export function bindingFromWheelEvent(event: WheelEvent): KeyBinding | null { + if (event.deltaY === 0) return null + const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown' + return { device: 'mouse', code, ...eventModifiers(event) } +} + +function modifiersMatch(binding: KeyBinding, event: MouseEvent | KeyboardEvent | WheelEvent) { + const { mod, shift, alt } = eventModifiers(event) + return binding.mod === mod && binding.shift === shift && binding.alt === alt +} + +export function bindingMatchesKeyboardEvent(binding: KeyBinding, event: KeyboardEvent): boolean { + return ( + binding.device === 'keyboard' && binding.code === event.code && modifiersMatch(binding, event) + ) +} + +export function bindingMatchesMouseEvent(binding: KeyBinding, event: MouseEvent): boolean { + return ( + binding.device === 'mouse' && + binding.code === `Mouse${event.button}` && + modifiersMatch(binding, event) + ) +} + +export function bindingMatchesWheelEvent(binding: KeyBinding, event: WheelEvent): boolean { + if (binding.device !== 'mouse' || event.deltaY === 0) return false + const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown' + return binding.code === code && modifiersMatch(binding, event) +} + +/** + * Whether a combination may be saved, ignoring who it belongs to. Returns the + * problem to show instead of the binding when it may not. + */ +export function validateBinding( + binding: KeyBinding, + conflicts: BindingConflict[], +): BindingIssue | null { + const unmodified = !binding.mod && !binding.shift && !binding.alt + if (unmodified && MODIFIER_REQUIRED_CODES.has(binding.code)) { + return { kind: 'needs-modifier' } + } + if (unmodified && !MODIFIER_FREE_CODES.has(binding.code)) { + return { kind: 'needs-modifier' } + } + + const conflict = conflicts.find((entry) => bindingsEqual(entry.binding, binding)) + if (conflict) { + return { kind: 'conflict', label: conflict.label, reserved: conflict.reserved ?? false } + } + + return null +} + +const CODE_LABELS: Record = { + ArrowUp: '\u2191', + ArrowDown: '\u2193', + ArrowLeft: '\u2190', + ArrowRight: '\u2192', + PageUp: 'Page Up', + PageDown: 'Page Down', + Escape: 'Esc', + Space: 'Space', + Insert: 'Insert', + Delete: 'Delete', + Comma: ',', + Period: '.', + Slash: '/', + Semicolon: ';', + Quote: "'", + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Minus: '-', + Equal: '=', + Backquote: '`', +} + +/** How a single code reads on a key cap. */ +export function keyCodeLabel(code: string): string { + if (code.startsWith('Key')) return code.slice(3) + if (code.startsWith('Digit')) return code.slice(5) + if (code.startsWith('Numpad')) return `Num ${code.slice(6)}` + return CODE_LABELS[code] ?? code +} + +/** The combination split into the pieces a key cap is drawn for. */ +export function bindingParts(binding: KeyBinding, labels: KeybindingLabels): string[] { + const parts: string[] = [] + if (binding.mod) parts.push(labels.mod) + if (binding.alt) parts.push(labels.alt) + if (binding.shift) parts.push(labels.shift) + + if (binding.code === 'WheelUp') parts.push(labels.wheelUp) + else if (binding.code === 'WheelDown') parts.push(labels.wheelDown) + else if (binding.device === 'mouse') parts.push(labels.mouseButton[binding.code] ?? binding.code) + else parts.push(keyCodeLabel(binding.code)) + + return parts +} + +export function bindingText(binding: KeyBinding, labels: KeybindingLabels): string { + return bindingParts(binding, labels).join(' + ') +}