feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -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);
});
});

查看文件

@@ -0,0 +1,21 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import bridge from '../../../services/bridge';
const canManuallySortNotes = (notesParentType: string, noteSortOrder: string, selectedFolderInTrash: boolean) => {
if (notesParentType !== 'Folder') return false;
if (selectedFolderInTrash) return false;
if (noteSortOrder !== 'order') {
const doIt = bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), {
buttons: [_('Do it now'), _('Cancel')],
});
if (!doIt) return false;
Setting.setValue('notes.sortOrder.field', 'order');
return false;
}
return true;
};
export default canManuallySortNotes;

查看文件

@@ -0,0 +1,54 @@
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import { ListRenderer, NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { Size } from '@joplin/utils/types';
import { Dispatch } from 'redux';
import { EventEmitter } from 'events';
import { HighlightedWord } from '@joplin/lib/reducer';
export interface Props {
themeId: number;
selectedNoteIds: string[];
notes: NoteEntity[];
pinnedNoteIds?: string[];
dispatch: Dispatch;
watchedNoteFiles: string[];
publishedNoteIds: string[];
isFolderPublished: boolean;
plugins: PluginStates;
selectedFolderId: string;
customCss: string;
notesParentType: string;
noteSortOrder: string;
uncompletedTodosOnTop: boolean;
showCompletedTodos: boolean;
resizableLayoutEventEmitter: EventEmitter;
isInsertingNotes: boolean;
folders: FolderEntity[];
size: Size;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
searches: any[];
selectedSearchId: string;
highlightedWords: HighlightedWord[];
provisionalNoteIds: string[];
visible: boolean;
focusedField: string;
parentFolderIsReadOnly: boolean;
listRenderer: ListRenderer;
columns: NoteListColumns;
selectedFolderInTrash: boolean;
}
export enum BaseBreakpoint {
Sm = 75,
Md = 80,
Lg = 120,
Xl = 474,
}
export interface Breakpoints {
Sm: number;
Md: number;
Lg: number;
Xl: number;
}

查看文件

@@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import usePrevious from '@joplin/lib/hooks/usePrevious';
const useActiveDescendantId = (selectedFolderId: string, selectedNoteIds: string[]) => {
const selectedNoteIdsRef = useRef(selectedNoteIds);
selectedNoteIdsRef.current = selectedNoteIds;
const [focusedNoteId, setFocusedNoteId] = useState('');
useEffect(() => {
setFocusedNoteId(selectedNoteIdsRef.current?.[0] ?? '');
}, [selectedFolderId]);
const previousSelectedNoteIds = usePrevious(selectedNoteIds, []);
let activeNoteId = focusedNoteId;
if (!selectedNoteIds.includes(activeNoteId)) {
// Prefer added items
activeNoteId = selectedNoteIds.find(id => !previousSelectedNoteIds.includes(id)) ?? selectedNoteIds[0] ?? '';
}
useEffect(() => {
if (focusedNoteId !== activeNoteId) {
setFocusedNoteId(activeNoteId);
}
}, [focusedNoteId, activeNoteId]);
return { activeNoteId, setActiveNoteId: setFocusedNoteId };
};
export default useActiveDescendantId;

查看文件

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

查看文件

@@ -0,0 +1,106 @@
import * as React from 'react';
import { useCallback, DragEventHandler, MutableRefObject, useState, useEffect } from 'react';
import Note from '@joplin/lib/models/Note';
import canManuallySortNotes from './canManuallySortNotes';
import { Size } from '@joplin/utils/types';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
const useDragAndDrop = (
parentFolderIsReadOnly: boolean,
selectedNoteIds: string[],
selectedFolderId: string,
listRef: MutableRefObject<HTMLDivElement>,
scrollTop: number,
itemSize: Size,
notesParentType: string,
noteSortOrder: string,
uncompletedTodosOnTop: boolean,
showCompletedTodos: boolean,
flow: ItemFlow,
itemsPerLine: number,
selectedFolderInTrash: boolean,
) => {
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);
const onGlobalDrop = useCallback(() => {
setDragOverTargetNoteIndex(null);
}, []);
useEffect(() => {
document.addEventListener('dragend', onGlobalDrop);
return () => {
document.removeEventListener('dragend', onGlobalDrop);
};
}, [onGlobalDrop]);
const onDragStart: DragEventHandler = useCallback(event => {
if (parentFolderIsReadOnly) return false;
let noteIds = [];
// Here there is two cases:
// - If multiple notes are selected, we drag the group
// - If only one note is selected, we drag the note that was clicked on
// (which might be different from the currently selected note)
if (selectedNoteIds.length >= 2) {
noteIds = selectedNoteIds;
} else {
const clickedNoteId = event.currentTarget.getAttribute('data-id');
if (clickedNoteId) noteIds.push(clickedNoteId);
}
if (!noteIds.length) return false;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
return true;
}, [parentFolderIsReadOnly, selectedNoteIds]);
const dragTargetNoteIndex = useCallback((event: React.DragEvent) => {
const rect = listRef.current.getBoundingClientRect();
const lineIndexFloat = (event.clientY - rect.top + scrollTop) / itemSize.height;
if (flow === ItemFlow.TopToBottom) {
return Math.abs(Math.round(lineIndexFloat));
} else {
const lineIndex = Math.floor(lineIndexFloat);
const rowIndexFloat = (event.clientX - rect.left) / itemSize.width;
const rowIndex = Math.round(rowIndexFloat);
return lineIndex * itemsPerLine + rowIndex;
}
}, [listRef, itemSize, scrollTop, flow, itemsPerLine]);
const onDragOver: DragEventHandler = useCallback(event => {
if (notesParentType !== 'Folder') return;
if (selectedFolderInTrash) return;
const dt = event.dataTransfer;
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
const newIndex = dragTargetNoteIndex(event);
if (dragOverTargetNoteIndex === newIndex) return;
setDragOverTargetNoteIndex(newIndex);
}
}, [notesParentType, dragTargetNoteIndex, dragOverTargetNoteIndex, selectedFolderInTrash]);
const onDrop: DragEventHandler = useCallback(async (event) => {
const dt = event.dataTransfer;
if (!dt.types.includes('text/x-jop-note-ids')) return;
// TODO: check that parent type is folder
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;
setDragOverTargetNoteIndex(null);
const targetNoteIndex = dragTargetNoteIndex(event);
const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids'));
await Note.insertNotesAt(selectedFolderId, noteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
}, [notesParentType, dragTargetNoteIndex, noteSortOrder, selectedFolderId, uncompletedTodosOnTop, showCompletedTodos, selectedFolderInTrash]);
return { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex };
};
export default useDragAndDrop;

