feat: 全局撤销/重做(ADR-010)
This commit is contained in:
+26
-3
@@ -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 />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user