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,379 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { Props } from './utils/types';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import NoteListItem from '../NoteListItem/NoteListItem';
|
||||
import useItemCss from './utils/useItemCss';
|
||||
import useOnContextMenu from '../NoteListItem/utils/useOnContextMenu';
|
||||
import useVisibleRange from './utils/useVisibleRange';
|
||||
import useScroll from './utils/useScroll';
|
||||
import useFocusNote from './utils/useFocusNote';
|
||||
import useOnNoteClick from './utils/useOnNoteClick';
|
||||
import useMoveNote from './utils/useMoveNote';
|
||||
import useOnKeyDown from './utils/useOnKeyDown';
|
||||
import * as focusElementNoteList from './commands/focusElementNoteList';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import useDragAndDrop from './utils/useDragAndDrop';
|
||||
import { itemIsInTrash } from '@joplin/lib/services/trash';
|
||||
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useActiveDescendantId from './utils/useActiveDescendantId';
|
||||
import getNoteElementIdFromJoplinId from '../NoteListItem/utils/getNoteElementIdFromJoplinId';
|
||||
import useFocusVisible from './utils/useFocusVisible';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
|
||||
import { createSelector } from 'reselect';
|
||||
import { isFolderPublished, ShareType } from '@joplin/lib/services/share/reducer';
|
||||
import useAutoScroll from './utils/useAutoScroll';
|
||||
import { reorderNotesWithPinned } from '@joplin/lib/services/noteList/noteListSnippetUtils';
|
||||
|
||||
const selectPublishedNoteIds = createSelector(
|
||||
(state: AppState) => state.shareService.shares,
|
||||
shares => shares.filter(s => s.type === ShareType.Note && !!s.note_id).map(s => s.note_id),
|
||||
);
|
||||
|
||||
const commands = {
|
||||
focusElementNoteList,
|
||||
};
|
||||
|
||||
const NoteList = (props: Props) => {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
const listRenderer = props.listRenderer;
|
||||
|
||||
const displayNotes = useMemo(() => {
|
||||
return reorderNotesWithPinned(props.notes, props.pinnedNoteIds);
|
||||
}, [props.notes, props.pinnedNoteIds]);
|
||||
|
||||
const itemSize: Size = useMemo(() => {
|
||||
return {
|
||||
width: listRenderer.itemSize.width ? listRenderer.itemSize.width : props.size.width,
|
||||
height: listRenderer.itemSize.height,
|
||||
};
|
||||
}, [listRenderer.itemSize, props.size.width]);
|
||||
|
||||
const itemsPerLine = useMemo(() => {
|
||||
if (listRenderer.flow === ItemFlow.TopToBottom) {
|
||||
return 1;
|
||||
} else {
|
||||
return Math.max(1, Math.floor(props.size.width / itemSize.width));
|
||||
}
|
||||
}, [listRenderer.flow, props.size.width, itemSize.width]);
|
||||
|
||||
const { scrollTop, onScroll, makeItemIndexVisible } = useScroll(
|
||||
itemsPerLine,
|
||||
displayNotes.length,
|
||||
itemSize,
|
||||
props.size,
|
||||
listRef,
|
||||
);
|
||||
|
||||
const [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount] = useVisibleRange(
|
||||
itemsPerLine,
|
||||
scrollTop,
|
||||
props.size,
|
||||
itemSize,
|
||||
displayNotes.length,
|
||||
);
|
||||
|
||||
const { activeNoteId, setActiveNoteId } = useActiveDescendantId(props.selectedFolderId, props.selectedNoteIds);
|
||||
const focusNote = useFocusNote(listRef, displayNotes, makeItemIndexVisible, setActiveNoteId);
|
||||
|
||||
const moveNote = useMoveNote(
|
||||
props.notesParentType,
|
||||
props.noteSortOrder,
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
props.uncompletedTodosOnTop,
|
||||
props.showCompletedTodos,
|
||||
displayNotes,
|
||||
props.selectedFolderInTrash,
|
||||
makeItemIndexVisible,
|
||||
focusNote,
|
||||
props.dispatch,
|
||||
);
|
||||
|
||||
const noteItemStyle = useMemo(() => {
|
||||
return {
|
||||
width: 'auto',
|
||||
height: itemSize.height,
|
||||
};
|
||||
}, [itemSize.height]);
|
||||
|
||||
const noteListStyle = useMemo(() => {
|
||||
return {
|
||||
width: props.size.width,
|
||||
height: props.size.height,
|
||||
};
|
||||
}, [props.size]);
|
||||
|
||||
const onNoteClick = useOnNoteClick(props.dispatch, focusNote);
|
||||
|
||||
const onNoteDoubleClick = useOnNoteDoubleClick();
|
||||
|
||||
const onKeyDown = useOnKeyDown(
|
||||
activeNoteId,
|
||||
props.selectedNoteIds,
|
||||
moveNote,
|
||||
makeItemIndexVisible,
|
||||
focusNote,
|
||||
props.notes,
|
||||
props.dispatch,
|
||||
visibleItemCount,
|
||||
props.notes.length,
|
||||
listRenderer.flow,
|
||||
itemsPerLine,
|
||||
props.showCompletedTodos,
|
||||
props.uncompletedTodosOnTop,
|
||||
);
|
||||
|
||||
useItemCss(listRenderer.itemCss);
|
||||
|
||||
useEffect(() => {
|
||||
CommandService.instance().registerRuntime(commands.focusElementNoteList.declaration.name, commands.focusElementNoteList.runtime(focusNote));
|
||||
return () => {
|
||||
CommandService.instance().unregisterRuntime(commands.focusElementNoteList.declaration.name);
|
||||
};
|
||||
}, [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,
|
||||
props.notes,
|
||||
props.dispatch,
|
||||
props.watchedNoteFiles,
|
||||
props.plugins,
|
||||
props.customCss,
|
||||
);
|
||||
|
||||
const { onDragStart, onDragOver, onDrop, dragOverTargetNoteIndex } = useDragAndDrop(props.parentFolderIsReadOnly,
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
listRef,
|
||||
scrollTop,
|
||||
itemSize,
|
||||
props.notesParentType,
|
||||
props.noteSortOrder,
|
||||
props.uncompletedTodosOnTop,
|
||||
props.showCompletedTodos,
|
||||
listRenderer.flow,
|
||||
itemsPerLine,
|
||||
props.selectedFolderInTrash,
|
||||
);
|
||||
|
||||
// 2024-04-01: Whatever the below effect is supposed to be doing has been lost in time and even
|
||||
// if it's doing something useful it should be refactored. In my tests, removing it doesn't
|
||||
// affect anything - including scrolling with the keyboard and switching notes so there's a
|
||||
// chance that whatever it's doing is being done more cleanly somewhere else. If a focus
|
||||
// related-bug is found, it should be fixed from scratch, without touching this event, although
|
||||
// it could possibly be used as a reference.
|
||||
//
|
||||
// * * *
|
||||
|
||||
// const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
||||
// const previousNoteCount = usePrevious(props.notes.length, 0);
|
||||
// const previousVisible = usePrevious(props.visible, false);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (previousSelectedNoteIds !== props.selectedNoteIds && props.selectedNoteIds.length === 1) {
|
||||
// const id = props.selectedNoteIds[0];
|
||||
// const doRefocus = props.notes.length < previousNoteCount && !props.focusedField;
|
||||
|
||||
// for (let i = 0; i < props.notes.length; i++) {
|
||||
// if (props.notes[i].id === id) {
|
||||
// makeItemIndexVisible(i);
|
||||
// if (doRefocus) {
|
||||
// const ref = itemRefs.current[id];
|
||||
// if (ref) {
|
||||
// focus('NoteList::doRefocus', ref);
|
||||
// }
|
||||
// }
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]);
|
||||
|
||||
const { focusVisible, onFocus, onBlur, onKeyUp } = useFocusVisible(listRef, () => {
|
||||
focusNote(activeNoteId);
|
||||
});
|
||||
|
||||
const highlightedWords = useMemo(() => {
|
||||
if (props.notesParentType === 'Search') {
|
||||
const query = BaseModel.byId(props.searches, props.selectedSearchId);
|
||||
if (query) return props.highlightedWords;
|
||||
}
|
||||
return [];
|
||||
}, [props.notesParentType, props.searches, props.selectedSearchId, props.highlightedWords]);
|
||||
|
||||
const renderEmptyList = () => {
|
||||
if (props.notes.length) return null;
|
||||
// Role status is necessary for the screenreader to announce that the list is empty, since when there are
|
||||
// zero items there is not list to render
|
||||
return <div className="emptylist" role="status">{getEmptyFolderMessage(props.folders, props.selectedFolderId)}</div>;
|
||||
};
|
||||
|
||||
const renderFiller = (key: string, style: React.CSSProperties) => {
|
||||
if (!displayNotes.length) return null;
|
||||
if (style.height as number <= 0) return null;
|
||||
return <div key={key} style={style}></div>;
|
||||
};
|
||||
|
||||
const renderNotes = () => {
|
||||
if (!displayNotes.length) return [];
|
||||
|
||||
const output: React.ReactNode[] = [];
|
||||
|
||||
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
|
||||
const note = displayNotes[i];
|
||||
const isSelected = props.selectedNoteIds.includes(note.id);
|
||||
|
||||
output.push(
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
ref={el => { itemRefs.current[note.id] = el; }}
|
||||
index={i}
|
||||
dragIndex={dragOverTargetNoteIndex}
|
||||
noteCount={displayNotes.length}
|
||||
itemSize={itemSize}
|
||||
onChange={listRenderer.onChange}
|
||||
onClick={onNoteClick}
|
||||
onDoubleClick={onNoteDoubleClick}
|
||||
onContextMenu={onItemContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
style={noteItemStyle}
|
||||
highlightedWords={highlightedWords}
|
||||
isProvisional={props.provisionalNoteIds.includes(note.id)}
|
||||
flow={listRenderer.flow}
|
||||
note={note}
|
||||
tabIndex={-1}
|
||||
focusVisible={focusVisible && activeNoteId === note.id}
|
||||
isSelected={isSelected}
|
||||
isWatched={props.watchedNoteFiles.includes(note.id)}
|
||||
isPublished={props.publishedNoteIds.includes(note.id) || (!!note.is_shared && !note.share_id) || props.isFolderPublished}
|
||||
listRenderer={listRenderer}
|
||||
dispatch={props.dispatch}
|
||||
columns={props.columns}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const topFillerHeight = startLineIndex * itemSize.height;
|
||||
const bottomFillerHeight = (totalLineCount - endLineIndex - 1) * itemSize.height;
|
||||
|
||||
const fillerBaseStyle = useMemo(() => {
|
||||
// return { width: 'auto', border: '1px solid red', backgroundColor: 'green' };
|
||||
return { width: 'auto' };
|
||||
}, []);
|
||||
|
||||
const topFillerStyle = useMemo(() => {
|
||||
return { ...fillerBaseStyle, height: topFillerHeight };
|
||||
}, [fillerBaseStyle, topFillerHeight]);
|
||||
|
||||
const bottomFillerStyle = useMemo(() => {
|
||||
return { ...fillerBaseStyle, height: bottomFillerHeight };
|
||||
}, [fillerBaseStyle, bottomFillerHeight]);
|
||||
|
||||
const notesStyle = useMemo(() => {
|
||||
const output: React.CSSProperties = {};
|
||||
|
||||
if (listRenderer.flow === ItemFlow.LeftToRight) {
|
||||
output.flexFlow = 'row wrap';
|
||||
} else {
|
||||
output.flexDirection = 'column';
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [listRenderer.flow]);
|
||||
|
||||
const onContainerContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
const isFromKeyboard = event.button === -1;
|
||||
if (event.isDefaultPrevented() || !isFromKeyboard) return;
|
||||
onItemContextMenu({ itemId: activeNoteId });
|
||||
}, [onItemContextMenu, activeNoteId]);
|
||||
|
||||
const hasNotes = !!props.notes.length;
|
||||
return (
|
||||
<div
|
||||
role={hasNotes ? 'listbox' : 'group'}
|
||||
aria-label={hasNotes ? _('Notes') : null}
|
||||
aria-activedescendant={activeNoteId ? getNoteElementIdFromJoplinId(activeNoteId) : undefined}
|
||||
aria-multiselectable={hasNotes ? true : undefined}
|
||||
tabIndex={0}
|
||||
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
className="note-list"
|
||||
style={noteListStyle}
|
||||
ref={listRef}
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onDrop={onDrop}
|
||||
onContextMenu={onContainerContextMenu}
|
||||
id='notes-list'
|
||||
>
|
||||
{renderEmptyList()}
|
||||
{renderFiller('top', topFillerStyle)}
|
||||
<div className='notes' role='presentation' style={notesStyle}>
|
||||
{renderNotes()}
|
||||
</div>
|
||||
{renderFiller('bottom', bottomFillerStyle)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConnectProps {
|
||||
windowId: string;
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? Folder.byId(state.folders, state.selectedFolderId) : null;
|
||||
const userId = state.settings['sync.userId'];
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
|
||||
return {
|
||||
notes: windowState.notes,
|
||||
pinnedNoteIds: (state.settings['notes.pinnedNoteIds'] as string[]) || [],
|
||||
folders: state.folders,
|
||||
selectedNoteIds: windowState.selectedNoteIds,
|
||||
selectedFolderId: windowState.selectedFolderId,
|
||||
themeId: state.settings.theme,
|
||||
notesParentType: state.notesParentType,
|
||||
searches: state.searches,
|
||||
selectedSearchId: windowState.selectedSearchId,
|
||||
watchedNoteFiles: state.watchedNoteFiles,
|
||||
publishedNoteIds: selectPublishedNoteIds(state),
|
||||
isFolderPublished: state.notesParentType === 'Folder' ? isFolderPublished(state, windowState.selectedFolderId) : false,
|
||||
provisionalNoteIds: state.provisionalNoteIds,
|
||||
isInsertingNotes: state.isInsertingNotes,
|
||||
noteSortOrder: state.settings['notes.sortOrder.field'],
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
showCompletedTodos: state.settings.showCompletedTodos,
|
||||
highlightedWords: state.highlightedWords,
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customViewerCss,
|
||||
focusedField: state.focusedField,
|
||||
parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false,
|
||||
selectedFolderInTrash: itemIsInTrash(selectedFolder),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NoteList);
|
||||
在新工单中引用
屏蔽一个用户