refactor: format and lint codebase (#971)

This commit is contained in:
Aman Harwara
2022-04-13 22:02:34 +05:30
committed by GitHub
parent dc9c1ea0fc
commit 8e467f9e6d
367 changed files with 13778 additions and 16093 deletions

View File

@@ -0,0 +1,184 @@
import { isDev } from '@/Utils'
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
import { ApplicationEvent, ContentType, SNNote, SNTag } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { AccountMenuPane } from '@/Components/AccountMenu'
type StructuredItemsCount = {
notes: number
tags: number
deleted: number
archived: number
}
export class AccountMenuState {
show = false
signingOut = false
otherSessionsSignOut = false
server: string | undefined = undefined
enableServerOption = false
notesAndTags: (SNNote | SNTag)[] = []
isEncryptionEnabled = false
encryptionStatusString = ''
isBackupEncrypted = false
showSignIn = false
showRegister = false
shouldAnimateCloseMenu = false
currentPane = AccountMenuPane.GeneralMenu
constructor(private application: WebApplication, private appEventListeners: (() => void)[]) {
makeObservable(this, {
show: observable,
signingOut: observable,
otherSessionsSignOut: observable,
server: observable,
enableServerOption: observable,
notesAndTags: observable,
isEncryptionEnabled: observable,
encryptionStatusString: observable,
isBackupEncrypted: observable,
showSignIn: observable,
showRegister: observable,
currentPane: observable,
shouldAnimateCloseMenu: observable,
setShow: action,
setShouldAnimateClose: action,
toggleShow: action,
setSigningOut: action,
setIsEncryptionEnabled: action,
setEncryptionStatusString: action,
setIsBackupEncrypted: action,
setOtherSessionsSignOut: action,
setCurrentPane: action,
setEnableServerOption: action,
setServer: action,
notesAndTagsCount: computed,
})
this.addAppLaunchedEventObserver()
this.streamNotesAndTags()
}
addAppLaunchedEventObserver = (): void => {
this.appEventListeners.push(
this.application.addEventObserver(async () => {
runInAction(() => {
if (isDev && window.devAccountServer) {
this.setServer(window.devAccountServer)
this.application.setCustomHost(window.devAccountServer).catch(console.error)
} else {
this.setServer(this.application.getHost())
}
})
}, ApplicationEvent.Launched),
)
}
streamNotesAndTags = (): void => {
this.appEventListeners.push(
this.application.streamItems([ContentType.Note, ContentType.Tag], () => {
runInAction(() => {
this.notesAndTags = this.application.items.getItems([ContentType.Note, ContentType.Tag])
})
}),
)
}
setShow = (show: boolean): void => {
this.show = show
}
setShouldAnimateClose = (shouldAnimateCloseMenu: boolean): void => {
this.shouldAnimateCloseMenu = shouldAnimateCloseMenu
}
closeAccountMenu = (): void => {
this.setShouldAnimateClose(true)
setTimeout(() => {
this.setShow(false)
this.setShouldAnimateClose(false)
this.setCurrentPane(AccountMenuPane.GeneralMenu)
}, 150)
}
setSigningOut = (signingOut: boolean): void => {
this.signingOut = signingOut
}
setServer = (server: string | undefined): void => {
this.server = server
}
setEnableServerOption = (enableServerOption: boolean): void => {
this.enableServerOption = enableServerOption
}
setIsEncryptionEnabled = (isEncryptionEnabled: boolean): void => {
this.isEncryptionEnabled = isEncryptionEnabled
}
setEncryptionStatusString = (encryptionStatusString: string): void => {
this.encryptionStatusString = encryptionStatusString
}
setIsBackupEncrypted = (isBackupEncrypted: boolean): void => {
this.isBackupEncrypted = isBackupEncrypted
}
setShowSignIn = (showSignIn: boolean): void => {
this.showSignIn = showSignIn
}
setShowRegister = (showRegister: boolean): void => {
this.showRegister = showRegister
}
toggleShow = (): void => {
if (this.show) {
this.closeAccountMenu()
} else {
this.setShow(true)
}
}
setOtherSessionsSignOut = (otherSessionsSignOut: boolean): void => {
this.otherSessionsSignOut = otherSessionsSignOut
}
setCurrentPane = (pane: AccountMenuPane): void => {
this.currentPane = pane
}
get notesAndTagsCount(): number {
return this.notesAndTags.length
}
get structuredNotesAndTagsCount(): StructuredItemsCount {
const count: StructuredItemsCount = {
notes: 0,
archived: 0,
deleted: 0,
tags: 0,
}
for (const item of this.notesAndTags) {
if (item.archived) {
count.archived++
}
if (item.trashed) {
count.deleted++
}
if (item.content_type === ContentType.Note) {
count.notes++
}
if (item.content_type === ContentType.Tag) {
count.tags++
}
}
return count
}
}

View File

@@ -0,0 +1,22 @@
import { UuidString } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class ActionsMenuState {
hiddenSections: Record<UuidString, boolean> = {}
constructor() {
makeObservable(this, {
hiddenSections: observable,
toggleSectionVisibility: action,
reset: action,
})
}
toggleSectionVisibility = (uuid: UuidString): void => {
this.hiddenSections[uuid] = !this.hiddenSections[uuid]
}
reset = (): void => {
this.hiddenSections = {}
}
}

View File

@@ -0,0 +1,429 @@
import { Bridge } from '@/Services/Bridge'
import { storage, StorageKey } from '@/Services/LocalStorage'
import { WebApplication, WebAppEvent } from '@/UIModels/Application'
import { AccountMenuState } from '@/UIModels/AppState/AccountMenuState'
import { isDesktopApplication } from '@/Utils'
import {
ApplicationEvent,
ContentType,
DeinitSource,
NoteViewController,
PrefKey,
SNNote,
SmartView,
SNTag,
SystemViewId,
removeFromArray,
PayloadSource,
Uuid,
} from '@standardnotes/snjs'
import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'
import { ActionsMenuState } from './ActionsMenuState'
import { FeaturesState } from './FeaturesState'
import { FilesState } from './FilesState'
import { NotesState } from './NotesState'
import { NotesViewState } from './NotesViewState'
import { NoteTagsState } from './NoteTagsState'
import { NoAccountWarningState } from './NoAccountWarningState'
import { PreferencesState } from './PreferencesState'
import { PurchaseFlowState } from './PurchaseFlowState'
import { QuickSettingsState } from './QuickSettingsState'
import { SearchOptionsState } from './SearchOptionsState'
import { SubscriptionState } from './SubscriptionState'
import { SyncState } from './SyncState'
import { TagsState } from './TagsState'
export enum AppStateEvent {
TagChanged,
ActiveEditorChanged,
PanelResized,
EditorFocused,
BeganBackupDownload,
EndedBackupDownload,
WindowDidFocus,
WindowDidBlur,
}
export type PanelResizedData = {
panel: string
collapsed: boolean
}
export enum EventSource {
UserInteraction,
Script,
}
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>
export class AppState {
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
application: WebApplication
observers: ObserverCallback[] = []
locked = true
unsubApp: any
webAppEventDisposer?: () => void
onVisibilityChange: any
showBetaWarning: boolean
private multiEditorSupport = false
readonly quickSettingsMenu = new QuickSettingsState()
readonly accountMenu: AccountMenuState
readonly actionsMenu = new ActionsMenuState()
readonly preferences = new PreferencesState()
readonly purchaseFlow: PurchaseFlowState
readonly noAccountWarning: NoAccountWarningState
readonly noteTags: NoteTagsState
readonly sync = new SyncState()
readonly searchOptions: SearchOptionsState
readonly notes: NotesState
readonly features: FeaturesState
readonly tags: TagsState
readonly notesView: NotesViewState
readonly subscription: SubscriptionState
readonly files: FilesState
isSessionsModalVisible = false
private appEventObserverRemovers: (() => void)[] = []
private readonly tagChangedDisposer: IReactionDisposer
constructor(application: WebApplication, private bridge: Bridge) {
this.application = application
this.notes = new NotesState(
application,
this,
async () => {
await this.notifyEvent(AppStateEvent.ActiveEditorChanged)
},
this.appEventObserverRemovers,
)
this.noteTags = new NoteTagsState(application, this, this.appEventObserverRemovers)
this.features = new FeaturesState(application)
this.tags = new TagsState(application, this.appEventObserverRemovers, this.features)
this.noAccountWarning = new NoAccountWarningState(application, this.appEventObserverRemovers)
this.accountMenu = new AccountMenuState(application, this.appEventObserverRemovers)
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.addAppEventObserver()
this.streamNotesAndTags()
this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
const event = visible ? AppStateEvent.WindowDidFocus : AppStateEvent.WindowDidBlur
this.notifyEvent(event).catch(console.error)
}
this.registerVisibilityObservers()
if (this.bridge.appVersion.includes('-beta')) {
this.showBetaWarning = storage.get(StorageKey.ShowBetaWarning) ?? true
} else {
this.showBetaWarning = false
}
makeObservable(this, {
selectedTag: computed,
showBetaWarning: observable,
isSessionsModalVisible: observable,
preferences: observable,
enableBetaWarning: action,
disableBetaWarning: action,
openSessionsModal: action,
closeSessionsModal: action,
})
this.tagChangedDisposer = this.tagChangedNotifier()
}
deinit(source: DeinitSource): void {
if (source === DeinitSource.SignOut) {
storage.remove(StorageKey.ShowBetaWarning)
this.noAccountWarning.reset()
}
;(this.application as unknown) = undefined
this.actionsMenu.reset()
this.unsubApp?.()
this.unsubApp = undefined
this.observers.length = 0
this.appEventObserverRemovers.forEach((remover) => remover())
this.appEventObserverRemovers.length = 0
this.features.deinit()
;(this.features as unknown) = undefined
this.webAppEventDisposer?.()
this.webAppEventDisposer = undefined
;(this.quickSettingsMenu as unknown) = undefined
;(this.accountMenu as unknown) = undefined
;(this.actionsMenu as unknown) = undefined
;(this.preferences as unknown) = undefined
;(this.purchaseFlow as unknown) = undefined
;(this.noteTags as unknown) = undefined
;(this.sync as unknown) = undefined
;(this.searchOptions as unknown) = undefined
;(this.notes as unknown) = undefined
;(this.features as unknown) = undefined
;(this.tags as unknown) = undefined
;(this.notesView as unknown) = undefined
document.removeEventListener('visibilitychange', this.onVisibilityChange)
this.onVisibilityChange = undefined
this.tagChangedDisposer()
;(this.tagChangedDisposer as unknown) = undefined
}
openSessionsModal(): void {
this.isSessionsModalVisible = true
}
closeSessionsModal(): void {
this.isSessionsModalVisible = false
}
disableBetaWarning() {
this.showBetaWarning = false
storage.set(StorageKey.ShowBetaWarning, false)
}
enableBetaWarning() {
this.showBetaWarning = true
storage.set(StorageKey.ShowBetaWarning, true)
}
public get version(): string {
return this.bridge.appVersion
}
async openNewNote(title?: string) {
if (!this.multiEditorSupport) {
this.closeActiveNoteController()
}
const selectedTag = this.selectedTag
const activeRegularTagUuid =
selectedTag && selectedTag instanceof SNTag ? selectedTag.uuid : undefined
await this.application.noteControllerGroup.createNoteView(
undefined,
title,
activeRegularTagUuid,
)
}
getActiveNoteController() {
return this.application.noteControllerGroup.noteControllers[0]
}
getNoteControllers() {
return this.application.noteControllerGroup.noteControllers
}
closeNoteController(controller: NoteViewController) {
this.application.noteControllerGroup.closeNoteView(controller)
}
closeActiveNoteController() {
this.application.noteControllerGroup.closeActiveNoteView()
}
closeAllNoteControllers() {
this.application.noteControllerGroup.closeAllNoteViews()
}
noteControllerForNote(uuid: Uuid) {
for (const controller of this.getNoteControllers()) {
if (controller.note.uuid === uuid) {
return controller
}
}
return undefined
}
isGlobalSpellcheckEnabled(): boolean {
return this.application.getPreference(PrefKey.EditorSpellcheck, true)
}
async toggleGlobalSpellcheck() {
const currentValue = this.isGlobalSpellcheckEnabled()
return this.application.setPreference(PrefKey.EditorSpellcheck, !currentValue)
}
private tagChangedNotifier(): IReactionDisposer {
return reaction(
() => this.tags.selectedUuid,
() => {
const tag = this.tags.selected
const previousTag = this.tags.previouslySelected
if (!tag) {
return
}
if (this.application.items.isTemplateItem(tag)) {
return
}
this.notifyEvent(AppStateEvent.TagChanged, {
tag,
previousTag,
}).catch(console.error)
},
)
}
public get selectedTag(): SNTag | SmartView | undefined {
return this.tags.selected
}
public set selectedTag(tag: SNTag | SmartView | undefined) {
this.tags.selected = tag
}
streamNotesAndTags() {
this.application.streamItems<SNNote | SNTag>(
[ContentType.Note, ContentType.Tag],
async ({ changed, inserted, removed, source }) => {
if (![PayloadSource.PreSyncSave, PayloadSource.RemoteRetrieved].includes(source)) {
return
}
const removedNotes = removed.filter((i) => i.content_type === ContentType.Note)
for (const removedNote of removedNotes) {
const noteController = this.noteControllerForNote(removedNote.uuid)
if (noteController) {
this.closeNoteController(noteController)
}
}
const changedOrInserted = [...changed, ...inserted].filter(
(i) => i.content_type === ContentType.Note,
)
const selectedTag = this.tags.selected
const isBrowswingTrashedNotes =
selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.TrashedNotes
const isBrowsingArchivedNotes =
selectedTag instanceof SmartView && selectedTag.uuid === SystemViewId.ArchivedNotes
for (const note of changedOrInserted) {
const noteController = this.noteControllerForNote(note.uuid)
if (!noteController) {
continue
}
if (note.trashed && !isBrowswingTrashedNotes && !this.searchOptions.includeTrashed) {
this.closeNoteController(noteController)
} else if (
note.archived &&
!isBrowsingArchivedNotes &&
!this.searchOptions.includeArchived &&
!this.application.getPreference(PrefKey.NotesShowArchived, false)
) {
this.closeNoteController(noteController)
}
}
},
)
}
addAppEventObserver() {
this.unsubApp = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.Started:
this.locked = true
break
case ApplicationEvent.Launched:
this.locked = false
if (window.location.search.includes('purchase=true')) {
this.purchaseFlow.openPurchaseFlow()
}
break
case ApplicationEvent.SyncStatusChanged:
this.sync.update(this.application.sync.getSyncStatus())
break
}
})
}
isLocked() {
return this.locked
}
registerVisibilityObservers() {
if (isDesktopApplication()) {
this.webAppEventDisposer = this.application.addWebEventObserver((event) => {
if (event === WebAppEvent.DesktopWindowGainedFocus) {
this.notifyEvent(AppStateEvent.WindowDidFocus).catch(console.error)
} else if (event === WebAppEvent.DesktopWindowLostFocus) {
this.notifyEvent(AppStateEvent.WindowDidBlur).catch(console.error)
}
})
} else {
/* Tab visibility listener, web only */
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
}
/** @returns A function that unregisters this observer */
addObserver(callback: ObserverCallback) {
this.observers.push(callback)
return () => {
removeFromArray(this.observers, callback)
}
}
async notifyEvent(eventName: AppStateEvent, data?: any) {
/**
* Timeout is particularly important so we can give all initial
* controllers a chance to construct before propogting any events *
*/
return new Promise<void>((resolve) => {
setTimeout(async () => {
for (const callback of this.observers) {
await callback(eventName, data)
}
resolve()
})
})
}
/** Returns the tags that are referncing this note */
public getNoteTags(note: SNNote) {
return this.application.items.itemsReferencingItem(note).filter((ref) => {
return ref.content_type === ContentType.Tag
}) as SNTag[]
}
panelDidResize(name: string, collapsed: boolean) {
const data: PanelResizedData = {
panel: name,
collapsed: collapsed,
}
this.notifyEvent(AppStateEvent.PanelResized, data).catch(console.error)
}
editorDidFocus(eventSource: EventSource) {
this.notifyEvent(AppStateEvent.EditorFocused, { eventSource: eventSource }).catch(console.error)
}
beganBackupDownload() {
this.notifyEvent(AppStateEvent.BeganBackupDownload).catch(console.error)
}
endedBackupDownload(success: boolean) {
this.notifyEvent(AppStateEvent.EndedBackupDownload, { success: success }).catch(console.error)
}
}

