Desktop: Fixes #12355: Auto-scroll to selected note from 'Go to Anything' search results (#14591)

This commit is contained in:
Harsh Gupta
2026-03-07 21:42:31 +05:30
committed by GitHub
parent e736e05d1c
commit 1db9903926
5 changed files with 158 additions and 0 deletions

View File

@@ -341,9 +341,11 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList2.js
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
packages/app-desktop/gui/NoteList/utils/useAutoScroll.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js

2
.gitignore vendored
View File

@@ -314,9 +314,11 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList2.js
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.js
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.js
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
packages/app-desktop/gui/NoteList/utils/types.js
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
packages/app-desktop/gui/NoteList/utils/useAutoScroll.js
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js

View File

@@ -30,6 +30,7 @@ import useFocusVisible from './utils/useFocusVisible';
import { stateUtils } from '@joplin/lib/reducer';
import { connect } from 'react-redux';
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
import useAutoScroll from './utils/useAutoScroll';
const commands = {
focusElementNoteList,
@@ -131,6 +132,10 @@ const NoteList = (props: Props) => {
};
}, [focusNote]);
const selectedNoteId = props.selectedNoteIds.length === 1 ? props.selectedNoteIds[0] : '';
const targetIndex = props.notes.findIndex(note => note.id === selectedNoteId);
useAutoScroll(selectedNoteId, props.selectedFolderId, targetIndex, makeItemIndexVisible);
const onItemContextMenu = useOnContextMenu(
props.selectedNoteIds,
props.selectedFolderId,

View File

@@ -0,0 +1,106 @@
import useAutoScroll from './useAutoScroll';
import { renderHook } from '@testing-library/react';
type Props = {
selectedNoteId: string;
selectedFolderId: string;
targetIndex: number;
makeItemIndexVisible: (index: number)=> void;
};
describe('useAutoScroll', () => {
test('scrolls to the note when a new note is selected', () => {
const makeItemIndexVisible = jest.fn();
renderHook(() => useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible));
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
expect(makeItemIndexVisible).toHaveBeenCalledWith(5);
});
test('does not scroll when the same note is already selected', () => {
const makeItemIndexVisible = jest.fn();
const { rerender } = renderHook(() =>
useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible),
);
makeItemIndexVisible.mockClear();
rerender();
expect(makeItemIndexVisible).not.toHaveBeenCalled();
});
test('does not scroll for multi-selection or no selection', () => {
const makeItemIndexVisible = jest.fn();
renderHook(() => useAutoScroll('', 'folder-1', -1, makeItemIndexVisible));
expect(makeItemIndexVisible).not.toHaveBeenCalled();
});
test('defers scroll until notes load after folder change', () => {
const makeItemIndexVisible = jest.fn();
const { rerender } = renderHook(
(props: Props) => useAutoScroll(
props.selectedNoteId,
props.selectedFolderId,
props.targetIndex,
props.makeItemIndexVisible,
),
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: -1, makeItemIndexVisible } },
);
expect(makeItemIndexVisible).not.toHaveBeenCalled();
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 3, makeItemIndexVisible });
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
expect(makeItemIndexVisible).toHaveBeenCalledWith(3);
});
test('scrolls again when the folder changes even if note ID is the same', () => {
const makeItemIndexVisible = jest.fn();
const { rerender } = renderHook(
(props: Props) => useAutoScroll(
props.selectedNoteId,
props.selectedFolderId,
props.targetIndex,
props.makeItemIndexVisible,
),
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 2, makeItemIndexVisible } },
);
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 2, makeItemIndexVisible });
expect(makeItemIndexVisible).toHaveBeenCalledTimes(2);
});
test('does not scroll again when targetIndex changes after the pending flag is cleared', () => {
// Covers the case where a sort or filter changes targetIndex without a new selection.
// Without this guard, arrow-key navigation would trigger a spurious second scroll.
const makeItemIndexVisible = jest.fn();
const { rerender } = renderHook(
(props: Props) => useAutoScroll(
props.selectedNoteId,
props.selectedFolderId,
props.targetIndex,
props.makeItemIndexVisible,
),
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 5, makeItemIndexVisible } },
);
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 7, makeItemIndexVisible });
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,43 @@
import { useRef, useEffect } from 'react';
// Auto-scrolls the note list to the selected note when selection changes. Uses a pending flag
// to handle cross-folder navigation where notes may not be loaded on the first render.
const useAutoScroll = (
selectedNoteId: string,
selectedFolderId: string,
targetIndex: number,
makeItemIndexVisible: (index: number)=> void,
) => {
const lastNoteIdRef = useRef('');
const lastFolderIdRef = useRef('');
const scrollPendingRef = useRef(false); // true when scroll requested but notes not yet loaded
useEffect(() => {
// No selection or multi-selection — reset tracking state.
if (!selectedNoteId) {
lastNoteIdRef.current = '';
lastFolderIdRef.current = selectedFolderId;
scrollPendingRef.current = false;
return;
}
const isNewNote = selectedNoteId !== lastNoteIdRef.current;
const isFolderChange = selectedFolderId !== lastFolderIdRef.current;
if (isNewNote || isFolderChange) {
lastNoteIdRef.current = selectedNoteId;
lastFolderIdRef.current = selectedFolderId;
scrollPendingRef.current = true;
}
// targetIndex is -1 until the new folder's notes load — re-runs automatically when they do.
if (!scrollPendingRef.current || targetIndex === -1) return;
// makeItemIndexVisible has its own visibility guard and is a no-op when the note is
// already visible — this covers arrow-key and click navigation without double-scrolling.
makeItemIndexVisible(targetIndex);
scrollPendingRef.current = false;
}, [selectedNoteId, selectedFolderId, targetIndex, makeItemIndexVisible]);
};
export default useAutoScroll;