From f68584b4e2a9a25579dcad7e330875730a0d110a Mon Sep 17 00:00:00 2001 From: mesalogo Date: Thu, 24 Sep 2026 16:17:16 +0800 Subject: [PATCH] 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. --- UI-DESIGN.md | 1 + docs/features/magic-notes/README.md | 4 +- docs/features/magic-notes/progress.md | 13 +++ docs/features/magic-notes/technical-design.md | 12 ++- src/renderer/src/MagicNotesWorkspace.test.tsx | 30 +++++- src/renderer/src/MagicNotesWorkspace.tsx | 92 +++++++++++-------- src/renderer/src/MagicTodoDirectory.test.tsx | 37 ++++++++ src/renderer/src/MagicTodoDirectory.tsx | 49 ++++++++++ .../src/i18n/locales/en-US/magicNotes.ts | 2 + .../src/i18n/locales/zh-CN/magicNotes.ts | 2 + src/renderer/src/styles.css | 50 +++++++++- tests/magic-notes-layout.electron.test.ts | 28 ++++++ .../support/magic-notes-layout-regression.tsx | 6 +- 13 files changed, 278 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/MagicTodoDirectory.test.tsx create mode 100644 src/renderer/src/MagicTodoDirectory.tsx diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 29b860a6..856895d2 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -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`。 diff --git a/docs/features/magic-notes/README.md b/docs/features/magic-notes/README.md index a4390484..e665b4d4 100644 --- a/docs/features/magic-notes/README.md +++ b/docs/features/magic-notes/README.md @@ -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 diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index 172453ec..a1270788 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -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 diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index e0c9cbfe..e0844de1 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -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 diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index ea3eb256..fa9b33f5 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -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() + 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() + 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() @@ -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 () => { diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index 7ae9c3df..5e9c3b6d 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -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(null) - const paneResizeRef = useRef<{ pane: 'ai' | 'index'; pointerId: number; width: number } | undefined>(undefined) + const todoLayoutRef = useRef(null) + const paneResizeRef = useRef<{ pane: 'ai' | 'index' | 'todo'; pointerId: number; width: number } | undefined>(undefined) const composerRef = useRef(null) const continueEditingRef = useRef(null) const discardDraftRef = useRef(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): 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): 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
{ if (!enabled) return @@ -2310,8 +2334,8 @@ export function MagicNotesWorkspace({ value={todoFilter} />
-
-
+
+
{loadStatus === 'loading' ? (

{t('status.loadingTodos')} @@ -2342,16 +2366,12 @@ export function MagicNotesWorkspace({ ) : ( todoDirectories.map((directory) => ( -

-
-
-
{directory.todos.map((todo) => (
))} -
-
+ )) )}
- {selectedTodo ? ( + {renderPaneSeparator('todo')} + {selectedTodo ? (
{ + render(<> + + + + + + + ) + 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() +}) diff --git a/src/renderer/src/MagicTodoDirectory.tsx b/src/renderer/src/MagicTodoDirectory.tsx new file mode 100644 index 00000000..07482dba --- /dev/null +++ b/src/renderer/src/MagicTodoDirectory.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en-US/magicNotes.ts b/src/renderer/src/i18n/locales/en-US/magicNotes.ts index c6ecf276..0f14a82f 100644 --- a/src/renderer/src/i18n/locales/en-US/magicNotes.ts +++ b/src/renderer/src/i18n/locales/en-US/magicNotes.ts @@ -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' }, diff --git a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts index 035dc1b0..934b15db 100644 --- a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts +++ b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts @@ -169,6 +169,8 @@ export const magicNotes = { accessibility: { resizeAiPane: '调整编辑区与 AI 评论宽度', resizeIndexPane: '调整记录索引宽度', + resizeTodoPane: '调整待办列表与详情宽度', + todoPaneWidth: '待办列表宽度 {{width}} 像素', indexPaneWidth: '记录索引宽度 {{width}} 像素', aiPaneWidth: 'AI 评论栏 {{width}} 像素' }, diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 65e89c6a..bbffd693 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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 { diff --git a/tests/magic-notes-layout.electron.test.ts b/tests/magic-notes-layout.electron.test.ts index 45a1ba50..abf3891e 100644 --- a/tests/magic-notes-layout.electron.test.ts +++ b/tests/magic-notes-layout.electron.test.ts @@ -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); }); diff --git a/tests/support/magic-notes-layout-regression.tsx b/tests/support/magic-notes-layout-regression.tsx index 346b0048..aa48fb5c 100644 --- a/tests/support/magic-notes-layout-regression.tsx +++ b/tests/support/magic-notes-layout-regression.tsx @@ -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' }) }