View File

@@ -0,0 +1,87 @@
import { ApplicationEvent, FeatureIdentifier, FeatureStatus } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, runInAction, when } from 'mobx'
import { WebApplication } from '../Application'
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders'
export const TAG_FOLDERS_FEATURE_TOOLTIP = 'A Plus or Pro plan is required to enable Tag folders.'
export const SMART_TAGS_FEATURE_NAME = 'Smart Tags'
/**
* Holds state for premium/non premium features for the current user features,
* and eventually for in-development features (feature flags).
*/
export class FeaturesState {
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
_hasFolders = false
_hasSmartViews = false
_premiumAlertFeatureName: string | undefined
private unsub: () => void
constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders()
this._hasSmartViews = this.hasNativeSmartViews()
this._premiumAlertFeatureName = undefined
makeObservable(this, {
_hasFolders: observable,
_hasSmartViews: observable,
hasFolders: computed,
_premiumAlertFeatureName: observable,
showPremiumAlert: action,
closePremiumAlert: action,
})
this.showPremiumAlert = this.showPremiumAlert.bind(this)
this.closePremiumAlert = this.closePremiumAlert.bind(this)
this.unsub = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this._hasFolders = this.hasNativeFolders()
this._hasSmartViews = this.hasNativeSmartViews()
})
break
default:
break
}
})
}
public deinit() {
this.unsub()
}
public get hasFolders(): boolean {
return this._hasFolders
}
public get hasSmartViews(): boolean {
return this._hasSmartViews
}
public async showPremiumAlert(featureName: string): Promise<void> {
this._premiumAlertFeatureName = featureName
return when(() => this._premiumAlertFeatureName === undefined)
}
public async closePremiumAlert(): Promise<void> {
this._premiumAlertFeatureName = undefined
}
private hasNativeFolders(): boolean {
const status = this.application.features.getFeatureStatus(FeatureIdentifier.TagNesting)
return status === FeatureStatus.Entitled
}
private hasNativeSmartViews(): boolean {
const status = this.application.features.getFeatureStatus(FeatureIdentifier.SmartFilters)
return status === FeatureStatus.Entitled
}
}

