feat: resize todo panes and collapse note groups

Let users drag the to-do list/detail divider and remember its width. Note headings independently collapse task rows without clearing selected details. Use a 1px divider with a transparent drag target and compact spacing.

Validation: 115 focused tests including native Electron input; typecheck and lint passed.

Release note: Resize the Magic Notes to-do list with a thin draggable divider and collapse tasks by source note.
This commit is contained in:
mesalogo
2026-09-24 16:17:16 +08:00
parent 7adae97baa
commit f68584b4e2
13 changed files with 278 additions and 48 deletions
+27 -3
View File
@@ -754,7 +754,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
await openTodo()
const first = screen.getByRole('button', { name: /核对发布材料.*发布笔记/ })
expect(screen.getByRole('button', { name: '切换到笔记' })).toBeVisible()
expect(screen.queryByRole('separator')).not.toBeInTheDocument()
expect(screen.getByRole('separator', { name: '调整待办列表与详情宽度' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '返回总览' })).not.toBeInTheDocument()
expect(first).toHaveAttribute('aria-controls', `magic-todo-detail-${todo.id}`)
const list = first.closest('.magic-notes-list')!
@@ -764,7 +764,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
const toolbar = screen.getByRole('searchbox').closest('.magic-todo-toolbar')!
expect(toolbar).toContainElement(screen.getByRole('group', { name: '筛选待办' }))
const css = readFileSync('src/renderer/src/styles.css', 'utf8')
expect(css).toMatch(/\.magic-todo-workspace\s*\{[^}]*grid-template-columns: minmax\(280px, 34%\) minmax\(0, 1fr\)/)
expect(css).toContain('grid-template-columns: var(--magic-notes-todo-width, 320px) 1px minmax(0, 1fr)')
expect(css).toContain('@container magic-notes-page (max-width: 700px)')
expect(css).toMatch(/\.magic-todo-workspace--selected > \.magic-notes-list,\s*\.magic-todo-detail--empty\s*\{\s*display: none/)
fireEvent.click(screen.getByRole('button', { name: /准备演示.*演示笔记/ }))
@@ -786,6 +786,30 @@ describe('MagicNotesWorkspace detail behavior', () => {
expect(listTodos).toHaveBeenCalledOnce()
})
it('resizes the todo split and collapses note groups without clearing the selected detail', async () => {
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
await openTodo()
const separator = screen.getByRole('separator', { name: '调整待办列表与详情宽度' })
Object.defineProperties(separator, { setPointerCapture: { value: vi.fn() }, hasPointerCapture: { value: () => true }, releasePointerCapture: { value: vi.fn() } })
fireEvent.pointerDown(separator, { button: 0, pointerId: 21 })
fireEvent.pointerMove(separator, { clientX: 401, pointerId: 21 })
fireEvent.pointerUp(separator, { pointerId: 21 })
expect(separator).toHaveAttribute('aria-valuenow', '400')
fireEvent.keyDown(separator, { key: 'ArrowRight' })
expect(separator).toHaveAttribute('aria-valuenow', '416')
const group = screen.getByRole('button', { name: '发布笔记 (1)' })
fireEvent.click(group)
expect(group).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: /核对发布材料.*发布笔记/ })).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: '待办详情' })).toBeVisible()
fireEvent.click(group)
expect(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ })).toBeVisible()
view.unmount()
render(<MagicNotesWorkspace onNotify={onNotify} />)
await openTodo()
expect(screen.getByRole('separator', { name: '调整待办列表与详情宽度' })).toHaveAttribute('aria-valuenow', '416')
})
it('retries source failures and ignores a late source from a previously expanded task', async () => {
get.mockRejectedValueOnce(new Error('source failed'))
render(<MagicNotesWorkspace onNotify={onNotify} />)
@@ -851,7 +875,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
await openNote()
fireEvent.click(screen.getByRole('button', { name: '显示 AI 评论' }))
expect(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })).toHaveAttribute('aria-valuenow', width)
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toEqual({ indexPaneOpen: true, indexPaneWidth: 168, aiPaneOpen: true, aiPaneWidth: Number(width) })
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toMatchObject({ indexPaneOpen: true, indexPaneWidth: 168, aiPaneOpen: true, aiPaneWidth: Number(width) })
})
it('resizes AI with pointer and keyboard and disables resizing in narrow layouts', async () => {
+56 -36
View File
@@ -7,7 +7,6 @@ import {
CircleAlert,
Circle,
FileText,
FolderTree,
Lightbulb,
ListTodo,
MoreHorizontal,
@@ -50,6 +49,7 @@ import type {
import type { ApplicationSettings, MagicNoteCommentMode } from '../../shared/application-settings-contracts'
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
import { MagicNoteContent } from './MagicNoteContent'
import { MagicTodoDirectory } from './MagicTodoDirectory'
import { MagicNoteEditor } from './MagicNoteEditor'
import { MagicCanvasEditor, canvasHasContent, type MagicCanvasEditorHandle } from './MagicCanvasEditor'
import type { MagicCanvasContentHandle } from './MagicCanvasContent'
@@ -106,6 +106,7 @@ type MagicNotesLayoutPreferences = {
indexPaneWidth: number
aiPaneOpen: boolean
aiPaneWidth: number
todoPaneWidth: number
}
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
@@ -113,7 +114,8 @@ function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
indexPaneOpen: true,
indexPaneWidth: defaultIndexPaneWidth,
aiPaneOpen: true,
aiPaneWidth: defaultAiPaneWidth
aiPaneWidth: defaultAiPaneWidth,
todoPaneWidth: 320
}
try {
const value = localStorage.getItem(magicNotesLayoutStorageKey)
@@ -124,6 +126,8 @@ function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
MagicNotesLayoutPreferences
>
return {
todoPaneWidth: typeof parsed.todoPaneWidth === 'number' && Number.isFinite(parsed.todoPaneWidth)
? Math.max(240, parsed.todoPaneWidth) : defaults.todoPaneWidth,
indexPaneOpen: parsed.indexPaneOpen !== false,
indexPaneWidth: typeof parsed.indexPaneWidth === 'number' && Number.isFinite(parsed.indexPaneWidth)
? Math.min(maximumIndexPaneWidth, Math.max(minimumIndexPaneWidth, parsed.indexPaneWidth))
@@ -466,8 +470,10 @@ export function MagicNotesWorkspace({
)
const [indexPaneOpen, setIndexPaneOpen] = useState(initialLayoutPreferences.indexPaneOpen)
const [indexPaneWidth, setIndexPaneWidth] = useState(initialLayoutPreferences.indexPaneWidth)
const [todoPaneWidth, setTodoPaneWidth] = useState(initialLayoutPreferences.todoPaneWidth)
const [todoLayoutWidth, setTodoLayoutWidth] = useState(window.innerWidth)
const [narrowIndexOpen, setNarrowIndexOpen] = useState(false)
const [resizingPane, setResizingPane] = useState<'ai' | 'index'>()
const [resizingPane, setResizingPane] = useState<'ai' | 'index' | 'todo'>()
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
window.innerWidth
)
@@ -521,7 +527,8 @@ export function MagicNotesWorkspace({
const draftAnalysisContextRef = useRef(0)
const lastDraftAnalysisStartedAtRef = useRef(0)
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
const paneResizeRef = useRef<{ pane: 'ai' | 'index'; pointerId: number; width: number } | undefined>(undefined)
const todoLayoutRef = useRef<HTMLDivElement>(null)
const paneResizeRef = useRef<{ pane: 'ai' | 'index' | 'todo'; pointerId: number; width: number } | undefined>(undefined)
const composerRef = useRef<HTMLDivElement>(null)
const continueEditingRef = useRef<HTMLButtonElement>(null)
const discardDraftRef = useRef<HTMLButtonElement>(null)
@@ -603,9 +610,20 @@ export function MagicNotesWorkspace({
indexPaneOpen,
indexPaneWidth,
aiPaneOpen,
aiPaneWidth
aiPaneWidth,
todoPaneWidth
})
}, [indexPaneOpen, indexPaneWidth, aiPaneOpen, aiPaneWidth])
}, [indexPaneOpen, indexPaneWidth, aiPaneOpen, aiPaneWidth, todoPaneWidth])
useEffect(() => {
const layout = todoLayoutRef.current
if (!layout) return
const update = (): void => setTodoLayoutWidth(layout.getBoundingClientRect().width || window.innerWidth)
update()
const observer = new ResizeObserver(update)
observer.observe(layout)
return () => observer.disconnect()
}, [detailView, libraryView])
useEffect(() => {
const layout = magicNotesLayoutRef.current
@@ -1507,6 +1525,8 @@ export function MagicNotesWorkspace({
? [...entries, editingEntry] : [...entries]).reverse()
}, [detail, editingEntry])
const isNarrowLayout = magicNotesLayoutWidth <= 800
const todoPaneWidthLimits = { minimum: 240, maximum: Math.max(240, todoLayoutWidth - 301) }
const displayedTodoWidth = clampMagicNotesPaneWidth(todoPaneWidth, todoPaneWidthLimits)
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
// Clamp displayed widths without replacing the user's saved desktop preferences.
const layoutInnerWidth = Math.max(0, magicNotesLayoutWidth - 2)
@@ -1534,6 +1554,7 @@ export function MagicNotesWorkspace({
event.currentTarget.releasePointerCapture(event.pointerId)
}
if (resize.pane === 'ai') setAiPaneWidth(resize.width)
else if (resize.pane === 'todo') setTodoPaneWidth(resize.width)
else setIndexPaneWidth(resize.width)
setResizingPane(undefined)
}
@@ -1541,37 +1562,40 @@ export function MagicNotesWorkspace({
const resize = paneResizeRef.current
if (!resize || resize.pointerId !== event.pointerId) return
const isAi = resize.pane === 'ai'
const limits = isAi ? aiPaneWidthLimits : indexPaneWidthLimits
if (isNarrowLayout) { finishPaneResize(event); return }
const bounds = getLayoutBounds()
const isTodo = resize.pane === 'todo'
const limits = isTodo ? todoPaneWidthLimits : isAi ? aiPaneWidthLimits : indexPaneWidthLimits
if (isTodo ? magicNotesLayoutWidth <= 700 : isNarrowLayout) { finishPaneResize(event); return }
const bounds = isTodo ? todoLayoutRef.current!.getBoundingClientRect() : getLayoutBounds()
resize.width = clampMagicNotesPaneWidth(isAi ? bounds.right - 1 - event.clientX : event.clientX - bounds.left - 1, limits)
magicNotesLayoutRef.current?.style.setProperty(`--magic-notes-${resize.pane}-width`, `${resize.width}px`)
const layout = isTodo ? todoLayoutRef.current : magicNotesLayoutRef.current
layout?.style.setProperty(`--magic-notes-${resize.pane}-width`, `${resize.width}px`)
event.currentTarget.setAttribute('aria-valuenow', String(resize.width))
event.currentTarget.setAttribute('aria-valuetext', t(isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width: resize.width }))
event.currentTarget.setAttribute('aria-valuetext', t(isTodo ? 'accessibility.todoPaneWidth' : isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width: resize.width }))
}
const startPaneResize = (pane: 'ai' | 'index', event: React.PointerEvent<HTMLDivElement>): void => {
const limits = pane === 'ai' ? aiPaneWidthLimits : indexPaneWidthLimits
if (event.button !== 0 || isNarrowLayout || limits.maximum <= limits.minimum) return
const startPaneResize = (pane: 'ai' | 'index' | 'todo', event: React.PointerEvent<HTMLDivElement>): void => {
const limits = pane === 'todo' ? todoPaneWidthLimits : pane === 'ai' ? aiPaneWidthLimits : indexPaneWidthLimits
if (event.button !== 0 || (pane === 'todo' ? magicNotesLayoutWidth <= 700 : isNarrowLayout) || limits.maximum <= limits.minimum) return
event.preventDefault()
paneResizeRef.current = { pane, pointerId: event.pointerId, width: pane === 'ai' ? displayedAiWidth : displayedIndexWidth }
paneResizeRef.current = { pane, pointerId: event.pointerId, width: pane === 'todo' ? displayedTodoWidth : pane === 'ai' ? displayedAiWidth : displayedIndexWidth }
event.currentTarget.setPointerCapture(event.pointerId)
setResizingPane(pane)
}
const renderPaneSeparator = (pane: 'ai' | 'index'): React.JSX.Element => {
const renderPaneSeparator = (pane: 'ai' | 'index' | 'todo'): React.JSX.Element => {
const isAi = pane === 'ai'
const limits = isAi ? aiPaneWidthLimits : indexPaneWidthLimits
const width = isAi ? displayedAiWidth : displayedIndexWidth
const enabled = !isNarrowLayout && limits.maximum > limits.minimum
const setWidth = isAi ? setAiPaneWidth : setIndexPaneWidth
const isTodo = pane === 'todo'
const limits = isTodo ? todoPaneWidthLimits : isAi ? aiPaneWidthLimits : indexPaneWidthLimits
const width = isTodo ? displayedTodoWidth : isAi ? displayedAiWidth : displayedIndexWidth
const enabled = (isTodo ? magicNotesLayoutWidth > 700 : !isNarrowLayout) && limits.maximum > limits.minimum
const setWidth = isTodo ? setTodoPaneWidth : isAi ? setAiPaneWidth : setIndexPaneWidth
return <div
aria-controls={isAi ? 'magic-notes-ai-pane' : 'magic-notes-index'}
aria-controls={isTodo ? 'magic-todo-list' : isAi ? 'magic-notes-ai-pane' : 'magic-notes-index'}
aria-disabled={!enabled}
aria-label={t(isAi ? 'accessibility.resizeAiPane' : 'accessibility.resizeIndexPane')}
aria-label={t(isTodo ? 'accessibility.resizeTodoPane' : isAi ? 'accessibility.resizeAiPane' : 'accessibility.resizeIndexPane')}
aria-orientation="vertical"
aria-valuemax={limits.maximum}
aria-valuemin={limits.minimum}
aria-valuenow={width}
aria-valuetext={t(isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width })}
aria-valuetext={t(isTodo ? 'accessibility.todoPaneWidth' : isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width })}
className={`magic-notes-pane-resize-handle magic-notes-${pane}-resize-handle`}
onKeyDown={(event) => {
if (!enabled) return
@@ -2310,8 +2334,8 @@ export function MagicNotesWorkspace({
value={todoFilter}
/>
</div>
<div className={`magic-todo-workspace${selectedTodo ? ' magic-todo-workspace--selected' : ''}`}>
<div className="magic-notes-list">
<div ref={todoLayoutRef} style={{ '--magic-notes-todo-width': `${displayedTodoWidth}px` } as React.CSSProperties} className={`magic-todo-workspace${selectedTodo ? ' magic-todo-workspace--selected' : ''}${resizingPane === 'todo' ? ' magic-todo-workspace--resizing' : ''}`}>
<div className="magic-notes-list" id="magic-todo-list">
{loadStatus === 'loading' ? (
<p className="magic-notes-muted">
{t('status.loadingTodos')}
@@ -2342,16 +2366,12 @@ export function MagicNotesWorkspace({
</>
) : (
todoDirectories.map((directory) => (
<section
className="magic-todo-directory"
<MagicTodoDirectory
key={directory.noteId}
noteId={directory.noteId}
title={directory.noteTitle}
count={directory.todos.length}
>
<div className="magic-todo-directory__heading">
<FolderTree aria-hidden="true" size={14} />
<strong>{directory.noteTitle}</strong>
<span>{directory.todos.length}</span>
</div>
<div className="magic-todo-directory__items">
{directory.todos.map((todo) => (
<div className="magic-todo-task" key={todo.id}>
<TodoListItem
@@ -2373,12 +2393,12 @@ export function MagicNotesWorkspace({
/>
</div>
))}
</div>
</section>
</MagicTodoDirectory>
))
)}
</div>
{selectedTodo ? (
{renderPaneSeparator('todo')}
{selectedTodo ? (
<section
aria-label={t('todos.detailLabel')}
className="magic-todo-detail"
@@ -0,0 +1,37 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { MagicTodoDirectory } from './MagicTodoDirectory'
afterEach(cleanup)
it('independently collapses groups with unique, stable controlled regions', () => {
render(<>
<MagicTodoDirectory noteId="first" title="First note" count={1}>
<button>First task</button>
</MagicTodoDirectory>
<MagicTodoDirectory noteId="second" title="Second note" count={1}>
<button>Second task</button>
</MagicTodoDirectory>
</>)
const first = screen.getByRole('button', { name: 'First note (1)' })
const second = screen.getByRole('button', { name: 'Second note (1)' })
const itemsId = first.getAttribute('aria-controls')!
const items = document.getElementById(itemsId)
expect(itemsId).not.toBe(second.getAttribute('aria-controls'))
expect(items).toContainElement(screen.getByRole('button', { name: 'First task' }))
expect(first).toHaveAttribute('aria-expanded', 'true')
first.focus()
fireEvent.click(first)
expect(first).toHaveAttribute('aria-expanded', 'false')
expect(first).toHaveFocus()
expect(items).not.toBeVisible()
expect(screen.queryByRole('button', { name: 'First task' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Second task' })).toBeVisible()
expect(second).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(first)
expect(first).toHaveAttribute('aria-expanded', 'true')
expect(first).toHaveAttribute('aria-controls', itemsId)
expect(screen.getByRole('button', { name: 'First task' })).toBeVisible()
})
+49
View File
@@ -0,0 +1,49 @@
import { ChevronRight, FolderTree } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
type MagicTodoDirectoryProps = {
noteId: string
title: string
count: number
children: ReactNode
}
export function MagicTodoDirectory({
noteId,
title,
count,
children
}: MagicTodoDirectoryProps): React.JSX.Element {
const [expanded, setExpanded] = useState(true)
const itemsId = useId()
return (
<section className="magic-todo-directory" data-note-id={noteId}>
<button
aria-controls={itemsId}
aria-expanded={expanded}
aria-label={`${title} (${count})`}
className="magic-todo-directory__heading"
onClick={() => setExpanded((current) => !current)}
type="button"
>
<ChevronRight
aria-hidden="true"
size={14}
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
<FolderTree aria-hidden="true" size={14} />
<strong>{title}</strong>
<span>{count}</span>
</button>
<div
className="magic-todo-directory__items"
hidden={!expanded}
id={itemsId}
style={{ display: expanded ? undefined : 'none' }}
>
{children}
</div>
</section>
)
}
@@ -181,6 +181,8 @@ export const magicNotes = {
accessibility: {
resizeAiPane: 'Resize the editor and AI comments panes',
resizeIndexPane: 'Resize the record index',
resizeTodoPane: 'Resize the to-do list and details',
todoPaneWidth: 'To-do list width {{width}} pixels',
indexPaneWidth: 'Record index width {{width}} pixels',
aiPaneWidth: 'AI comments pane, {{width}} pixels'
},
@@ -169,6 +169,8 @@ export const magicNotes = {
accessibility: {
resizeAiPane: '调整编辑区与 AI 评论宽度',
resizeIndexPane: '调整记录索引宽度',
resizeTodoPane: '调整待办列表与详情宽度',
todoPaneWidth: '待办列表宽度 {{width}} 像素',
indexPaneWidth: '记录索引宽度 {{width}} 像素',
aiPaneWidth: 'AI 评论栏 {{width}} 像素'
},
+45 -5
View File
@@ -1697,12 +1697,17 @@
flex: 1;
min-height: 0;
min-width: 0;
grid-template-columns: minmax(280px, 34%) minmax(0, 1fr);
grid-template-columns: var(--magic-notes-todo-width, 320px) 1px minmax(0, 1fr);
overflow: hidden;
}
.magic-todo-workspace > .magic-notes-list {
padding-right: var(--space-3);
padding-right: var(--space-2);
}
.magic-todo-workspace > .magic-notes-todo-resize-handle {
background: transparent;
margin-left: -4px;
}
.magic-todo-detail {
@@ -1711,8 +1716,7 @@
min-height: 0;
overflow: auto;
align-content: start;
border-left: 1px solid var(--border-subtle);
padding: var(--space-3) var(--space-4) var(--space-4);
padding: var(--space-3) var(--space-2) var(--space-4);
gap: var(--space-4);
}
@@ -1740,6 +1744,28 @@
}
}
.magic-todo-workspace--resizing {
cursor: col-resize;
user-select: none;
}
.magic-todo-workspace--resizing > .magic-notes-todo-resize-handle::after {
background: var(--accent);
width: 2px;
}
@container magic-notes-page (min-width: 701px) {
.magic-todo-workspace > .magic-notes-todo-resize-handle {
display: block;
}
}
@container magic-notes-page (max-width: 700px) {
.magic-todo-workspace > .magic-notes-todo-resize-handle {
display: none;
}
}
.magic-todo-detail h3,
.magic-todo-instructions {
margin: 0;
@@ -1797,7 +1823,21 @@
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto auto minmax(0, 1fr) auto;
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.magic-todo-directory__heading:hover {
background: var(--surface-subtle);
}
.magic-todo-directory__heading:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.magic-todo-directory__heading strong {