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:
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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}} 像素'
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }) }
|
||||
|
||||
Reference in New Issue
Block a user