View File

@@ -0,0 +1,169 @@
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import {
ClassicFileReader,
StreamingFileReader,
StreamingFileSaver,
ClassicFileSaver,
parseFileName,
} from '@standardnotes/filepicker'
import { ClientDisplayableError, SNFile } from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
import { WebApplication } from '../Application'
export class FilesState {
constructor(private application: WebApplication) {}
public async downloadFile(file: SNFile): Promise<void> {
let downloadingToastId = ''
try {
const saver = StreamingFileSaver.available()
? new StreamingFileSaver(file.name)
: new ClassicFileSaver()
const isUsingStreamingSaver = saver instanceof StreamingFileSaver
if (isUsingStreamingSaver) {
await saver.selectFileToSaveTo()
}
downloadingToastId = addToast({
type: ToastType.Loading,
message: 'Downloading file...',
})
const decryptedBytesArray: Uint8Array[] = []
await this.application.files.downloadFile(file, async (decryptedBytes: Uint8Array) => {
if (isUsingStreamingSaver) {
await saver.pushBytes(decryptedBytes)
} else {
decryptedBytesArray.push(decryptedBytes)
}
})
if (isUsingStreamingSaver) {
await saver.finish()
} else {
const finalBytes = concatenateUint8Arrays(decryptedBytesArray)
saver.saveFile(file.name, finalBytes)
}
addToast({
type: ToastType.Success,
message: 'Successfully downloaded file',
})
} catch (error) {
console.error(error)
addToast({
type: ToastType.Error,
message: 'There was an error while downloading the file',
})
}
if (downloadingToastId.length > 0) {
dismissToast(downloadingToastId)
}
}
public async uploadNewFile(fileOrHandle?: File | FileSystemFileHandle) {
let toastId = ''
try {
const minimumChunkSize = this.application.files.minimumChunkSize()
const shouldUseStreamingReader = StreamingFileReader.available()
const picker = shouldUseStreamingReader ? StreamingFileReader : ClassicFileReader
const maxFileSize = picker.maximumFileSize()
const selectedFiles =
fileOrHandle instanceof File
? [fileOrHandle]
: StreamingFileReader.available() && fileOrHandle instanceof FileSystemFileHandle
? await StreamingFileReader.getFilesFromHandles([fileOrHandle])
: await picker.selectFiles()
if (selectedFiles.length === 0) {
return
}
const uploadedFiles: SNFile[] = []
for (const file of selectedFiles) {
if (!shouldUseStreamingReader && maxFileSize && file.size >= maxFileSize) {
this.application.alertService
.alert(
`This file exceeds the limits supported in this browser. To upload files greater than ${
maxFileSize / BYTES_IN_ONE_MEGABYTE
}MB, please use the desktop application or the Chrome browser.`,
`Cannot upload file "${file.name}"`,
)
.catch(console.error)
continue
}
toastId = addToast({
type: ToastType.Loading,
message: `Uploading file "${file.name}"...`,
})
const operation = await this.application.files.beginNewFileUpload()
if (operation instanceof ClientDisplayableError) {
addToast({
type: ToastType.Error,
message: 'Unable to start upload session',
})
throw new Error('Unable to start upload session')
}
const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => {
await this.application.files.pushBytesForUpload(operation, chunk, index, isLast)
}
const fileResult = await picker.readFile(file, minimumChunkSize, onChunk)
if (!fileResult.mimeType) {
const { ext } = parseFileName(file.name)
fileResult.mimeType = await this.application.getArchiveService().getMimeType(ext)
}
const uploadedFile = await this.application.files.finishUpload(operation, fileResult)
if (uploadedFile instanceof ClientDisplayableError) {
addToast({
type: ToastType.Error,
message: 'Unable to close upload session',
})
throw new Error('Unable to close upload session')
}
uploadedFiles.push(uploadedFile)
dismissToast(toastId)
addToast({
type: ToastType.Success,
message: `Uploaded file "${uploadedFile.name}"`,
})
}
return uploadedFiles
} catch (error) {
console.error(error)
if (toastId.length > 0) {
dismissToast(toastId)
}
addToast({
type: ToastType.Error,
message: 'There was an error while uploading the file',
})
}
return undefined
}
}

View File

@@ -0,0 +1,41 @@
import { storage, StorageKey } from '@/Services/LocalStorage'
import { SNApplication, ApplicationEvent } from '@standardnotes/snjs'
import { runInAction, makeObservable, observable, action } from 'mobx'
export class NoAccountWarningState {
show: boolean
constructor(application: SNApplication, appObservers: (() => void)[]) {
this.show = application.hasAccount()
? false
: storage.get(StorageKey.ShowNoAccountWarning) ?? true
appObservers.push(
application.addEventObserver(async () => {
runInAction(() => {
this.show = false
})
}, ApplicationEvent.SignedIn),
application.addEventObserver(async () => {
if (application.hasAccount()) {
runInAction(() => {
this.show = false
})
}
}, ApplicationEvent.Started),
)
makeObservable(this, {
show: observable,
hide: action,
})
}
hide = (): void => {
this.show = false
storage.set(StorageKey.ShowNoAccountWarning, false)
}
reset = (): void => {
storage.remove(StorageKey.ShowNoAccountWarning)
}
}

View File

