fix: add new-entry cancel, full-width note content, and restore focus in magic notes
- Add a Cancel action to the new-entry composer with discard confirmation - Remove the 880px cap so note content matches the toolbar width - Focus the chat composer only after a deferred workspace leave commits - Return focus to a sensible target after cancelling new/edited entries and switching entry type
This commit is contained in:
@@ -14770,6 +14770,28 @@ describe("App", () => {
|
|||||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('focuses the chat composer after a guarded Magic Notes leave to a new conversation', async () => {
|
||||||
|
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||||
|
const note = {
|
||||||
|
id: '00000000-0000-4000-8000-000000000602', title: 'Focus note', preview: '',
|
||||||
|
entryCount: 0, pinned: false, tags: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-01T00:00:00.000Z', entries: [],
|
||||||
|
}
|
||||||
|
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note], tags: [] })
|
||||||
|
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Focus note.*条记录/ }))
|
||||||
|
fireEvent.change(await screen.findByLabelText('笔记标题'), { target: { value: 'Unsaved focus title' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
|
||||||
|
const discard = await screen.findByRole('button', { name: '放弃草稿并切换' })
|
||||||
|
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)) })
|
||||||
|
// A real pointer click moves focus to the confirmation button before the leave completes.
|
||||||
|
discard.focus()
|
||||||
|
fireEvent.click(discard)
|
||||||
|
await waitFor(() => expect(screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })).toHaveFocus())
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => {
|
it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => {
|
||||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||||
const note = {
|
const note = {
|
||||||
|
|||||||
@@ -2937,6 +2937,14 @@ function App(): React.JSX.Element {
|
|||||||
},
|
},
|
||||||
[setConversationInput],
|
[setConversationInput],
|
||||||
);
|
);
|
||||||
|
// Leaving a workspace can be deferred (draft flush or discard confirmation),
|
||||||
|
// so focus the composer only after the chat view is actually committed.
|
||||||
|
const showChatAndFocusComposer = useCallback((): void => {
|
||||||
|
requestWorkspaceLeave("chat", () => {
|
||||||
|
commitView("chat");
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
});
|
||||||
|
}, [commitView, requestWorkspaceLeave]);
|
||||||
const setQuickActionInput = useCallback((value: string): void => {
|
const setQuickActionInput = useCallback((value: string): void => {
|
||||||
setConversationInput(activeConversationIdRef.current, value);
|
setConversationInput(activeConversationIdRef.current, value);
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
@@ -4049,7 +4057,7 @@ function App(): React.JSX.Element {
|
|||||||
isUnusedConversation(currentConversation)
|
isUnusedConversation(currentConversation)
|
||||||
) {
|
) {
|
||||||
if (preview) preview.ready(currentConversation);
|
if (preview) preview.ready(currentConversation);
|
||||||
else { setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); }
|
else showChatAndFocusComposer();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const conversation = createConversation(
|
const conversation = createConversation(
|
||||||
@@ -4064,10 +4072,10 @@ function App(): React.JSX.Element {
|
|||||||
};
|
};
|
||||||
setConversations(nextConversations);
|
setConversations(nextConversations);
|
||||||
if (preview) preview.ready(conversation);
|
if (preview) preview.ready(conversation);
|
||||||
else { setActiveId(conversation.id); setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); }
|
else { setActiveId(conversation.id); showChatAndFocusComposer(); }
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[notify, projects, setActiveId, setView],
|
[notify, projects, setActiveId, setView, showChatAndFocusComposer],
|
||||||
);
|
);
|
||||||
const activeProjectDisplayName = activeProject
|
const activeProjectDisplayName = activeProject
|
||||||
? getProjectDisplayText(activeProject, tWorkspace).name
|
? getProjectDisplayText(activeProject, tWorkspace).name
|
||||||
@@ -7184,7 +7192,7 @@ function App(): React.JSX.Element {
|
|||||||
]);
|
]);
|
||||||
setSelectedAssistantTaskId(undefined);
|
setSelectedAssistantTaskId(undefined);
|
||||||
setActiveId(nextBranch.id);
|
setActiveId(nextBranch.id);
|
||||||
setView("chat");
|
showChatAndFocusComposer();
|
||||||
if (narrowWindow) {
|
if (narrowWindow) {
|
||||||
closeNarrowSidebar();
|
closeNarrowSidebar();
|
||||||
}
|
}
|
||||||
@@ -7192,7 +7200,6 @@ function App(): React.JSX.Element {
|
|||||||
tone: "success",
|
tone: "success",
|
||||||
message: t("notices.conversationBranched"),
|
message: t("notices.conversationBranched"),
|
||||||
});
|
});
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify({
|
notify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
@@ -10890,10 +10897,7 @@ function App(): React.JSX.Element {
|
|||||||
? current
|
? current
|
||||||
: [...current, libraryId],
|
: [...current, libraryId],
|
||||||
);
|
);
|
||||||
setView("chat");
|
showChatAndFocusComposer();
|
||||||
requestAnimationFrame(() =>
|
|
||||||
inputRef.current?.focus(),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
onPauseSource={(sourceId) =>
|
onPauseSource={(sourceId) =>
|
||||||
runKnowledgeSourceAction(() =>
|
runKnowledgeSourceAction(() =>
|
||||||
|
|||||||
@@ -822,6 +822,33 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
|||||||
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content }))
|
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cancels a new entry only after confirming that its draft will be discarded', async () => {
|
||||||
|
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||||
|
await openNote()
|
||||||
|
newEntry()
|
||||||
|
const composer = document.querySelector('.magic-note-composer')!
|
||||||
|
fireEvent.click(screen.getByTestId('magic-note-editor'))
|
||||||
|
fireEvent.click(within(composer as HTMLElement).getByRole('button', { name: '取消' }))
|
||||||
|
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||||
|
expect(document.querySelector('.magic-note-composer')).toHaveClass('magic-note-composer--active')
|
||||||
|
fireEvent.click(within(document.querySelector('.magic-note-composer') as HTMLElement).getByRole('button', { name: '取消' }))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '放弃草稿并切换' }))
|
||||||
|
await waitFor(() => expect(document.querySelector('.magic-note-composer')).not.toHaveClass('magic-note-composer--active'))
|
||||||
|
await waitFor(() => expect(document.getElementById(`magic-note-entry-${entryId}`)).toHaveFocus())
|
||||||
|
expect(createEntry).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns focus to the entry edit button after cancelling an inline edit', async () => {
|
||||||
|
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||||
|
await openNote()
|
||||||
|
const article = document.getElementById(`magic-note-entry-${entryId}`)!
|
||||||
|
fireEvent.click(within(article).getByRole('button', { name: '编辑' }))
|
||||||
|
await within(article).findByTestId('magic-note-editor')
|
||||||
|
fireEvent.click(within(article).getByRole('button', { name: '取消' }))
|
||||||
|
await waitFor(() => expect(within(article).getByRole('button', { name: '编辑' })).toHaveFocus())
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps todo filters and search when opening its source note and returning', async () => {
|
it('keeps todo filters and search when opening its source note and returning', async () => {
|
||||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||||
await screen.findByText(detail.title)
|
await screen.findByText(detail.title)
|
||||||
@@ -1655,9 +1682,9 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
|||||||
showAi()
|
showAi()
|
||||||
const header = document.querySelector('.magic-note-composer__header')!
|
const header = document.querySelector('.magic-note-composer__header')!
|
||||||
const actions = header.querySelector('.magic-note-canvas-actions')!
|
const actions = header.querySelector('.magic-note-canvas-actions')!
|
||||||
expect(Array.from(actions.querySelectorAll('button'), (button) => button.textContent)).toEqual(['分析画布草稿', '保存记录'])
|
expect(Array.from(actions.querySelectorAll('button'), (button) => button.textContent)).toEqual(['分析画布草稿', '取消', '保存记录'])
|
||||||
expect(header.nextElementSibling).toBe(screen.getByTestId('canvas-editor'))
|
expect(header.nextElementSibling).toBe(screen.getByTestId('canvas-editor'))
|
||||||
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument()
|
expect(screen.getAllByRole('button', { name: '取消' })).toHaveLength(1)
|
||||||
expect(document.querySelector('.magic-note-composer > footer')).toBeNull()
|
expect(document.querySelector('.magic-note-composer > footer')).toBeNull()
|
||||||
expect(screen.queryByText(/不随笔画自动请求|保存后自动分析画布/)).not.toBeInTheDocument()
|
expect(screen.queryByText(/不随笔画自动请求|保存后自动分析画布/)).not.toBeInTheDocument()
|
||||||
// An empty flush permits switching to the saved record without a dirty draft.
|
// An empty flush permits switching to the saved record without a dirty draft.
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ type DraftSwitchTarget =
|
|||||||
| { kind: 'select-entry'; entry: MagicNoteEntry }
|
| { kind: 'select-entry'; entry: MagicNoteEntry }
|
||||||
| { kind: 'entry-type'; value: 'text' | 'canvas' }
|
| { kind: 'entry-type'; value: 'text' | 'canvas' }
|
||||||
| { kind: 'cancel-edit' }
|
| { kind: 'cancel-edit' }
|
||||||
|
| { kind: 'cancel-new-entry' }
|
||||||
| { kind: 'overview' }
|
| { kind: 'overview' }
|
||||||
| { kind: 'library-view'; value: LibraryView }
|
| { kind: 'library-view'; value: LibraryView }
|
||||||
| { kind: 'create-note'; title: string }
|
| { kind: 'create-note'; title: string }
|
||||||
@@ -1125,11 +1126,40 @@ export function MagicNotesWorkspace({
|
|||||||
if (target.kind === 'entry-type') {
|
if (target.kind === 'entry-type') {
|
||||||
discardComposerDraft()
|
discardComposerDraft()
|
||||||
setEntryType(target.value)
|
setEntryType(target.value)
|
||||||
|
// The composer remounts with the new type; move focus into the new editor.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (target.value === 'canvas' && composerCanvasRef.current) { composerCanvasRef.current.focus(); return }
|
||||||
|
composerRef.current?.querySelector<HTMLElement>('.ql-editor, [data-testid="magic-note-editor"]')?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target.kind === 'cancel-new-entry') {
|
||||||
|
discardComposerDraft()
|
||||||
|
setEntryType('text')
|
||||||
|
clearValidation('new-entry')
|
||||||
|
// Move focus out of the composer after any confirmation restores it, so the
|
||||||
|
// composer collapses without dropping focus onto the document body.
|
||||||
|
const fallbackEntryId = detail?.entries.some((entry) => entry.id === selectedEntryId) ? selectedEntryId : detail?.entries.at(-1)?.id
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (active instanceof HTMLElement && active !== document.body && !composerRef.current?.contains(active)) return
|
||||||
|
const entry = fallbackEntryId ? document.getElementById(`magic-note-entry-${fallbackEntryId}`) : null
|
||||||
|
if (entry) { entry.focus({ preventScroll: true }); return }
|
||||||
|
if (active instanceof HTMLElement) active.blur()
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (target.kind === 'cancel-edit') {
|
if (target.kind === 'cancel-edit') {
|
||||||
|
const editedEntryId = editingEntry?.id
|
||||||
discardEditingDraft()
|
discardEditingDraft()
|
||||||
setSelectedEntryId((current) => detail?.entries.some((entry) => entry.id === current) ? current : detail?.entries.at(-1)?.id ?? '')
|
setSelectedEntryId((current) => detail?.entries.some((entry) => entry.id === current) ? current : detail?.entries.at(-1)?.id ?? '')
|
||||||
|
// The inline editor unmounts; return focus to the entry that was being edited.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const article = editedEntryId ? document.getElementById(`magic-note-entry-${editedEntryId}`) : null
|
||||||
|
if (!article) return
|
||||||
|
const editButton = article.querySelector<HTMLButtonElement>('button[data-entry-edit]:not(:disabled)')
|
||||||
|
;(editButton ?? article).focus({ preventScroll: true })
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (target.kind === 'overview') {
|
if (target.kind === 'overview') {
|
||||||
@@ -1183,10 +1213,13 @@ export function MagicNotesWorkspace({
|
|||||||
void loadDetail(target.noteId, target.entryId)
|
void loadDetail(target.noteId, target.entryId)
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
clearValidation,
|
||||||
createNote,
|
createNote,
|
||||||
detail,
|
detail,
|
||||||
discardComposerDraft,
|
discardComposerDraft,
|
||||||
discardEditingDraft,
|
discardEditingDraft,
|
||||||
|
editingEntry?.id,
|
||||||
|
selectedEntryId,
|
||||||
focusSwitchTarget,
|
focusSwitchTarget,
|
||||||
loadDetail,
|
loadDetail,
|
||||||
libraryView,
|
libraryView,
|
||||||
@@ -2812,6 +2845,7 @@ export function MagicNotesWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
|
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
|
||||||
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
|
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
|
||||||
|
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}>{t('actions.cancel')}</button>
|
||||||
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEntry()}>{t('actions.saveEntry')}</button>
|
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEntry()}>{t('actions.saveEntry')}</button>
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -2868,6 +2902,14 @@ export function MagicNotesWorkspace({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
|
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}
|
||||||
|
>
|
||||||
|
{t('actions.cancel')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy)}
|
||||||
@@ -2964,6 +3006,7 @@ export function MagicNotesWorkspace({
|
|||||||
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||||
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||||
disabled={Boolean(busy) || editingEntry?.id === entry.id}
|
disabled={Boolean(busy) || editingEntry?.id === entry.id}
|
||||||
|
data-entry-edit=""
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestDraftSwitch({
|
requestDraftSwitch({
|
||||||
|
|||||||
@@ -2467,16 +2467,6 @@
|
|||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep a readable line length on wide windows. */
|
|
||||||
.magic-notes-stream-pane > :not(.magic-note-detail-header, .magic-note-detail-toolbar),
|
|
||||||
.magic-notes-stream-pane > .magic-note-detail-header > .magic-note-detail-title {
|
|
||||||
max-width: 880px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.magic-notes-stream-pane > * {
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@container magic-notes-detail (max-width: 600px) {
|
@container magic-notes-detail (max-width: 600px) {
|
||||||
.magic-notes-stream-pane { --magic-note-stream-gutter: var(--space-3); }
|
.magic-notes-stream-pane { --magic-note-stream-gutter: var(--space-3); }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user