Files
standardnotes-app-web/app/assets/javascripts/UIModels/AppState/NotesViewState.ts

605 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } 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<unknown>
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<SNNote>(ContentType.Note, () => {
void this.reloadNotes()
}),
application.streamItems<SNTag>([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<void> => {
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
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.application.noteControllerGroup.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') {
/** Use UserUpdatedAt instead */
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<void> => {
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
})
if (currentIndex + 1 < displayableNotes.length) {
const nextNote = displayableNotes[currentIndex + 1]
this.selectNoteWithScrollHandling(nextNote).catch(console.error)
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`)
nextNoteElement?.focus()
}
}
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) {
const currentIndex = displayableNotes.indexOf(this.activeControllerNote)
if (currentIndex - 1 >= 0) {
const previousNote = displayableNotes[currentIndex - 1]
this.selectNoteWithScrollHandling(previousNote).catch(console.error)
const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`)
previousNoteElement?.focus()
return true
} else {
return false
}
}
return undefined
}
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
}
}
handleTagChange = () => {
const activeNoteController = this.getActiveNoteController()
if (activeNoteController?.isTemplateNote) {
this.application.noteControllerGroup.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<void> {
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()
}
}