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
+1
View File
@@ -761,6 +761,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 默认进入笔记总览,不自动打开第一篇笔记。笔记以自适应卡片网格平铺,卡片显示标题、最多两行内容预览、记录数和本地化更新时间。点击笔记进入详情,详情不保留内部左列表;“返回总览”恢复搜索、滚动位置和入口焦点。新建入口在笔记总览与待办页可见,创建成功后进入笔记详情。
- 待办页是独立任务视图:搜索任务标题、说明或来源笔记,搜索框右侧同一工具栏放置“全部 / 未完成 / 已完成”筛选,显示匹配数与总数;任务按来源笔记分组。列表独立滚动,页头、搜索和筛选保持可见,窄屏工具栏空间不足时换行。直接勾选完成或恢复未完成,不自动选择任务;状态变化后按当前筛选更新列表。
- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
- 待办左右栏分割线支持拖拽,并记住列表宽度;键盘左右方向键每次调整 `16px`,Home/End 调整至边界。列表默认 `320px`、最小 `240px`,详情至少保留 `300px`;窄屏隐藏分割线,临时缩窄窗口不覆盖已保存宽度。每个来源笔记标题可独立收起或展开待办列表,保留标题与数量,通过 `aria-expanded` 和 `aria-controls` 表明状态;收起不清除右侧当前详情。
- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
- 笔记详情主区按新到旧连续展示全部记录。打开笔记即在标题下、历史记录上显示空白输入区,保留文字/画布类型选择和保存操作,标题栏与索引均不提供“新增记录”按钮。新增保存成功后重建空白输入区,保留所选类型;自动分析结束也不转入旧记录编辑。旧记录通过显式编辑入口修改,保存使用同一 ID 和返回的修订号。点击索引或当前笔记内的 AI 来源只按 entry ID 滚动定位,不卸载编辑器、不清草稿、不询问离开。空白输入区离开不提示,返回总览、打开其他笔记或改为编辑另一条记录时仍保护未保存修改。
- 详情页的一级标题显示当前笔记已保存的标题,不显示模块说明;全局范围、返回导航和索引开关保持可见。原标题输入框使用正文大小,保留原有编辑、失焦保存与键盘操作,不形成第二个醒目标题。新建文字记录的输入区初始约四行,随内容自然增高,保留原有调整大小与滚动方式;此高度不作用于旧记录编辑器或画布。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
+3 -1
View File
@@ -15,7 +15,9 @@ above the history, with text/canvas selection and Save. Creating a record resets
that composer; editing a saved record requires its explicit Edit action. The separate To-dos view
keeps search, status filters and grouped tasks on one page, with direct completion
and a stable left list/right detail layout for instructions, source content and AI
comments. Narrow containers use on-demand detail with an explicit return to the
comments. The divider can be dragged and remembers its width; each note group
can independently collapse its tasks while retaining its title and count.
Narrow containers use on-demand detail with an explicit return to the
mounted list. One content-sized button switches to the other view in the page
header immediately before New note; status filters sit beside search and wrap
when needed. Note detail removes the surrounding frame and stream padding while
+13
View File
@@ -1,5 +1,18 @@
# Magic Notes Progress
## 2026-09-24: Resizable Todo List and Collapsible Note Groups
- Todo list/detail uses a 1px divider with a transparent 9px pointer target and
8px content padding on each side. Dragging and keyboard resizing persist the
list width; narrow layouts retain the saved desktop preference.
- Source-note headings independently collapse their task rows while retaining
the count and selected detail. Buttons support native keyboard activation.
- Focused workspace, disclosure and Electron layout tests passed: 3 files,
115 tests. Native Electron input verified dragging, keyboard expansion,
width restoration after reload and narrow-window transitions. Typecheck and
lint passed. A full-suite validation attempt was cancelled; no full-suite
success is claimed for this change.
## 2026-09-20: Shared Inline Help
The canvas page-count question mark now uses `InlineHelp`, superseding the native
+10 -2
View File
@@ -392,8 +392,16 @@ At a page container width of 700px or less, CSS hides the mounted list while a
task is selected and shows an explicit return button in the detail area. Returning
clears selection and restores row focus without scrolling; source-note return
retains selection and focuses this visible return button on narrow layouts.
There is no todo detail route, separate AI sidebar or resize handle. Note detail
retains its existing AI pane preferences.
There is no todo detail route or separate AI sidebar. A shared accessible resize
separator displays a 1px line with a transparent 9px hit area and 8px adjacent
content padding. It adjusts the list width, initially 320px, with a 240px minimum and at
least 300px reserved for detail. Pointer dragging and 16px arrow steps/Home/End
persist `todoPaneWidth` in `goodbuddy.magic-notes-layout.v1`. Narrow layouts hide
the separator; window constraints do not overwrite the saved desktop width.
Each source-note heading is a disclosure button with its matching task count.
Groups start expanded and can independently hide their mounted rows without
clearing the selected detail. Collapse state lasts while the group is mounted.
Note detail retains its existing AI pane preferences.
A single secondary button lives in `PageHeader.actions` before New note. Its
label and icon describe the destination, and `requestDraftSwitch` retains the
+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 {
+28
View File
@@ -127,6 +127,34 @@ it('resizes record columns with native Electron input and preserves desktop widt
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-stream-pane")).scrollbarWidth'), 'auto');
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-stream-pane")).scrollbarColor'), 'auto');
win.webContents.debugger.detach();
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')});
await wait('!!document.querySelector("#magic-library-switch")');
await click('#magic-library-switch');
await wait('!!document.querySelector("#magic-todo-select-layout-todo")');
await click('#magic-todo-select-layout-todo');
const todoHandle = '.magic-notes-todo-resize-handle';
assert.equal(await js('getComputedStyle(document.querySelector(".magic-todo-workspace")).gridTemplateColumns.split(" ")[1]'), '1px');
assert.equal((await rect(todoHandle)).width, 9, 'Transparent hit area remains easy to drag');
await drag(todoHandle, 100);
const todoWidth = (await rect('#magic-todo-list')).width;
assert(todoWidth > 410 && todoWidth < 430, 'Todo drag width: ' + todoWidth);
await click('.magic-todo-directory__heading');
assert.equal(await js('document.querySelector(".magic-todo-directory__items").checkVisibility()'), false);
assert.equal(await js('document.querySelector(".magic-todo-detail").checkVisibility()'), true);
win.focus(); win.webContents.focus();
await key('.magic-todo-directory__heading', 'Space');
assert.equal(await js('document.querySelector(".magic-todo-directory__items").checkVisibility()'), true);
win.setContentSize(760, 640); await settle(); await settle();
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-todo-resize-handle")).display'), 'block');
win.setContentSize(600, 640); await settle(); await settle();
assert.equal(await js('document.querySelector(".magic-notes-todo-resize-handle").checkVisibility()'), false);
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
win.setContentSize(1280, 800); await settle(); await settle();
assert.equal((await rect('#magic-todo-list')).width, todoWidth);
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')});
await wait('!!document.querySelector("#magic-library-switch")');
await click('#magic-library-switch');
assert.equal((await rect('#magic-todo-list')).width, todoWidth);
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ emptyHeight, grownHeight, dragged, withoutAi, constrained, narrowStream, persisted: true }));
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
@@ -15,7 +15,11 @@ const note = {
}
window.goodbuddy = {
magicNotes: {
list: async () => ({ notes: [note] }), get: async () => note, listTodos: async () => ({ todos: [] }),
list: async () => ({ notes: [note] }), get: async () => note, listTodos: async () => ({ todos: [{
id: 'layout-todo', noteId: note.id, noteTitle: note.title, entryId: 'layout-entry',
title: 'Layout task', instructions: 'Task details', completed: false, revision: 1,
comments: [], createdAt: note.createdAt, updatedAt: note.updatedAt
}] }),
onChanged: () => () => {}, onAnalysisEvent: () => () => {}
},
updates: { getSettings: async () => ({ magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'combined' }) }