From e7da36288e8968058294900d477fe44aadd59ed4 Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 20:28:24 +0800 Subject: [PATCH 1/5] feat: add configurable commands, shortcuts and mouse gestures --- src/app/useAppNavigation.test.ts | 88 +++++++++ src/app/useAppNavigation.ts | 76 +++++++- src/input/controller.test.ts | 216 ++++++++++++++++++++++ src/input/controller.ts | 299 +++++++++++++++++++++++++++++++ src/input/defaults.ts | 50 ++++++ src/input/gestures.ts | 54 ++++++ src/input/menus.test.ts | 51 ++++++ src/input/menus.ts | 158 ++++++++++++++++ src/input/mouse.test.ts | 110 ++++++++++++ src/input/mouse.ts | 149 +++++++++++++++ src/input/shortcuts.ts | 167 +++++++++++++++++ 11 files changed, 1411 insertions(+), 7 deletions(-) create mode 100644 src/app/useAppNavigation.test.ts create mode 100644 src/input/controller.test.ts create mode 100644 src/input/controller.ts create mode 100644 src/input/defaults.ts create mode 100644 src/input/gestures.ts create mode 100644 src/input/menus.test.ts create mode 100644 src/input/menus.ts create mode 100644 src/input/mouse.test.ts create mode 100644 src/input/mouse.ts create mode 100644 src/input/shortcuts.ts diff --git a/src/app/useAppNavigation.test.ts b/src/app/useAppNavigation.test.ts new file mode 100644 index 00000000..02d83d47 --- /dev/null +++ b/src/app/useAppNavigation.test.ts @@ -0,0 +1,88 @@ +import { createPinia } from 'pinia' +import { effectScope } from 'vue' +import { expect, it } from 'vitest' +import { useAppNavigation } from './useAppNavigation' + +it('travels between ordinary pages and preserves filters and plugin routes', async () => { + const navigation = useAppNavigation(createPinia()) + navigation.navigate('cards') + navigation.selectedDeck.value = 'deck-one' + navigation.searchQuery.value = 'tag:reading' + navigation.openPluginPage('stats', 'summary') + expect(navigation.canGoBack.value).toBe(true) + navigation.goBack() + expect(navigation.tab.value).toBe('cards') + expect(navigation.selectedDeck.value).toBe('deck-one') + expect(navigation.searchQuery.value).toBe('tag:reading') + navigation.goForward() + expect(navigation.pluginPage.value).toEqual({ pluginId: 'stats', pageId: 'summary' }) + navigation.goBack() + navigation.navigate('settings') + expect(navigation.canGoForward.value).toBe(false) +}) +it('does not restore active review sessions and resets history when switching libraries', () => { + const navigation = useAppNavigation(createPinia()) + navigation.navigate('cards') + navigation.review.value = { deckId: 'one' } + expect(navigation.canGoBack.value).toBe(false) + expect(navigation.goBack()).toBe(false) + expect(navigation.review.value).toEqual({ deckId: 'one' }) + navigation.navigate('sources') + navigation.goBack() + expect(navigation.tab.value).toBe('overview') + expect(navigation.review.value).toBeNull() + navigation.resetLibrary() + expect(navigation.canGoBack.value).toBe(false) + expect(navigation.canGoForward.value).toBe(false) +}) +it('waits for the reading leave guard and rejects stale or rejected navigation', async () => { + const approvals: ((value: boolean) => void)[] = [] + const navigation = useAppNavigation( + createPinia(), + () => new Promise((resolve) => approvals.push(resolve)), + ) + const cards = navigation.navigate('cards') + approvals.shift()!(true) + await cards + const back = navigation.goBack() + expect(navigation.tab.value).toBe('cards') + approvals.shift()!(false) + await back + expect(navigation.tab.value).toBe('cards') + expect(navigation.canGoBack.value).toBe(true) + const old = navigation.goBack(), + newer = navigation.navigate('settings') + approvals.shift()!(true) + await old + expect(navigation.tab.value).toBe('cards') + approvals.shift()!(true) + await newer + expect(navigation.tab.value).toBe('settings') + const previous = navigation.goBack() + navigation.resetLibrary() + approvals.shift()!(true) + await previous + expect(navigation.tab.value).toBe('settings') + expect(navigation.canGoBack.value).toBe(false) +}) +it('keeps instances isolated and ignores a leave approval after disposal', async () => { + let finish!: (allowed: boolean) => void + const scope = effectScope() + const first = scope.run(() => + useAppNavigation( + createPinia(), + () => + new Promise((resolve) => { + finish = resolve + }), + ), + )! + const second = useAppNavigation(createPinia()) + const pending = first.navigate('cards') + second.navigate('sources') + scope.stop() + finish(true) + await pending + expect(first.tab.value).toBe('overview') + expect(second.tab.value).toBe('sources') +}) diff --git a/src/app/useAppNavigation.ts b/src/app/useAppNavigation.ts index 1734f2b3..3601d1de 100644 --- a/src/app/useAppNavigation.ts +++ b/src/app/useAppNavigation.ts @@ -1,10 +1,60 @@ import { storeToRefs, type Pinia } from 'pinia' -import { getCurrentScope, onScopeDispose } from 'vue' +import { computed, getCurrentScope, onScopeDispose, ref } from 'vue' import { useNavigationStore } from '../stores/navigation' export function useAppNavigation(pinia?: Pinia, beforeLeave?: () => boolean | Promise) { const store = useNavigationStore(pinia) let alive = true, generation = 0 + function location() { + return { + tab: store.tab, + pluginPage: store.pluginPage ? { ...store.pluginPage } : undefined, + selectedDeck: store.selectedDeck, + searchQuery: store.searchQuery, + filterState: store.filterState, + historyCardId: store.historyCardId, + } + } + type Location = ReturnType + const past: Location[] = [], + future: Location[] = [] + // Ref-backed lengths expose availability without persisting navigation history. + const historyVersion = ref(0) + const mayTravel = () => !store.review && !store.mixed + function visit(action: () => void) { + return transition(() => { + const previous = location(), + record = mayTravel() + action() + if (record && JSON.stringify(previous) !== JSON.stringify(location())) { + past.push(previous) + if (past.length > 100) past.shift() + future.length = 0 + historyVersion.value++ + } + }) + } + function restore(value: Location) { + store.navigate(value.tab) + store.pluginPage = value.pluginPage ? { ...value.pluginPage } : undefined + store.selectedDeck = value.selectedDeck + store.searchQuery = value.searchQuery + store.filterState = value.filterState + store.historyCardId = value.historyCardId + } + function travel(back: boolean) { + if (!mayTravel()) return false + const from = back ? past : future, + to = back ? future : past + if (!from.length) return false + return transition(() => { + if (!mayTravel() || !from.length) return + const next = from.pop()! + to.push(location()) + restore(next) + historyVersion.value++ + }) + } if (getCurrentScope()) onScopeDispose(() => { alive = false @@ -22,21 +72,33 @@ export function useAppNavigation(pinia?: Pinia, beforeLeave?: () => boolean | Pr } return { ...storeToRefs(store), + canGoBack: computed(() => { + void historyVersion.value + return mayTravel() && past.length > 0 + }), + canGoForward: computed(() => { + void historyVersion.value + return mayTravel() && future.length > 0 + }), + goBack: () => travel(true), + goForward: () => travel(false), navigate: (next: Parameters[0], after?: () => void) => - transition(() => { + visit(() => { store.navigate(next) after?.() }), transition, openPluginPage: (...args: Parameters) => - transition(() => store.openPluginPage(...args)), - startReading: () => transition(store.startReading), + visit(() => store.openPluginPage(...args)), + startReading: () => visit(store.startReading), openHistory: (...args: Parameters) => - transition(() => store.openHistory(...args)), - openDeck: (...args: Parameters) => - transition(() => store.openDeck(...args)), + visit(() => store.openHistory(...args)), + openDeck: (...args: Parameters) => visit(() => store.openDeck(...args)), resetLibrary: () => { generation++ + past.length = 0 + future.length = 0 + historyVersion.value++ store.resetLibrary() }, } diff --git a/src/input/controller.test.ts b/src/input/controller.test.ts new file mode 100644 index 00000000..f03c648a --- /dev/null +++ b/src/input/controller.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useSettingsStore } from '../settings-store' +import { + createInputController, + INPUT_SETTINGS_KEY, + normalizeInputPreferences, + type InputController, +} from './controller' +import { chordFromEvent, chordsOverlap, matchesChord, normalizeChord } from './shortcuts' + +let values: Map +const controllers: InputController[] = [] +beforeEach(() => { + values = new Map() + useSettingsStore({ + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value) + }, + }) +}) +afterEach(() => { + controllers.splice(0).forEach((controller) => controller.dispose()) + document.body.innerHTML = '' + vi.restoreAllMocks() +}) +function setup() { + const scope = ref('library-a'), + controller = createInputController(scope) + controllers.push(controller) + return { scope, controller } +} +function key(value: string, options: KeyboardEventInit = {}) { + return new KeyboardEvent('keydown', { key: value, cancelable: true, bubbles: true, ...options }) +} +it('normalizes portable chords, matches the right OS, and detects cross-platform collisions', () => { + expect(normalizeChord('ctrl+shift+k')).toBe('Control+Shift+K') + expect(normalizeChord('Mod+Ctrl+K')).toBeUndefined() + expect(normalizeChord('Tab')).toBeUndefined() + expect(matchesChord(key('k', { ctrlKey: true }), 'Mod+K', false)).toBe(true) + expect(matchesChord(key('k', { ctrlKey: true }), 'Mod+K', true)).toBe(false) + expect(matchesChord(key('k', { metaKey: true }), 'Mod+K', true)).toBe(true) + expect(matchesChord(key('K', { ctrlKey: true, shiftKey: true }), 'Mod+K', false)).toBe(false) + expect(chordsOverlap('Mod+K', 'Control+K')).toBe(true) + expect(chordsOverlap('Control+K', 'Meta+K')).toBe(false) + expect(chordFromEvent(key('a', { isComposing: true }))).toBeUndefined() + expect(chordFromEvent(key('+', { shiftKey: true }))).toBe('Shift+Plus') +}) +it('persists remapped and disabled chords, restores and resets without changing unrelated preferences', async () => { + const { controller } = setup(), + run = vi.fn() + controller.register({ + id: 'app.add', + title: '添加', + context: 'global', + defaultShortcuts: ['A'], + execute: run, + }) + await controller.saveShortcuts('app.add', ['Mod+Shift+K']) + expect(controller.dispatch(key('a'))).toBe(false) + expect(controller.dispatch(key('K', { ctrlKey: true, shiftKey: true }))).toBe(true) + expect(run).toHaveBeenCalledTimes(1) + const reloaded = setup().controller + expect(reloaded.bindings('app.add')).toEqual(['Mod+Shift+K']) + await controller.saveShortcuts('app.add', []) + expect(controller.bindings('app.add')).toEqual([]) + await controller.resetShortcuts('app.add') + expect(controller.bindings('app.add')).toEqual(['A']) + expect(values.has(INPUT_SETTINGS_KEY)).toBe(true) +}) +it('rejects collisions and reserved editor typing or native clipboard/undo keys', async () => { + const { controller } = setup() + await expect(controller.saveShortcuts('app.add', ['R'])).rejects.toThrow('冲突') + expect(controller.conflicts('app.add', ['Mod+Z']).map((entry) => entry.id)).toContain('app.undo') + expect(controller.conflicts('app.cards', ['B'])).toEqual([]) + for (const value of ['A', 'Mod+Z', 'Control+C', 'Meta+A']) + await expect(controller.saveShortcuts('editor.save', [value])).rejects.toThrow('原生') + await controller.saveShortcuts('editor.save', ['Mod+Shift+S']) + expect(controller.matches('editor.save', key('S', { ctrlKey: true, shiftKey: true }))).toBe(true) +}) +it('never dispatches ambiguous persisted bindings or unavailable commands', () => { + values.set( + INPUT_SETTINGS_KEY, + JSON.stringify({ shortcuts: { 'custom.one': ['K'], 'custom.two': ['K'] } }), + ) + const { controller } = setup(), + first = vi.fn(), + second = vi.fn() + controller.register({ id: 'custom.one', title: 'One', context: 'global', execute: first }) + let available = true + controller.register({ + id: 'custom.two', + title: 'Two', + context: 'global', + available: () => available, + execute: second, + }) + expect(controller.dispatch(key('k'))).toBe(false) + expect(first).not.toHaveBeenCalled() + available = false + expect(controller.dispatch(key('k'))).toBe(true) + expect(first).toHaveBeenCalledTimes(1) +}) +it('keeps plugin catalogue while its view closes and removes executable callbacks on disable', async () => { + const { controller } = setup(), + execute = vi.fn() + const definition = controller.registerDefinition({ + id: 'plugin:tools:trim', + title: 'Trim', + context: 'material', + defaultShortcuts: ['Mod+Shift+T'], + }) + await controller.saveShortcuts('plugin:tools:trim', ['Mod+Shift+L']) + expect(await controller.run('plugin:tools:trim')).toBe(false) + const remove = controller.register({ + id: 'plugin:tools:trim', + title: 'Trim', + context: 'material', + execute, + }) + expect( + controller.commands.value.find((entry) => entry.id === 'plugin:tools:trim')?.defaultShortcuts, + ).toEqual(['Mod+Shift+T']) + expect(await controller.run('plugin:tools:trim')).toBe(true) + remove() + expect(controller.commands.value.some((entry) => entry.id === 'plugin:tools:trim')).toBe(true) + expect(await controller.run('plugin:tools:trim')).toBe(false) + definition() + expect(controller.commands.value.some((entry) => entry.id === 'plugin:tools:trim')).toBe(false) + expect(controller.bindings('plugin:tools:trim')).toEqual(['Mod+Shift+L']) +}) +it('preserves native text edits, shadow editors, IME, dialog and control navigation', () => { + const { controller } = setup(), + execute = vi.fn() + controller.register({ + id: 'app.add', + title: 'Add', + context: 'global', + defaultShortcuts: ['A'], + execute, + }) + for (const options of [{ isComposing: true }, { repeat: true }, { keyCode: 229 }]) + expect(controller.dispatch(key('a', options))).toBe(false) + const host = document.createElement('div'), + input = document.createElement('textarea') + document.body.append(host) + host.attachShadow({ mode: 'open' }).append(input) + let handled = false + const listener = (event: KeyboardEvent) => { + handled = controller.dispatch(event) + } + window.addEventListener('keydown', listener) + input.dispatchEvent(key('a', { composed: true })) + window.removeEventListener('keydown', listener) + expect(handled).toBe(false) + document.body.append(Object.assign(document.createElement('dialog'), { open: true })) + expect(controller.dispatch(key('a'))).toBe(false) + expect(execute).not.toHaveBeenCalled() +}) +it('does not report failed persistence as saved and ignores old-scope completion', async () => { + const { controller, scope } = setup() + useSettingsStore({ + getItem: () => null, + setItem: async () => { + throw new Error('disk full') + }, + }) + await expect(controller.saveShortcuts('app.add', ['K'])).rejects.toThrow('disk full') + expect(controller.bindings('app.add')).toEqual(['A']) + expect(controller.error.value).toContain('未能保存') + let resolve!: () => void + useSettingsStore({ + getItem: () => null, + setItem: () => + new Promise((done) => { + resolve = done + }), + }) + const saving = controller.saveShortcuts('app.add', ['K']) + await Promise.resolve() + await Promise.resolve() + scope.value = 'library-b' + resolve() + await saving + expect(controller.bindings('app.add')).toEqual(['A']) +}) +it('normalizes malformed imported settings and applies independent instance state', async () => { + expect( + normalizeInputPreferences({ + gestures: { + enabled: 'true', + threshold: NaN, + width: 500, + color: 'url(x)', + bindings: { LL: 'x', LR: 'plugin:ok:run' }, + }, + }), + ).toMatchObject({ + gestures: { + enabled: false, + threshold: 18, + width: 12, + color: '#6fa880', + bindings: { LR: 'plugin:ok:run' }, + }, + }) + const a = setup().controller, + b = setup().controller + await a.saveShortcuts('app.add', ['K']) + expect(a.bindings('app.add')).toEqual(['K']) + expect(b.bindings('app.add')).toEqual(['A']) + a.dispose() + expect(await a.run('app.add')).toBe(false) +}) diff --git a/src/input/controller.ts b/src/input/controller.ts new file mode 100644 index 00000000..2e7f082d --- /dev/null +++ b/src/input/controller.ts @@ -0,0 +1,299 @@ +import { + computed, + inject, + onScopeDispose, + provide, + ref, + shallowReactive, + watch, + type InjectionKey, + type Ref, +} from 'vue' +import { persistSetting, readSetting } from '../settings-store' +import { shortcutBlocked } from '../shortcuts' +import { inputDefaults, type InputDefinition } from './defaults' +import { chordsOverlap, editorChordAllowed, matchesChord, normalizeChord } from './shortcuts' +import { defaultGestures, validGesture, type GestureSettings } from './gestures' +import { installMouseGestures } from './mouse' + +export interface InputCommand extends InputDefinition { + available?: () => boolean + execute: () => void | Promise +} +export interface InputPreferences { + shortcuts: Record + gestures: GestureSettings +} +export const INPUT_SETTINGS_KEY = 'repeaty.input.v1' +function defaults(): InputPreferences { + return { + shortcuts: {}, + gestures: { ...defaultGestures, bindings: { ...defaultGestures.bindings } }, + } +} +export function normalizeInputPreferences(raw: unknown): InputPreferences { + const result = defaults() + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return result + const value = raw as Partial + if (value.shortcuts && typeof value.shortcuts === 'object') + for (const [id, chords] of Object.entries(value.shortcuts).slice(0, 1000)) + if ( + /^[a-zA-Z0-9:._-]{1,200}$/.test(id) && + !['__proto__', 'prototype', 'constructor'].includes(id) && + Array.isArray(chords) && + chords.length <= 3 && + chords.every((key) => typeof key === 'string' && normalizeChord(key)) + ) + result.shortcuts[id] = [...new Set(chords.map((key) => normalizeChord(key)!))] + const gesture = value.gestures + if (gesture && typeof gesture === 'object') { + for (const key of ['enabled', 'trail', 'hint'] as const) + if (typeof gesture[key] === 'boolean') result.gestures[key] = gesture[key] + for (const [key, min, max] of [ + ['threshold', 8, 48], + ['pointThreshold', 2, 24], + ['width', 1, 12], + ['opacity', 0.05, 1], + ] as const) + if (typeof gesture[key] === 'number' && Number.isFinite(gesture[key])) + result.gestures[key] = Math.min(max, Math.max(min, gesture[key])) + if (/^#[0-9a-f]{6}$/i.test(gesture.color)) result.gestures.color = gesture.color + if (gesture.bindings && typeof gesture.bindings === 'object') { + result.gestures.bindings = {} + for (const [pattern, id] of Object.entries(gesture.bindings).slice(0, 100)) + if (validGesture(pattern) && typeof id === 'string' && /^[a-zA-Z0-9:._-]{1,200}$/.test(id)) + result.gestures.bindings[pattern] = id + } + } + return result +} +/** Settings survive temporarily missing plugins. Executable handlers never do. */ +export function createInputController(scope: Ref) { + const state = ref(defaults()) + const error = ref('') + const handlers = shallowReactive(new Map()) + const definitions = shallowReactive(new Map()) + let alive = true, + revision = 0 + let queue = Promise.resolve() + const commands = computed(() => { + const result = new Map( + inputDefaults.map((entry) => [entry.id, { ...entry, available: () => false, execute() {} }]), + ) + for (const [id, entry] of definitions) + result.set(id, { ...entry, available: () => false, execute() {} }) + for (const [id, entries] of handlers) { + const entry = entries.at(-1) + if (entry) + result.set(id, { + ...entry, + defaultShortcuts: entry.defaultShortcuts ?? result.get(id)?.defaultShortcuts, + }) + } + return [...result.values()] + }) + function restore() { + revision++ + try { + state.value = normalizeInputPreferences(JSON.parse(readSetting(INPUT_SETTINGS_KEY) ?? '{}')) + } catch { + state.value = defaults() + } + error.value = '' + } + const stop = watch(scope, restore, { immediate: true, flush: 'sync' }) + function bindings(id: string) { + return ( + (Object.hasOwn(state.value.shortcuts, id) ? state.value.shortcuts[id] : undefined) ?? + commands.value + .find((entry) => entry.id === id) + ?.defaultShortcuts?.map(normalizeChord) + .filter((value): value is string => !!value) ?? + [] + ) + } + function register(command: InputCommand) { + if (!alive || !/^[a-zA-Z0-9:._-]{1,200}$/.test(command.id)) throw new Error('命令标识无效。') + const entries = handlers.get(command.id) ?? [] + handlers.set(command.id, [...entries, command]) + let active = true + return () => { + if (!active) return + active = false + const remaining = (handlers.get(command.id) ?? []).filter((value) => value !== command) + if (remaining.length) handlers.set(command.id, remaining) + else handlers.delete(command.id) + } + } + async function run(id: string): Promise { + const command = commands.value.find((entry) => entry.id === id) + if (!alive || !command || command.available?.() === false) return false + const expectedScope = scope.value, + expectedRevision = revision + try { + await command.execute() + return true + } catch (issue) { + if (alive && scope.value === expectedScope && revision === expectedRevision) + error.value = issue instanceof Error ? issue.message : '操作未能完成,请重试。' + return false + } + } + function dispatch(event: KeyboardEvent) { + if (!alive || shortcutBlocked(event)) return false + const matching = commands.value.filter( + (entry) => + entry.available?.() !== false && bindings(entry.id).some((key) => matchesChord(event, key)), + ) + // A conflict never chooses an arbitrary destructive action. + if (matching.length !== 1) return false + const command = matching[0]! + if ( + command.id === 'review.reveal' && + (event.target as HTMLElement)?.closest?.('button, summary') + ) + return false + event.preventDefault() + void run(command.id) + return true + } + function save(update: (previous: InputPreferences) => InputPreferences) { + const expectedScope = scope.value, + expectedRevision = revision + const work = queue + .catch(() => {}) + .then(async () => { + if (!alive || expectedScope !== scope.value || expectedRevision !== revision) + throw new Error('学习库已切换,请重新打开设置。') + const value = normalizeInputPreferences(update(state.value)) + await persistSetting(INPUT_SETTINGS_KEY, JSON.stringify(value)) + if (!alive || expectedScope !== scope.value || expectedRevision !== revision) return + state.value = value + error.value = '' + }) + queue = work + return work.catch((issue: unknown) => { + if (alive && expectedScope === scope.value && expectedRevision === revision) + error.value = '设置未能保存,请重试。' + throw issue + }) + } + function conflicts(id: string, chords: readonly string[]) { + const command = commands.value.find((entry) => entry.id === id) + return commands.value.filter((other) => { + if (id === other.id || !command) return false + if (command.context === 'editor' || other.context === 'editor') { + if (command.context !== other.context) return false + } else if ( + command.context !== 'global' && + other.context !== 'global' && + command.context !== other.context + ) + return false + // These two browsing commands intentionally share keys with their review equivalents. + if ([id, other.id].includes('app.cards') && [id, other.id].includes('review.bury')) + return false + return chords.some((key) => + bindings(other.id).some((candidate) => chordsOverlap(key, candidate)), + ) + }) + } + return { + state, + error, + commands, + register, + run, + dispatch, + bindings, + conflicts, + registerDefinition(definition: InputDefinition) { + definitions.set(definition.id, definition) + return () => { + if (definitions.get(definition.id) === definition) definitions.delete(definition.id) + } + }, + matches: (id: string, event: KeyboardEvent) => { + const command = commands.value.find((entry) => entry.id === id) + if ( + !bindings(id).some( + (key) => + (command?.context !== 'editor' || editorChordAllowed(key)) && matchesChord(event, key), + ) + ) + return false + return ( + command?.context !== 'editor' || + commands.value.filter( + (entry) => + entry.context === 'editor' && + bindings(entry.id).some((key) => matchesChord(event, key)), + ).length === 1 + ) + }, + saveShortcuts(id: string, values: string[]) { + if (values.length > 3 || values.some((value) => !normalizeChord(value))) + return Promise.reject(new Error('快捷键格式无效。')) + if ( + commands.value.find((entry) => entry.id === id)?.context === 'editor' && + values.some((value) => !editorChordAllowed(value)) + ) + return Promise.reject( + new Error('编辑命令需要组合键,不能占用原生撤销、选区或剪贴板快捷键。'), + ) + if (conflicts(id, values).length) + return Promise.reject(new Error('快捷键与其他命令冲突,请先修改冲突项。')) + return save((previous) => ({ + ...previous, + shortcuts: { ...previous.shortcuts, [id]: values }, + })) + }, + resetShortcuts(id?: string) { + return save((previous) => { + const shortcuts = { ...previous.shortcuts } + if (id) delete shortcuts[id] + return { ...previous, shortcuts: id ? shortcuts : {} } + }) + }, + saveGestures(gestures: GestureSettings) { + return save((previous) => ({ ...previous, gestures })) + }, + resetGestures() { + return save((previous) => ({ ...previous, gestures: defaults().gestures })) + }, + restore, + dispose() { + alive = false + revision++ + stop() + handlers.clear() + definitions.clear() + }, + } +} +export type InputController = ReturnType +export const INPUT_COMMANDS: InjectionKey = Symbol('input-commands') +export function useOptionalInputCommands() { + return inject(INPUT_COMMANDS, undefined) +} +export function useInputCommands(scope: Ref) { + const controller = createInputController(scope) + provide(INPUT_COMMANDS, controller) + const disposeMouse = installMouseGestures(controller, scope) + onScopeDispose(() => { + disposeMouse() + controller.dispose() + }) + return controller +} +export function matchesInputShortcut( + input: InputController | undefined, + id: string, + event: KeyboardEvent, +) { + return input + ? input.matches(id, event) + : (inputDefaults + .find((entry) => entry.id === id) + ?.defaultShortcuts?.some((key) => matchesChord(event, key)) ?? false) +} diff --git a/src/input/defaults.ts b/src/input/defaults.ts new file mode 100644 index 00000000..6a79ff37 --- /dev/null +++ b/src/input/defaults.ts @@ -0,0 +1,50 @@ +export type InputContext = 'global' | 'review' | 'material' | 'editor' +export interface InputDefinition { + id: string + title: string + context: InputContext + defaultShortcuts?: readonly string[] +} +export const inputDefaults: readonly InputDefinition[] = [ + { id: 'app.back', title: '后退', context: 'global', defaultShortcuts: ['Alt+ArrowLeft'] }, + { id: 'app.forward', title: '前进', context: 'global', defaultShortcuts: ['Alt+ArrowRight'] }, + { id: 'document.top', title: '滚动到顶部', context: 'global' }, + { id: 'document.bottom', title: '滚动到底部', context: 'global' }, + { id: 'app.undo', title: '撤销最近操作', context: 'global', defaultShortcuts: ['Mod+Z'] }, + { + id: 'app.redo', + title: '重做最近操作', + context: 'global', + defaultShortcuts: ['Mod+Shift+Z', 'Mod+Y'], + }, + { id: 'app.add', title: '添加卡片 / 笔记', context: 'global', defaultShortcuts: ['A'] }, + { id: 'app.search', title: '搜索卡片', context: 'global', defaultShortcuts: ['/'] }, + { id: 'app.cards', title: '浏览卡片', context: 'global', defaultShortcuts: ['B'] }, + { id: 'app.review', title: '开始复习', context: 'global', defaultShortcuts: ['R'] }, + { id: 'app.shortcuts', title: '快捷键', context: 'global', defaultShortcuts: ['Shift+?'] }, + { id: 'review.edit', title: '编辑当前卡片', context: 'review', defaultShortcuts: ['E'] }, + { + id: 'review.delete', + title: '删除当前笔记', + context: 'review', + defaultShortcuts: ['Mod+Delete'], + }, + { id: 'review.bury', title: '今天暂缓', context: 'review', defaultShortcuts: ['B'] }, + { id: 'review.suspend', title: '暂停当前卡片', context: 'review', defaultShortcuts: ['S'] }, + { id: 'review.reveal', title: '显示答案', context: 'review', defaultShortcuts: ['Space'] }, + { id: 'review.again', title: '重来', context: 'review', defaultShortcuts: ['1'] }, + { id: 'review.hard', title: '困难', context: 'review', defaultShortcuts: ['2'] }, + { id: 'review.good', title: '良好', context: 'review', defaultShortcuts: ['3'] }, + { id: 'review.easy', title: '简单', context: 'review', defaultShortcuts: ['4'] }, + { id: 'review.close', title: '结束复习', context: 'review', defaultShortcuts: ['Escape'] }, + { id: 'editor.save', title: '保存当前表单', context: 'editor', defaultShortcuts: ['Mod+Enter'] }, + { id: 'editor.cloze', title: '添加挖空', context: 'editor', defaultShortcuts: ['Mod+Shift+C'] }, + { id: 'material.copy', title: '复制选区', context: 'material' }, + { id: 'material.preview', title: '切换阅读视图', context: 'material' }, + { id: 'material.export', title: '导出原文件', context: 'material' }, + { id: 'material.excerpt', title: '摘录选区', context: 'material' }, + { id: 'material.card', title: '选区制卡', context: 'material' }, + { id: 'material.cloze', title: '选区挖空', context: 'material' }, + { id: 'material.annotation', title: '批注选区', context: 'material' }, + { id: 'material.share', title: '分享选区', context: 'material' }, +] diff --git a/src/input/gestures.ts b/src/input/gestures.ts new file mode 100644 index 00000000..adf42d41 --- /dev/null +++ b/src/input/gestures.ts @@ -0,0 +1,54 @@ +export type Direction = 'L' | 'R' | 'U' | 'D' +export interface GesturePoint { + x: number + y: number +} +export interface GestureSettings { + enabled: boolean + bindings: Record + threshold: number + pointThreshold: number + trail: boolean + hint: boolean + color: string + width: number + opacity: number +} +export const defaultGestures: GestureSettings = { + enabled: false, + bindings: { L: 'app.back', R: 'app.forward', LU: 'document.top', LD: 'document.bottom' }, + threshold: 18, + pointThreshold: 6, + trail: true, + hint: true, + color: '#6fa880', + width: 3, + opacity: 0.65, +} +export function validGesture(value: string): boolean { + return /^[LRUD]{1,8}$/.test(value) && !/(.)\1/.test(value) +} +export function gestureLabel(value: string): string { + return [...value] + .map((letter) => ({ L: '←', R: '→', U: '↑', D: '↓' })[letter as Direction] ?? '') + .join(' ') +} +export function createGestureTracker(start: GesturePoint, threshold: number) { + let anchor = start + let path = '' + return { + move(point: GesturePoint) { + const dx = point.x - anchor.x, + dy = point.y - anchor.y + if (Math.hypot(dx, dy) < threshold) return path + const direction: Direction = + Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'R' : 'L') : dy > 0 ? 'D' : 'U' + if (path.at(-1) !== direction && path.length < 8) path += direction + anchor = point + return path + }, + get path() { + return path + }, + } +} diff --git a/src/input/menus.test.ts b/src/input/menus.test.ts new file mode 100644 index 00000000..5f4df2b1 --- /dev/null +++ b/src/input/menus.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import { arrangeMenu, createMenuPreferences, defaultMenus, normalizeMenus } from './menus' +const adapter = vi.hoisted(() => ({ read: vi.fn(), save: vi.fn() })) +vi.mock('../settings-store', () => ({ readSetting: adapter.read, persistSetting: adapter.save })) +afterEach(() => vi.resetAllMocks()) +it('orders available actions only, preserves essential operations, and retains missing plugin preferences', () => { + const config = { order: ['plugin:missing:run', 'b', 'a'], hidden: ['a', 'copy'] } + expect( + arrangeMenu( + [{ id: 'a' }, { id: 'copy', essential: true }, { id: 'b' }, { id: 'new' }], + config, + ).map((entry) => entry.id), + ).toEqual(['b', 'copy', 'new']) + expect(config.order).toContain('plugin:missing:run') +}) +it('bounds invalid preferences and preserves empty action lists', () => { + expect(normalizeMenus({ version: 50 })).toEqual(defaultMenus()) + const raw = defaultMenus() + raw.selectionOpacity = 0 + raw.surfaces['reading-context'] = { order: ['a', 'a', '__proto__', ' + + + diff --git a/src/desktop/DesktopSettings.vue b/src/desktop/DesktopSettings.vue index 906429d3..d87d31d0 100644 --- a/src/desktop/DesktopSettings.vue +++ b/src/desktop/DesktopSettings.vue @@ -5,8 +5,9 @@ import { workspaceStatus } from './workspaces' import { displayFilesystemPath } from './display-path' import McpSettings from './McpSettings.vue' import CaptureSettings from './CaptureSettings.vue' +import DesktopExperienceSettings from './DesktopExperienceSettings.vue' import { SettingGroup } from '../components/ui' -defineProps<{ section?: 'workspace' | 'mcp' | 'capture' }>() +defineProps<{ section?: 'workspace' | 'mcp' | 'capture' | 'experience' }>() const path = computed(() => workspaceStatus.value?.active?.path) diff --git a/src/components/MaterialTree.vue b/src/components/MaterialTree.vue index 3aecf382..33993879 100644 --- a/src/components/MaterialTree.vue +++ b/src/components/MaterialTree.vue @@ -1,14 +1,6 @@ + + diff --git a/plugins/web-lookup/src/LookupSettings.vue b/plugins/web-lookup/src/LookupSettings.vue new file mode 100644 index 00000000..8dad1c71 --- /dev/null +++ b/plugins/web-lookup/src/LookupSettings.vue @@ -0,0 +1,129 @@ + + + diff --git a/plugins/web-lookup/src/index.ts b/plugins/web-lookup/src/index.ts new file mode 100644 index 00000000..d6e23214 --- /dev/null +++ b/plugins/web-lookup/src/index.ts @@ -0,0 +1,86 @@ +import { defineComponent, h, ref } from 'vue' +import { + onDispose, + readSetting, + registerCommand, + registerSettings, + persistSetting, + type ExtensionPlugin, +} from '@repeaty/plugin-api' +import LookupSettings from './LookupSettings.vue' +import LookupDialog from './LookupDialog.vue' +import { defaults, normalizeTargets, type LookupTarget } from './lookup' +import { text } from './text' +export default function activate(): ExtensionPlugin { + const targets = ref(defaults()), + error = ref('') + let alive = true + onDispose(() => { + alive = false + }) + try { + const saved = readSetting('targets') + if (saved) targets.value = normalizeTargets(JSON.parse(saved)) + } catch { + error.value = text('查询目标无法读取,当前使用默认目标。') + } + async function save(value: LookupTarget[]) { + const next = normalizeTargets(value) + await persistSetting('targets', JSON.stringify(next)) + if (alive) { + targets.value = next + error.value = '' + } + } + registerSettings({ + id: 'targets', + get title() { + return text('选区查询') + }, + category: [ + { + id: 'tools', + get title() { + return text('工具') + }, + }, + { + id: 'web-lookup', + get title() { + return text('选区查询') + }, + }, + ], + component: defineComponent({ + setup: () => () => h(LookupSettings, { targets: targets.value, error: error.value, save }), + }), + }) + registerCommand({ + id: 'lookup', + get title() { + return text('查询选中文字') + }, + formats: ['*'], + component: defineComponent({ + props: ['context', 'onClose'], + setup: (props) => () => + h(LookupDialog, { + selection: props.context.selection?.text ?? '', + targets: targets.value, + onClose: props.onClose, + }), + }), + }) + return { + id: 'web-lookup', + type: 'extension', + kind: 'frontend', + version: 1, + get label() { + return text('选区查询') + }, + get description() { + return text('使用搜索、词典和问答网站查询选中文字。') + }, + } +} diff --git a/plugins/web-lookup/src/lookup.test.ts b/plugins/web-lookup/src/lookup.test.ts new file mode 100644 index 00000000..4ed7ced0 --- /dev/null +++ b/plugins/web-lookup/src/lookup.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import { defaults, lookupUrl, normalizeTargets } from './lookup' +it('encodes selected text once without changing the target origin', () => { + const input = '中文 & a=1 #? / 😀' + const url = new URL(lookupUrl('https://example.org/search?q={text}', input)) + expect(url.origin).toBe('https://example.org') + expect(url.searchParams.get('q')).toBe(input) +}) +it.each([ + 'javascript:{text}', + 'http://example.org/?q={text}', + 'https://{text}.org/', + 'https://user:pass@example.org/?q={text}', + 'file:///{text}', + 'https://example.org/search', +])('rejects unsafe or incomplete target %s', (template) => { + expect(() => lookupUrl(template, 'text')).toThrow() +}) +it('bounds content and validates stored targets without accepting duplicate identities', () => { + expect(() => lookupUrl(defaults()[0]!.template, 'x'.repeat(10001))).toThrow() + expect(() => normalizeTargets([defaults()[0], defaults()[0]])).toThrow() + expect(normalizeTargets([])).toEqual([]) + expect(normalizeTargets(defaults())).toEqual(defaults()) +}) diff --git a/plugins/web-lookup/src/lookup.ts b/plugins/web-lookup/src/lookup.ts new file mode 100644 index 00000000..2c987aaf --- /dev/null +++ b/plugins/web-lookup/src/lookup.ts @@ -0,0 +1,74 @@ +export interface LookupTarget { + id: string + label: string + template: string + enabled: boolean +} +export function defaults(): LookupTarget[] { + return [ + { + id: 'duckduckgo', + label: 'DuckDuckGo', + template: 'https://duckduckgo.com/?q={text}', + enabled: true, + }, + { + id: 'google', + label: 'Google', + template: 'https://www.google.com/search?q={text}', + enabled: true, + }, + { + id: 'wikipedia', + label: 'Wikipedia', + template: 'https://en.wikipedia.org/w/index.php?search={text}', + enabled: true, + }, + { id: 'chatgpt', label: 'ChatGPT', template: 'https://chatgpt.com/?q={text}', enabled: true }, + ] +} +export function lookupUrl(template: string, text: string): string { + if (!text.trim() || text.length > 10000) throw new Error('请选择 1 至 10000 个字符。') + if (template.length > 2048 || !template.includes('{text}')) + throw new Error('网址必须包含 {text} 占位符。') + const base = new URL(template.replaceAll('{text}', 'QUERY')) + if ( + base.protocol !== 'https:' || + base.username || + base.password || + !base.hostname.includes('.') || + /[{}]/.test(base.origin) || + template.split('://')[1]?.split(/[/?#]/)[0]?.includes('{text}') + ) + throw new Error('请使用固定网站的 HTTPS 网址,不要填写登录信息。') + const url = template.replaceAll('{text}', encodeURIComponent(text)) + if (url.length > 24000) throw new Error('查询内容过长,请缩短选区。') + return url +} +export function normalizeTargets(value: unknown): LookupTarget[] { + if (!Array.isArray(value) || value.length > 32) + throw new Error('查询目标无效,最多可配置 32 项。') + const ids = new Set() + return value.map((entry: LookupTarget) => { + if ( + !entry || + typeof entry.id !== 'string' || + !/^[a-z0-9-]{1,64}$/.test(entry.id) || + ids.has(entry.id) || + typeof entry.label !== 'string' || + !entry.label.trim() || + entry.label.length > 80 || + typeof entry.template !== 'string' || + typeof entry.enabled !== 'boolean' + ) + throw new Error('请填写有效且不重复的查询目标。') + lookupUrl(entry.template, 'test') + ids.add(entry.id) + return { + id: entry.id, + label: entry.label.trim(), + template: entry.template.trim(), + enabled: entry.enabled, + } + }) +} diff --git a/plugins/web-lookup/src/text.ts b/plugins/web-lookup/src/text.ts new file mode 100644 index 00000000..d6dc719a --- /dev/null +++ b/plugins/web-lookup/src/text.ts @@ -0,0 +1,39 @@ +import { locale } from '@repeaty/plugin-api' +const english: Record = { + '查询目标已保存。': 'Lookup targets saved.', + 选区查询: 'Selection lookup', + 工具: 'Tools', + 查询选中文字: 'Look up selected text', + 查询目标: 'Lookup targets', + '查询目标无法读取,当前使用默认目标。': + 'Saved targets could not be read. Default targets are shown.', + '查询目标未保存,请重试。': 'Targets were not saved. Please retry.', + '使用搜索、词典和问答网站查询选中文字。': + 'Look up selected text in search engines, dictionaries and question-answering sites.', + '仅在点击网站后发送下面的选区。不会自动发送整篇材料、学习记录或附件。': + 'Only the selection below is sent when you click a site. Entire materials, study history and attachments are not sent automatically.', + '请先在阅读器中选中文字。': 'Select text in the reader first.', + '没有启用的查询目标,请到设置中添加。': 'No lookup targets are enabled. Add one in Settings.', + '在网址中用 {text} 表示选中文字。文字会编码后填入链接,在浏览器中打开。': + 'Use {text} in the URL for selected text. It is URL-encoded before the link opens in your browser.', + 恢复默认: 'Restore defaults', + 名称: 'Name', + 查询网址: 'Lookup URL', + 上移: 'Move up', + 下移: 'Move down', + 删除: 'Delete', + 新查询目标: 'New lookup target', + 添加目标: 'Add target', + 保存: 'Save', + 关闭: 'Close', + '请选择 1 至 10000 个字符。': 'Select between 1 and 10,000 characters.', + '网址必须包含 {text} 占位符。': 'The URL must contain the {text} placeholder.', + '请使用固定网站的 HTTPS 网址,不要填写登录信息。': + 'Use an HTTPS URL with a fixed host and no login information.', + '查询内容过长,请缩短选区。': 'The query is too long. Select less text.', + '查询目标无效,最多可配置 32 项。': 'Invalid lookup targets. You can configure up to 32.', + '请填写有效且不重复的查询目标。': 'Enter valid targets with distinct IDs.', +} +export function text(key: string) { + return locale.value === 'zh-CN' ? key : (english[key] ?? key) +} diff --git a/plugins/web-lookup/tsconfig.json b/plugins/web-lookup/tsconfig.json new file mode 100644 index 00000000..149e988b --- /dev/null +++ b/plugins/web-lookup/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vitest.config.ts"] +} diff --git a/plugins/web-lookup/vitest.config.ts b/plugins/web-lookup/vitest.config.ts new file mode 100644 index 00000000..1e4e554b --- /dev/null +++ b/plugins/web-lookup/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + test: { include: ['src/**/*.test.ts'], environment: 'node' }, +}) diff --git a/plugins/wordpress-publish/manifest.json b/plugins/wordpress-publish/manifest.json index f7f895f2..0b01b1e8 100644 --- a/plugins/wordpress-publish/manifest.json +++ b/plugins/wordpress-publish/manifest.json @@ -1,7 +1,7 @@ { "id": "wordpress-publish", "name": "WordPress 发布", - "version": "1.0.0", + "version": "1.0.1", "apiVersion": 1, "dataVersion": 1, "kind": "frontend", diff --git a/plugins/wordpress-publish/package.json b/plugins/wordpress-publish/package.json index 9a679c85..314c6b3e 100644 --- a/plugins/wordpress-publish/package.json +++ b/plugins/wordpress-publish/package.json @@ -1,6 +1,6 @@ { "name": "@repeaty/plugin-wordpress-publish", - "version": "1.0.0", + "version": "1.0.1", "private": true, "type": "module", "scripts": { diff --git a/plugins/wordpress-publish/src/index.ts b/plugins/wordpress-publish/src/index.ts index 220db2c9..b7520171 100644 --- a/plugins/wordpress-publish/src/index.ts +++ b/plugins/wordpress-publish/src/index.ts @@ -4,7 +4,7 @@ import PublishPage from '@repeaty/publishing-client/ui' import ConnectionSettings from '@repeaty/publishing-client/settings' import { createWordPress } from './client' -const t = (zh: string, en: string) => (locale.value === 'en' ? en : zh) +const t = (zh: string, en: string) => (!locale.value.startsWith('zh') ? en : zh) const icon = () => h( 'svg', diff --git a/scripts/build-official-plugins.mjs b/scripts/build-official-plugins.mjs index 86f763a2..5ac9240a 100644 --- a/scripts/build-official-plugins.mjs +++ b/scripts/build-official-plugins.mjs @@ -17,6 +17,7 @@ const ids = [ 'static-publish', 'wordpress-publish', 'discourse-publish', + 'web-lookup', ] await mkdir('plugins/packages', { recursive: true }) for (const id of ids) { diff --git a/src/App.vue b/src/App.vue index 6dfcd8c1..55d5a174 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@