@@ -0,0 +1,232 @@
import { ElementIds } from '@/ElementIDs'
import {
ApplicationEvent,
ContentType,
PrefKey,
SNNote,
SNTag,
UuidString,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { WebApplication } from '../Application'
import { AppState } from './AppState'
export class NoteTagsState {
autocompleteInputFocused = false
autocompleteSearchQuery = ''
autocompleteTagHintFocused = false
autocompleteTagResults: SNTag[] = []
focusedTagResultUuid: UuidString | undefined = undefined
focusedTagUuid: UuidString | undefined = undefined
tags: SNTag[] = []
tagsContainerMaxWidth: number | 'auto' = 0
addNoteToParentFolders: boolean
constructor(
private application: WebApplication,
private appState: AppState,
appEventListeners: (() => void)[],
) {
makeObservable(this, {
autocompleteInputFocused: observable,
autocompleteSearchQuery: observable,
autocompleteTagHintFocused: observable,
autocompleteTagResults: observable,
focusedTagUuid: observable,
focusedTagResultUuid: observable,
tags: observable,
tagsContainerMaxWidth: observable,
autocompleteTagHintVisible: computed,
setAutocompleteInputFocused: action,
setAutocompleteSearchQuery: action,
setAutocompleteTagHintFocused: action,
setAutocompleteTagResults: action,
setFocusedTagResultUuid: action,
setFocusedTagUuid: action,
setTags: action,
setTagsContainerMaxWidth: action,
})
this.addNoteToParentFolders = application.getPreference(PrefKey.NoteAddToParentFolders, true)
appEventListeners.push(
application.streamItems(ContentType.Tag, () => {
this.reloadTags()
}),
application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.addNoteToParentFolders = application.getPreference(
PrefKey.NoteAddToParentFolders,
true,
)
}),
)
}
get activeNote(): SNNote | undefined {
return this.appState.notes.activeNoteController?.note
}
get autocompleteTagHintVisible(): boolean {
return (
this.autocompleteSearchQuery !== '' &&
!this.autocompleteTagResults.some(
(tagResult) => tagResult.title === this.autocompleteSearchQuery,
)
)
}
setAutocompleteInputFocused(focused: boolean): void {
this.autocompleteInputFocused = focused
}
setAutocompleteSearchQuery(query: string): void {
this.autocompleteSearchQuery = query
}
setAutocompleteTagHintFocused(focused: boolean): void {
this.autocompleteTagHintFocused = focused
}
setAutocompleteTagResults(results: SNTag[]): void {
this.autocompleteTagResults = results
}
setFocusedTagUuid(tagUuid: UuidString | undefined): void {
this.focusedTagUuid = tagUuid
}
setFocusedTagResultUuid(tagUuid: UuidString | undefined): void {
this.focusedTagResultUuid = tagUuid
}
setTags(tags: SNTag[]): void {
this.tags = tags
}
setTagsContainerMaxWidth(width: number): void {
this.tagsContainerMaxWidth = width
}
clearAutocompleteSearch(): void {
this.setAutocompleteSearchQuery('')
this.setAutocompleteTagResults([])
}
async createAndAddNewTag(): Promise<void> {
const newTag = await this.application.mutator.findOrCreateTag(this.autocompleteSearchQuery)
await this.addTagToActiveNote(newTag)
this.clearAutocompleteSearch()
}
focusNextTag(tag: SNTag): void {
const nextTagIndex = this.getTagIndex(tag, this.tags) + 1
if (nextTagIndex > -1 && this.tags.length > nextTagIndex) {
const nextTag = this.tags[nextTagIndex]
this.setFocusedTagUuid(nextTag.uuid)
}
}
focusNextTagResult(tagResult: SNTag): void {
const nextTagResultIndex = this.getTagIndex(tagResult, this.autocompleteTagResults) + 1
if (nextTagResultIndex > -1 && this.autocompleteTagResults.length > nextTagResultIndex) {
const nextTagResult = this.autocompleteTagResults[nextTagResultIndex]
this.setFocusedTagResultUuid(nextTagResult.uuid)
}
}
focusPreviousTag(tag: SNTag): void {
const previousTagIndex = this.getTagIndex(tag, this.tags) - 1
if (previousTagIndex > -1 && this.tags.length > previousTagIndex) {
const previousTag = this.tags[previousTagIndex]
this.setFocusedTagUuid(previousTag.uuid)
}
}
focusPreviousTagResult(tagResult: SNTag): void {
const previousTagResultIndex = this.getTagIndex(tagResult, this.autocompleteTagResults) - 1
if (
previousTagResultIndex > -1 &&
this.autocompleteTagResults.length > previousTagResultIndex
) {
const previousTagResult = this.autocompleteTagResults[previousTagResultIndex]
this.setFocusedTagResultUuid(previousTagResult.uuid)
}
}
searchActiveNoteAutocompleteTags(): void {
const newResults = this.application.items.searchTags(
this.autocompleteSearchQuery,
this.activeNote,
)
this.setAutocompleteTagResults(newResults)
}
getTagIndex(tag: SNTag, tagsArr: SNTag[]): number {
return tagsArr.findIndex((t) => t.uuid === tag.uuid)
}
reloadTags(): void {
const { activeNote } = this
if (activeNote) {
const tags = this.application.items.getSortedTagsForNote(activeNote)
this.setTags(tags)
}
}
reloadTagsContainerMaxWidth(): void {
const editorWidth = document.getElementById(ElementIds.EditorColumn)?.clientWidth
if (editorWidth) {
this.setTagsContainerMaxWidth(editorWidth)
}
}
async addTagToActiveNote(tag: SNTag): Promise<void> {
const { activeNote } = this
if (activeNote) {
await this.application.items.addTagToNote(activeNote, tag, this.addNoteToParentFolders)
this.application.sync.sync().catch(console.error)
this.reloadTags()
}
}
async removeTagFromActiveNote(tag: SNTag): Promise<void> {
const { activeNote } = this
if (activeNote) {
await this.application.mutator.changeItem(tag, (mutator) => {
mutator.removeItemAsRelationship(activeNote)
})
this.application.sync.sync().catch(console.error)
this.reloadTags()
}
}
getSortedTagsForNote(note: SNNote): SNTag[] {
const tags = this.application.items.getSortedTagsForNote(note)
const sortFunction = (tagA: SNTag, tagB: SNTag): number => {
const a = this.getLongTitle(tagA)
const b = this.getLongTitle(tagB)
if (a < b) {
return -1
}
if (b > a) {
return 1
}
return 0
}
return tags.sort(sortFunction)
}
getPrefixTitle(tag: SNTag): string | undefined {
return this.application.items.getTagPrefixTitle(tag)
}
getLongTitle(tag: SNTag): string {
return this.application.items.getTagLongTitle(tag)
}
}

View File

