feat: 全局撤销/重做(ADR-010)

This commit is contained in:
chenshoulin
2026-10-24 21:21:36 +08:00
parent 29100b6d7d
commit 648b1ddca4
8 changed files with 533 additions and 17 deletions
+26 -3
View File
@@ -10,14 +10,17 @@ import SideDrawer from '@/components/SideDrawer.vue'
import TagManagerPanel from '@/components/TagManagerPanel.vue'
import ToastHost from '@/components/ToastHost.vue'
import TrashPanel from '@/components/TrashPanel.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useKeyboardShortcuts, useUndoShortcuts } from '@/composables/useKeyboardShortcuts'
import { useTaskStore } from '@/stores/tasks'
import { useThemeStore } from '@/stores/theme'
import { useToastStore } from '@/stores/toast'
import { useUiStore } from '@/stores/ui'
const SHORTCUTS = [
{ keys: 'N', description: '聚焦「待办」列的快速新增输入框' },
{ keys: '/', description: '聚焦搜索框' },
{ keys: 'Ctrl+Z', description: '撤销上一步操作' },
{ keys: 'Ctrl+⇧Z', description: '重做被撤销的操作' },
{ keys: '?', description: '打开本帮助' },
{ keys: 'Esc', description: '关闭当前面板' },
] as const
@@ -33,6 +36,7 @@ const CARD_SHORTCUTS = [
const store = useTaskStore()
const ui = useUiStore()
const theme = useThemeStore()
const toast = useToastStore()
const { notice } = storeToRefs(store)
onMounted(() => {
@@ -40,6 +44,22 @@ onMounted(() => {
theme.init()
})
/**
* 撤销 / 重做。历史为空时给一句提示 —— 否则按键毫无反应,
* 用户会以为是快捷键没生效。成功时不提示:列表已经变了,本身就是反馈。
*/
function undo(): void {
if (store.undo()) return
toast.show({ message: '没有可撤销的操作', duration: 2000 })
}
function redo(): void {
if (store.redo()) return
toast.show({ message: '没有可重做的操作', duration: 2000 })
}
useUndoShortcuts({ undo, redo })
// 帮助面板是应用级的,因此在任何视图下都能用 ?
useKeyboardShortcuts({
'?': () => {
@@ -80,7 +100,7 @@ useKeyboardShortcuts({
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
class="w-20 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
{{ item.keys }}
</dt>
@@ -96,7 +116,7 @@ useKeyboardShortcuts({
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in CARD_SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
class="w-20 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
{{ item.keys }}
</dt>
@@ -105,6 +125,9 @@ useKeyboardShortcuts({
</dl>
<p class="mt-4 text-xs text-slate-400 dark:text-zinc-500">在输入框内输入时不会触发快捷键。</p>
<p class="mt-1 text-xs text-slate-400 dark:text-zinc-500">
撤销与重做在 macOS 上把 Ctrl 换成 ⌘;在输入框内则交还浏览器,撤销的是文字输入。
</p>
</SideDrawer>
<ToastHost />
+62 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { isEditableTarget, matchShortcut, type ShortcutEventLike } from './useKeyboardShortcuts'
import {
isEditableTarget,
matchShortcut,
matchUndoShortcut,
type ShortcutEventLike,
type UndoEventLike,
} from './useKeyboardShortcuts'
function makeEvent(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
return {
@@ -14,6 +20,19 @@ function makeEvent(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLik
}
}
function makeUndoEvent(overrides: Partial<UndoEventLike> = {}): UndoEventLike {
return {
key: 'z',
target: document.body,
defaultPrevented: false,
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
...overrides,
}
}
function makeElement(tag: string, contentEditable = false): HTMLElement {
const element = document.createElement(tag)
// 用 setAttribute 而非 property:jsdom 不会把 contentEditable 属性反射到 attribute
@@ -83,3 +102,45 @@ describe('matchShortcut', () => {
expect(matchShortcut(makeEvent({ key: 'z' }), { n: vi.fn() })).toBeNull()
})
})
describe('matchUndoShortcut', () => {
it('Ctrl+Z 与 Cmd+Z 都是撤销', () => {
expect(matchUndoShortcut(makeUndoEvent())).toBe('undo')
expect(matchUndoShortcut(makeUndoEvent({ ctrlKey: false, metaKey: true }))).toBe('undo')
})
it('Shift+Z 是重做', () => {
expect(matchUndoShortcut(makeUndoEvent({ shiftKey: true }))).toBe('redo')
expect(
matchUndoShortcut(makeUndoEvent({ ctrlKey: false, metaKey: true, shiftKey: true })),
).toBe('redo')
})
it('Ctrl+Y 也认作重做,但 Cmd+Y 不认', () => {
expect(matchUndoShortcut(makeUndoEvent({ key: 'y' }))).toBe('redo')
expect(matchUndoShortcut(makeUndoEvent({ key: 'y', ctrlKey: false, metaKey: true }))).toBeNull()
})
it('没有修饰键时不是撤销快捷键', () => {
expect(matchUndoShortcut(makeUndoEvent({ ctrlKey: false }))).toBeNull()
})
it('其它键不触发', () => {
expect(matchUndoShortcut(makeUndoEvent({ key: 'a' }))).toBeNull()
expect(matchUndoShortcut(makeUndoEvent({ key: 'f' }))).toBeNull()
})
it('带 Alt 的组合不劫持', () => {
expect(matchUndoShortcut(makeUndoEvent({ altKey: true }))).toBeNull()
})
it('输入框内交还浏览器,撤销的是文字输入', () => {
expect(matchUndoShortcut(makeUndoEvent({ target: makeElement('input') }))).toBeNull()
expect(matchUndoShortcut(makeUndoEvent({ target: makeElement('textarea') }))).toBeNull()
expect(matchUndoShortcut(makeUndoEvent({ target: makeElement('div', true) }))).toBeNull()
})
it('已被处理的事件不重复触发', () => {
expect(matchUndoShortcut(makeUndoEvent({ defaultPrevented: true }))).toBeNull()
})
})
+52
View File
@@ -58,3 +58,55 @@ export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
}
/** 撤销 / 重做命令 */
export type UndoCommand = 'undo' | 'redo'
export interface UndoEventLike extends ShortcutEventLike {
shiftKey: boolean
}
/**
* 解析撤销 / 重做快捷键。
*
* 单独一条通道,而不是放宽 matchShortcut:那个函数的语义是「裸键」,
* 带修饰键的组合一律交还给浏览器(Ctrl+F 查找、Cmd+R 刷新)。
* 撤销是唯一需要修饰键的快捷键,混进去会让上述放行规则失效。
*
* 同样遵守「输入框内不劫持」:编辑任务详情时按 Ctrl+Z 应当撤销文本输入,
* 而不是把整个任务的状态回滚掉。
*
* 重做:`Shift+Z` 是跨平台通用写法;额外的 `Ctrl+Y` 照顾 Windows 习惯
* (不认 `Cmd+Y`,macOS 上那不是重做)。
*/
export function matchUndoShortcut(event: UndoEventLike): UndoCommand | null {
if (event.defaultPrevented) return null
if (event.altKey) return null
if (!event.ctrlKey && !event.metaKey) return null
if (isEditableTarget(event.target)) return null
const key = event.key.toLowerCase()
if (key === 'y' && event.ctrlKey && !event.shiftKey) return 'redo'
if (key !== 'z') return null
return event.shiftKey ? 'redo' : 'undo'
}
export interface UndoHandlers {
undo: () => void
redo: () => void
}
/** 把撤销 / 重做快捷键挂到 document 上;生命周期与组件一致 */
export function useUndoShortcuts(handlers: UndoHandlers): void {
function onKeydown(event: KeyboardEvent): void {
const command = matchUndoShortcut(event)
if (!command) return
event.preventDefault()
if (command === 'undo') handlers.undo()
else handlers.redo()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
}
+186 -3
View File
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
import type { Task } from '@/types/task'
import { useTaskStore } from './tasks'
import { UNDO_LIMIT, useTaskStore } from './tasks'
/**
* 每次调用都换一个全新的 Pinia 实例,以模拟「重新打开页面」。
@@ -872,7 +872,12 @@ describe('批量编辑', () => {
const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
save.mockClear()
expect(store.updateTasks(created.map((task) => task.id), { priority: 3 })).toBe(3)
expect(
store.updateTasks(
created.map((task) => task.id),
{ priority: 3 },
),
).toBe(3)
expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true)
expect(save).toHaveBeenCalledTimes(1)
})
@@ -1069,7 +1074,9 @@ describe('标签治理', () => {
store.createTask({ title: 'C', tags: ['后端'] })
expect(store.renameTag('前端', 'Frontend')).toBe(2)
expect(store.allTags).toEqual(['Frontend', '后端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')))
expect(store.allTags).toEqual(
['Frontend', '后端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')),
)
expect(store.tasks.find((task) => task.title === 'A')?.tags).toEqual(['Frontend', '设计'])
})
@@ -1109,3 +1116,179 @@ describe('标签治理', () => {
expect(store.trashTasks[0]?.tags).toEqual(['前端'])
})
})
describe('撤销与重做', () => {
it('初始状态下没有可撤销 / 可重做的步骤', () => {
const store = freshStore()
expect(store.canUndo).toBe(false)
expect(store.canRedo).toBe(false)
expect(store.undo()).toBe(false)
expect(store.redo()).toBe(false)
})
it('撤销单条创建', () => {
const store = freshStore()
store.createTask({ title: 'A' })
expect(store.activeTasks).toHaveLength(1)
expect(store.undo()).toBe(true)
expect(store.activeTasks).toHaveLength(0)
})
it('批量创建只需撤销一次', () => {
const store = freshStore()
store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
expect(store.activeTasks).toHaveLength(3)
store.undo()
expect(store.activeTasks).toHaveLength(0)
})
it('撤销批量更新,恢复所有被改动的字段', () => {
const store = freshStore()
const created = store.createTasks([
{ title: 'A', priority: 1, tags: ['前端'] },
{ title: 'B', priority: 1 },
])
store.updateTasks(
created.map((task) => task.id),
{ priority: 3, status: 'doing', addTags: ['紧急'] },
)
expect(store.byColumn.doing).toHaveLength(2)
expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true)
store.undo()
expect(store.byColumn.todo).toHaveLength(2)
expect(store.byColumn.doing).toHaveLength(0)
expect(store.activeTasks.every((task) => task.priority === 1)).toBe(true)
expect(store.allTags).toEqual(['前端'])
})
it('撤销批量归档', () => {
const store = freshStore()
const created = store.createTasks([{ title: 'A' }, { title: 'B' }])
expect(store.archiveTasks(created.map((task) => task.id))).toBe(2)
expect(store.activeTasks.every((task) => task.archivedAt !== null)).toBe(true)
store.undo()
expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true)
})
it('撤销状态流转会一并还原 completedAt', () => {
const store = freshStore()
const task = store.createTask({ title: 'A' })
store.updateTask(task?.id ?? '', { status: 'done' })
expect(store.activeTasks[0]?.completedAt).not.toBeNull()
store.undo()
expect(store.activeTasks[0]?.status).toBe('todo')
expect(store.activeTasks[0]?.completedAt).toBeNull()
})
it('撤销删除后任务回到原列,且不再出现在回收站', () => {
const store = freshStore()
const task = store.createTask({ title: 'A', status: 'doing' })
store.removeTask(task?.id ?? '')
expect(store.trashTasks).toHaveLength(1)
store.undo()
expect(store.trashTasks).toHaveLength(0)
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
})
it('撤销标签重命名', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端'] })
store.renameTag('前端', 'Frontend')
expect(store.allTags).toEqual(['Frontend'])
store.undo()
expect(store.allTags).toEqual(['前端'])
})
it('撤销后可以重做', () => {
const store = freshStore()
store.createTask({ title: 'A' })
store.undo()
expect(store.activeTasks).toHaveLength(0)
expect(store.canRedo).toBe(true)
expect(store.redo()).toBe(true)
expect(store.activeTasks.map((task) => task.title)).toEqual(['A'])
expect(store.canRedo).toBe(false)
})
it('撤销之后做新操作会清空重做栈', () => {
const store = freshStore()
store.createTask({ title: 'A' })
store.undo()
store.createTask({ title: 'B' })
expect(store.canRedo).toBe(false)
store.undo()
expect(store.activeTasks).toHaveLength(0)
})
it('连续撤销回到最初状态,再按也不会出错', () => {
const store = freshStore()
store.createTask({ title: 'A' })
store.createTask({ title: 'B' })
expect(store.undo()).toBe(true)
expect(store.undo()).toBe(true)
expect(store.undo()).toBe(false)
expect(store.activeTasks).toHaveLength(0)
})
it(`历史深度不超过 ${UNDO_LIMIT} 步`, () => {
const store = freshStore()
for (let index = 0; index <= UNDO_LIMIT + 5; index += 1) {
store.createTask({ title: `T${index}` })
}
let undone = 0
while (store.undo()) undone += 1
expect(undone).toBe(UNDO_LIMIT)
// 共建了 UNDO_LIMIT + 6 条,撤销上限步数后,最早的 6 条仍在看板上
expect(store.activeTasks).toHaveLength(6)
})
it('撤销结果会落盘', () => {
const repository = createMemoryTaskRepository()
const store = freshStore(repository)
store.createTask({ title: 'A' })
expect(repository.load()).toHaveLength(1)
store.undo()
expect(repository.load()).toHaveLength(0)
})
it('启动时清理过期回收站不占用撤销机会', () => {
const longAgo = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString()
const repository = createMemoryTaskRepository([
{
id: 'old',
title: '很久以前删除',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: longAgo,
updatedAt: longAgo,
completedAt: null,
archivedAt: null,
deletedAt: longAgo,
},
])
const store = freshStore(repository)
expect(store.trashTasks).toHaveLength(0)
expect(store.canUndo).toBe(false)
})
})
+97 -3
View File
@@ -4,6 +4,7 @@
* 职责边界:
* - Store 是内存态的唯一来源,所有读写都经由 TaskRepository
* - 每次变更后 persist + broadcast,保证刷新不丢、多标签页一致
* - commit() 是唯一的写入出口,撤销历史也只在这一个地方记录
* - 纯计算(排序、分组)委托给 @/utils/sortOrder,便于单独测试
*/
@@ -43,6 +44,15 @@ import {
/** 跨标签页同步使用的频道名 */
export const SYNC_CHANNEL_NAME = 'kanban.sync'
/**
* 撤销历史的深度上限。
*
* 快照只持有数组引用:所有变更都是不可变替换(`tasks.value = [...]`),
* 没被改动的 Task 对象在相邻快照之间共享,因此 50 步的实际内存增量
* 只是「被改过的那几个对象」,与总任务数无关。
*/
export const UNDO_LIMIT = 50
export interface CreateTaskInput {
title: string
description?: string
@@ -83,6 +93,28 @@ export const useTaskStore = defineStore('tasks', () => {
/** 可用的启动快照,按时间倒序 */
const snapshots = ref<SnapshotInfo[]>([])
/**
* 撤销 / 重做历史。
*
* 用「整份 tasks 数组快照」而不是命令模式:所有变更都是不可变替换,
* 未改动的 Task 在相邻快照之间共享引用,代价近乎为零;命令模式则要为
* 三十多个写方法各写一份逆操作,漏一个就是静默的数据不一致。
*
* 历史不落盘:刷新页面即清空,与常见编辑器的行为一致。
*/
const undoStack = ref<Task[][]>([])
const redoStack = ref<Task[][]>([])
/** 是否还有可撤销 / 可重做的步骤,供界面提示使用 */
const canUndo = computed(() => undoStack.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)
/**
* 上一次 commit 之后的快照 —— 也就是「下一次变更之前的状态」。
* commit() 时把它压入撤销栈,因此所有写方法都不必自己记录 before。
*/
let lastCommitted: Task[] = []
let repository: TaskRepository | null = null
let channel: BroadcastChannel | null = null
@@ -145,7 +177,27 @@ export const useTaskStore = defineStore('tasks', () => {
channel?.postMessage({ type: 'tasks-changed' } satisfies SyncMessage)
}
/**
* 把「本次变更之前的状态」记入撤销栈,并作废 redo。
*
* redo 必须清空:撤销两步之后又做了新操作,原来那条重做分支已经
* 和当前状态分叉,继续保留会让 Ctrl+Shift+Z 跳到一个不相干的状态。
*/
function recordHistory(): void {
undoStack.value = [...undoStack.value, lastCommitted].slice(-UNDO_LIMIT)
redoStack.value = []
lastCommitted = tasks.value
}
/** 丢弃全部历史(跨标签页同步后、启动清理后调用) */
function resetHistory(): void {
undoStack.value = []
redoStack.value = []
lastCommitted = tasks.value
}
function commit(): void {
recordHistory()
persist()
broadcastChange()
}
@@ -154,6 +206,9 @@ export const useTaskStore = defineStore('tasks', () => {
if (!repository) return
try {
tasks.value = sortByOrder(repository.load())
// 另一标签页改过数据:本页的撤销历史已经与当前状态分叉。
// 继续保留会在下次撤销时把对方的改动一并抹掉,因此直接作废。
resetHistory()
} catch {
// 另一标签页可能正在写入,读取失败时保留当前内存态即可
}
@@ -259,6 +314,9 @@ export const useTaskStore = defineStore('tasks', () => {
// 先清理过期回收站再写启动快照,避免快照里又留下一份本该消失的数据
purgeExpiredTrash()
// 启动时的过期清理不是用户操作,不该占用一次撤销机会。
// 同时这里把 lastCommitted 对齐到加载后的状态,作为本会话的历史基线。
resetHistory()
writeStartupSnapshot()
if (options.crossTab !== false) setupChannel()
@@ -506,9 +564,7 @@ export const useTaskStore = defineStore('tasks', () => {
/** 永久删除多个任务;返回实际删除数量 */
function purgeTasks(ids: readonly string[]): number {
const idSet = new Set(ids)
const remaining = tasks.value.filter(
(task) => !(idSet.has(task.id) && task.deletedAt !== null),
)
const remaining = tasks.value.filter((task) => !(idSet.has(task.id) && task.deletedAt !== null))
const removed = tasks.value.length - remaining.length
if (removed === 0) return 0
@@ -582,6 +638,39 @@ export const useTaskStore = defineStore('tasks', () => {
commit()
}
/**
* 撤销上一次变更。返回是否真的撤销了(历史为空时为 false)。
*
* 刻意**不走 commit()**:撤销本身不是一次新的业务变更,
* 若也压栈,用户按 Ctrl+Z 就会在两步之间来回跳。
*/
function undo(): boolean {
const previous = undoStack.value[undoStack.value.length - 1]
if (previous === undefined) return false
redoStack.value = [...redoStack.value, tasks.value]
undoStack.value = undoStack.value.slice(0, -1)
tasks.value = previous
lastCommitted = previous
persist()
broadcastChange()
return true
}
/** 重做被撤销的变更;撤销之后又做了新操作时,redo 已在 commit 里被清空 */
function redo(): boolean {
const next = redoStack.value[redoStack.value.length - 1]
if (next === undefined) return false
undoStack.value = [...undoStack.value, tasks.value]
redoStack.value = redoStack.value.slice(0, -1)
tasks.value = next
lastCommitted = next
persist()
broadcastChange()
return true
}
/** 导出为可读 JSON(T14) */
function exportJson(): string {
return serializeState(tasks.value, true)
@@ -703,6 +792,7 @@ export const useTaskStore = defineStore('tasks', () => {
channel?.close()
channel = null
repository = null
resetHistory()
}
return {
@@ -716,6 +806,8 @@ export const useTaskStore = defineStore('tasks', () => {
byColumn,
counts,
allTags,
canUndo,
canRedo,
init,
createTask,
createTasks,
@@ -730,6 +822,8 @@ export const useTaskStore = defineStore('tasks', () => {
emptyTrash,
purgeExpiredTrash,
moveTask,
undo,
redo,
exportJson,
importJson,
mergeJson,