查看文件

@@ -0,0 +1,35 @@
import { useRef, useCallback, RefObject } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
import { NoteEntity } from '@joplin/lib/services/database/types';
export type FocusNote = (noteId: string)=> void;
type ContainerRef = RefObject<HTMLElement>;
type OnMakeIndexVisible = (i: number)=> void;
type OnSetActiveId = (id: string)=> void;
const useFocusNote = (
containerRef: ContainerRef, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible, setActiveNoteId: OnSetActiveId,
) => {
const notesRef = useRef(notes);
notesRef.current = notes;
const focusNote: FocusNote = useCallback((noteId: string) => {
if (noteId) {
setActiveNoteId(noteId);
}
// The note list container should have focus even when a note list item is visibly selected.
// The visibly focused item is determined by activeNoteId and is communicated to accessibility
// tools using aria- attributes
focus('useFocusNote', containerRef.current);
const targetIndex = notesRef.current.findIndex(note => note.id === noteId);
if (targetIndex > -1) {
makeItemIndexVisible(targetIndex);
}
}, [containerRef, makeItemIndexVisible, setActiveNoteId]);
return focusNote;
};
export default useFocusNote;

查看文件

@@ -0,0 +1,45 @@
import { useCallback, useState, useRef, RefObject } from 'react';
const useFocusVisible = (containerRef: RefObject<HTMLElement>, onFocusEnter: ()=> void) => {
const [focusVisible, setFocusVisible] = useState(false);
const onFocusEnterRef = useRef(onFocusEnter);
onFocusEnterRef.current = onFocusEnter;
const focusVisibleRef = useRef(focusVisible);
focusVisibleRef.current = focusVisible;
const onFocusVisible = useCallback(() => {
if (!focusVisibleRef.current) {
setFocusVisible(true);
onFocusEnterRef.current();
}
}, []);
const onFocus = useCallback(() => {
if (containerRef.current.matches(':focus-visible')) {
onFocusVisible();
}
}, [containerRef, onFocusVisible]);
const onKeyUp = useCallback(() => {
if (containerRef.current.contains(document.activeElement)) {
onFocusVisible();
}
}, [containerRef, onFocusVisible]);
const onBlur = useCallback(() => setFocusVisible(false), []);
return {
focusVisible,
onFocus,
// When focus becomes visible due to a key press, but the item was already
// focused, no new focus event is emitted and the browser :focus-visible doesn't
// change. As such, we need to handle this case ourselves.
onKeyUp,
onBlur,
};
};
export default useFocusVisible;

