fix: align magic note actions with conversation list menus
Move pin and whole-note deletion into hover and focus menus on note rows. Preserve editor drafts when operating on notes and reuse destructive confirmation with keyboard navigation. Commit the eight affected files in full, including existing application-settings integration and UI design edits in those files. Validation was performed against the shared worktree; unrelated changes remain uncommitted. Release note: 魔法笔记的置顶和删除移至列表项的更多菜单,与会话列表保持一致,并保留未保存的编辑内容。
This commit is contained in:
+8
-5
@@ -315,9 +315,10 @@
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 侧栏底部固定显示文件、终端、浏览器和资源监控四个系统工具图标,设置按钮独立靠右。工具
|
||||
图标只负责打开或聚焦助手工作栏面板,不在侧栏复制工具内容。已有稳定设置入口时,不在顶栏
|
||||
重复提供同一入口。
|
||||
- 侧栏底部提供应用中心入口,设置按钮独立靠右。中心与共同应用设置使用和全局设置相同的整窗 Modal;魔法笔记、智能心跳、本机推理可启用、常驻与排序,
|
||||
取消常驻不关闭功能。知识库固定显示在首个可选应用槽位之后,再过滤隐藏的可选项;不参与启用、常驻或排序,中心只提供“打开”,原工作区实际配置保留,没有实际设置时不添加通用设置按钮。
|
||||
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
|
||||
具体规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
|
||||
### 6.9 助手工作栏
|
||||
|
||||
@@ -335,8 +336,8 @@
|
||||
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
|
||||
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
|
||||
- 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中,
|
||||
单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。主侧栏系统工具快捷
|
||||
入口按当前上下文聚焦最近使用的匹配实例,额外实例仍从“+”目录创建。
|
||||
单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。选择已有 Tab 只激活
|
||||
该实例,保持其目标和历史;主侧栏不再提供按当前上下文匹配实例的重复工具入口。
|
||||
- Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换
|
||||
“当前项目”和“所有项目”;无项目任务在“所有项目”中标注“未绑定项目”。范围变化不得创建第二个 Task Center。每个 Task 只
|
||||
关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也
|
||||
@@ -714,6 +715,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
也可从页面标题区隐藏和恢复;宽度与显示状态均记住用户选择。分隔条保留 `separator`
|
||||
语义及方向键、Home、End 操作,窄窗口堆叠布局不显示拖拽分隔条。
|
||||
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
|
||||
- 整篇笔记的置顶、取消置顶和删除放在列表项的“更多”菜单,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;打开菜单不切换笔记,置顶或删除其他笔记不丢弃当前草稿。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
@@ -750,6 +752,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
|
||||
- 应用中心交付时,上述“平台功能 → 魔法笔记”的设置迁入应用共同设置界面;原页签如保留则只提供定位入口,不维护第二份表单。笔记页与应用中心共用配置;旧入口开关绑定业务启用值,迁为“启用应用”并保留原值,常驻使用独立配置。默认开启仅适用于未配置布尔值,只有独立显隐偏好才映射到常驻。此迁移规则见[应用导航 PRD](./docs/features/application-tool-navigation/prd.md#fr-11-应用导航设置)。
|
||||
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
|
||||
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
|
||||
- 内置 MCP 中的“图片生成与编辑”是自动管理例外:该行始终可见,标记“内置 · 自动管理”,不提供独立启停、分配保存、移除或凭据编辑。直连 Model、OpenCode、Continue、DeepSeek Harness 的分配只由已保存图片模型的“允许 AI 在会话中调用”设置推导;至少一个开启则全部勾选,没有开启模型则全部取消。使用禁用的原生 Checkbox、Runtime 标签及关联说明,保留可聚焦的“前往图片模型设置”导航;远程适用范围通过 GoodBuddy Agent 说明持续可见。Ask、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Magic Notes Progress
|
||||
|
||||
## 2026-09-17: Note List Action Menu
|
||||
|
||||
- Moved pin/unpin and whole-note deletion from the editor header to each note's
|
||||
action menu, following the [UI design system](../../../UI-DESIGN.md#137-魔法笔记).
|
||||
The menu uses existing update/delete IPC and shared destructive confirmation.
|
||||
- Added regression coverage for menu keyboard behavior, portal placement,
|
||||
dismissal, pin ordering, revision failures, deletion and retained drafts.
|
||||
- The focused Magic Notes suite passed 42 tests. The combined Magic Notes,
|
||||
conversation and shared-primitives suites passed 332 tests. Typecheck and
|
||||
targeted ESLint passed.
|
||||
- Full `npm test`: 370 files and 4,440 tests passed, 2 files and 3 tests failed,
|
||||
9 files and 67 tests skipped. Two heartbeat migration fixtures relabel a current
|
||||
database as an old version and fail on the existing `pinned` column. One
|
||||
OpenCode runtime-reuse request timed out; its two tests passed on isolated rerun.
|
||||
- Full lint reported 22 `document` no-undef errors in the unrelated, untracked
|
||||
`docs/features/story-graph/demo.js`. No unrelated files were changed to resolve
|
||||
these validation failures. Interactive Electron and touch verification remain
|
||||
unperformed.
|
||||
|
||||
## 2026-09-15: Automatic External Refresh
|
||||
|
||||
- Implemented the shared-database notification and Main/preload subscription
|
||||
|
||||
@@ -45,6 +45,22 @@ Read failures use the existing retryable refresh error and retain successful
|
||||
data and drafts. Retry uses the same draft-preserving loader. Interaction rules
|
||||
are owned by [the UI design system](../../../UI-DESIGN.md#137-魔法笔记).
|
||||
|
||||
## Note List Actions
|
||||
|
||||
Each note row has a selection button and a sibling action-menu button. The menu
|
||||
uses the conversation action styles and shared `DestructiveConfirmActions`, and
|
||||
is portalled to `document.body`. Its position follows the trigger on scrolling,
|
||||
window resizing and confirmation-size changes. Hiding the list, filtering out the
|
||||
target or leaving the notes panel closes the menu.
|
||||
|
||||
Pinning uses the target summary's ID and revision through the existing update
|
||||
IPC. It updates the sorted summary and matching selected detail without resetting
|
||||
title, composer or entry-edit drafts. Deleting uses the explicitly confirmed
|
||||
target ID; deleting another note uses the draft-preserving refresh path. Deleting
|
||||
the selected note clears its entry drafts only after the write succeeds, then
|
||||
selects a remaining note. These are renderer changes; database and Agent contracts
|
||||
are unchanged.
|
||||
|
||||
## Agent Search Contract
|
||||
|
||||
`note_search` accepts an integer `limit` from 1 to 100, defaulting to 8.
|
||||
@@ -71,6 +87,8 @@ daemon implementation update.
|
||||
channel, payload-free callback and listener removal.
|
||||
- `MagicNotesWorkspace.test.tsx` covers external updates and deletion, selection,
|
||||
source reload, editor identity and draft saves, coalescing, deferred writes,
|
||||
asynchronous selection races, error retry and unmount cleanup.
|
||||
asynchronous selection races, error retry and unmount cleanup. List-action
|
||||
coverage includes portal placement, keyboard navigation, dismissal, delete
|
||||
confirmation, target selection, revision failures and draft preservation.
|
||||
|
||||
No schema migration, dependency, refresh control or network service is added.
|
||||
|
||||
@@ -12,6 +12,7 @@ import i18n from './i18n'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import {
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
@@ -173,6 +174,7 @@ const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
|
||||
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
||||
const create = vi.fn<DesktopApi['magicNotes']['create']>()
|
||||
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
||||
const update = vi.fn<DesktopApi['magicNotes']['update']>()
|
||||
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
|
||||
const updateEntry = vi.fn<DesktopApi['magicNotes']['updateEntry']>()
|
||||
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
|
||||
@@ -193,6 +195,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -217,6 +220,7 @@ beforeEach(() => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -229,6 +233,7 @@ beforeEach(() => {
|
||||
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
|
||||
create.mockResolvedValue(alternateDetail(thirdNoteId, '新笔记'))
|
||||
remove.mockResolvedValue()
|
||||
update.mockResolvedValue({ ...detail, pinned: true, revision: 2 })
|
||||
const createdDetail: MagicNoteDetail = {
|
||||
...detail,
|
||||
revision: detail.revision + 1,
|
||||
@@ -351,6 +356,7 @@ beforeEach(() => {
|
||||
listTodos,
|
||||
create,
|
||||
remove,
|
||||
update,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
analyze,
|
||||
@@ -374,6 +380,116 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('MagicNotesWorkspace', () => {
|
||||
it('portals note actions with keyboard navigation, confirmation and outside dismissal', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const trigger = await screen.findByRole('button', { name: `更多笔记操作 ${detail.title}` })
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ right: 5000, bottom: 5000 } as DOMRect)
|
||||
fireEvent.click(trigger)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(menu.parentElement).toBe(document.body)
|
||||
expect(parseFloat(menu.style.left)).toBeLessThanOrEqual(window.innerWidth - 8)
|
||||
expect(parseFloat(menu.style.top)).toBeLessThanOrEqual(window.innerHeight - 8)
|
||||
expect(screen.getByRole('menuitem', { name: '置顶笔记' })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'End' })
|
||||
const deletion = screen.getByRole('menuitem', { name: '删除笔记' })
|
||||
expect(deletion).toHaveFocus()
|
||||
fireEvent.click(deletion)
|
||||
expect(screen.getByRole('alertdialog')).toHaveTextContent(detail.title)
|
||||
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: '删除笔记' })).toHaveFocus()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
act(() => screen.getByLabelText('笔记标题').focus())
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏左侧列表' }))
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([false, true])('pins a note without replacing selected drafts (selected: %s)', async (selected) => {
|
||||
const second = alternateDetail(secondNoteId, '第二篇笔记')
|
||||
list.mockResolvedValue({ notes: [detail, second] })
|
||||
const target = selected ? detail : second
|
||||
update.mockResolvedValue({ ...target, pinned: true, revision: 2 })
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const title = await screen.findByLabelText('笔记标题')
|
||||
act(() => title.focus())
|
||||
fireEvent.change(title, { target: { value: '未保存标题' } })
|
||||
const composer = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(composer)
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
const editors = screen.getAllByTestId('magic-note-editor')
|
||||
fireEvent.click(editors[1]!)
|
||||
const trigger = screen.getByRole('button', { name: `更多笔记操作 ${target.title}` })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '置顶笔记' }))
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith({ noteId: target.id, pinned: true, expectedRevision: 1 }))
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => expect(container.querySelector('.magic-note-list-item__title')).toHaveTextContent(target.title))
|
||||
expect(title).toHaveValue('未保存标题')
|
||||
expect(screen.getAllByTestId('magic-note-editor')).toEqual(editors)
|
||||
expect(document.getElementById(`magic-note-select-${noteId}`)).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(trigger)
|
||||
update.mockRejectedValueOnce(new Error('revision conflict'))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'revision conflict' })))
|
||||
expect(update).toHaveBeenLastCalledWith({ noteId: target.id, pinned: false, expectedRevision: 2 })
|
||||
expect(title).toHaveValue('未保存标题')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||||
await waitFor(() => expect(updateEntry).toHaveBeenCalledWith(expect.objectContaining({ expectedRevision: 1, content: { version: 1, ops: [{ insert: '新的句子\n' }] } })))
|
||||
})
|
||||
|
||||
it('deletes an unselected note without clearing the selected composer or dirty title', async () => {
|
||||
const second = alternateDetail(secondNoteId, '第二篇笔记')
|
||||
list.mockResolvedValue({ notes: [detail, second] })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const title = await screen.findByLabelText('笔记标题')
|
||||
fireEvent.change(title, { target: { value: '未保存标题' } })
|
||||
const editor = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(editor)
|
||||
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${second.title}` }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
list.mockResolvedValue({ notes: [detail] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(second.id))
|
||||
await waitFor(() => expect(screen.queryByText(second.title)).not.toBeInTheDocument())
|
||||
expect(title).toHaveValue('未保存标题')
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(editor)
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
|
||||
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content: { version: 1, ops: [{ insert: '新的句子\n' }] } }))
|
||||
})
|
||||
|
||||
it('retains drafts on delete failure and selects a remaining note after confirmed deletion', async () => {
|
||||
const second = alternateDetail(secondNoteId, '第二篇笔记')
|
||||
list.mockResolvedValue({ notes: [detail, second] })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByLabelText('笔记标题')
|
||||
const editor = screen.getByTestId('magic-note-editor')
|
||||
fireEvent.click(editor)
|
||||
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
remove.mockRejectedValueOnce(new Error('delete failed'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'delete failed' })))
|
||||
expect(screen.getByTestId('magic-note-editor')).toBe(editor)
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
list.mockResolvedValue({ notes: [second] })
|
||||
get.mockResolvedValue(second)
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
await screen.findByDisplayValue(second.title)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('magic-note-editor')).not.toBe(editor)
|
||||
await waitFor(() => expect(document.getElementById(`magic-note-select-${second.id}`)).toHaveFocus())
|
||||
})
|
||||
|
||||
it('coalesces external writes, keeps selection and refreshes the selected detail', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await screen.findByDisplayValue(detail.title)
|
||||
@@ -390,7 +506,8 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(screen.getByText(added.title)).toBeInTheDocument()
|
||||
expect(get).toHaveBeenLastCalledWith(noteId)
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('button', { name: '取消置顶' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${updated.title}` }))
|
||||
expect(screen.getByRole('menuitem', { name: '取消置顶' })).toBeInTheDocument()
|
||||
|
||||
list.mockResolvedValue({ notes: [added] })
|
||||
get.mockResolvedValue(added)
|
||||
@@ -505,7 +622,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
list.mockImplementationOnce(() => new Promise((resolve) => { resolveList = resolve }))
|
||||
act(() => changeListener?.())
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Second note/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Second note/, pressed: false }))
|
||||
await screen.findByDisplayValue(second.title)
|
||||
await act(async () => resolveList({ notes: [detail, second] }))
|
||||
expect(screen.getByDisplayValue(second.title)).toBeInTheDocument()
|
||||
@@ -577,20 +694,19 @@ describe('MagicNotesWorkspace', () => {
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
|
||||
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '删除笔记' })[1]!
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/刷新失败,已保留当前内容:刷新暂时不可用/)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('笔记标题')).toHaveValue(detail.title)
|
||||
expect(
|
||||
screen.getByRole('button', { name: /发布笔记/ })
|
||||
screen.getByRole('button', { name: /发布笔记/, pressed: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
const updatedTime = screen
|
||||
.getByRole('button', { name: /发布笔记/ })
|
||||
.getByRole('button', { name: /发布笔记/, pressed: true })
|
||||
.querySelector('time')
|
||||
expect(updatedTime).toHaveAttribute('datetime', detail.updatedAt)
|
||||
expect(updatedTime).toHaveTextContent('更新于')
|
||||
@@ -662,17 +778,10 @@ describe('MagicNotesWorkspace', () => {
|
||||
)
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
const deleteNote = screen.getByRole('button', {
|
||||
name: '删除笔记'
|
||||
})
|
||||
expect(container.querySelector('.magic-note-detail-header button')).toBeNull()
|
||||
const deleteEntry = screen.getByRole('button', {
|
||||
name: '删除记录'
|
||||
})
|
||||
expect(deleteNote).toHaveClass(
|
||||
'icon-button',
|
||||
'magic-note-detail-header__delete'
|
||||
)
|
||||
expect(deleteNote).toHaveTextContent('')
|
||||
expect(deleteEntry).toHaveClass('danger-button', 'danger-button--quiet')
|
||||
expect(deleteEntry).toHaveTextContent('删除记录')
|
||||
|
||||
@@ -1043,7 +1152,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(continueEditing).toHaveFocus()
|
||||
expect(get).toHaveBeenCalledTimes(callsBeforeSwitch)
|
||||
expect(
|
||||
screen.getByRole('button', { name: /发布笔记/ })
|
||||
screen.getByRole('button', { name: /发布笔记/, pressed: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
discardAndSwitch.focus()
|
||||
@@ -1076,7 +1185,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
|
||||
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /第二篇笔记/ })
|
||||
screen.getByRole('button', { name: /第二篇笔记/, pressed: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
@@ -1186,7 +1295,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /发布笔记/ })
|
||||
screen.getByRole('button', { name: /发布笔记/, pressed: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(
|
||||
@@ -1316,10 +1425,9 @@ describe('MagicNotesWorkspace', () => {
|
||||
|
||||
await screen.findByText('记录正文')
|
||||
list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
|
||||
fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '删除笔记' })[1]!
|
||||
)
|
||||
const retry = await screen.findByRole('button', { name: '重试' })
|
||||
|
||||
let resolveRefreshDetail:
|
||||
@@ -1431,6 +1539,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -1467,6 +1576,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -1586,6 +1696,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FolderTree,
|
||||
Lightbulb,
|
||||
ListTodo,
|
||||
MoreHorizontal,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
@@ -22,11 +23,13 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type {
|
||||
MagicNoteAnalysisOptions,
|
||||
MagicNoteCommentDirection,
|
||||
@@ -39,7 +42,7 @@ import type {
|
||||
MagicNoteSummary,
|
||||
MagicTodoItem
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
import type { MagicNoteCommentMode } from '../../shared/application-settings-contracts'
|
||||
import type { ApplicationSettings, MagicNoteCommentMode } from '../../shared/application-settings-contracts'
|
||||
import { MagicNoteContent } from './MagicNoteContent'
|
||||
import { MagicNoteEditor } from './MagicNoteEditor'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
@@ -47,6 +50,7 @@ import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
EmptyState,
|
||||
DestructiveConfirmActions,
|
||||
PageHeader,
|
||||
PageTabs,
|
||||
SegmentedControl,
|
||||
@@ -54,6 +58,7 @@ import {
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
export type MagicNotesWorkspaceProps = {
|
||||
applicationSettings?: ApplicationSettings
|
||||
onNotify: (notification: AppNotificationInput) => void
|
||||
}
|
||||
|
||||
@@ -393,7 +398,8 @@ function TodoListItem({
|
||||
}
|
||||
|
||||
export function MagicNotesWorkspace({
|
||||
onNotify
|
||||
onNotify,
|
||||
applicationSettings
|
||||
}: MagicNotesWorkspaceProps): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('magicNotes')
|
||||
const tRef = useRef(t)
|
||||
@@ -474,12 +480,14 @@ export function MagicNotesWorkspace({
|
||||
const [todos, setTodos] = useState<MagicTodoItem[]>([])
|
||||
const [libraryView, setLibraryView] = useState<LibraryView>('notes')
|
||||
const [todoFilter, setTodoFilter] = useState<TodoFilter>('active')
|
||||
const [commentMode, setCommentMode] =
|
||||
const [loadedCommentMode, setCommentMode] =
|
||||
useState<MagicNoteCommentMode>('immediate')
|
||||
const [commentDirection, setCommentDirection] =
|
||||
useState<MagicNoteCommentDirection>('general')
|
||||
const [commentFormat, setCommentFormat] =
|
||||
const [loadedCommentFormat, setCommentFormat] =
|
||||
useState<MagicNoteCommentFormat>('combined')
|
||||
const commentMode = applicationSettings?.magicNoteCommentMode ?? loadedCommentMode
|
||||
const commentFormat = applicationSettings?.magicNoteCommentFormat ?? loadedCommentFormat
|
||||
const [selectedNoteId, setSelectedNoteId] = useState('')
|
||||
const [selectedTodoId, setSelectedTodoId] = useState('')
|
||||
const [detail, setDetail] = useState<MagicNoteDetail>()
|
||||
@@ -498,6 +506,9 @@ export function MagicNotesWorkspace({
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [deletingNote, setDeletingNote] = useState(false)
|
||||
const [noteActionsId, setNoteActionsId] = useState('')
|
||||
const noteActionsRef = useRef<HTMLDivElement>(null)
|
||||
const noteActionTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const [composerKey, setComposerKey] = useState(0)
|
||||
const [editingEntry, setEditingEntry] = useState<MagicNoteEntry>()
|
||||
const [deletingEntryId, setDeletingEntryId] = useState('')
|
||||
@@ -1412,6 +1423,97 @@ export function MagicNotesWorkspace({
|
||||
: notes
|
||||
}, [notes, search])
|
||||
|
||||
const actionNote = useMemo(
|
||||
() => listPaneOpen && libraryView === 'notes' && !pendingDraftSwitch
|
||||
? visibleNotes.find((note) => note.id === noteActionsId)
|
||||
: undefined,
|
||||
[libraryView, listPaneOpen, noteActionsId, pendingDraftSwitch, visibleNotes]
|
||||
)
|
||||
if (noteActionsId && !actionNote) {
|
||||
setNoteActionsId('')
|
||||
setDeletingNote(false)
|
||||
}
|
||||
|
||||
const closeNoteActions = (): void => {
|
||||
setNoteActionsId('')
|
||||
setDeletingNote(false)
|
||||
noteActionTriggerRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const surface = noteActionsRef.current
|
||||
const trigger = noteActionTriggerRef.current
|
||||
if (!noteActionsId || !surface || !trigger) return
|
||||
const position = (): void => {
|
||||
const anchor = trigger.getBoundingClientRect()
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px`
|
||||
surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px`
|
||||
}
|
||||
position()
|
||||
surface.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true })
|
||||
const dismiss = (event: Event): void => {
|
||||
if (event.target instanceof Node && !surface.contains(event.target) && !trigger.contains(event.target)) {
|
||||
setNoteActionsId('')
|
||||
setDeletingNote(false)
|
||||
}
|
||||
}
|
||||
const observer = new ResizeObserver(position)
|
||||
observer.observe(surface)
|
||||
document.addEventListener('pointerdown', dismiss)
|
||||
document.addEventListener('focusin', dismiss)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
document.removeEventListener('pointerdown', dismiss)
|
||||
document.removeEventListener('focusin', dismiss)
|
||||
window.removeEventListener('resize', position)
|
||||
window.removeEventListener('scroll', position, true)
|
||||
}
|
||||
}, [noteActionsId])
|
||||
|
||||
const pinNote = async (note: MagicNoteSummary): Promise<void> => {
|
||||
const operation = 'pin-note'
|
||||
if (!beginBusy(operation)) return
|
||||
closeNoteActions()
|
||||
try {
|
||||
const updated = await window.goodbuddy.magicNotes.update({
|
||||
noteId: note.id,
|
||||
pinned: !note.pinned,
|
||||
expectedRevision: note.revision
|
||||
})
|
||||
applyNoteSummary(updated)
|
||||
setDetail((current) => current?.id === updated.id ? updated : current)
|
||||
} catch (error) {
|
||||
notifyError(error)
|
||||
} finally {
|
||||
endBusy(operation)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNote = async (note: MagicNoteSummary): Promise<void> => {
|
||||
const operation = 'delete-note'
|
||||
if (!beginBusy(operation)) return
|
||||
try {
|
||||
await window.goodbuddy.magicNotes.remove(note.id)
|
||||
notifySuccess(t('notifications.noteDeleted'))
|
||||
closeNoteActions()
|
||||
if (requestedNoteIdRef.current === note.id) {
|
||||
discardComposerDraft()
|
||||
discardEditingDraft()
|
||||
await refreshNotes()
|
||||
requestAnimationFrame(() => document.getElementById(`magic-note-select-${requestedNoteIdRef.current}`)?.focus())
|
||||
} else {
|
||||
await refreshNotes(requestedNoteIdRef.current, true)
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error)
|
||||
} finally {
|
||||
endBusy(operation)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleTodos = useMemo(() => {
|
||||
const normalized = search.trim().toLocaleLowerCase()
|
||||
return todos.filter((todo) => {
|
||||
@@ -2057,8 +2159,8 @@ export function MagicNotesWorkspace({
|
||||
</>
|
||||
) : (
|
||||
visibleNotes.map((note) => (
|
||||
<div className="magic-note-row" key={note.id}>
|
||||
<button
|
||||
key={note.id}
|
||||
id={`magic-note-select-${note.id}`}
|
||||
aria-pressed={selectedNoteId === note.id}
|
||||
className={`magic-note-list-item ${
|
||||
@@ -2101,8 +2203,66 @@ export function MagicNotesWorkspace({
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={`magic-note-actions-${note.id}`}
|
||||
aria-expanded={noteActionsId === note.id}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('actions.more', { title: note.title })}
|
||||
title={t('actions.more', { title: note.title })}
|
||||
className="magic-note-more icon-button"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
noteActionTriggerRef.current = event.currentTarget
|
||||
setDeletingNote(false)
|
||||
setNoteActionsId((current) => current === note.id ? '' : note.id)
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{actionNote && createPortal(
|
||||
<div
|
||||
className="conversation-actions magic-note-actions"
|
||||
id={`magic-note-actions-${actionNote.id}`}
|
||||
aria-label={t('actions.more', { title: actionNote.title })}
|
||||
ref={noteActionsRef}
|
||||
role="menu"
|
||||
onKeyDown={(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeNoteActions()
|
||||
return
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
||||
items[next]?.focus()
|
||||
}}
|
||||
>
|
||||
<button role="menuitem" type="button" disabled={Boolean(busy)} onClick={() => void pinNote(actionNote)}>
|
||||
{actionNote.pinned ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
{t(actionNote.pinned ? 'actions.unpinNote' : 'actions.pinNote')}
|
||||
</button>
|
||||
<DestructiveConfirmActions
|
||||
triggerRole="menuitem"
|
||||
triggerLabel={t('actions.deleteNote')}
|
||||
confirmLabel={t('actions.deleteNote')}
|
||||
confirming={deletingNote}
|
||||
disabled={Boolean(busy)}
|
||||
icon={<Trash2 size={14} />}
|
||||
message={t('confirmations.deleteNote', { title: actionNote.title })}
|
||||
onCancel={() => setDeletingNote(false)}
|
||||
onRequestConfirm={() => setDeletingNote(true)}
|
||||
onConfirm={() => void deleteNote(actionNote)}
|
||||
/>
|
||||
</div>, document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2305,7 +2465,10 @@ export function MagicNotesWorkspace({
|
||||
aria-label={t('notes.titleLabel')}
|
||||
maxLength={100}
|
||||
value={titleDraft}
|
||||
onBlur={() => void updateTitle()}
|
||||
onBlur={(event) => {
|
||||
if (event.relatedTarget instanceof Element && event.relatedTarget.closest('.magic-note-more, .magic-note-actions')) return
|
||||
void updateTitle()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setTitleDraft(event.target.value)
|
||||
clearValidation('note-title')
|
||||
@@ -2320,54 +2483,6 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
aria-label={t(
|
||||
detail.pinned
|
||||
? 'actions.unpinNote'
|
||||
: 'actions.pinNote'
|
||||
)}
|
||||
className="icon-button"
|
||||
title={t(
|
||||
detail.pinned
|
||||
? 'actions.unpinNote'
|
||||
: 'actions.pinNote'
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const operation = 'pin-note'
|
||||
if (!beginBusy(operation)) {
|
||||
return
|
||||
}
|
||||
void window.goodbuddy.magicNotes
|
||||
.update({
|
||||
noteId: detail.id,
|
||||
pinned: !detail.pinned,
|
||||
expectedRevision: detail.revision
|
||||
})
|
||||
.then(applyDetail)
|
||||
.catch((pinError) =>
|
||||
notifyError(pinError)
|
||||
)
|
||||
.finally(() => endBusy(operation))
|
||||
}}
|
||||
>
|
||||
{detail.pinned ? (
|
||||
<PinOff size={15} />
|
||||
) : (
|
||||
<Pin size={15} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('actions.deleteNote')}
|
||||
className="icon-button magic-note-detail-header__delete"
|
||||
title={t('actions.deleteNote')}
|
||||
type="button"
|
||||
onClick={() => setDeletingNote(true)}
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{validation?.target === 'note-title' && (
|
||||
<p
|
||||
@@ -2379,46 +2494,6 @@ export function MagicNotesWorkspace({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{deletingNote && (
|
||||
<div className="magic-note-delete-confirmation">
|
||||
<span>
|
||||
{t('confirmations.deleteNote', {
|
||||
title: detail.title
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => setDeletingNote(false)}
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-solid"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const operation = 'delete-note'
|
||||
if (!beginBusy(operation)) {
|
||||
return
|
||||
}
|
||||
void window.goodbuddy.magicNotes
|
||||
.remove(detail.id)
|
||||
.then(async () => {
|
||||
notifySuccess(t('notifications.noteDeleted'))
|
||||
setDeletingNote(false)
|
||||
await refreshNotes()
|
||||
})
|
||||
.catch((deleteError) =>
|
||||
notifyError(deleteError)
|
||||
)
|
||||
.finally(() => endBusy(operation))
|
||||
}}
|
||||
>
|
||||
{t('actions.deleteNote')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="magic-note-composer" ref={composerRef}>
|
||||
<MagicNoteEditor
|
||||
key={`${detail.id}-${composerKey}`}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const magicNotes = {
|
||||
todos: 'To-dos'
|
||||
},
|
||||
actions: {
|
||||
more: 'More note actions {{title}}',
|
||||
cancel: 'Cancel',
|
||||
retry: 'Retry',
|
||||
clearFilters: 'Clear filters',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const magicNotes = {
|
||||
todos: '待办'
|
||||
},
|
||||
actions: {
|
||||
more: '更多笔记操作 {{title}}',
|
||||
cancel: '取消',
|
||||
retry: '重试',
|
||||
clearFilters: '清除筛选',
|
||||
|
||||
+28
-32
@@ -1656,6 +1656,34 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-row {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.magic-note-more.icon-button {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.magic-note-row:hover .magic-note-more,
|
||||
.magic-note-row:focus-within .magic-note-more,
|
||||
.magic-note-more[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.magic-note-row .magic-note-list-item__title {
|
||||
padding-right: var(--space-6);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.magic-note-more.icon-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.magic-note-list-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -1876,38 +1904,6 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.magic-note-detail-header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-detail-header__delete {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.magic-note-detail-header__delete:hover {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.magic-note-delete-confirmation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-delete-confirmation > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.magic-notes-field-error {
|
||||
margin: 0;
|
||||
color: var(--danger-strong);
|
||||
|
||||
Reference in New Issue
Block a user