@@ -0,0 +1,426 @@
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,
NoteViewController,
} from '@standardnotes/snjs'
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
import { WebApplication } from '../Application'
import { AppState } from './AppState'
export class NotesState {
lastSelectedNote: SNNote | undefined
selectedNotes: Record<UuidString, SNNote> = {}
contextMenuOpen = false
contextMenuPosition: { top?: number; left: number; bottom?: number } = {
top: 0,
left: 0,
}
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }
contextMenuMaxHeight: number | 'auto' = 'auto'
showProtectedWarning = false
showRevisionHistoryModal = false
constructor(
private application: WebApplication,
private appState: AppState,
private onActiveEditorChanged: () => Promise<void>,
appEventListeners: (() => void)[],
) {
makeObservable(this, {
selectedNotes: observable,
contextMenuOpen: observable,
contextMenuPosition: observable,
showProtectedWarning: observable,
showRevisionHistoryModal: observable,
selectedNotesCount: computed,
trashedNotesCount: computed,
setContextMenuOpen: action,
setContextMenuClickLocation: action,
setContextMenuPosition: action,
setContextMenuMaxHeight: action,
setShowProtectedWarning: action,
setShowRevisionHistoryModal: action,
unselectNotes: action,
})
appEventListeners.push(
application.streamItems<SNNote>(ContentType.Note, ({ changed, inserted, removed }) => {
runInAction(() => {
for (const removedNote of removed) {
delete this.selectedNotes[removedNote.uuid]
}
for (const note of [...changed, ...inserted]) {
if (this.selectedNotes[note.uuid]) {
this.selectedNotes[note.uuid] = note
}
}
})
}),
)
}
get activeNoteController(): NoteViewController | undefined {
return this.application.noteControllerGroup.noteControllers[0]
}
get selectedNotesCount(): number {
return Object.keys(this.selectedNotes).length
}
get trashedNotesCount(): number {
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
}
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift)
if (userTriggered && (hasMeta || hasCtrl)) {
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 (userTriggered && hasShift) {
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.activeNoteController?.note?.uuid === noteUuid) {
return
}
const note = this.application.items.findItem(noteUuid) as SNNote | undefined
if (!note) {
console.warn('Tried accessing a non-existant note of UUID ' + noteUuid)
return
}
if (this.activeNoteController) {
this.application.noteControllerGroup.closeActiveNoteView()
}
await this.application.noteControllerGroup.createNoteView(noteUuid)
this.appState.noteTags.reloadTags()
await this.onActiveEditorChanged()
}
setContextMenuOpen(open: boolean): void {
this.contextMenuOpen = open
}
setContextMenuClickLocation(location: { x: number; y: number }): void {
this.contextMenuClickLocation = location
}
setContextMenuPosition(position: { top?: number; left: number; bottom?: number }): void {
this.contextMenuPosition = position
}
setContextMenuMaxHeight(maxHeight: number | 'auto'): void {
this.contextMenuMaxHeight = maxHeight
}
reloadContextMenuLayout(): void {
const { clientHeight } = document.documentElement
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
const maxContextMenuHeight = parseFloat(defaultFontSize) * 30
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
// Open up-bottom is default behavior
let openUpBottom = true
if (footerHeightInPx) {
const bottomSpace = clientHeight - footerHeightInPx - this.contextMenuClickLocation.y
const upSpace = this.contextMenuClickLocation.y
// If not enough space to open up-bottom
if (maxContextMenuHeight > bottomSpace) {
// If there's enough space, open bottom-up
if (upSpace > maxContextMenuHeight) {
openUpBottom = false
this.setContextMenuMaxHeight('auto')
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
} else {
if (upSpace > bottomSpace) {
this.setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
openUpBottom = false
} else {
this.setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
}
}
} else {
this.setContextMenuMaxHeight('auto')
}
}
if (openUpBottom) {
this.setContextMenuPosition({
top: this.contextMenuClickLocation.y,
left: this.contextMenuClickLocation.x,
})
} else {
this.setContextMenuPosition({
bottom: clientHeight - this.contextMenuClickLocation.y,
left: this.contextMenuClickLocation.x,
})
}
}
async changeSelectedNotes(mutate: (mutator: NoteMutator) => void): Promise<void> {
await this.application.mutator.changeItems(Object.values(this.selectedNotes), mutate, false)
this.application.sync.sync().catch(console.error)
}
setHideSelectedNotePreviews(hide: boolean): void {
this.changeSelectedNotes((mutator) => {
mutator.hidePreview = hide
}).catch(console.error)
}
setLockSelectedNotes(lock: boolean): void {
this.changeSelectedNotes((mutator) => {
mutator.locked = lock
}).catch(console.error)
}
async setTrashSelectedNotes(trashed: boolean): Promise<void> {
if (trashed) {
const notesDeleted = await this.deleteNotes(false)
if (notesDeleted) {
runInAction(() => {
this.unselectNotes()
this.contextMenuOpen = false
})
}
} else {
await this.changeSelectedNotes((mutator) => {
mutator.trashed = trashed
})
runInAction(() => {
this.unselectNotes()
this.contextMenuOpen = false
})
}
}
async deleteNotesPermanently(): Promise<void> {
await this.deleteNotes(true)
}
async deleteNotes(permanently: boolean): Promise<boolean> {
if (Object.values(this.selectedNotes).some((note) => note.locked)) {
const text = StringUtils.deleteLockedNotesAttempt(this.selectedNotesCount)
this.application.alertService.alert(text).catch(console.error)
return false
}
const title = Strings.trashNotesTitle
let noteTitle = undefined
if (this.selectedNotesCount === 1) {
const selectedNote = Object.values(this.selectedNotes)[0]
noteTitle = selectedNote.title.length ? `'${selectedNote.title}'` : 'this note'
}
const text = StringUtils.deleteNotes(permanently, this.selectedNotesCount, noteTitle)
if (
await confirmDialog({
title,
text,
confirmButtonStyle: 'danger',
})
) {
if (permanently) {
for (const note of Object.values(this.selectedNotes)) {
await this.application.mutator.deleteItem(note)
delete this.selectedNotes[note.uuid]
}
} else {
await this.changeSelectedNotes((mutator) => {
mutator.trashed = true
})
}
return true
}
return false
}
setPinSelectedNotes(pinned: boolean): void {
this.changeSelectedNotes((mutator) => {
mutator.pinned = pinned
}).catch(console.error)
}
async setArchiveSelectedNotes(archived: boolean): Promise<void> {
if (Object.values(this.selectedNotes).some((note) => note.locked)) {
this.application.alertService
.alert(StringUtils.archiveLockedNotesAttempt(archived, this.selectedNotesCount))
.catch(console.error)
return
}
await this.changeSelectedNotes((mutator) => {
mutator.archived = archived
})
runInAction(() => {
this.selectedNotes = {}
this.contextMenuOpen = false
})
}
async setProtectSelectedNotes(protect: boolean): Promise<void> {
const selectedNotes = Object.values(this.selectedNotes)
if (protect) {
await this.application.mutator.protectNotes(selectedNotes)
this.setShowProtectedWarning(true)
} else {
await this.application.mutator.unprotectNotes(selectedNotes)
this.setShowProtectedWarning(false)
}
}
unselectNotes(): void {
this.selectedNotes = {}
}
getSpellcheckStateForNote(note: SNNote) {
return note.spellcheck != undefined
? note.spellcheck
: this.appState.isGlobalSpellcheckEnabled()
}
async toggleGlobalSpellcheckForNote(note: SNNote) {
await this.application.mutator.changeItem<NoteMutator>(
note,
(mutator) => {
mutator.toggleSpellcheck()
},
false,
)
this.application.sync.sync().catch(console.error)
}
async addTagToSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = Object.values(this.selectedNotes)
const parentChainTags = this.application.items.getTagParentChain(tag)
const tagsToAdd = [...parentChainTags, tag]
await Promise.all(
tagsToAdd.map(async (tag) => {
await this.application.mutator.changeItem(tag, (mutator) => {
for (const note of selectedNotes) {
mutator.addItemAsRelationship(note)
}
})
}),
)
this.application.sync.sync().catch(console.error)
}
async removeTagFromSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = Object.values(this.selectedNotes)
await this.application.mutator.changeItem(tag, (mutator) => {
for (const note of selectedNotes) {
mutator.removeItemAsRelationship(note)
}
})
this.application.sync.sync().catch(console.error)
}
isTagInSelectedNotes(tag: SNTag): boolean {
const selectedNotes = Object.values(this.selectedNotes)
return selectedNotes.every((note) =>
this.appState.getNoteTags(note).find((noteTag) => noteTag.uuid === tag.uuid),
)
}
setShowProtectedWarning(show: boolean): void {
this.showProtectedWarning = show
}
async emptyTrash(): Promise<void> {
if (
await confirmDialog({
text: StringEmptyTrash(this.trashedNotesCount),
confirmButtonStyle: 'danger',
})
) {
this.application.mutator.emptyTrash().catch(console.error)
this.application.sync.sync().catch(console.error)
}
}
private get io() {
return this.application.io
}
setShowRevisionHistoryModal(show: boolean): void {
this.showRevisionHistoryModal = show
}
}

View File