查看文件

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
const useItemCss = (itemCss: string) => {
useEffect(() => {
const element = document.createElement('style');
element.setAttribute('type', 'text/css');
element.appendChild(document.createTextNode(`
.note-list-item {
${itemCss};
}
`));
document.head.appendChild(element);
return () => {
element.remove();
};
}, [itemCss]);
};
export default useItemCss;

查看文件

@@ -0,0 +1,37 @@
import BaseModel from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { useCallback } from 'react';
import canManuallySortNotes from './canManuallySortNotes';
import { FocusNote } from './useFocusNote';
import { Dispatch } from 'redux';
const useMoveNote = (notesParentType: string, noteSortOrder: string, selectedNoteIds: string[], selectedFolderId: string, uncompletedTodosOnTop: boolean, showCompletedTodos: boolean, notes: NoteEntity[], selectedFolderInTrash: boolean, makeItemIndexVisible: (itemIndex: number)=> void, focusNote: FocusNote, dispatch: Dispatch) => {
const moveNote = useCallback((direction: number, inc: number) => {
if (!canManuallySortNotes(notesParentType, noteSortOrder, selectedFolderInTrash)) return;
const noteId = selectedNoteIds[0];
let targetNoteIndex = BaseModel.modelIndexById(notes, noteId);
if ((direction === 1)) {
targetNoteIndex += inc + 1;
}
if ((direction === -1)) {
targetNoteIndex -= inc;
}
void Note.insertNotesAt(selectedFolderId, selectedNoteIds, targetNoteIndex, uncompletedTodosOnTop, showCompletedTodos);
// The note will be moved to the target index, so we need to update the scroll amount to make it visible
dispatch({
type: 'NOTE_SELECT',
id: noteId,
});
makeItemIndexVisible(targetNoteIndex);
focusNote(noteId);
}, [selectedFolderId, noteSortOrder, notes, notesParentType, selectedNoteIds, uncompletedTodosOnTop, showCompletedTodos, selectedFolderInTrash, makeItemIndexVisible, focusNote, dispatch]);
return moveNote;
};
export default useMoveNote;

查看文件

