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
一些检查失败了
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
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户