From 4e8045159f6aba4e56aac2a0dcfb69adca04675c Mon Sep 17 00:00:00 2001 From: bboysoul Date: Mon, 14 Sep 2026 17:59:23 +0800 Subject: [PATCH] feat: improve memo editing workflow --- frontend/src/App.vue | 28 +- frontend/src/MemoIntegration.test.ts | 23 +- frontend/src/MemoPanel.test.ts | 756 ++++++++++++++++++++- frontend/src/MemoPanel.vue | 171 ++++- frontend/src/components/MemoEditor.test.ts | 310 ++++++++- frontend/src/components/MemoEditor.vue | 127 +++- frontend/src/components/MemoRow.test.ts | 19 +- frontend/src/components/MemoRow.vue | 6 +- frontend/src/lib/app-shell-state.test.ts | 27 + frontend/src/lib/app-shell-state.ts | 14 + frontend/src/memo.css | 4 +- frontend/src/style.test.ts | 3 +- 12 files changed, 1398 insertions(+), 90 deletions(-) create mode 100644 frontend/src/lib/app-shell-state.test.ts create mode 100644 frontend/src/lib/app-shell-state.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0db6287..0c5e7f7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,6 +12,7 @@ import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionEx import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search' import { nextDialogFocusIndex } from './lib/list-purge' +import { deriveMemoShellState } from './lib/app-shell-state' import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' @@ -155,6 +156,11 @@ const habitComposer = ref | null>(null) const countdownComposer = ref | null>(null) const memoPanel = ref | null>(null) const memoTrash = ref(false) +const memoDetailOpen = ref(false) +const compactLayout = ref(window.innerWidth <= 930) +const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value })) +const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert) +const showFloatingAdd = computed(() => memoShellState.value.showFab) const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) let suppressTaskClickId = '' @@ -618,6 +624,7 @@ async function switchView(view: View, listId?: string) { writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value) page.value = 1 selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null + if (view !== 'memos') memoDetailOpen.value = false if (view === 'trash') await loadTrash() else if (view === 'today') await loadTodayView() else if (!isTaskView(view)) tasks.value = [] @@ -1306,11 +1313,16 @@ function handleSearchBlur() { if (!query.value.trim()) mobileSearchOpen.value = false } +function handleViewportResize() { + compactLayout.value = window.innerWidth <= 930 + handleArchivedListViewportChange() +} + onMounted(() => { document.addEventListener('pointerdown', handleArchivedListOutsidePointer) document.addEventListener('keydown', handleArchivedListEscape) document.addEventListener('keydown', handleTaskSearchShortcut) - window.addEventListener('resize', handleArchivedListViewportChange) + window.addEventListener('resize', handleViewportResize) document.addEventListener('scroll', handleArchivedListViewportChange, true) void bootstrap() }) @@ -1318,7 +1330,7 @@ onUnmounted(() => { document.removeEventListener('pointerdown', handleArchivedListOutsidePointer) document.removeEventListener('keydown', handleArchivedListEscape) document.removeEventListener('keydown', handleTaskSearchShortcut) - window.removeEventListener('resize', handleArchivedListViewportChange) + window.removeEventListener('resize', handleViewportResize) document.removeEventListener('scroll', handleArchivedListViewportChange, true) }) @@ -1334,9 +1346,9 @@ onUnmounted(() => { {{ error }} -
+
- diff --git a/frontend/src/components/MemoRow.test.ts b/frontend/src/components/MemoRow.test.ts index 12844d4..e939964 100644 --- a/frontend/src/components/MemoRow.test.ts +++ b/frontend/src/components/MemoRow.test.ts @@ -10,8 +10,8 @@ afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())) describe('MemoRow', () => { it('renders a compact selectable row with plain excerpt and semantic update time', async () => { const host = document.createElement('div'); document.body.append(host) - const selected: string[] = [] - const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string) => selected.push(id) })) + const selected: Array<{ id: string; opener: EventTarget | null }> = [] + const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string, opener: EventTarget | null) => selected.push({ id, opener }) })) app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick() const button = host.querySelector('button')! expect(button.classList.contains('active')).toBe(true) @@ -19,7 +19,8 @@ describe('MemoRow', () => { expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机') expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at) button.click() - expect(selected).toEqual(['m1']) + expect(selected).toHaveLength(1) + expect(selected[0]).toEqual({ id: 'm1', opener: button }) }) it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => { @@ -39,4 +40,16 @@ describe('MemoRow', () => { await nextTick() expect(excerpt()).toBe('暂无正文') }) + + it('updates the displayed timestamp when the memo prop changes', async () => { + const host = document.createElement('div'); document.body.append(host) + const current = ref({ ...memo }) + const app = createApp(() => h(MemoRow, { memo: current.value })) + app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick() + const before = host.querySelector('time')?.textContent + current.value = { ...current.value, updated_at: '2026-10-20T10:30:00Z' } + await nextTick() + expect(host.querySelector('time')?.textContent).not.toBe(before) + expect(host.querySelector('time')?.getAttribute('datetime')).toBe('2026-10-20T10:30:00Z') + }) }) diff --git a/frontend/src/components/MemoRow.vue b/frontend/src/components/MemoRow.vue index dc9da61..a077987 100644 --- a/frontend/src/components/MemoRow.vue +++ b/frontend/src/components/MemoRow.vue @@ -3,13 +3,13 @@ import { computed } from 'vue' export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null } const props = defineProps<{ memo: MemoListItem; active?: boolean }>() -const emit = defineEmits<{ select: [id: string] }>() +const emit = defineEmits<{ select: [id: string, opener: EventTarget | null] }>() const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim()) -const updated = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at)) +const updated = computed(() => new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at)))