@@ -0,0 +1,201 @@
import * as React from 'react';
import BaseModel from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import { FocusNote } from './useFocusNote';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
import { KeyboardEventKey } from '@joplin/lib/dom';
import announceForAccessibility from '../../utils/announceForAccessibility';
import { _ } from '@joplin/lib/locale';
const useOnKeyDown = (
activeNoteId: string,
selectedNoteIds: string[],
moveNote: (direction: number, inc: number)=> void,
makeItemIndexVisible: (itemIndex: number)=> void,
focusNote: FocusNote,
notes: NoteEntity[],
dispatch: Dispatch,
visibleItemCount: number,
noteCount: number,
flow: ItemFlow,
itemsPerLine: number,
showCompletedTodos: boolean,
uncompletedTodosOnTop: boolean,
) => {
const scrollNoteIndex = useCallback((visibleItemCount: number, key: KeyboardEventKey, ctrlKey: boolean, metaKey: boolean, noteIndex: number) => {
if (flow === ItemFlow.TopToBottom) {
if (key === 'PageUp') {
noteIndex -= (visibleItemCount - 1);
} else if (key === 'PageDown') {
noteIndex += (visibleItemCount - 1);
} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) {
noteIndex = noteCount - 1;
} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) {
noteIndex = 0;
} else if (key === 'ArrowUp' && !metaKey) {
noteIndex -= 1;
} else if (key === 'ArrowDown' && !metaKey) {
noteIndex += 1;
}
if (noteIndex < 0) noteIndex = 0;
if (noteIndex > noteCount - 1) noteIndex = noteCount - 1;
}
if (flow === ItemFlow.LeftToRight) {
if (key === 'PageUp') {
noteIndex -= (visibleItemCount - itemsPerLine);
} else if (key === 'PageDown') {
noteIndex += (visibleItemCount - itemsPerLine);
} else if ((key === 'End' && ctrlKey) || (key === 'ArrowDown' && metaKey)) {
noteIndex = noteCount - 1;
} else if ((key === 'Home' && ctrlKey) || (key === 'ArrowUp' && metaKey)) {
noteIndex = 0;
} else if (key === 'ArrowUp' && !metaKey) {
noteIndex -= itemsPerLine;
} else if (key === 'ArrowDown' && !metaKey) {
noteIndex += itemsPerLine;
} else if (key === 'ArrowLeft' && !metaKey) {
noteIndex -= 1;
} else if (key === 'ArrowRight' && !metaKey) {
noteIndex += 1;
}
if (noteIndex < 0) noteIndex = 0;
if (noteIndex > noteCount - 1) noteIndex = noteCount - 1;
}
return noteIndex;
}, [noteCount, flow, itemsPerLine]);
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback(async (event) => {
const noteIds = selectedNoteIds;
const key = event.key as KeyboardEventKey;
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(key) && event.altKey) {
if (flow === ItemFlow.TopToBottom) {
await moveNote(key === 'ArrowDown' ? 1 : -1, 1);
} else {
if (key === 'ArrowRight') {
await moveNote(1, 1);
} else if (key === 'ArrowLeft') {
await moveNote(-1, 1);
} else if (key === 'ArrowUp') {
await moveNote(-1, itemsPerLine);
} else if (key === 'ArrowDown') {
await moveNote(1, itemsPerLine);
}
}
event.preventDefault();
} else if (notes.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
const noteId = activeNoteId ?? notes[0]?.id;
let noteIndex = BaseModel.modelIndexById(notes, noteId);
noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex);
const newSelectedNote = notes[noteIndex];
if (event.shiftKey) {
if (selectedNoteIds.includes(newSelectedNote.id)) {
dispatch({
type: 'NOTE_SELECT_REMOVE',
id: noteId,
});
}
dispatch({
type: 'NOTE_SELECT_ADD',
id: newSelectedNote.id,
});
} else {
dispatch({
type: 'NOTE_SELECT',
id: newSelectedNote.id,
});
}
makeItemIndexVisible(noteIndex);
focusNote(newSelectedNote.id);
event.preventDefault();
}
if (noteIds.length) {
if (key === 'Delete' && event.shiftKey || (key === 'Backspace' && event.metaKey && event.altKey)) {
event.preventDefault();
if (CommandService.instance().isEnabled('permanentlyDeleteNote')) {
void CommandService.instance().execute('permanentlyDeleteNote', noteIds);
}
} else if (key === 'Delete' || (key === 'Backspace' && event.metaKey)) {
event.preventDefault();
if (CommandService.instance().isEnabled('deleteNote')) {
void CommandService.instance().execute('deleteNote', noteIds);
}
}
}
if (noteIds.length && key === ' ') {
event.preventDefault();
const selectedNotes = BaseModel.modelsByIds(notes, noteIds);
const todos = selectedNotes.filter(n => !!n.is_todo);
if (!todos.length) return;
const firstNoteIndex = notes.findIndex(n => n.id === todos[0].id);
let nextSelectedNoteIndex = firstNoteIndex + 1;
if (nextSelectedNoteIndex > notes.length - 1) nextSelectedNoteIndex = notes.length - 1;
const nextSelectedNote = nextSelectedNoteIndex >= 0 ? notes[nextSelectedNoteIndex] : todos[0];
for (let i = 0; i < todos.length; i++) {
const toggledTodo = Note.toggleTodoCompleted(todos[i]);
await Note.save(toggledTodo);
}
// When the settings `uncompletedTodosOnTop` or `showCompletedTodos` are enabled, the
// note that got set as completed or uncompleted is going to disappear from view,
// possibly hidden or moved to the top or bottom of the note list. It is assumed that
// the user does not want to keep that note selected since the to-do is indeed
// "completed". And by keeping that selection, the cursor would jump, making you lose
// context if you have multiple to-dos that need to be ticked. For that reason we set
// the selection to the next note in the list, which also ensures that the scroll
// position doesn't change. This is the same behaviour as when deleting a note.
const maintainScrollPosition = !showCompletedTodos || uncompletedTodosOnTop;
if (maintainScrollPosition) {
dispatch({ type: 'NOTE_SELECT', noteId: nextSelectedNote.id });
}
dispatch({ type: 'NOTE_SORT' });
if (!maintainScrollPosition) focusNote(todos[0].id);
const wasCompleted = !!todos[0].todo_completed;
announceForAccessibility(!wasCompleted ? _('Complete') : _('Incomplete'));
}
// Check for isDefaultPrevented to allow plugins to call .preventDefault
if (key === 'Enter' && !event.isDefaultPrevented()) {
event.preventDefault();
if (event.shiftKey) {
void CommandService.instance().execute('focusElement', 'sideBar');
} else {
void CommandService.instance().execute('focusElement', 'noteTitle');
}
}
if (key.toUpperCase() === 'A' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
dispatch({
type: 'NOTE_SELECT_ALL',
});
}
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine, showCompletedTodos, uncompletedTodosOnTop]);
return onKeyDown;
};
export default useOnKeyDown;

