import * as React from 'react'; import { useMemo, useEffect, useState, useRef, useCallback } from 'react'; import { AppState } from '../../app.reducer'; import eventManager, { EventName } from '@joplin/lib/eventManager'; import NoteListUtils from '../utils/NoteListUtils'; import { _ } from '@joplin/lib/locale'; import time from '@joplin/lib/time'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import bridge from '../../services/bridge'; import Setting from '@joplin/lib/models/Setting'; import NoteListItem from '../NoteListItem'; import CommandService from '@joplin/lib/services/CommandService'; import shim from '@joplin/lib/shim'; import styled from 'styled-components'; import { themeStyle } from '@joplin/lib/theme'; import ItemList from '../ItemList'; const { connect } = require('react-redux'); import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; import { Props } from './utils/types'; import usePrevious from '../hooks/usePrevious'; import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly'; import { FolderEntity } from '@joplin/lib/services/database/types'; import ItemChange from '@joplin/lib/models/ItemChange'; const commands = [ require('./commands/focusElementNoteList'), ]; const StyledRoot = styled.div` width: 100%; height: 100%; background-color: ${(props: any) => props.theme.backgroundColor3}; border-right: 1px solid ${(props: any) => props.theme.dividerColor}; `; const itemAnchorRefs_: any = { current: {}, }; export const itemAnchorRef = (itemId: string) => { if (itemAnchorRefs_.current[itemId] && itemAnchorRefs_.current[itemId].current) return itemAnchorRefs_.current[itemId].current; return null; }; const NoteListComponent = (props: Props) => { const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null); const [width, setWidth] = useState(0); const [, setHeight] = useState(0); useEffect(() => { itemAnchorRefs_.current = {}; CommandService.instance().registerCommands(commands); return () => { itemAnchorRefs_.current = {}; CommandService.instance().unregisterCommands(commands); }; }, []); const [itemHeight, setItemHeight] = useState(34); const focusItemIID_ = useRef(null); const noteListRef = useRef(null); const itemListRef = useRef(null); let globalDragEndEventRegistered_ = false; const style = useMemo(() => { const theme = themeStyle(props.themeId); return { root: { backgroundColor: theme.backgroundColor, }, listItem: { maxWidth: '100%', height: itemHeight, boxSizing: 'border-box', display: 'flex', alignItems: 'stretch', backgroundColor: theme.backgroundColor, borderBottom: `1px solid ${theme.dividerColor}`, }, listItemSelected: { backgroundColor: theme.selectedColor, }, listItemTitle: { fontFamily: theme.fontFamily, fontSize: theme.fontSize, textDecoration: 'none', color: theme.color, cursor: 'default', whiteSpace: 'nowrap', flex: 1, display: 'flex', alignItems: 'center', overflow: 'hidden', }, listItemTitleCompleted: { opacity: 0.5, textDecoration: 'line-through', }, }; }, [props.themeId, itemHeight]); const itemContextMenu = useCallback((event: any) => { const currentItemId = event.currentTarget.getAttribute('data-id'); if (!currentItemId) return; let noteIds = []; if (props.selectedNoteIds.indexOf(currentItemId) < 0) { noteIds = [currentItemId]; } else { noteIds = props.selectedNoteIds; } if (!noteIds.length) return; const menu = NoteListUtils.makeContextMenu(noteIds, { notes: props.notes, dispatch: props.dispatch, watchedNoteFiles: props.watchedNoteFiles, plugins: props.plugins, inConflictFolder: props.selectedFolderId === Folder.conflictFolderId(), customCss: props.customCss, }); menu.popup({ window: bridge().window() }); }, [props.selectedNoteIds, props.notes, props.dispatch, props.watchedNoteFiles, props.plugins, props.selectedFolderId, props.customCss]); const onGlobalDrop_ = () => { unregisterGlobalDragEndEvent_(); setDragOverTargetNoteIndex(null); }; const registerGlobalDragEndEvent_ = () => { if (globalDragEndEventRegistered_) return; globalDragEndEventRegistered_ = true; document.addEventListener('dragend', onGlobalDrop_); }; const unregisterGlobalDragEndEvent_ = () => { globalDragEndEventRegistered_ = false; document.removeEventListener('dragend', onGlobalDrop_); }; const dragTargetNoteIndex_ = (event: any) => { return Math.abs(Math.round((event.clientY - itemListRef.current.offsetTop() + itemListRef.current.offsetScroll()) / itemHeight)); }; const noteItem_noteDragOver = (event: any) => { if (props.notesParentType !== 'Folder') 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; registerGlobalDragEndEvent_(); setDragOverTargetNoteIndex(newIndex); } }; const canManuallySortNotes = async () => { if (props.notesParentType !== 'Folder') return false; if (props.noteSortOrder !== 'order') { const doIt = await 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; }; const noteItem_noteDrop = async (event: any) => { // TODO: check that parent type is folder if (!canManuallySortNotes()) { return; } const dt = event.dataTransfer; unregisterGlobalDragEndEvent_(); setDragOverTargetNoteIndex(null); const targetNoteIndex = dragTargetNoteIndex_(event); const noteIds: string[] = JSON.parse(dt.getData('text/x-jop-note-ids')); void Note.insertNotesAt(props.selectedFolderId, noteIds, targetNoteIndex, props.uncompletedTodosOnTop, props.showCompletedTodos); }; const noteItem_checkboxClick = async (event: any, item: any) => { const checked = event.target.checked; const newNote = { id: item.id, todo_completed: checked ? time.unixMs() : 0, }; await Note.save(newNote, { userSideValidation: true }); eventManager.emit(EventName.TodoToggle, { noteId: item.id, note: newNote }); }; const noteItem_titleClick = async (event: any, item: any) => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); props.dispatch({ type: 'NOTE_SELECT_TOGGLE', id: item.id, }); } else if (event.shiftKey) { event.preventDefault(); props.dispatch({ type: 'NOTE_SELECT_EXTEND', id: item.id, }); } else { props.dispatch({ type: 'NOTE_SELECT', id: item.id, }); } }; const noteItem_dragStart = useCallback((event: any) => { if (props.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 (props.selectedNoteIds.length >= 2) { noteIds = props.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)); // While setting // event.dataTransfer.effectAllowed = 'move'; // causes the drag cursor to have a "move", rather than an "add", icon, // this breaks note drag and drop into the markdown editor. return true; }, [props.parentFolderIsReadOnly, props.selectedNoteIds]); const renderItem = useCallback((item: any, index: number) => { const highlightedWords = () => { if (props.notesParentType === 'Search') { const query = BaseModel.byId(props.searches, props.selectedSearchId); if (query) { return props.highlightedWords; } } return []; }; if (!itemAnchorRefs_.current[item.id]) itemAnchorRefs_.current[item.id] = React.createRef(); const ref = itemAnchorRefs_.current[item.id]; return = 0} isWatched={props.watchedNoteFiles.indexOf(item.id) < 0} itemCount={props.notes.length} onCheckboxClick={noteItem_checkboxClick} onDragStart={noteItem_dragStart} onNoteDragOver={noteItem_noteDragOver} onTitleClick={noteItem_titleClick} onContextMenu={itemContextMenu} draggable={!props.parentFolderIsReadOnly} />; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, [style, props.themeId, width, itemHeight, dragOverTargetNoteIndex, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles, props.notes, props.notesParentType, props.searches, props.selectedSearchId, props.highlightedWords, props.parentFolderIsReadOnly, ]); const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []); const previousNotes = usePrevious(props.notes, []); 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 < previousNotes.length && !props.focusedField; for (let i = 0; i < props.notes.length; i++) { if (props.notes[i].id === id) { itemListRef.current.makeItemIndexVisible(i); if (doRefocus) { const ref = itemAnchorRef(id); if (ref) ref.focus(); } break; } } } if (previousVisible !== props.visible) { updateSizeState(); } }, [previousSelectedNoteIds, previousNotes, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]); const scrollNoteIndex_ = (keyCode: any, ctrlKey: any, metaKey: any, noteIndex: any) => { if (keyCode === 33) { // Page Up noteIndex -= (itemListRef.current.visibleItemCount() - 1); } else if (keyCode === 34) { // Page Down noteIndex += (itemListRef.current.visibleItemCount() - 1); } else if ((keyCode === 35 && ctrlKey) || (keyCode === 40 && metaKey)) { // CTRL+End, CMD+Down noteIndex = props.notes.length - 1; } else if ((keyCode === 36 && ctrlKey) || (keyCode === 38 && metaKey)) { // CTRL+Home, CMD+Up noteIndex = 0; } else if (keyCode === 38 && !metaKey) { // Up noteIndex -= 1; } else if (keyCode === 40 && !metaKey) { // Down noteIndex += 1; } if (noteIndex < 0) noteIndex = 0; if (noteIndex > props.notes.length - 1) noteIndex = props.notes.length - 1; return noteIndex; }; const noteItem_noteMove = async (direction: number) => { if (!canManuallySortNotes()) { return; } const noteIds = props.selectedNoteIds; const noteId = noteIds[0]; let targetNoteIndex = BaseModel.modelIndexById(props.notes, noteId); if ((direction === 1)) { targetNoteIndex += 2; } if ((direction === -1)) { targetNoteIndex -= 1; } void Note.insertNotesAt(props.selectedFolderId, noteIds, targetNoteIndex, props.uncompletedTodosOnTop, props.showCompletedTodos); }; const onKeyDown = async (event: any) => { const keyCode = event.keyCode; const noteIds = props.selectedNoteIds; if ((keyCode === 40 || keyCode === 38) && event.altKey) { // (DOWN / UP) & ALT await noteItem_noteMove(keyCode === 40 ? 1 : -1); event.preventDefault(); } else if (noteIds.length > 0 && (keyCode === 40 || keyCode === 38 || keyCode === 33 || keyCode === 34 || keyCode === 35 || keyCode === 36)) { // DOWN / UP / PAGEDOWN / PAGEUP / END / HOME const noteId = noteIds[0]; let noteIndex = BaseModel.modelIndexById(props.notes, noteId); noteIndex = scrollNoteIndex_(keyCode, event.ctrlKey, event.metaKey, noteIndex); const newSelectedNote = props.notes[noteIndex]; props.dispatch({ type: 'NOTE_SELECT', id: newSelectedNote.id, }); itemListRef.current.makeItemIndexVisible(noteIndex); focusNoteId_(newSelectedNote.id); event.preventDefault(); } if (noteIds.length && (keyCode === 46 || (keyCode === 8 && event.metaKey))) { // DELETE / CMD+Backspace event.preventDefault(); void CommandService.instance().execute('deleteNote', noteIds); // await NoteListUtils.confirmDeleteNotes(noteIds); } if (noteIds.length && keyCode === 32) { // SPACE event.preventDefault(); const notes = BaseModel.modelsByIds(props.notes, noteIds); const todos = notes.filter((n: any) => !!n.is_todo); if (!todos.length) return; for (let i = 0; i < todos.length; i++) { const toggledTodo = Note.toggleTodoCompleted(todos[i]); await Note.save(toggledTodo); } focusNoteId_(todos[0].id); } if (keyCode === 9) { // TAB event.preventDefault(); if (event.shiftKey) { void CommandService.instance().execute('focusElement', 'sideBar'); } else { void CommandService.instance().execute('focusElement', 'noteTitle'); } } if (event.keyCode === 65 && (event.ctrlKey || event.metaKey)) { // Ctrl+A key event.preventDefault(); props.dispatch({ type: 'NOTE_SELECT_ALL', }); } }; const focusNoteId_ = (noteId: string) => { // - We need to focus the item manually otherwise focus might be lost when the // list is scrolled and items within it are being rebuilt. // - We need to use an interval because when leaving the arrow pressed, the rendering // of items might lag behind and so the ref is not yet available at this point. if (!itemAnchorRef(noteId)) { if (focusItemIID_.current) shim.clearInterval(focusItemIID_.current); focusItemIID_.current = shim.setInterval(() => { if (itemAnchorRef(noteId)) { itemAnchorRef(noteId).focus(); shim.clearInterval(focusItemIID_.current); focusItemIID_.current = null; } }, 10); } else { itemAnchorRef(noteId).focus(); } }; const updateSizeState = () => { setWidth(noteListRef.current.clientWidth); setHeight(noteListRef.current.clientHeight); }; const resizableLayout_resize = () => { updateSizeState(); }; useEffect(() => { props.resizableLayoutEventEmitter.on('resize', resizableLayout_resize); return () => { props.resizableLayoutEventEmitter.off('resize', resizableLayout_resize); }; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, [props.resizableLayoutEventEmitter]); useEffect(() => { updateSizeState(); return () => { if (focusItemIID_.current) { shim.clearInterval(focusItemIID_.current); focusItemIID_.current = null; } CommandService.instance().componentUnregisterCommands(commands); }; }, []); // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied useEffect(() => { // When a note list item is styled by userchrome.css, its height is reflected. // Ref. https://github.com/laurent22/joplin/pull/6542 if (dragOverTargetNoteIndex !== null) { // When dragged, its height should not be considered. // Ref. https://github.com/laurent22/joplin/issues/6639 return; } const noteItem = Object.values(itemAnchorRefs_.current)[0]?.current; const actualItemHeight = noteItem?.getHeight() ?? 0; if (actualItemHeight >= 8) { // To avoid generating too many narrow items setItemHeight(actualItemHeight); } }); const renderEmptyList = () => { if (props.notes.length) return null; const theme = themeStyle(props.themeId); const padding = 10; const emptyDivStyle = { padding: `${padding}px`, fontSize: theme.fontSize, color: theme.color, backgroundColor: theme.backgroundColor, fontFamily: theme.fontFamily, }; return
{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}
; }; const renderItemList = () => { if (!props.notes.length) return null; return ( ); }; if (!props.size) throw new Error('props.size is required'); return ( {renderEmptyList()} {renderItemList()} ); }; const mapStateToProps = (state: AppState) => { const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? BaseModel.byId(state.folders, state.selectedFolderId) : null; const userId = state.settings['sync.userId']; return { notes: state.notes, folders: state.folders, selectedNoteIds: state.selectedNoteIds, selectedFolderId: state.selectedFolderId, themeId: state.settings.theme, notesParentType: state.notesParentType, searches: state.searches, selectedSearchId: state.selectedSearchId, watchedNoteFiles: state.watchedNoteFiles, 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.customCss, focusedField: state.focusedField, parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false, }; }; export default connect(mapStateToProps)(NoteListComponent);