@@ -0,0 +1,545 @@
import {
ApplicationEvent,
CollectionSort,
CollectionSortProperty,
ContentType,
findInArray,
NotesDisplayCriteria,
PrefKey,
SmartView,
SNNote,
SNTag,
SystemViewId,
UuidString,
} from '@standardnotes/snjs'
import { action, autorun, computed, makeObservable, observable, reaction } from 'mobx'
import { AppState, AppStateEvent } from '.'
import { WebApplication } from '../Application'
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 {
completedFullSync = false
noteFilterText = ''
notes: SNNote[] = []
notesToDisplay = 0
pageSize = 0
panelTitle = 'All Notes'
panelWidth = 0
renderedNotes: SNNote[] = []
searchSubmitted = false
selectedNotes: Record<UuidString, SNNote> = {}
showDisplayOptionsMenu = false
displayOptions = {
sortBy: CollectionSort.CreatedAt,
sortReverse: false,
hidePinned: false,
showArchived: false,
showTrashed: false,
hideProtected: false,
hideTags: true,
hideDate: false,
hideNotePreview: false,
hideEditorIcon: false,
}
constructor(
private application: WebApplication,
private appState: AppState,
appObservers: (() => void)[],
) {
this.resetPagination()
appObservers.push(
application.streamItems<SNNote>(ContentType.Note, () => {
this.reloadNotes()
const activeNote = this.appState.notes.activeNoteController?.note
if (this.application.getAppState().notes.selectedNotesCount < 2) {
if (activeNote) {
const browsingTrashedNotes =
this.appState.selectedTag instanceof SmartView &&
this.appState.selectedTag?.uuid === SystemViewId.TrashedNotes
if (
activeNote.trashed &&
!browsingTrashedNotes &&
!this.appState?.searchOptions.includeTrashed
) {
this.selectNextOrCreateNew()
} else if (!this.selectedNotes[activeNote.uuid]) {
this.selectNote(activeNote).catch(console.error)
}
} else {
this.selectFirstNote()
}
}
}),
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()
this.reloadNotes()
if (
this.appState.selectedTag &&
findInArray(tags, 'uuid', this.appState.selectedTag.uuid)
) {
/** Tag title could have changed */
this.reloadPanelTitle()
}
}),
application.addEventObserver(async () => {
this.reloadPreferences()
}, ApplicationEvent.PreferencesChanged),
application.addEventObserver(async () => {
this.appState.closeAllNoteControllers()
this.selectFirstNote()
this.setCompletedFullSync(false)
}, ApplicationEvent.SignedIn),
application.addEventObserver(async () => {
this.reloadNotes()
if (
this.notes.length === 0 &&
this.appState.selectedTag instanceof SmartView &&
this.appState.selectedTag.uuid === SystemViewId.AllNotes &&
this.noteFilterText === '' &&
!this.appState.notes.activeNoteController
) {
this.createPlaceholderNote()?.catch(console.error)
}
this.setCompletedFullSync(true)
}, ApplicationEvent.CompletedFullSync),
autorun(() => {
if (appState.notes.selectedNotes) {
this.syncSelectedNotes()
}
}),
reaction(
() => [
appState.searchOptions.includeProtectedContents,
appState.searchOptions.includeArchived,
appState.searchOptions.includeTrashed,
],
() => {
this.reloadNotesDisplayOptions()
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,
selectedNotes: observable,
showDisplayOptionsMenu: observable,
reloadNotes: action,
reloadPanelTitle: action,
reloadPreferences: action,
resetPagination: action,
setCompletedFullSync: action,
setNoteFilterText: action,
syncSelectedNotes: action,
setShowDisplayOptionsMenu: action,
onFilterEnter: action,
handleFilterTextChanged: action,
optionsSubtitle: computed,
})
window.onresize = () => {
this.resetPagination(true)
}
}
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
}
get activeEditorNote() {
return this.appState.notes.activeNoteController?.note
}
reloadPanelTitle = () => {
let title = this.panelTitle
if (this.isFiltering) {
const resultCount = this.notes.length
title = `${resultCount} search results`
} else if (this.appState.selectedTag) {
title = `${this.appState.selectedTag.title}`
}
this.panelTitle = title
}
reloadNotes = () => {
const tag = this.appState.selectedTag
if (!tag) {
return
}
const notes = this.application.items.getDisplayableNotes()
const renderedNotes = notes.slice(0, this.notesToDisplay)
this.notes = notes
this.renderedNotes = renderedNotes
this.reloadPanelTitle()
}
reloadNotesDisplayOptions = () => {
const tag = this.appState.selectedTag
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 = () => {
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()
}
this.reloadNotes()
const width = this.application.getPreference(PrefKey.NotesPanelWidth)
if (width) {
this.panelWidth = width
}
if (freshDisplayOptions.sortBy !== currentSortBy) {
this.selectFirstNote()
}
}
createNewNote = async () => {
this.appState.notes.unselectNotes()
let title = `Note ${this.notes.length + 1}`
if (this.isFiltering) {
title = this.noteFilterText
}
await this.appState.openNewNote(title)
this.reloadNotes()
this.appState.noteTags.reloadTags()
}
createPlaceholderNote = () => {
const selectedTag = this.appState.selectedTag
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
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)
}
selectNote = 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 = () => {
const note = this.getFirstNonProtectedNote()
if (note) {
this.selectNote(note, false, false).catch(console.error)
this.resetScrollPosition()
}
}
selectNextNote = () => {
const displayableNotes = this.notes
const currentIndex = displayableNotes.findIndex((candidate) => {
return candidate.uuid === this.activeEditorNote?.uuid
})
if (currentIndex + 1 < displayableNotes.length) {
const nextNote = displayableNotes[currentIndex + 1]
this.selectNote(nextNote).catch(console.error)
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`)
nextNoteElement?.focus()
}
}
selectNextOrCreateNew = () => {
const note = this.getFirstNonProtectedNote()
if (note) {
this.selectNote(note, false, false).catch(console.error)
} else {
this.appState.closeActiveNoteController()
}
}
selectPreviousNote = () => {
const displayableNotes = this.notes
if (this.activeEditorNote) {
const currentIndex = displayableNotes.indexOf(this.activeEditorNote)
if (currentIndex - 1 >= 0) {
const previousNote = displayableNotes[currentIndex - 1]
this.selectNote(previousNote).catch(console.error)
const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`)
previousNoteElement?.focus()
return true
} else {
return false
}
}
return undefined
}
setNoteFilterText = (text: string) => {
this.noteFilterText = text
}
syncSelectedNotes = () => {
this.selectedNotes = this.appState.notes.selectedNotes
}
handleEditorChange = async () => {
const activeNote = this.appState.getActiveNoteController()?.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 = () => {
this.resetScrollPosition()
this.setShowDisplayOptionsMenu(false)
this.setNoteFilterText('')
this.application.getDesktopService().searchText()
this.resetPagination()
/* Capture db load state before beginning reloadNotes,
since this status may change during reload */
const dbLoaded = this.application.isDatabaseLoaded()
this.reloadNotesDisplayOptions()
this.reloadNotes()
const hasSomeNotes = this.notes.length > 0
if (hasSomeNotes) {
this.selectFirstNote()
} else if (dbLoaded) {
if (this.activeEditorNote && !this.notes.includes(this.activeEditorNote)) {
this.appState.closeActiveNoteController()
}
}
}
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)
}
handleFilterTextChanged = () => {
if (this.searchSubmitted) {
this.searchSubmitted = false
}
this.reloadNotesDisplayOptions()
this.reloadNotes()
}
clearFilterText = () => {
this.setNoteFilterText('')
this.onFilterEnter()
this.handleFilterTextChanged()
this.resetPagination()
}
}

View File

@@ -0,0 +1,37 @@
import { PreferenceId } from '@/Components/Preferences/PreferencesMenu'
import { action, computed, makeObservable, observable } from 'mobx'
const DEFAULT_PANE = 'account'
export class PreferencesState {
private _open = false
currentPane: PreferenceId = DEFAULT_PANE
constructor() {
makeObservable<PreferencesState, '_open'>(this, {
_open: observable,
currentPane: observable,
openPreferences: action,
closePreferences: action,
setCurrentPane: action,
isOpen: computed,
})
}
setCurrentPane = (prefId: PreferenceId): void => {
this.currentPane = prefId
}
openPreferences = (): void => {
this._open = true
}
closePreferences = (): void => {
this._open = false
this.currentPane = DEFAULT_PANE
}
get isOpen(): boolean {
return this._open
}
}

View File

@@ -0,0 +1,41 @@
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
import { action, makeObservable, observable } from 'mobx'
import { WebApplication } from '../Application'
export enum PurchaseFlowPane {
SignIn,
CreateAccount,
}
export class PurchaseFlowState {
isOpen = false
currentPane = PurchaseFlowPane.CreateAccount
constructor(private application: WebApplication) {
makeObservable(this, {
isOpen: observable,
currentPane: observable,
setCurrentPane: action,
openPurchaseFlow: action,
closePurchaseFlow: action,
})
}
setCurrentPane = (currentPane: PurchaseFlowPane): void => {
this.currentPane = currentPane
}
openPurchaseFlow = (): void => {
const user = this.application.getUser()
if (!user) {
this.isOpen = true
} else {
loadPurchaseFlowUrl(this.application).catch(console.error)
}
}
closePurchaseFlow = (): void => {
this.isOpen = false
}
}

View File

@@ -0,0 +1,49 @@
import { action, makeObservable, observable } from 'mobx'
export class QuickSettingsState {
open = false
shouldAnimateCloseMenu = false
focusModeEnabled = false
constructor() {
makeObservable(this, {
open: observable,
shouldAnimateCloseMenu: observable,
focusModeEnabled: observable,
setOpen: action,
setShouldAnimateCloseMenu: action,
setFocusModeEnabled: action,
toggle: action,
closeQuickSettingsMenu: action,
})
}
setOpen = (open: boolean): void => {
this.open = open
}
setShouldAnimateCloseMenu = (shouldAnimate: boolean): void => {
this.shouldAnimateCloseMenu = shouldAnimate
}
setFocusModeEnabled = (enabled: boolean): void => {
this.focusModeEnabled = enabled
}
toggle = (): void => {
if (this.open) {
this.closeQuickSettingsMenu()
} else {
this.setOpen(true)
}
}
closeQuickSettingsMenu = (): void => {
this.setShouldAnimateCloseMenu(true)
setTimeout(() => {
this.setOpen(false)
this.setShouldAnimateCloseMenu(false)
}, 150)
}
}

View File

@@ -0,0 +1,54 @@
import { ApplicationEvent } from '@standardnotes/snjs'
import { makeObservable, observable, action, runInAction } from 'mobx'
import { WebApplication } from '../Application'
export class SearchOptionsState {
includeProtectedContents = false
includeArchived = false
includeTrashed = false
constructor(private application: WebApplication, appObservers: (() => void)[]) {
makeObservable(this, {
includeProtectedContents: observable,
includeTrashed: observable,
includeArchived: observable,
toggleIncludeArchived: action,
toggleIncludeTrashed: action,
toggleIncludeProtectedContents: action,
refreshIncludeProtectedContents: action,
})
appObservers.push(
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents()
}, ApplicationEvent.UnprotectedSessionBegan),
this.application.addEventObserver(async () => {
this.refreshIncludeProtectedContents()
}, ApplicationEvent.UnprotectedSessionExpired),
)
}
toggleIncludeArchived = (): void => {
this.includeArchived = !this.includeArchived
}
toggleIncludeTrashed = (): void => {
this.includeTrashed = !this.includeTrashed
}
refreshIncludeProtectedContents = (): void => {
this.includeProtectedContents = this.application.hasUnprotectedAccessSession()
}
toggleIncludeProtectedContents = async (): Promise<void> => {
if (this.includeProtectedContents) {
this.includeProtectedContents = false
} else {
await this.application.authorizeSearchingProtectedNotesText()
runInAction(() => {
this.refreshIncludeProtectedContents()
})
}
}
}