查看文件

@@ -0,0 +1,41 @@
import * as React from 'react';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import { FocusNote } from './useFocusNote';
const useOnNoteClick = (dispatch: Dispatch, focusNote: FocusNote) => {
const onNoteClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const noteId = event.currentTarget.getAttribute('data-id');
const targetTagName = event.target ? (event.target as HTMLElement).tagName : '';
// If we are for example on a checkbox, don't process the click since it
// should be handled by the checkbox onChange handler.
if (['INPUT'].includes(targetTagName)) return;
focusNote(noteId);
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
dispatch({
type: 'NOTE_SELECT_TOGGLE',
id: noteId,
});
} else if (event.shiftKey) {
event.preventDefault();
dispatch({
type: 'NOTE_SELECT_EXTEND',
id: noteId,
});
} else {
dispatch({
type: 'NOTE_SELECT',
id: noteId,
});
}
}, [dispatch, focusNote]);
return onNoteClick;
};
export default useOnNoteClick;

查看文件

@@ -0,0 +1,10 @@
import * as React from 'react';
import { useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService';
export default () => {
return useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const noteId = event.currentTarget.getAttribute('data-id');
void CommandService.instance().execute('openNoteInNewWindow', noteId);
}, []);
};

查看文件

@@ -0,0 +1,105 @@
import * as React from 'react';
import { Size } from '@joplin/utils/types';
import { useCallback, useState, useRef, useMemo } from 'react';
const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, listSize: Size, listRef: React.MutableRefObject<HTMLDivElement>) => {
const [scrollTop, setScrollTop] = useState(0);
const lastScrollSetTime = useRef(0);
const maxScrollTop = useMemo(() => {
return Math.max(0, itemSize.height * noteCount - listSize.height);
}, [itemSize.height, noteCount, listSize.height]);
// This ugly hack is necessary because setting scrollTop at a high
// frequency, while scrolling with the keyboard, is unreliable - the
// property will appear to be set (reading it back gives the correct value),
// but the scrollbar will not be at the expected position. That can be
// verified by moving the scrollbar a little and reading the event value -
// it will be different from what was set, and what was read.
//
// As a result, since we can't rely on setting or reading that value (to
// check if it's correct), we forcefully set it multiple times over the next
// few milliseconds, hoping that maybe one of these attempts will stick.
//
// This is most likely a race condition in either Chromium or Electron
// although I couldn't find an upstream issue.
//
// Setting the value only once after a short time, for example 10ms, helps
// but still fails now and then. Setting it after 500ms would probably work
// reliably but it's too slow so it makes sense to do it in an interval.
// const setScrollTopLikeYouMeanItTimer = useRef(null);
// const setScrollTopLikeYouMeanItStartTime = useRef(0);
// const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
// if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItStartTime.current = Date.now();
// listRef.current.scrollTop = newScrollTop;
// lastScrollSetTime.current = Date.now();
// setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
// if (!listRef.current) {
// shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItTimer.current = null;
// return;
// }
// listRef.current.scrollTop = newScrollTop;
// lastScrollSetTime.current = Date.now();
// if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 1000) {
// shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItTimer.current = null;
// }
// }, 10);
// }, [listRef]);
// useEffect(() => {
// if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
// setScrollTopLikeYouMeanItTimer.current = null;
// }, []);
const makeItemIndexVisible = useCallback((itemIndex: number) => {
const lineTopFloat = scrollTop / itemSize.height;
const topFloat = lineTopFloat * itemsPerLine;
const lineBottomFloat = (scrollTop + listSize.height - itemSize.height) / itemSize.height;
const bottomFloat = lineBottomFloat * itemsPerLine;
const top = Math.min(noteCount - 1, Math.floor(topFloat) + 1);
const bottom = Math.max(0, Math.floor(bottomFloat));
if (itemIndex >= top && itemIndex <= bottom) return;
const lineIndex = Math.floor(itemIndex / itemsPerLine);
let newScrollTop = 0;
if (itemIndex < top) {
newScrollTop = itemSize.height * lineIndex;
} else {
newScrollTop = itemSize.height * (lineIndex + 1) - listSize.height;
}
if (newScrollTop < 0) newScrollTop = 0;
if (newScrollTop > maxScrollTop) newScrollTop = maxScrollTop;
setScrollTop(newScrollTop);
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
// setScrollTopLikeYouMeanIt(newScrollTop);
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, listRef]); // , setScrollTopLikeYouMeanIt]);
const onScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
// console.info('ON SCROLL', event.currentTarget.scrollTop, 'Ignore:', Date.now() - lastScrollSetTime.current < 500);
// Ignore the scroll event if it has just been set programmatically.
if (Date.now() - lastScrollSetTime.current < 10) return;
setScrollTop(event.currentTarget.scrollTop);
}, []);
return {
scrollTop,
onScroll,
makeItemIndexVisible,
};
};
export default useScroll;

