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:
@@ -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