View File

@@ -0,0 +1,119 @@
import {
ApplicationEvent,
ClientDisplayableError,
convertTimestampToMilliseconds,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { WebApplication } from '../Application'
type Subscription = {
planName: string
cancelled: boolean
endsAt: number
}
type AvailableSubscriptions = {
[key: string]: {
name: string
}
}
export class SubscriptionState {
userSubscription: Subscription | undefined = undefined
availableSubscriptions: AvailableSubscriptions | undefined = undefined
constructor(private application: WebApplication, appObservers: (() => void)[]) {
makeObservable(this, {
userSubscription: observable,
availableSubscriptions: observable,
userSubscriptionName: computed,
userSubscriptionExpirationDate: computed,
isUserSubscriptionExpired: computed,
isUserSubscriptionCanceled: computed,
setUserSubscription: action,
setAvailableSubscriptions: action,
})
appObservers.push(
application.addEventObserver(async () => {
if (application.hasAccount()) {
this.getSubscriptionInfo().catch(console.error)
}
}, ApplicationEvent.Launched),
application.addEventObserver(async () => {
this.getSubscriptionInfo().catch(console.error)
}, ApplicationEvent.SignedIn),
application.addEventObserver(async () => {
this.getSubscriptionInfo().catch(console.error)
}, ApplicationEvent.UserRolesChanged),
)
}
get userSubscriptionName(): string {
if (
this.availableSubscriptions &&
this.userSubscription &&
this.availableSubscriptions[this.userSubscription.planName]
) {
return this.availableSubscriptions[this.userSubscription.planName].name
}
return ''
}
get userSubscriptionExpirationDate(): Date | undefined {
if (!this.userSubscription) {
return undefined
}
return new Date(convertTimestampToMilliseconds(this.userSubscription.endsAt))
}
get isUserSubscriptionExpired(): boolean {
if (!this.userSubscriptionExpirationDate) {
return false
}
return this.userSubscriptionExpirationDate.getTime() < new Date().getTime()
}
get isUserSubscriptionCanceled(): boolean {
return Boolean(this.userSubscription?.cancelled)
}
public setUserSubscription(subscription: Subscription): void {
this.userSubscription = subscription
}
public setAvailableSubscriptions(subscriptions: AvailableSubscriptions): void {
this.availableSubscriptions = subscriptions
}
private async getAvailableSubscriptions() {
try {
const subscriptions = await this.application.getAvailableSubscriptions()
if (!(subscriptions instanceof ClientDisplayableError)) {
this.setAvailableSubscriptions(subscriptions)
}
} catch (error) {
console.error(error)
}
}
private async getSubscription() {
try {
const subscription = await this.application.getUserSubscription()
if (!(subscription instanceof ClientDisplayableError)) {
this.setUserSubscription(subscription)
}
} catch (error) {
console.error(error)
}
}
private async getSubscriptionInfo() {
await this.getSubscription()
await this.getAvailableSubscriptions()
}
}

View File

@@ -0,0 +1,33 @@
import { SyncOpStatus } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class SyncState {
inProgress = false
errorMessage?: string = undefined
humanReadablePercentage?: string = undefined
constructor() {
makeObservable(this, {
inProgress: observable,
errorMessage: observable,
humanReadablePercentage: observable,
update: action,
})
}
update = (status: SyncOpStatus): void => {
this.errorMessage = status.error?.message
this.inProgress = status.syncInProgress
const stats = status.getStats()
const completionPercentage =
stats.uploadCompletionCount === 0 ? 0 : stats.uploadCompletionCount / stats.uploadTotalCount
if (completionPercentage === 0) {
this.humanReadablePercentage = undefined
} else {
this.humanReadablePercentage = completionPercentage.toLocaleString(undefined, {
style: 'percent',
})
}
}
}

View File

@@ -0,0 +1,530 @@
import { confirmDialog } from '@/Services/AlertService'
import { STRING_DELETE_TAG } from '@/Strings'
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants'
import {
ComponentAction,
ContentType,
MessageData,
SNApplication,
SmartView,
SNTag,
TagMutator,
UuidString,
isSystemView,
FindItem,
} from '@standardnotes/snjs'
import { action, computed, makeAutoObservable, makeObservable, observable, runInAction } from 'mobx'
import { WebApplication } from '../Application'
import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './FeaturesState'
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 rootTags = allTags.filter(hasNoParent)
return rootTags
}
const tagSiblings = (application: SNApplication, tag: SNTag): SNTag[] => {
const withoutCurrentTag = (tags: SNTag[]) => tags.filter((other) => other.uuid !== tag.uuid)
const isTemplateTag = application.items.isTemplateItem(tag)
const parentTag = !isTemplateTag && application.items.getTagParent(tag)
if (parentTag) {
const siblingsAndTag = application.items.getTagChildren(parentTag)
return withoutCurrentTag(siblingsAndTag)
}
return withoutCurrentTag(rootTags(application))
}
const isValidFutureSiblings = (
application: SNApplication,
futureSiblings: SNTag[],
tag: SNTag,
): boolean => {
const siblingWithSameName = futureSiblings.find((otherTag) => otherTag.title === tag.title)
if (siblingWithSameName) {
application.alertService
?.alert(
`A tag with the name ${tag.title} already exists at this destination. Please rename this tag before moving and try again.`,
)
.catch(console.error)
return false
}
return true
}
export class TagsState {
tags: SNTag[] = []
smartViews: SmartView[] = []
allNotesCount_ = 0
selected_: AnyTag | undefined
previouslySelected_: AnyTag | undefined
editing_: SNTag | SmartView | undefined
addingSubtagTo: SNTag | undefined
contextMenuOpen = false
contextMenuPosition: { top?: number; left: number; bottom?: number } = {
top: 0,
left: 0,
}
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }
contextMenuMaxHeight: number | 'auto' = 'auto'
private readonly tagsCountsState: TagsCountsState
constructor(
private application: WebApplication,
appEventListeners: (() => void)[],
private features: FeaturesState,
) {
this.tagsCountsState = new TagsCountsState(this.application)
this.selected_ = undefined
this.previouslySelected_ = undefined
this.editing_ = undefined
this.addingSubtagTo = undefined
this.smartViews = this.application.items.getSmartViews()
this.selected_ = this.smartViews[0]
makeObservable(this, {
tags: observable.ref,
smartViews: observable.ref,
hasAtLeastOneFolder: computed,
allNotesCount_: observable,
allNotesCount: computed,
setAllNotesCount: action,
selected_: observable.ref,
previouslySelected_: observable.ref,
previouslySelected: computed,
editing_: observable.ref,
selected: computed,
selectedUuid: computed,
editingTag: computed,
addingSubtagTo: observable,
setAddingSubtagTo: action,
assignParent: action,
rootTags: computed,
tagsCount: computed,
createNewTemplate: action,
undoCreateNewTag: action,
save: action,
remove: action,
contextMenuOpen: observable,
contextMenuPosition: observable,
contextMenuMaxHeight: observable,
contextMenuClickLocation: observable,
setContextMenuOpen: action,
setContextMenuClickLocation: action,
setContextMenuPosition: action,
setContextMenuMaxHeight: action,
})
appEventListeners.push(
this.application.streamItems(
[ContentType.Tag, ContentType.SmartView],
({ changed, removed }) => {
runInAction(() => {
this.tags = this.application.items.getDisplayableItems<SNTag>(ContentType.Tag)
this.smartViews = this.application.items.getSmartViews()
const selectedTag = this.selected_
if (selectedTag && !isSystemView(selectedTag as SmartView)) {
if (FindItem(removed, selectedTag.uuid)) {
this.selected_ = this.smartViews[0]
}
const updated = FindItem(changed, selectedTag.uuid)
if (updated) {
this.selected_ = updated as AnyTag
}
} else {
this.selected_ = this.smartViews[0]
}
})
},
),
)
appEventListeners.push(
this.application.items.addNoteCountChangeObserver((tagUuid) => {
if (!tagUuid) {
this.setAllNotesCount(this.application.items.allCountableNotesCount())
} else {
this.tagsCountsState.update([this.application.items.findItem(tagUuid) as SNTag])
}
}),
)
}
async createSubtagAndAssignParent(parent: SNTag, title: string) {
const hasEmptyTitle = title.length === 0
if (hasEmptyTitle) {
this.setAddingSubtagTo(undefined)
return
}
const createdTag = (await this.application.mutator.createTagOrSmartView(title)) as SNTag
const futureSiblings = this.application.items.getTagChildren(parent)
if (!isValidFutureSiblings(this.application, futureSiblings, createdTag)) {
this.setAddingSubtagTo(undefined)
this.remove(createdTag, false).catch(console.error)
return
}
this.assignParent(createdTag.uuid, parent.uuid).catch(console.error)
this.application.sync.sync().catch(console.error)
runInAction(() => {
this.selected = createdTag as SNTag
})
this.setAddingSubtagTo(undefined)
}
setAddingSubtagTo(tag: SNTag | undefined): void {
this.addingSubtagTo = tag
}
setContextMenuOpen(open: boolean): void {
this.contextMenuOpen = open
}
setContextMenuClickLocation(location: { x: number; y: number }): void {
this.contextMenuClickLocation = location
}
setContextMenuPosition(position: { top?: number; left: number; bottom?: number }): void {
this.contextMenuPosition = position
}
setContextMenuMaxHeight(maxHeight: number | 'auto'): void {
this.contextMenuMaxHeight = maxHeight
}
reloadContextMenuLayout(): void {
const { clientHeight } = document.documentElement
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
const maxContextMenuHeight = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
// Open up-bottom is default behavior
let openUpBottom = true
if (footerHeightInPx) {
const bottomSpace = clientHeight - footerHeightInPx - this.contextMenuClickLocation.y
const upSpace = this.contextMenuClickLocation.y
// If not enough space to open up-bottom
if (maxContextMenuHeight > bottomSpace) {
// If there's enough space, open bottom-up
if (upSpace > maxContextMenuHeight) {
openUpBottom = false
this.setContextMenuMaxHeight('auto')
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
} else {
if (upSpace > bottomSpace) {
this.setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
openUpBottom = false
} else {
this.setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
}
}
} else {
this.setContextMenuMaxHeight('auto')
}
}
if (openUpBottom) {
this.setContextMenuPosition({
top: this.contextMenuClickLocation.y,
left: this.contextMenuClickLocation.x,
})
} else {
this.setContextMenuPosition({
bottom: clientHeight - this.contextMenuClickLocation.y,
left: this.contextMenuClickLocation.x,
})
}
}
public get allLocalRootTags(): SNTag[] {
if (this.editing_ instanceof SNTag && this.application.items.isTemplateItem(this.editing_)) {
return [this.editing_, ...this.rootTags]
}
return this.rootTags
}
public getNotesCount(tag: SNTag): number {
return this.tagsCountsState.counts[tag.uuid] || 0
}
getChildren(tag: SNTag): SNTag[] {
if (this.application.items.isTemplateItem(tag)) {
return []
}
const children = this.application.items.getTagChildren(tag)
const childrenUuids = children.map((childTag) => childTag.uuid)
const childrenTags = this.tags.filter((tag) => childrenUuids.includes(tag.uuid))
return childrenTags
}
isValidTagParent(parent: SNTag, tag: SNTag): boolean {
return this.application.items.isValidTagParent(parent, tag)
}
public hasParent(tagUuid: UuidString): boolean {
const item = this.application.items.findItem(tagUuid)
return !!item && !!(item as SNTag).parentId
}
public async assignParent(tagUuid: string, futureParentUuid: string | undefined): Promise<void> {
const tag = this.application.items.findItem(tagUuid) as SNTag
const currentParent = this.application.items.getTagParent(tag)
const currentParentUuid = currentParent?.uuid
if (currentParentUuid === futureParentUuid) {
return
}
const futureParent =
futureParentUuid && (this.application.items.findItem(futureParentUuid) as SNTag)
if (!futureParent) {
const futureSiblings = rootTags(this.application)
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
return
}
await this.application.mutator.unsetTagParent(tag)
} else {
const futureSiblings = this.application.items.getTagChildren(futureParent)
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
return
}
await this.application.mutator.setTagParent(futureParent, tag)
}
await this.application.sync.sync()
}
get rootTags(): SNTag[] {
return this.tags.filter((tag) => !this.application.items.getTagParent(tag))
}
get tagsCount(): number {
return this.tags.length
}
setAllNotesCount(allNotesCount: number) {
this.allNotesCount_ = allNotesCount
}
public get allNotesCount(): number {
return this.allNotesCount_
}
public get previouslySelected(): AnyTag | undefined {
return this.previouslySelected_
}
public get selected(): AnyTag | undefined {
return this.selected_
}
public set selected(tag: AnyTag | undefined) {
if (tag && tag.conflictOf) {
this.application.mutator
.changeAndSaveItem(tag, (mutator) => {
mutator.conflictOf = undefined
})
.catch(console.error)
}
const selectionHasNotChanged = this.selected_?.uuid === tag?.uuid
if (selectionHasNotChanged) {
return
}
this.previouslySelected_ = this.selected_
this.selected_ = tag
}
public setExpanded(tag: SNTag, expanded: boolean) {
this.application.mutator
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
mutator.expanded = expanded
})
.catch(console.error)
}
public get selectedUuid(): UuidString | undefined {
return this.selected_?.uuid
}
public get editingTag(): SNTag | SmartView | undefined {
return this.editing_
}
public set editingTag(editingTag: SNTag | SmartView | undefined) {
this.editing_ = editingTag
this.selected = editingTag
}
public async createNewTemplate() {
const isAlreadyEditingATemplate =
this.editing_ && this.application.items.isTemplateItem(this.editing_)
if (isAlreadyEditingATemplate) {
return
}
const newTag = (await this.application.mutator.createTemplateItem(ContentType.Tag)) as SNTag
runInAction(() => {
this.editing_ = newTag
})
}
public undoCreateNewTag() {
this.editing_ = undefined
const previousTag = this.previouslySelected_ || this.smartViews[0]
this.selected = previousTag
}
public async remove(tag: SNTag | SmartView, userTriggered: boolean) {
let shouldDelete = !userTriggered
if (userTriggered) {
shouldDelete = await confirmDialog({
text: STRING_DELETE_TAG,
confirmButtonStyle: 'danger',
})
}
if (shouldDelete) {
this.application.mutator.deleteItem(tag).catch(console.error)
this.selected = this.smartViews[0]
}
}
public async save(tag: SNTag | SmartView, newTitle: string) {
const hasEmptyTitle = newTitle.length === 0
const hasNotChangedTitle = newTitle === tag.title
const isTemplateChange = this.application.items.isTemplateItem(tag)
const siblings = tag instanceof SNTag ? tagSiblings(this.application, tag) : []
const hasDuplicatedTitle = siblings.some(
(other) => other.title.toLowerCase() === newTitle.toLowerCase(),
)
runInAction(() => {
this.editing_ = undefined
})
if (hasEmptyTitle || hasNotChangedTitle) {
if (isTemplateChange) {
this.undoCreateNewTag()
}
return
}
if (hasDuplicatedTitle) {
if (isTemplateChange) {
this.undoCreateNewTag()
}
this.application.alertService
?.alert('A tag with this name already exists.')
.catch(console.error)
return
}
if (isTemplateChange) {
const isSmartViewTitle = this.application.items.isSmartViewTitle(newTitle)
if (isSmartViewTitle) {
if (!this.features.hasSmartViews) {
await this.features.showPremiumAlert(SMART_TAGS_FEATURE_NAME)
return
}
}
const insertedTag = await this.application.mutator.createTagOrSmartView(newTitle)
this.application.sync.sync().catch(console.error)
runInAction(() => {
this.selected = insertedTag as SNTag
})
} else {
await this.application.mutator.changeAndSaveItem<TagMutator>(tag, (mutator) => {
mutator.title = newTitle
})
}
}
public onFoldersComponentMessage(action: ComponentAction, data: MessageData): void {
if (action === ComponentAction.SelectItem) {
const item = data.item
if (!item) {
return
}
if (item.content_type === ContentType.Tag || item.content_type === ContentType.SmartView) {
const matchingTag = this.application.items.findItem(item.uuid)
if (matchingTag) {
this.selected = matchingTag as AnyTag
return
}
}
} else if (action === ComponentAction.ClearSelection) {
this.selected = this.smartViews[0]
}
}
public get hasAtLeastOneFolder(): boolean {
return this.tags.some((tag) => !!this.application.items.getTagParent(tag))
}
}
class TagsCountsState {
public counts: { [uuid: string]: number } = {}
public constructor(private application: WebApplication) {
makeAutoObservable(this, {
counts: observable.ref,
update: action,
})
}
public update(tags: SNTag[]) {
const newCounts: { [uuid: string]: number } = Object.assign({}, this.counts)
tags.forEach((tag) => {
newCounts[tag.uuid] = this.application.items.countableNotesForTag(tag)
})
this.counts = newCounts
}
}

View File

@@ -0,0 +1 @@
export { AppState, AppStateEvent, EventSource, PanelResizedData } from './AppState'