查看文件

@@ -0,0 +1,61 @@
import useVisibleRange from './useVisibleRange';
import { renderHook } from '@testing-library/react';
import { Size } from '@joplin/utils/types';
describe('useVisibleRange', () => {
test('should calculate indexes', () => {
// IN: scrollTop, listSize, itemSize, noteCount, flow
//
// OUT: [itemsPerLine, startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount]
const testCases: [number, number, Size, Size, number, ReturnType<typeof useVisibleRange>][] = [
[
1,
150,
{ width: 100, height: 400 },
{ width: 100, height: 100 },
8,
[1, 5, 1, 5, 8, 5],
],
[
2,
100,
{ width: 220, height: 380 },
{ width: 100, height: 100 },
12,
[2, 9, 1, 4, 6, 8],
],
[
2,
50,
{ width: 220, height: 300 },
{ width: 100, height: 100 },
9,
[0, 7, 0, 3, 5, 8],
],
[
4,
0,
{ width: 410, height: 450 },
{ width: 100, height: 100 },
30,
[0, 19, 0, 4, 8, 20],
],
];
for (const [scrollTop, listSize, itemSize, noteCount, flow, expected] of testCases) {
const { result } = renderHook(() => useVisibleRange(
scrollTop,
listSize,
itemSize,
noteCount,
flow,
));
expect(result.current).toEqual(expected);
}
});
});

