diff --git a/.eslintignore b/.eslintignore index 3cbedff200..16fe037cc6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -276,6 +276,7 @@ packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js packages/app-desktop/gui/NoteList/utils/types.js packages/app-desktop/gui/NoteList/utils/useItemCss.js packages/app-desktop/gui/NoteList/utils/useRenderedNote.js +packages/app-desktop/gui/NoteList/utils/useVisibleRange.js packages/app-desktop/gui/NoteListControls/NoteListControls.js packages/app-desktop/gui/NoteListControls/commands/focusSearch.js packages/app-desktop/gui/NoteListControls/commands/index.js diff --git a/.gitignore b/.gitignore index 578960e78a..c7a789da9e 100644 --- a/.gitignore +++ b/.gitignore @@ -262,6 +262,7 @@ packages/app-desktop/gui/NoteList/utils/defaultListRenderer.js packages/app-desktop/gui/NoteList/utils/types.js packages/app-desktop/gui/NoteList/utils/useItemCss.js packages/app-desktop/gui/NoteList/utils/useRenderedNote.js +packages/app-desktop/gui/NoteList/utils/useVisibleRange.js packages/app-desktop/gui/NoteListControls/NoteListControls.js packages/app-desktop/gui/NoteListControls/commands/focusSearch.js packages/app-desktop/gui/NoteListControls/commands/index.js diff --git a/packages/app-desktop/gui/NoteList/NoteList2.tsx b/packages/app-desktop/gui/NoteList/NoteList2.tsx index 24a726c7d8..3a06ac61e3 100644 --- a/packages/app-desktop/gui/NoteList/NoteList2.tsx +++ b/packages/app-desktop/gui/NoteList/NoteList2.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { useMemo, useCallback } from 'react'; +import { _ } from '@joplin/lib/locale'; +import { useMemo, useCallback, useState } from 'react'; import { AppState } from '../../app.reducer'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; const { connect } = require('react-redux'); @@ -13,8 +14,11 @@ import NoteListItem from '../NoteListItem/NoteListItem'; import useRenderedNotes from './utils/useRenderedNote'; import useItemCss from './utils/useItemCss'; import useOnContextMenu from '../NoteListItem/utils/useOnContextMenu'; +import useVisibleRange from './utils/useVisibleRange'; const NoteList = (props: Props) => { + const [scrollTop, setScrollTop] = useState(0); + const listRenderer = defaultListRenderer; if (listRenderer.flow !== ItemFlow.TopToBottom) throw new Error('Not implemented'); @@ -23,7 +27,9 @@ const NoteList = (props: Props) => { return listRenderer.itemSize; }, [listRenderer.itemSize]); - const renderedNotes = useRenderedNotes(props.notes, props.selectedNoteIds, itemSize, listRenderer); + const [startNoteIndex, endNoteIndex] = useVisibleRange(scrollTop, props.size, itemSize, props.notes.length); + + const renderedNotes = useRenderedNotes(startNoteIndex, endNoteIndex, props.notes, props.selectedNoteIds, itemSize, listRenderer); const noteItemStyle = useMemo(() => { return { @@ -74,17 +80,37 @@ const NoteList = (props: Props) => { props.customCss ); + const onScroll = useCallback((event: any) => { + setScrollTop(event.target.scrollTop); + }, []); + + const renderFiller = (key: string, height: number) => { + return
; + }; + + const renderEmptyList = () => { + if (props.notes.length) return null; + 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 renderNotes = () => { + if (!props.notes.length) return null; + const output: JSX.Element[] = []; - for (const renderedNote of renderedNotes) { + output.push(renderFiller('top', startNoteIndex * itemSize.height)); + + for (let i = startNoteIndex; i <= endNoteIndex; i++) { + const note = props.notes[i]; + const renderedNote = renderedNotes[note.id]; + output.push( { ); } + output.push(renderFiller('bottom', (props.notes.length - endNoteIndex - 1) * itemSize.height)); + return output; }; return ( -
+
+ {renderEmptyList()} {renderNotes()}
); diff --git a/packages/app-desktop/gui/NoteList/NoteListSource.tsx b/packages/app-desktop/gui/NoteList/NoteListSource.tsx index 57be29909b..feb758ec75 100644 --- a/packages/app-desktop/gui/NoteList/NoteListSource.tsx +++ b/packages/app-desktop/gui/NoteList/NoteListSource.tsx @@ -11,7 +11,6 @@ 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'; @@ -53,7 +52,7 @@ const NoteListComponent = (props: Props) => { }; }, []); - const [itemHeight, setItemHeight] = useState(34); + const itemHeight = 34; const focusItemIID_ = useRef(null); const noteListRef = useRef(null); @@ -408,37 +407,6 @@ const NoteListComponent = (props: Props) => { }; }, []); - // 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; @@ -461,7 +429,6 @@ const NoteListComponent = (props: Props) => { return ( - {renderEmptyList()} {renderItemList()} ); diff --git a/packages/app-desktop/gui/NoteList/style.scss b/packages/app-desktop/gui/NoteList/style.scss index 10fe949487..4125f15869 100644 --- a/packages/app-desktop/gui/NoteList/style.scss +++ b/packages/app-desktop/gui/NoteList/style.scss @@ -3,6 +3,15 @@ height: 100%; background-color: var(--joplin-background-color3); border-right: 1px solid var(--joplin-divider-color); + overflow-y: scroll; + + > .emptylist { + padding: 10px; + font-size: var(--joplin-font-size); + color: var(--joplin-color); + background-color: var(--joplin-background-color); + font-family: var(--joplin-font-family); + } } .note-list-item { diff --git a/packages/app-desktop/gui/NoteList/utils/types.ts b/packages/app-desktop/gui/NoteList/utils/types.ts index 5bafb45b6c..c6c0de7fb5 100644 --- a/packages/app-desktop/gui/NoteList/utils/types.ts +++ b/packages/app-desktop/gui/NoteList/utils/types.ts @@ -19,7 +19,7 @@ export interface Props { resizableLayoutEventEmitter: any; isInsertingNotes: boolean; folders: FolderEntity[]; - size: any; + size: Size; searches: any[]; selectedSearchId: string; highlightedWords: string[]; diff --git a/packages/app-desktop/gui/NoteList/utils/useRenderedNote.ts b/packages/app-desktop/gui/NoteList/utils/useRenderedNote.ts index 963b991923..7830a298e2 100644 --- a/packages/app-desktop/gui/NoteList/utils/useRenderedNote.ts +++ b/packages/app-desktop/gui/NoteList/utils/useRenderedNote.ts @@ -4,58 +4,56 @@ import { NoteEntity } from '@joplin/lib/services/database/types'; import { Size } from '@joplin/utils/types'; import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import * as Mustache from 'mustache'; +import { createHash } from 'crypto'; interface RenderedNote { id: string; + hash: string; html: string; } -const useRenderedNotes = (notes: NoteEntity[], selectedNoteIds: string[], itemSize: Size, listRenderer: ListRenderer) => { - const initialValue = notes.map(n => { - return { - id: n.id, - html: '', - }; - }); +const hashContent = (content: any) => { + return createHash('sha1').update(JSON.stringify(content)).digest('hex'); +}; - const [renderedNotes, setRenderedNotes] = useState(initialValue); +const prepareViewProps = async (dependencies: ListRendererDepependency[], note: NoteEntity, itemSize: Size, selected: boolean) => { + const output: any = {}; - const prepareViewProps = async (dependencies: ListRendererDepependency[], note: NoteEntity, itemSize: Size, selected: boolean) => { - const output: any = {}; - for (const dep of dependencies) { + for (const dep of dependencies) { - if (dep.startsWith('note.')) { - const splitted = dep.split('.'); - if (splitted.length !== 2) throw new Error(`Invalid dependency name: ${dep}`); - const propName = splitted.pop(); - if (!output.note) output.note = {}; - if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`); - output.note[propName] = (note as any)[propName]; - } - - if (dep.startsWith('item.size.')) { - const splitted = dep.split('.'); - if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`); - const propName = splitted.pop(); - if (!output.item) output.item = {}; - if (!output.item.size) output.item.size = {}; - if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`); - output.item.size[propName] = (itemSize as any)[propName]; - } - - if (dep === 'item.selected') { - if (!output.item) output.item = {}; - output.item.selected = selected; - } + if (dep.startsWith('note.')) { + const splitted = dep.split('.'); + if (splitted.length !== 2) throw new Error(`Invalid dependency name: ${dep}`); + const propName = splitted.pop(); + if (!output.note) output.note = {}; + if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`); + output.note[propName] = (note as any)[propName]; } - return output; - }; + if (dep.startsWith('item.size.')) { + const splitted = dep.split('.'); + if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`); + const propName = splitted.pop(); + if (!output.item) output.item = {}; + if (!output.item.size) output.item.size = {}; + if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`); + output.item.size[propName] = (itemSize as any)[propName]; + } + + if (dep === 'item.selected') { + if (!output.item) output.item = {}; + output.item.selected = selected; + } + } + + return output; +}; + +const useRenderedNotes = (startNoteIndex: number, endNoteIndex: number, notes: NoteEntity[], selectedNoteIds: string[], itemSize: Size, listRenderer: ListRenderer) => { + const [renderedNotes, setRenderedNotes] = useState>({}); useAsyncEffect(async (event) => { - const newRenderedNotes: RenderedNote[] = []; - - for (const note of notes) { + const renderNote = async (note: NoteEntity): Promise => { const view = await listRenderer.onRenderNote(await prepareViewProps( listRenderer.dependencies, note, @@ -63,16 +61,33 @@ const useRenderedNotes = (notes: NoteEntity[], selectedNoteIds: string[], itemSi selectedNoteIds.includes(note.id) )); - newRenderedNotes.push({ - id: note.id, - html: Mustache.render(listRenderer.itemTemplate, view), + if (event.cancelled) return null; + + const viewHash = hashContent(view); + + setRenderedNotes(prev => { + if (prev[note.id] && prev[note.id].hash === viewHash) return prev; + + return { + ...prev, + [note.id]: { + id: note.id, + hash: viewHash, + html: Mustache.render(listRenderer.itemTemplate, view), + }, + }; }); + }; + + const promises: Promise[] = []; + + for (let i = startNoteIndex; i <= endNoteIndex; i++) { + const note = notes[i]; + promises.push(renderNote(note)); } - if (event.cancelled) return null; - - setRenderedNotes(newRenderedNotes); - }, [notes, selectedNoteIds, itemSize]); + await Promise.all(promises); + }, [startNoteIndex, endNoteIndex, notes, selectedNoteIds, itemSize, listRenderer]); return renderedNotes; }; diff --git a/packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts b/packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts new file mode 100644 index 0000000000..8f0b11214a --- /dev/null +++ b/packages/app-desktop/gui/NoteList/utils/useVisibleRange.ts @@ -0,0 +1,22 @@ +import { Size } from '@joplin/utils/types'; +import { useMemo } from 'react'; + +const useVisibleRange = (scrollTop: number, listSize: Size, itemSize: Size, noteCount: number) => { + const visibleItemCount = useMemo(() => { + return Math.ceil(listSize.height / itemSize.height); + }, [listSize.height, itemSize.height]); + + const startNoteIndex = useMemo(() => { + return Math.floor(scrollTop / itemSize.height); + }, [scrollTop, itemSize.height]); + + const endNoteIndex = useMemo(() => { + let output = startNoteIndex + (visibleItemCount - 1); + if (output >= noteCount) output = noteCount - 1; + return output; + }, [visibleItemCount, startNoteIndex, noteCount]); + + return [startNoteIndex, endNoteIndex]; +}; + +export default useVisibleRange;