import { destroyAllObjectProperties } from '@/Utils' import { ApplicationEvent, CollectionSort, CollectionSortProperty, ContentType, DeinitSource, findInArray, NotesDisplayCriteria, NoteViewController, PrefKey, SmartView, SNNote, SNTag, SystemViewId, } from '@standardnotes/snjs' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { AppState, AppStateEvent } from '.' import { WebApplication } from '../Application' import { AbstractState } from './AbstractState' const MIN_NOTE_CELL_HEIGHT = 51.0 const DEFAULT_LIST_NUM_NOTES = 20 const ELEMENT_ID_SEARCH_BAR = 'search-bar' const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable' export type DisplayOptions = { sortBy: CollectionSortProperty sortReverse: boolean hidePinned: boolean showArchived: boolean showTrashed: boolean hideProtected: boolean hideTags: boolean hideNotePreview: boolean hideDate: boolean hideEditorIcon: boolean } export class NotesViewState extends AbstractState { completedFullSync = false noteFilterText = '' notes: SNNote[] = [] notesToDisplay = 0 pageSize = 0 panelTitle = 'All Notes' panelWidth = 0 renderedNotes: SNNote[] = [] searchSubmitted = false showDisplayOptionsMenu = false displayOptions = { sortBy: CollectionSort.CreatedAt, sortReverse: false, hidePinned: false, showArchived: false, showTrashed: false, hideProtected: false, hideTags: true, hideDate: false, hideNotePreview: false, hideEditorIcon: false, } private reloadNotesPromise?: Promise override deinit(source: DeinitSource) { super.deinit(source) ;(this.noteFilterText as unknown) = undefined ;(this.notes as unknown) = undefined ;(this.renderedNotes as unknown) = undefined ;(window.onresize as unknown) = undefined destroyAllObjectProperties(this) } constructor(application: WebApplication, override appState: AppState, appObservers: (() => void)[]) { super(application, appState) this.resetPagination() appObservers.push( application.streamItems(ContentType.Note, () => { void this.reloadNotes() }), application.streamItems([ContentType.Tag], async ({ changed, inserted }) => { const tags = [...changed, ...inserted] /** A tag could have changed its relationships, so we need to reload the filter */ this.reloadNotesDisplayOptions() void this.reloadNotes() if (appState.tags.selected && findInArray(tags, 'uuid', appState.tags.selected.uuid)) { /** Tag title could have changed */ this.reloadPanelTitle() } }), application.addEventObserver(async () => { void this.reloadPreferences() }, ApplicationEvent.PreferencesChanged), application.addEventObserver(async () => { this.application.noteControllerGroup.closeAllNoteControllers() void this.selectFirstNote() this.setCompletedFullSync(false) }, ApplicationEvent.SignedIn), application.addEventObserver(async () => { void this.reloadNotes().then(() => { if ( this.notes.length === 0 && appState.tags.selected instanceof SmartView && appState.tags.selected.uuid === SystemViewId.AllNotes && this.noteFilterText === '' && !this.getActiveNoteController() ) { this.createPlaceholderNote()?.catch(console.error) } }) this.setCompletedFullSync(true) }, ApplicationEvent.CompletedFullSync), reaction( () => [ appState.searchOptions.includeProtectedContents, appState.searchOptions.includeArchived, appState.searchOptions.includeTrashed, ], () => { this.reloadNotesDisplayOptions() void this.reloadNotes() }, ), appState.addObserver(async (eventName) => { if (eventName === AppStateEvent.TagChanged) { this.handleTagChange() } else if (eventName === AppStateEvent.ActiveEditorChanged) { this.handleEditorChange().catch(console.error) } else if (eventName === AppStateEvent.EditorFocused) { this.setShowDisplayOptionsMenu(false) } }), ) makeObservable(this, { completedFullSync: observable, displayOptions: observable.struct, noteFilterText: observable, notes: observable, notesToDisplay: observable, panelTitle: observable, renderedNotes: observable, showDisplayOptionsMenu: observable, reloadNotes: action, reloadPanelTitle: action, reloadPreferences: action, resetPagination: action, setCompletedFullSync: action, setNoteFilterText: action, setShowDisplayOptionsMenu: action, onFilterEnter: action, handleFilterTextChanged: action, optionsSubtitle: computed, }) window.onresize = () => { this.resetPagination(true) } } public getActiveNoteController(): NoteViewController | undefined { return this.application.noteControllerGroup.activeNoteViewController } public get activeControllerNote(): SNNote | undefined { return this.getActiveNoteController()?.note } setCompletedFullSync = (completed: boolean) => { this.completedFullSync = completed } setShowDisplayOptionsMenu = (enabled: boolean) => { this.showDisplayOptionsMenu = enabled } get searchBarElement() { return document.getElementById(ELEMENT_ID_SEARCH_BAR) } get isFiltering(): boolean { return !!this.noteFilterText && this.noteFilterText.length > 0 } reloadPanelTitle = () => { let title = this.panelTitle if (this.isFiltering) { const resultCount = this.notes.length title = `${resultCount} search results` } else if (this.appState.tags.selected) { title = `${this.appState.tags.selected.title}` } this.panelTitle = title } reloadNotes = async (): Promise => { if (this.reloadNotesPromise) { await this.reloadNotesPromise } this.reloadNotesPromise = this.performReloadNotes() await this.reloadNotesPromise } private async performReloadNotes() { const tag = this.appState.tags.selected if (!tag) { return } const notes = this.application.items.getDisplayableNotes() const renderedNotes = notes.slice(0, this.notesToDisplay) this.notes = notes runInAction(() => { this.renderedNotes = renderedNotes }) await this.recomputeSelectionAfterNotesReload() this.reloadPanelTitle() } private async recomputeSelectionAfterNotesReload() { const appState = this.appState const activeController = this.getActiveNoteController() const activeNote = activeController?.note const isSearching = this.noteFilterText.length > 0 const hasMultipleNotesSelected = appState.notes.selectedNotesCount >= 2 if (hasMultipleNotesSelected) { return } if (!activeNote) { await this.selectFirstNote() return } if (activeController.isTemplateNote) { return } const noteExistsInUpdatedResults = this.notes.find((note) => note.uuid === activeNote.uuid) if (!noteExistsInUpdatedResults && !isSearching) { this.closeNoteController(activeController) this.selectNextNote() return } const showTrashedNotes = (appState.tags.selected instanceof SmartView && appState.tags.selected?.uuid === SystemViewId.TrashedNotes) || appState?.searchOptions.includeTrashed const showArchivedNotes = (appState.tags.selected instanceof SmartView && appState.tags.selected.uuid === SystemViewId.ArchivedNotes) || appState.searchOptions.includeArchived || this.application.getPreference(PrefKey.NotesShowArchived, false) if ((activeNote.trashed && !showTrashedNotes) || (activeNote.archived && !showArchivedNotes)) { await this.selectNextOrCreateNew() } else if (!this.appState.notes.selectedNotes[activeNote.uuid]) { await this.selectNoteWithScrollHandling(activeNote).catch(console.error) } } reloadNotesDisplayOptions = () => { const tag = this.appState.tags.selected const searchText = this.noteFilterText.toLowerCase() const isSearching = searchText.length let includeArchived: boolean let includeTrashed: boolean if (isSearching) { includeArchived = this.appState.searchOptions.includeArchived includeTrashed = this.appState.searchOptions.includeTrashed } else { includeArchived = this.displayOptions.showArchived ?? false includeTrashed = this.displayOptions.showTrashed ?? false } const criteria = NotesDisplayCriteria.Create({ sortProperty: this.displayOptions.sortBy, sortDirection: this.displayOptions.sortReverse ? 'asc' : 'dsc', tags: tag instanceof SNTag ? [tag] : [], views: tag instanceof SmartView ? [tag] : [], includeArchived, includeTrashed, includePinned: !this.displayOptions.hidePinned, includeProtected: !this.displayOptions.hideProtected, searchQuery: { query: searchText, includeProtectedNoteText: this.appState.searchOptions.includeProtectedContents, }, }) this.application.items.setNotesDisplayCriteria(criteria) } reloadPreferences = async () => { const freshDisplayOptions = {} as DisplayOptions const currentSortBy = this.displayOptions.sortBy let sortBy = this.application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt) if (sortBy === CollectionSort.UpdatedAt || (sortBy as string) === 'client_updated_at') { sortBy = CollectionSort.UpdatedAt } freshDisplayOptions.sortBy = sortBy freshDisplayOptions.sortReverse = this.application.getPreference(PrefKey.SortNotesReverse, false) freshDisplayOptions.showArchived = this.application.getPreference(PrefKey.NotesShowArchived, false) freshDisplayOptions.showTrashed = this.application.getPreference(PrefKey.NotesShowTrashed, false) as boolean freshDisplayOptions.hidePinned = this.application.getPreference(PrefKey.NotesHidePinned, false) freshDisplayOptions.hideProtected = this.application.getPreference(PrefKey.NotesHideProtected, false) freshDisplayOptions.hideNotePreview = this.application.getPreference(PrefKey.NotesHideNotePreview, false) freshDisplayOptions.hideDate = this.application.getPreference(PrefKey.NotesHideDate, false) freshDisplayOptions.hideTags = this.application.getPreference(PrefKey.NotesHideTags, true) freshDisplayOptions.hideEditorIcon = this.application.getPreference(PrefKey.NotesHideEditorIcon, false) const displayOptionsChanged = freshDisplayOptions.sortBy !== this.displayOptions.sortBy || freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse || freshDisplayOptions.hidePinned !== this.displayOptions.hidePinned || freshDisplayOptions.showArchived !== this.displayOptions.showArchived || freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed || freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected || freshDisplayOptions.hideEditorIcon !== this.displayOptions.hideEditorIcon || freshDisplayOptions.hideTags !== this.displayOptions.hideTags this.displayOptions = freshDisplayOptions if (displayOptionsChanged) { this.reloadNotesDisplayOptions() } await this.reloadNotes() const width = this.application.getPreference(PrefKey.NotesPanelWidth) if (width) { this.panelWidth = width } if (freshDisplayOptions.sortBy !== currentSortBy) { await this.selectFirstNote() } } createNewNote = async () => { this.appState.notes.unselectNotes() let title = `Note ${this.notes.length + 1}` if (this.isFiltering) { title = this.noteFilterText } await this.appState.notes.createNewNoteController(title) this.appState.noteTags.reloadTags() } createPlaceholderNote = () => { const selectedTag = this.appState.tags.selected if (selectedTag && selectedTag instanceof SmartView && selectedTag.uuid !== SystemViewId.AllNotes) { return } return this.createNewNote() } get optionsSubtitle(): string { let base = '' if (this.displayOptions.sortBy === CollectionSort.CreatedAt) { base += ' Date Added' } else if (this.displayOptions.sortBy === CollectionSort.UpdatedAt) { base += ' Date Modified' } else if (this.displayOptions.sortBy === CollectionSort.Title) { base += ' Title' } if (this.displayOptions.showArchived) { base += ' | + Archived' } if (this.displayOptions.showTrashed) { base += ' | + Trashed' } if (this.displayOptions.hidePinned) { base += ' | – Pinned' } if (this.displayOptions.hideProtected) { base += ' | – Protected' } if (this.displayOptions.sortReverse) { base += ' | Reversed' } return base } paginate = () => { this.notesToDisplay += this.pageSize void this.reloadNotes() if (this.searchSubmitted) { this.application.getDesktopService()?.searchText(this.noteFilterText) } } resetPagination = (keepCurrentIfLarger = false) => { const clientHeight = document.documentElement.clientHeight this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT) if (this.pageSize === 0) { this.pageSize = DEFAULT_LIST_NUM_NOTES } if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) { return } this.notesToDisplay = this.pageSize } getFirstNonProtectedNote = () => { return this.notes.find((note) => !note.protected) } get notesListScrollContainer() { return document.getElementById(ELEMENT_ID_SCROLL_CONTAINER) } selectNoteWithScrollHandling = async ( note: SNNote, userTriggered?: boolean, scrollIntoView = true, ): Promise => { await this.appState.notes.selectNote(note.uuid, userTriggered) if (scrollIntoView) { const noteElement = document.getElementById(`note-${note.uuid}`) noteElement?.scrollIntoView({ behavior: 'smooth', }) } } selectFirstNote = async () => { const note = this.getFirstNonProtectedNote() if (note) { await this.selectNoteWithScrollHandling(note, false, false) this.resetScrollPosition() } } selectNextNote = () => { const displayableNotes = this.notes const currentIndex = displayableNotes.findIndex((candidate) => { return candidate.uuid === this.activeControllerNote?.uuid }) let nextIndex = currentIndex + 1 while (nextIndex < displayableNotes.length) { const nextNote = displayableNotes[nextIndex] nextIndex++ if (nextNote.protected) { continue } this.selectNoteWithScrollHandling(nextNote).catch(console.error) const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`) nextNoteElement?.focus() return } } selectNextOrCreateNew = async () => { const note = this.getFirstNonProtectedNote() if (note) { await this.selectNoteWithScrollHandling(note, false, false).catch(console.error) } else { await this.createNewNote() } } selectPreviousNote = () => { const displayableNotes = this.notes if (!this.activeControllerNote) { return } const currentIndex = displayableNotes.indexOf(this.activeControllerNote) let previousIndex = currentIndex - 1 while (previousIndex >= 0) { const previousNote = displayableNotes[previousIndex] previousIndex-- if (previousNote.protected) { continue } this.selectNoteWithScrollHandling(previousNote).catch(console.error) const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`) previousNoteElement?.focus() return } } setNoteFilterText = (text: string) => { if (text === this.noteFilterText) { return } this.noteFilterText = text this.handleFilterTextChanged() } handleEditorChange = async () => { const activeNote = this.application.noteControllerGroup.activeNoteViewController?.note if (activeNote && activeNote.conflictOf) { this.application.mutator .changeAndSaveItem(activeNote, (mutator) => { mutator.conflictOf = undefined }) .catch(console.error) } if (this.isFiltering) { this.application.getDesktopService()?.searchText(this.noteFilterText) } } resetScrollPosition = () => { if (this.notesListScrollContainer) { this.notesListScrollContainer.scrollTop = 0 this.notesListScrollContainer.scrollLeft = 0 } } private closeNoteController(controller: NoteViewController): void { this.application.noteControllerGroup.closeNoteController(controller) } handleTagChange = () => { const activeNoteController = this.getActiveNoteController() if (activeNoteController?.isTemplateNote) { this.closeNoteController(activeNoteController) } this.resetScrollPosition() this.setShowDisplayOptionsMenu(false) this.setNoteFilterText('') this.application.getDesktopService()?.searchText() this.resetPagination() this.reloadNotesDisplayOptions() void this.reloadNotes() } onFilterEnter = () => { /** * For Desktop, performing a search right away causes * input to lose focus. We wait until user explicity hits * enter before highlighting desktop search results. */ this.searchSubmitted = true this.application.getDesktopService()?.searchText(this.noteFilterText) } public async insertCurrentIfTemplate(): Promise { const controller = this.getActiveNoteController() if (!controller) { return } if (controller.isTemplateNote) { await controller.insertTemplatedNote() } } handleFilterTextChanged = () => { if (this.searchSubmitted) { this.searchSubmitted = false } this.reloadNotesDisplayOptions() void this.reloadNotes() } clearFilterText = () => { this.setNoteFilterText('') this.onFilterEnter() this.handleFilterTextChanged() this.resetPagination() } }