查看文件

@@ -0,0 +1,66 @@
import { Size } from '@joplin/utils/types';
import { useMemo } from 'react';
const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size, itemSize: Size, noteCount: number) => {
const startLineIndexFloat = useMemo(() => {
return scrollTop / itemSize.height;
}, [scrollTop, itemSize.height]);
const endLineIndexFloat = useMemo(() => {
return startLineIndexFloat + (listSize.height / itemSize.height);
}, [startLineIndexFloat, listSize.height, itemSize.height]);
const startLineIndex = useMemo(() => {
return Math.floor(startLineIndexFloat);
}, [startLineIndexFloat]);
const endLineIndex = useMemo(() => {
return Math.floor(endLineIndexFloat);
}, [endLineIndexFloat]);
const visibleLineCount = useMemo(() => {
return endLineIndex - startLineIndex + 1;
}, [endLineIndex, startLineIndex]);
const visibleItemCount = useMemo(() => {
return visibleLineCount * itemsPerLine;
}, [visibleLineCount, itemsPerLine]);
const startNoteIndex = useMemo(() => {
return itemsPerLine * startLineIndex;
}, [itemsPerLine, startLineIndex]);
const endNoteIndex = useMemo(() => {
let output = (endLineIndex + 1) * itemsPerLine - 1;
if (output >= noteCount) output = noteCount - 1;
return output;
}, [endLineIndex, itemsPerLine, noteCount]);
const totalLineCount = useMemo(() => {
return Math.ceil(noteCount / itemsPerLine);
}, [noteCount, itemsPerLine]);
// Note: Leave this here to test the note list scroll behaviour. Also add "item.index" to the
// rows in defaultListRenderer to check whether the value here matches what's being displayed.
// `useScroll` can also be changed to display the effective scroll value.
// console.info('=======================================');
// console.info('scrollTop', scrollTop);
// console.info('itemsPerLine', itemsPerLine);
// console.info('listSize.height', listSize.height);
// console.info('itemSize.height', itemSize.height);
// console.info('startLineIndexFloat', startLineIndexFloat);
// console.info('endLineIndexFloat', endLineIndexFloat);
// console.info('visibleLineCount', visibleLineCount);
// console.info('startNoteIndex', startNoteIndex);
// console.info('endNoteIndex', endNoteIndex);
// console.info('startLineIndex', startLineIndex);
// console.info('endLineIndex', endLineIndex);
// console.info('totalLineCount', totalLineCount);
// console.info('visibleItemCount', visibleItemCount);
// console.info('=======================================');
return [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount];
};
export default useVisibleRange;