feat: generic items list (#1035)

This commit is contained in:
Aman Harwara
2022-05-22 18:51:46 +05:30
committed by GitHub
parent 1643311d08
commit 6401da2570
76 changed files with 1808 additions and 1281 deletions

View File

@@ -7,7 +7,6 @@ import {
ContentType,
DeinitSource,
PrefKey,
SNNote,
SNTag,
removeFromArray,
WebOrDesktopDeviceInterface,
@@ -17,7 +16,7 @@ import { ActionsMenuState } from './ActionsMenuState'
import { FeaturesState } from './FeaturesState'
import { FilesState } from './FilesState'
import { NotesState } from './NotesState'
import { NotesViewState } from './NotesViewState'
import { ContentListViewState } from './ContentListViewState'
import { NoteTagsState } from './NoteTagsState'
import { NoAccountWarningState } from './NoAccountWarningState'
import { PreferencesState } from './PreferencesState'
@@ -29,6 +28,8 @@ import { SyncState } from './SyncState'
import { TagsState } from './TagsState'
import { FilePreviewModalState } from './FilePreviewModalState'
import { AbstractState } from './AbstractState'
import { SelectedItemsState } from './SelectedItemsState'
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
export enum AppStateEvent {
TagChanged,
@@ -70,7 +71,7 @@ export class AppState extends AbstractState {
readonly files: FilesState
readonly noAccountWarning: NoAccountWarningState
readonly notes: NotesState
readonly notesView: NotesViewState
readonly contentListView: ContentListViewState
readonly noteTags: NoteTagsState
readonly preferences = new PreferencesState()
readonly purchaseFlow: PurchaseFlowState
@@ -79,6 +80,7 @@ export class AppState extends AbstractState {
readonly subscription: SubscriptionState
readonly sync = new SyncState()
readonly tags: TagsState
readonly selectedItems: SelectedItemsState
isSessionsModalVisible = false
@@ -89,6 +91,7 @@ export class AppState extends AbstractState {
constructor(application: WebApplication, private device: WebOrDesktopDeviceInterface) {
super(application)
this.selectedItems = new SelectedItemsState(application, this, this.appEventObserverRemovers)
this.notes = new NotesState(
application,
this,
@@ -106,8 +109,8 @@ export class AppState extends AbstractState {
this.searchOptions = new SearchOptionsState(application, this.appEventObserverRemovers)
this.subscription = new SubscriptionState(application, this.appEventObserverRemovers)
this.purchaseFlow = new PurchaseFlowState(application)
this.notesView = new NotesViewState(application, this, this.appEventObserverRemovers)
this.files = new FilesState(application)
this.contentListView = new ContentListViewState(application, this, this.appEventObserverRemovers)
this.files = new FilesState(application, this, this.appEventObserverRemovers)
this.addAppEventObserver()
this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
@@ -177,8 +180,8 @@ export class AppState extends AbstractState {
this.notes.deinit(source)
;(this.notes as unknown) = undefined
this.notesView.deinit(source)
;(this.notesView as unknown) = undefined
this.contentListView.deinit(source)
;(this.contentListView as unknown) = undefined
this.noteTags.deinit(source)
;(this.noteTags as unknown) = undefined
@@ -321,8 +324,8 @@ export class AppState extends AbstractState {
}
/** Returns the tags that are referncing this note */
public getNoteTags(note: SNNote) {
return this.application.items.itemsReferencingItem(note).filter((ref) => {
public getItemTags(item: ListableContentItem) {
return this.application.items.itemsReferencingItem(item).filter((ref) => {
return ref.content_type === ContentType.Tag
}) as SNTag[]
}

View File

@@ -1,72 +1,64 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
import { destroyAllObjectProperties } from '@/Utils'
import {
ApplicationEvent,
CollectionSort,
CollectionSortProperty,
ContentType,
DeinitSource,
findInArray,
NotesDisplayCriteria,
NoteViewController,
PrefKey,
SmartView,
SNNote,
SNTag,
SystemViewId,
DisplayOptions,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { AppState, AppStateEvent } from '.'
import { WebApplication } from '../Application'
import { AbstractState } from './AbstractState'
import { WebDisplayOptions } from './WebDisplayOptions'
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'
const MinNoteCellHeight = 51.0
const DefaultListNumNotes = 20
const ElementIdSearchBar = 'search-bar'
const ElementIdScrollContainer = 'notes-scrollable'
const SupportsFileSelectionState = false
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 {
export class ContentListViewState extends AbstractState {
completedFullSync = false
noteFilterText = ''
notes: SNNote[] = []
items: ListableContentItem[] = []
notesToDisplay = 0
pageSize = 0
panelTitle = 'All Notes'
panelWidth = 0
renderedNotes: SNNote[] = []
renderedItems: ListableContentItem[] = []
searchSubmitted = false
showDisplayOptionsMenu = false
displayOptions = {
displayOptions: DisplayOptions = {
sortBy: CollectionSort.CreatedAt,
sortReverse: false,
hidePinned: false,
showArchived: false,
showTrashed: false,
hideProtected: false,
sortDirection: 'dsc',
includePinned: true,
includeArchived: false,
includeTrashed: false,
includeProtected: true,
}
webDisplayOptions: WebDisplayOptions = {
hideTags: true,
hideDate: false,
hideNotePreview: false,
hideEditorIcon: false,
}
private reloadNotesPromise?: Promise<unknown>
private reloadItemsPromise?: Promise<unknown>
override deinit(source: DeinitSource) {
super.deinit(source)
;(this.noteFilterText as unknown) = undefined
;(this.notes as unknown) = undefined
;(this.renderedNotes as unknown) = undefined
;(this.renderedItems as unknown) = undefined
;(window.onresize as unknown) = undefined
destroyAllObjectProperties(this)
@@ -79,7 +71,7 @@ export class NotesViewState extends AbstractState {
appObservers.push(
application.streamItems<SNNote>(ContentType.Note, () => {
void this.reloadNotes()
void this.reloadItems()
}),
application.streamItems<SNTag>([ContentType.Tag], async ({ changed, inserted }) => {
@@ -88,7 +80,7 @@ export class NotesViewState extends AbstractState {
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions()
void this.reloadNotes()
void this.reloadItems()
if (appState.tags.selected && findInArray(tags, 'uuid', appState.tags.selected.uuid)) {
/** Tag title could have changed */
@@ -100,11 +92,11 @@ export class NotesViewState extends AbstractState {
}, ApplicationEvent.PreferencesChanged),
application.addEventObserver(async () => {
this.application.noteControllerGroup.closeAllNoteControllers()
void this.selectFirstNote()
void this.selectFirstItem()
this.setCompletedFullSync(false)
}, ApplicationEvent.SignedIn),
application.addEventObserver(async () => {
void this.reloadNotes().then(() => {
void this.reloadItems().then(() => {
if (
this.notes.length === 0 &&
appState.tags.selected instanceof SmartView &&
@@ -126,7 +118,7 @@ export class NotesViewState extends AbstractState {
],
() => {
this.reloadNotesDisplayOptions()
void this.reloadNotes()
void this.reloadItems()
},
),
@@ -144,14 +136,15 @@ export class NotesViewState extends AbstractState {
makeObservable(this, {
completedFullSync: observable,
displayOptions: observable.struct,
webDisplayOptions: observable.struct,
noteFilterText: observable,
notes: observable,
notesToDisplay: observable,
panelTitle: observable,
renderedNotes: observable,
renderedItems: observable,
showDisplayOptionsMenu: observable,
reloadNotes: action,
reloadItems: action,
reloadPanelTitle: action,
reloadPreferences: action,
resetPagination: action,
@@ -186,7 +179,7 @@ export class NotesViewState extends AbstractState {
}
get searchBarElement() {
return document.getElementById(ELEMENT_ID_SEARCH_BAR)
return document.getElementById(ElementIdSearchBar)
}
get isFiltering(): boolean {
@@ -206,17 +199,17 @@ export class NotesViewState extends AbstractState {
this.panelTitle = title
}
reloadNotes = async (): Promise<void> => {
if (this.reloadNotesPromise) {
await this.reloadNotesPromise
reloadItems = async (): Promise<void> => {
if (this.reloadItemsPromise) {
await this.reloadItemsPromise
}
this.reloadNotesPromise = this.performReloadNotes()
this.reloadItemsPromise = this.performReloadItems()
await this.reloadNotesPromise
await this.reloadItemsPromise
}
private async performReloadNotes() {
private async performReloadItems() {
const tag = this.appState.tags.selected
if (!tag) {
return
@@ -224,32 +217,43 @@ export class NotesViewState extends AbstractState {
const notes = this.application.items.getDisplayableNotes()
const renderedNotes = notes.slice(0, this.notesToDisplay)
const items = this.application.items.getDisplayableNotesAndFiles()
this.notes = notes
const renderedItems = items.slice(0, this.notesToDisplay)
runInAction(() => {
this.renderedNotes = renderedNotes
this.notes = notes
this.items = items
this.renderedItems = renderedItems
})
await this.recomputeSelectionAfterNotesReload()
await this.recomputeSelectionAfterItemsReload()
this.reloadPanelTitle()
}
private async recomputeSelectionAfterNotesReload() {
private async recomputeSelectionAfterItemsReload() {
const appState = this.appState
const activeController = this.getActiveNoteController()
const activeNote = activeController?.note
const isSearching = this.noteFilterText.length > 0
const hasMultipleNotesSelected = appState.notes.selectedNotesCount >= 2
const hasMultipleItemsSelected = appState.selectedItems.selectedItemsCount >= 2
if (hasMultipleNotesSelected) {
if (hasMultipleItemsSelected) {
return
}
const selectedItem = Object.values(appState.selectedItems.selectedItems)[0]
const isSelectedItemFile =
this.items.includes(selectedItem) && selectedItem && selectedItem.content_type === ContentType.File
if (isSelectedItemFile && !SupportsFileSelectionState) {
return
}
if (!activeNote) {
await this.selectFirstNote()
await this.selectFirstItem()
return
}
@@ -262,7 +266,7 @@ export class NotesViewState extends AbstractState {
if (!noteExistsInUpdatedResults && !isSearching) {
this.closeNoteController(activeController)
this.selectNextNote()
this.selectNextItem()
return
}
@@ -277,9 +281,9 @@ export class NotesViewState extends AbstractState {
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)
await this.selectNextItemOrCreateNewNote()
} else if (!this.appState.selectedItems.selectedItems[activeNote.uuid]) {
await this.appState.selectedItems.selectItem(activeNote.uuid).catch(console.error)
}
}
@@ -295,30 +299,32 @@ export class NotesViewState extends AbstractState {
includeArchived = this.appState.searchOptions.includeArchived
includeTrashed = this.appState.searchOptions.includeTrashed
} else {
includeArchived = this.displayOptions.showArchived ?? false
includeTrashed = this.displayOptions.showTrashed ?? false
includeArchived = this.displayOptions.includeArchived ?? false
includeTrashed = this.displayOptions.includeTrashed ?? false
}
const criteria = NotesDisplayCriteria.Create({
sortProperty: this.displayOptions.sortBy,
sortDirection: this.displayOptions.sortReverse ? 'asc' : 'dsc',
const criteria: DisplayOptions = {
sortBy: this.displayOptions.sortBy,
sortDirection: this.displayOptions.sortDirection,
tags: tag instanceof SNTag ? [tag] : [],
views: tag instanceof SmartView ? [tag] : [],
includeArchived,
includeTrashed,
includePinned: !this.displayOptions.hidePinned,
includeProtected: !this.displayOptions.hideProtected,
includePinned: this.displayOptions.includePinned,
includeProtected: this.displayOptions.includeProtected,
searchQuery: {
query: searchText,
includeProtectedNoteText: this.appState.searchOptions.includeProtectedContents,
},
})
}
this.application.items.setNotesDisplayCriteria(criteria)
this.application.items.setPrimaryItemDisplayOptions(criteria)
}
reloadPreferences = async () => {
const freshDisplayOptions = {} as DisplayOptions
const newDisplayOptions = {} as DisplayOptions
const newWebDisplayOptions = {} as WebDisplayOptions
const currentSortBy = this.displayOptions.sortBy
let sortBy = this.application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
@@ -326,42 +332,47 @@ export class NotesViewState extends AbstractState {
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)
newDisplayOptions.sortBy = sortBy
newDisplayOptions.sortDirection =
this.application.getPreference(PrefKey.SortNotesReverse, false) === false ? 'dsc' : 'asc'
newDisplayOptions.includeArchived = this.application.getPreference(PrefKey.NotesShowArchived, false)
newDisplayOptions.includeTrashed = this.application.getPreference(PrefKey.NotesShowTrashed, false) as boolean
newDisplayOptions.includePinned = !this.application.getPreference(PrefKey.NotesHidePinned, false)
newDisplayOptions.includeProtected = !this.application.getPreference(PrefKey.NotesHideProtected, false)
newWebDisplayOptions.hideNotePreview = this.application.getPreference(PrefKey.NotesHideNotePreview, false)
newWebDisplayOptions.hideDate = this.application.getPreference(PrefKey.NotesHideDate, false)
newWebDisplayOptions.hideTags = this.application.getPreference(PrefKey.NotesHideTags, true)
newWebDisplayOptions.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
newDisplayOptions.sortBy !== this.displayOptions.sortBy ||
newDisplayOptions.sortDirection !== this.displayOptions.sortDirection ||
newDisplayOptions.includePinned !== this.displayOptions.includePinned ||
newDisplayOptions.includeArchived !== this.displayOptions.includeArchived ||
newDisplayOptions.includeTrashed !== this.displayOptions.includeTrashed ||
newDisplayOptions.includeProtected !== this.displayOptions.includeProtected ||
newWebDisplayOptions.hideNotePreview !== this.webDisplayOptions.hideNotePreview ||
newWebDisplayOptions.hideDate !== this.webDisplayOptions.hideDate ||
newWebDisplayOptions.hideEditorIcon !== this.webDisplayOptions.hideEditorIcon ||
newWebDisplayOptions.hideTags !== this.webDisplayOptions.hideTags
this.displayOptions = freshDisplayOptions
this.displayOptions = newDisplayOptions
this.webDisplayOptions = newWebDisplayOptions
if (displayOptionsChanged) {
this.reloadNotesDisplayOptions()
}
await this.reloadNotes()
await this.reloadItems()
const width = this.application.getPreference(PrefKey.NotesPanelWidth)
if (width) {
this.panelWidth = width
}
if (freshDisplayOptions.sortBy !== currentSortBy) {
await this.selectFirstNote()
if (newDisplayOptions.sortBy !== currentSortBy) {
await this.selectFirstItem()
}
}
@@ -389,6 +400,7 @@ export class NotesViewState extends AbstractState {
get optionsSubtitle(): string {
let base = ''
if (this.displayOptions.sortBy === CollectionSort.CreatedAt) {
base += ' Date Added'
} else if (this.displayOptions.sortBy === CollectionSort.UpdatedAt) {
@@ -396,28 +408,34 @@ export class NotesViewState extends AbstractState {
} else if (this.displayOptions.sortBy === CollectionSort.Title) {
base += ' Title'
}
if (this.displayOptions.showArchived) {
if (this.displayOptions.includeArchived) {
base += ' | + Archived'
}
if (this.displayOptions.showTrashed) {
if (this.displayOptions.includeTrashed) {
base += ' | + Trashed'
}
if (this.displayOptions.hidePinned) {
if (!this.displayOptions.includePinned) {
base += ' | Pinned'
}
if (this.displayOptions.hideProtected) {
if (!this.displayOptions.includeProtected) {
base += ' | Protected'
}
if (this.displayOptions.sortReverse) {
if (this.displayOptions.sortDirection === 'asc') {
base += ' | Reversed'
}
return base
}
paginate = () => {
this.notesToDisplay += this.pageSize
void this.reloadNotes()
void this.reloadItems()
if (this.searchSubmitted) {
this.application.getDesktopService()?.searchText(this.noteFilterText)
@@ -426,9 +444,9 @@ export class NotesViewState extends AbstractState {
resetPagination = (keepCurrentIfLarger = false) => {
const clientHeight = document.documentElement.clientHeight
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT)
this.pageSize = Math.ceil(clientHeight / MinNoteCellHeight)
if (this.pageSize === 0) {
this.pageSize = DEFAULT_LIST_NUM_NOTES
this.pageSize = DefaultListNumNotes
}
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
return
@@ -436,60 +454,64 @@ export class NotesViewState extends AbstractState {
this.notesToDisplay = this.pageSize
}
getFirstNonProtectedNote = () => {
return this.notes.find((note) => !note.protected)
getFirstNonProtectedItem = () => {
return this.items.find((item) => !item.protected)
}
get notesListScrollContainer() {
return document.getElementById(ELEMENT_ID_SCROLL_CONTAINER)
return document.getElementById(ElementIdScrollContainer)
}
selectNoteWithScrollHandling = async (
note: SNNote,
userTriggered?: boolean,
scrollIntoView = true,
selectItemWithScrollHandling = async (
item: {
uuid: ListableContentItem['uuid']
},
{ userTriggered = false, scrollIntoView = true },
): Promise<void> => {
await this.appState.notes.selectNote(note.uuid, userTriggered)
await this.appState.selectedItems.selectItem(item.uuid, userTriggered)
if (scrollIntoView) {
const noteElement = document.getElementById(`note-${note.uuid}`)
noteElement?.scrollIntoView({
const itemElement = document.getElementById(item.uuid)
itemElement?.scrollIntoView({
behavior: 'smooth',
})
}
}
selectFirstNote = async () => {
const note = this.getFirstNonProtectedNote()
selectFirstItem = async () => {
const item = this.getFirstNonProtectedItem()
if (note) {
await this.selectNoteWithScrollHandling(note, false, false)
if (item) {
await this.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
})
this.resetScrollPosition()
}
}
selectNextNote = () => {
const displayableNotes = this.notes
selectNextItem = () => {
const displayableItems = this.items
const currentIndex = displayableNotes.findIndex((candidate) => {
return candidate.uuid === this.activeControllerNote?.uuid
const currentIndex = displayableItems.findIndex((candidate) => {
return candidate.uuid === this.appState.selectedItems.lastSelectedItem?.uuid
})
let nextIndex = currentIndex + 1
while (nextIndex < displayableNotes.length) {
const nextNote = displayableNotes[nextIndex]
while (nextIndex < displayableItems.length) {
const nextItem = displayableItems[nextIndex]
nextIndex++
if (nextNote.protected) {
if (nextItem.protected) {
continue
}
this.selectNoteWithScrollHandling(nextNote).catch(console.error)
this.selectItemWithScrollHandling(nextItem, { userTriggered: true }).catch(console.error)
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`)
const nextNoteElement = document.getElementById(nextItem.uuid)
nextNoteElement?.focus()
@@ -497,39 +519,42 @@ export class NotesViewState extends AbstractState {
}
}
selectNextOrCreateNew = async () => {
const note = this.getFirstNonProtectedNote()
selectNextItemOrCreateNewNote = async () => {
const item = this.getFirstNonProtectedItem()
if (note) {
await this.selectNoteWithScrollHandling(note, false, false).catch(console.error)
if (item) {
await this.selectItemWithScrollHandling(item, {
userTriggered: false,
scrollIntoView: false,
}).catch(console.error)
} else {
await this.createNewNote()
}
}
selectPreviousNote = () => {
const displayableNotes = this.notes
selectPreviousItem = () => {
const displayableItems = this.items
if (!this.activeControllerNote) {
if (!this.appState.selectedItems.lastSelectedItem) {
return
}
const currentIndex = displayableNotes.indexOf(this.activeControllerNote)
const currentIndex = displayableItems.indexOf(this.appState.selectedItems.lastSelectedItem)
let previousIndex = currentIndex - 1
while (previousIndex >= 0) {
const previousNote = displayableNotes[previousIndex]
const previousItem = displayableItems[previousIndex]
previousIndex--
if (previousNote.protected) {
if (previousItem.protected) {
continue
}
this.selectNoteWithScrollHandling(previousNote).catch(console.error)
this.selectItemWithScrollHandling(previousItem, { userTriggered: true }).catch(console.error)
const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`)
const previousNoteElement = document.getElementById(previousItem.uuid)
previousNoteElement?.focus()
@@ -591,7 +616,7 @@ export class NotesViewState extends AbstractState {
this.reloadNotesDisplayOptions()
void this.reloadNotes()
void this.reloadItems()
}
onFilterEnter = () => {
@@ -624,7 +649,7 @@ export class NotesViewState extends AbstractState {
this.reloadNotesDisplayOptions()
void this.reloadNotes()
void this.reloadItems()
}
clearFilterText = () => {

View File

@@ -1,4 +1,10 @@
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { PopoverTabs } from '@/Components/AttachedFilesPopover/PopoverTabs'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
import { confirmDialog } from '@/Services/AlertService'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import {
ClassicFileReader,
@@ -7,11 +13,202 @@ import {
ClassicFileSaver,
parseFileName,
} from '@standardnotes/filepicker'
import { ClientDisplayableError, FileItem } from '@standardnotes/snjs'
import { ChallengeReason, ClientDisplayableError, ContentType, FileItem } from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType, updateToast } from '@standardnotes/stylekit'
import { action, computed, makeObservable, observable, reaction } from 'mobx'
import { WebApplication } from '../Application'
import { AbstractState } from './AbstractState'
import { AppState } from './AppState'
const UnprotectedFileActions = [PopoverFileItemActionType.ToggleFileProtection]
const NonMutatingFileActions = [PopoverFileItemActionType.DownloadFile, PopoverFileItemActionType.PreviewFile]
type FileContextMenuLocation = { x: number; y: number }
export class FilesState extends AbstractState {
allFiles: FileItem[] = []
attachedFiles: FileItem[] = []
showFileContextMenu = false
fileContextMenuLocation: FileContextMenuLocation = { x: 0, y: 0 }
constructor(application: WebApplication, override appState: AppState, appObservers: (() => void)[]) {
super(application, appState)
makeObservable(this, {
allFiles: observable,
attachedFiles: observable,
showFileContextMenu: observable,
fileContextMenuLocation: observable,
selectedFiles: computed,
reloadAllFiles: action,
reloadAttachedFiles: action,
setShowFileContextMenu: action,
setFileContextMenuLocation: action,
})
appObservers.push(
application.streamItems(ContentType.File, () => {
this.reloadAllFiles()
this.reloadAttachedFiles()
}),
reaction(
() => appState.notes.selectedNotes,
() => {
this.reloadAttachedFiles()
},
),
)
}
get selectedFiles() {
return this.appState.selectedItems.getSelectedItems<FileItem>(ContentType.File)
}
setShowFileContextMenu = (enabled: boolean) => {
this.showFileContextMenu = enabled
}
setFileContextMenuLocation = (location: FileContextMenuLocation) => {
this.fileContextMenuLocation = location
}
reloadAllFiles = () => {
this.allFiles = this.application.items.getDisplayableFiles()
}
reloadAttachedFiles = () => {
const note = this.appState.notes.firstSelectedNote
if (note) {
this.attachedFiles = this.application.items.getFilesForNote(note)
}
}
deleteFile = async (file: FileItem) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
})
if (shouldDelete) {
const deletingToastId = addToast({
type: ToastType.Loading,
message: `Deleting file "${file.name}"...`,
})
await this.application.files.deleteFile(file)
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
})
dismissToast(deletingToastId)
}
}
attachFileToNote = async (file: FileItem) => {
const note = this.appState.notes.firstSelectedNote
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await this.application.items.associateFileWithNote(file, note)
}
detachFileFromNote = async (file: FileItem) => {
const note = this.appState.notes.firstSelectedNote
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await this.application.items.disassociateFileWithNote(file, note)
}
toggleFileProtection = async (file: FileItem) => {
let result: FileItem | undefined
if (file.protected) {
result = await this.application.mutator.unprotectFile(file)
} else {
result = await this.application.mutator.protectFile(file)
}
const isProtected = result ? result.protected : file.protected
return isProtected
}
authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => {
const authorizedFiles = await this.application.protections.authorizeProtectedActionForItems([file], challengeReason)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
renameFile = async (file: FileItem, fileName: string) => {
await this.application.items.renameFile(file, fileName)
}
handleFileAction = async (
action: PopoverFileItemAction,
currentTab: PopoverTabs,
): Promise<{
didHandleAction: boolean
}> => {
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
let isAuthorizedForAction = true
const requiresAuthorization = file.protected && !UnprotectedFileActions.includes(action.type)
if (requiresAuthorization) {
isAuthorizedForAction = await this.authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
}
if (!isAuthorizedForAction) {
return {
didHandleAction: false,
}
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await this.attachFileToNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
await this.detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
await this.deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
await this.downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await this.toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
await this.renameFile(file, action.payload.name)
break
case PopoverFileItemActionType.PreviewFile:
this.appState.filePreviewModal.activate(
file,
currentTab === PopoverTabs.AllFiles ? this.allFiles : this.attachedFiles,
)
break
}
if (!NonMutatingFileActions.includes(action.type)) {
this.application.sync.sync().catch(console.error)
}
return {
didHandleAction: true,
}
}
public async downloadFile(file: FileItem): Promise<void> {
let downloadingToastId = ''

View File

@@ -147,7 +147,7 @@ export class NoteTagsState extends AbstractState {
searchActiveNoteAutocompleteTags(): void {
const newResults = this.application.items.searchTags(
this.autocompleteSearchQuery,
this.appState.notesView.activeControllerNote,
this.appState.contentListView.activeControllerNote,
)
this.setAutocompleteTagResults(newResults)
}
@@ -157,7 +157,7 @@ export class NoteTagsState extends AbstractState {
}
reloadTags(): void {
const activeNote = this.appState.notesView.activeControllerNote
const activeNote = this.appState.contentListView.activeControllerNote
if (activeNote) {
const tags = this.application.items.getSortedTagsForNote(activeNote)
@@ -173,7 +173,7 @@ export class NoteTagsState extends AbstractState {
}
async addTagToActiveNote(tag: SNTag): Promise<void> {
const activeNote = this.appState.notesView.activeControllerNote
const activeNote = this.appState.contentListView.activeControllerNote
if (activeNote) {
await this.application.items.addTagToNote(activeNote, tag, this.addNoteToParentFolders)
@@ -183,7 +183,7 @@ export class NoteTagsState extends AbstractState {
}
async removeTagFromActiveNote(tag: SNTag): Promise<void> {
const activeNote = this.appState.notesView.activeControllerNote
const activeNote = this.appState.contentListView.activeControllerNote
if (activeNote) {
await this.application.mutator.changeItem(tag, (mutator) => {

View File

@@ -1,9 +1,8 @@
import { destroyAllObjectProperties } from '@/Utils'
import { confirmDialog } from '@/Services/AlertService'
import { KeyboardModifier } from '@/Services/IOService'
import { StringEmptyTrash, Strings, StringUtils } from '@/Strings'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import { UuidString, SNNote, NoteMutator, ContentType, SNTag, ChallengeReason, DeinitSource } from '@standardnotes/snjs'
import { SNNote, NoteMutator, ContentType, SNTag, DeinitSource, TagMutator } from '@standardnotes/snjs'
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
import { WebApplication } from '../Application'
import { AppState } from './AppState'
@@ -11,7 +10,6 @@ import { AbstractState } from './AbstractState'
export class NotesState extends AbstractState {
lastSelectedNote: SNNote | undefined
selectedNotes: Record<UuidString, SNNote> = {}
contextMenuOpen = false
contextMenuPosition: { top?: number; left: number; bottom?: number } = {
top: 0,
@@ -25,7 +23,6 @@ export class NotesState extends AbstractState {
override deinit(source: DeinitSource) {
super.deinit(source)
;(this.lastSelectedNote as unknown) = undefined
;(this.selectedNotes as unknown) = undefined
;(this.onActiveEditorChanged as unknown) = undefined
destroyAllObjectProperties(this)
@@ -40,12 +37,13 @@ export class NotesState extends AbstractState {
super(application, appState)
makeObservable(this, {
selectedNotes: observable,
contextMenuOpen: observable,
contextMenuPosition: observable,
showProtectedWarning: observable,
showRevisionHistoryModal: observable,
selectedNotes: computed,
firstSelectedNote: computed,
selectedNotesCount: computed,
trashedNotesCount: computed,
@@ -89,6 +87,14 @@ export class NotesState extends AbstractState {
)
}
get selectedNotes() {
return this.appState.selectedItems.getSelectedItems<SNNote>(ContentType.Note)
}
get firstSelectedNote(): SNNote | undefined {
return Object.values(this.selectedNotes)[0]
}
get selectedNotesCount(): number {
if (this.dealloced) {
return 0
@@ -101,79 +107,8 @@ export class NotesState extends AbstractState {
return this.application.items.trashedItems.length
}
private async selectNotesRange(selectedNote: SNNote): Promise<void> {
const notes = this.application.items.getDisplayableNotes()
const lastSelectedNoteIndex = notes.findIndex((note) => note.uuid == this.lastSelectedNote?.uuid)
const selectedNoteIndex = notes.findIndex((note) => note.uuid == selectedNote.uuid)
let notesToSelect = []
if (selectedNoteIndex > lastSelectedNoteIndex) {
notesToSelect = notes.slice(lastSelectedNoteIndex, selectedNoteIndex + 1)
} else {
notesToSelect = notes.slice(selectedNoteIndex, lastSelectedNoteIndex + 1)
}
const authorizedNotes = await this.application.authorizeProtectedActionForNotes(
notesToSelect,
ChallengeReason.SelectProtectedNote,
)
for (const note of authorizedNotes) {
runInAction(() => {
this.selectedNotes[note.uuid] = note
this.lastSelectedNote = note
})
}
}
async selectNote(uuid: UuidString, userTriggered?: boolean): Promise<void> {
const note = this.application.items.findItem(uuid) as SNNote
if (!note) {
return
}
if (this.selectedNotes[uuid]) {
return
}
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
const isMultipleSelectSingle = userTriggered && (hasMeta || hasCtrl)
const isMultipleSelectRange = userTriggered && hasShift
if (isMultipleSelectSingle) {
if (this.selectedNotes[uuid]) {
delete this.selectedNotes[uuid]
} else if (await this.application.authorizeNoteAccess(note)) {
runInAction(() => {
this.selectedNotes[uuid] = note
this.lastSelectedNote = note
})
}
} else if (isMultipleSelectRange) {
await this.selectNotesRange(note)
} else {
const shouldSelectNote = this.selectedNotesCount > 1 || !this.selectedNotes[uuid]
if (shouldSelectNote && (await this.application.authorizeNoteAccess(note))) {
runInAction(() => {
this.selectedNotes = {
[note.uuid]: note,
}
this.lastSelectedNote = note
})
}
}
if (this.selectedNotesCount === 1) {
await this.openNote(Object.keys(this.selectedNotes)[0])
}
}
private async openNote(noteUuid: string): Promise<void> {
if (this.appState.notesView.activeControllerNote?.uuid === noteUuid) {
async openNote(noteUuid: string): Promise<void> {
if (this.appState.contentListView.activeControllerNote?.uuid === noteUuid) {
return
}
@@ -359,7 +294,7 @@ export class NotesState extends AbstractState {
})
runInAction(() => {
this.selectedNotes = {}
this.appState.selectedItems.setSelectedItems({})
this.contextMenuOpen = false
})
}
@@ -376,7 +311,7 @@ export class NotesState extends AbstractState {
}
unselectNotes(): void {
this.selectedNotes = {}
this.appState.selectedItems.setSelectedItems({})
}
getSpellcheckStateForNote(note: SNNote) {
@@ -400,9 +335,9 @@ export class NotesState extends AbstractState {
const tagsToAdd = [...parentChainTags, tag]
await Promise.all(
tagsToAdd.map(async (tag) => {
await this.application.mutator.changeItem(tag, (mutator) => {
await this.application.mutator.changeItem<TagMutator>(tag, (mutator) => {
for (const note of selectedNotes) {
mutator.addItemAsRelationship(note)
mutator.addNote(note)
}
})
}),
@@ -422,7 +357,7 @@ export class NotesState extends AbstractState {
isTagInSelectedNotes(tag: SNTag): boolean {
const selectedNotes = this.getSelectedNotesList()
return selectedNotes.every((note) => this.appState.getNoteTags(note).find((noteTag) => noteTag.uuid === tag.uuid))
return selectedNotes.every((note) => this.appState.getItemTags(note).find((noteTag) => noteTag.uuid === tag.uuid))
}
setShowProtectedWarning(show: boolean): void {
@@ -445,10 +380,6 @@ export class NotesState extends AbstractState {
return Object.values(this.selectedNotes)
}
private get io() {
return this.application.io
}
setShowRevisionHistoryModal(show: boolean): void {
this.showRevisionHistoryModal = show
}

View File

@@ -0,0 +1,140 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
import { ChallengeReason, ContentType, KeyboardModifier, FileItem, SNNote, UuidString } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
import { WebApplication } from '../Application'
import { AbstractState } from './AbstractState'
import { AppState } from './AppState'
type SelectedItems = Record<UuidString, ListableContentItem>
export class SelectedItemsState extends AbstractState {
lastSelectedItem: ListableContentItem | undefined
selectedItems: SelectedItems = {}
constructor(application: WebApplication, override appState: AppState, appObservers: (() => void)[]) {
super(application)
makeObservable(this, {
selectedItems: observable,
selectedItemsCount: computed,
selectItem: action,
setSelectedItems: action,
})
appObservers.push(
application.streamItems<SNNote | FileItem>(
[ContentType.Note, ContentType.File],
({ changed, inserted, removed }) => {
runInAction(() => {
for (const removedNote of removed) {
delete this.selectedItems[removedNote.uuid]
}
for (const item of [...changed, ...inserted]) {
if (this.selectedItems[item.uuid]) {
this.selectedItems[item.uuid] = item
}
}
})
},
),
)
}
private get io() {
return this.application.io
}
get selectedItemsCount(): number {
return Object.keys(this.selectedItems).length
}
getSelectedItems = <T extends ListableContentItem>(contentType: ContentType) => {
const filteredEntries = Object.entries(this.appState.selectedItems.selectedItems).filter(
([_, item]) => item.content_type === contentType,
) as [UuidString, T][]
return Object.fromEntries<T>(filteredEntries)
}
setSelectedItems = (selectedItems: SelectedItems) => {
this.selectedItems = selectedItems
}
private selectItemsRange = async (selectedItem: ListableContentItem): Promise<void> => {
const items = this.appState.contentListView.renderedItems
const lastSelectedItemIndex = items.findIndex((item) => item.uuid == this.lastSelectedItem?.uuid)
const selectedItemIndex = items.findIndex((item) => item.uuid == selectedItem.uuid)
let itemsToSelect = []
if (selectedItemIndex > lastSelectedItemIndex) {
itemsToSelect = items.slice(lastSelectedItemIndex, selectedItemIndex + 1)
} else {
itemsToSelect = items.slice(selectedItemIndex, lastSelectedItemIndex + 1)
}
const authorizedItems = await this.application.protections.authorizeProtectedActionForItems(
itemsToSelect,
ChallengeReason.SelectProtectedNote,
)
for (const item of authorizedItems) {
runInAction(() => {
this.selectedItems[item.uuid] = item
this.lastSelectedItem = item
})
}
}
selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
): Promise<{
didSelect: boolean
}> => {
const item = this.application.items.findItem<ListableContentItem>(uuid)
if (!item) {
return {
didSelect: false,
}
}
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
const hasMoreThanOneSelected = this.selectedItemsCount > 1
const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item)
if (userTriggered && (hasMeta || hasCtrl)) {
if (this.selectedItems[uuid] && hasMoreThanOneSelected) {
delete this.selectedItems[uuid]
} else if (isAuthorizedForAccess) {
this.selectedItems[uuid] = item
this.lastSelectedItem = item
}
} else if (userTriggered && hasShift) {
await this.selectItemsRange(item)
} else {
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedItems[uuid]
if (shouldSelectNote && isAuthorizedForAccess) {
this.setSelectedItems({
[item.uuid]: item,
})
this.lastSelectedItem = item
}
}
if (this.selectedItemsCount === 1) {
const item = Object.values(this.selectedItems)[0]
if (item.content_type === ContentType.Note) {
await this.appState.notes.openNote(item.uuid)
}
}
return {
didSelect: this.selectedItems[uuid] != undefined,
}
}
}

View File

@@ -25,7 +25,7 @@ type AnyTag = SNTag | SmartView
const rootTags = (application: SNApplication): SNTag[] => {
const hasNoParent = (tag: SNTag) => !application.items.getTagParent(tag)
const allTags = application.items.getDisplayableItems<SNTag>(ContentType.Tag)
const allTags = application.items.getDisplayableTags()
const rootTags = allTags.filter(hasNoParent)
return rootTags
@@ -132,7 +132,7 @@ export class TagsState extends AbstractState {
appEventListeners.push(
this.application.streamItems([ContentType.Tag, ContentType.SmartView], ({ changed, removed }) => {
runInAction(() => {
this.tags = this.application.items.getDisplayableItems<SNTag>(ContentType.Tag)
this.tags = this.application.items.getDisplayableTags()
this.smartViews = this.application.items.getSmartViews()

View File

@@ -0,0 +1,6 @@
export type WebDisplayOptions = {
hideTags: boolean
hideDate: boolean
hideNotePreview: boolean
hideEditorIcon: boolean
}