Skip to content

Commit f64c6a9

Browse files
authored
fix(editor): preserve valid focus and selection state (CoreBunch#213)
1 parent 2e8a6df commit f64c6a9

4 files changed

Lines changed: 78 additions & 1 deletion

File tree

src/__tests__/editor-store/undo-redo.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,43 @@ describe('Undo / Redo — basic lifecycle', () => {
8181
expect(nodesAfterRedo).toBe(nodesBeforeUndo)
8282
})
8383

84+
it('undo prunes selection that points at the reverted insertion', () => {
85+
const site = getStore().createSite('Test SiteDocument')
86+
const rootId = site.pages[0].rootNodeId
87+
const insertedId = useEditorStore.getState().insertNode('base.text', {}, rootId)
88+
89+
useEditorStore.getState().selectNode(insertedId)
90+
expect(useEditorStore.getState().selectedNodeIds).toEqual([insertedId])
91+
92+
useEditorStore.getState().undo()
93+
94+
const afterUndo = useEditorStore.getState()
95+
expect(afterUndo.site!.pages[0].nodes[insertedId]).toBeUndefined()
96+
expect(afterUndo.selectedNodeIds).toEqual([])
97+
expect(afterUndo.selectedNodeId).toBeNull()
98+
99+
const nextId = afterUndo.insertNode('base.text', {}, rootId)
100+
expect(nextId).not.toBe('')
101+
expect(useEditorStore.getState().site!.pages[0].nodes[nextId]).toBeDefined()
102+
})
103+
104+
it('redo prunes selection when replaying a deletion', () => {
105+
const site = getStore().createSite('Test SiteDocument')
106+
const rootId = site.pages[0].rootNodeId
107+
const insertedId = useEditorStore.getState().insertNode('base.text', {}, rootId)
108+
109+
useEditorStore.getState().deleteNode(insertedId)
110+
useEditorStore.getState().undo()
111+
useEditorStore.getState().selectNode(insertedId)
112+
113+
useEditorStore.getState().redo()
114+
115+
const afterRedo = useEditorStore.getState()
116+
expect(afterRedo.site!.pages[0].nodes[insertedId]).toBeUndefined()
117+
expect(afterRedo.selectedNodeIds).toEqual([])
118+
expect(afterRedo.selectedNodeId).toBeNull()
119+
})
120+
84121
it('canRedo is true after undo', () => {
85122
const s = getStore()
86123
const site = s.createSite('Test SiteDocument')

src/__tests__/panels/agentPanel.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,35 @@ describe('AgentPanel', () => {
345345
expect(screen.getByTestId('agent-new-chat-header-button')).toBeTruthy()
346346
})
347347

348+
it('autofocuses the composer when the open panel has no focused control', async () => {
349+
installModelFetch(true)
350+
renderAgentPanel({
351+
agentActiveCredentialId: TEST_CREDENTIAL.id,
352+
agentActiveModelId: 'model-1',
353+
})
354+
355+
const textarea = await screen.findByLabelText('Message to AI assistant')
356+
await waitFor(() => expect(document.activeElement).toBe(textarea))
357+
})
358+
359+
it('does not steal focus from New chat when its deferred autofocus runs', async () => {
360+
installModelFetch(true)
361+
renderAgentPanel({
362+
agentActiveCredentialId: TEST_CREDENTIAL.id,
363+
agentActiveModelId: 'model-1',
364+
})
365+
366+
const newChat = screen.getByRole('button', { name: 'New chat' })
367+
newChat.focus()
368+
fireEvent.click(newChat)
369+
expect(document.activeElement).toBe(newChat)
370+
371+
await act(async () => {
372+
await new Promise((resolve) => setTimeout(resolve, 75))
373+
})
374+
expect(document.activeElement).toBe(newChat)
375+
})
376+
348377
it('shows compact context, token, and cost detail beside the image action', async () => {
349378
const credential = { ...TEST_CREDENTIAL, id: 'cred_context_meter' }
350379
installModelFetch(true, true, 128_000, credential)

src/admin/pages/site/panels/AgentPanel/AgentComposer.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,15 @@ export function AgentComposer({
6464

6565
useEffect(() => {
6666
if (!isOpen) return
67-
const id = setTimeout(() => inputRef.current?.focus(), 50)
67+
const id = setTimeout(() => {
68+
const input = inputRef.current
69+
if (!input) return
70+
const panel = input.closest('[data-panel]')
71+
// A header or composer control may have received an explicit click
72+
// while this deferred autofocus was waiting.
73+
if (panel?.contains(document.activeElement)) return
74+
input.focus()
75+
}, 50)
6876
return () => clearTimeout(id)
6977
}, [isOpen])
7078

src/admin/pages/site/store/slices/site/undoRedoActions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { apply } from 'mutative'
1313
import { clonePackageJson } from '@core/site-dependencies/manifest'
1414
import { cloneSiteRuntimeConfig } from '@core/site-runtime'
15+
import { pruneCanvasSelectionDraft } from '../selectionSlice'
1516
import { collectDirtyFromSitePatches, mergeDirtyMarks } from './dirtyTracking'
1617
import type { SiteSlice, SiteSliceHelpers } from './types'
1718

@@ -47,6 +48,7 @@ export function createUndoRedoActions({ get, set }: SiteSliceHelpers): UndoRedoA
4748
if (!state.site.pages.find((p) => p.id === state.activePageId)) {
4849
state.activePageId = state.site.pages[0]?.id ?? null
4950
}
51+
pruneCanvasSelectionDraft(state)
5052
})
5153
},
5254

@@ -75,6 +77,7 @@ export function createUndoRedoActions({ get, set }: SiteSliceHelpers): UndoRedoA
7577
if (!state.site.pages.find((p) => p.id === state.activePageId)) {
7678
state.activePageId = state.site.pages[0]?.id ?? null
7779
}
80+
pruneCanvasSelectionDraft(state)
7881
})
7982
},
8083
}

0 commit comments

Comments
 (0)