feat: persist selected tag & note locally (#1851)
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { UuidString } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants'
|
||||
@@ -22,7 +21,7 @@ type Props = {
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
selectionController: SelectedItemsController
|
||||
selectedItems: Record<UuidString, ListableContentItem>
|
||||
selectedUuids: SelectedItemsController['selectedUuids']
|
||||
paginate: () => void
|
||||
}
|
||||
|
||||
@@ -34,7 +33,7 @@ const ContentList: FunctionComponent<Props> = ({
|
||||
navigationController,
|
||||
notesController,
|
||||
selectionController,
|
||||
selectedItems,
|
||||
selectedUuids,
|
||||
paginate,
|
||||
}) => {
|
||||
const { selectPreviousItem, selectNextItem } = selectionController
|
||||
@@ -82,7 +81,7 @@ const ContentList: FunctionComponent<Props> = ({
|
||||
key={item.uuid}
|
||||
application={application}
|
||||
item={item}
|
||||
selected={!!selectedItems[item.uuid]}
|
||||
selected={selectedUuids.has(item.uuid)}
|
||||
hideDate={hideDate}
|
||||
hidePreview={hideNotePreview}
|
||||
hideTags={hideTags}
|
||||
|
||||
@@ -109,7 +109,7 @@ const ContentListView: FunctionComponent<Props> = ({
|
||||
searchBarElement,
|
||||
} = itemListController
|
||||
|
||||
const { selectedItems, selectNextItem, selectPreviousItem } = selectionController
|
||||
const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController
|
||||
|
||||
const isFilesSmartView = useMemo(
|
||||
() => navigationController.selected?.uuid === SystemViewId.Files,
|
||||
@@ -276,7 +276,7 @@ const ContentListView: FunctionComponent<Props> = ({
|
||||
{renderedItems.length ? (
|
||||
<ContentList
|
||||
items={renderedItems}
|
||||
selectedItems={selectedItems}
|
||||
selectedUuids={selectedUuids}
|
||||
application={application}
|
||||
paginate={paginate}
|
||||
filesController={filesController}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SwitchProps } from '@/Components/Switch/SwitchProps'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { MenuItemType } from './MenuItemType'
|
||||
import RadioIndicator from '../RadioIndicator/RadioIndicator'
|
||||
import RadioIndicator from '../Radio/RadioIndicator'
|
||||
|
||||
type MenuItemProps = {
|
||||
children: ReactNode
|
||||
|
||||
@@ -9,6 +9,7 @@ import LabsPane from './Labs/Labs'
|
||||
import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSection'
|
||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||
import PlaintextDefaults from './PlaintextDefaults'
|
||||
import Persistence from './Persistence'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
@@ -18,6 +19,7 @@ type Props = {
|
||||
|
||||
const General: FunctionComponent<Props> = ({ viewControllerManager, application, extensionsLatestVersions }) => (
|
||||
<PreferencesPane>
|
||||
<Persistence application={application} />
|
||||
<PlaintextDefaults application={application} />
|
||||
<Defaults application={application} />
|
||||
<Tools application={application} />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import StyledRadioInput from '@/Components/Radio/StyledRadioInput'
|
||||
import { useState } from 'react'
|
||||
import { Title } from '../../PreferencesComponents/Content'
|
||||
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const ShouldPersistNoteStateKey = 'ShouldPersistNoteState'
|
||||
|
||||
const Persistence = ({ application }: Props) => {
|
||||
const [shouldPersistNoteState, setShouldPersistNoteState] = useState(application.getValue(ShouldPersistNoteStateKey))
|
||||
|
||||
const toggleStatePersistence = (shouldPersist: boolean) => {
|
||||
application.setValue(ShouldPersistNoteStateKey, shouldPersist)
|
||||
setShouldPersistNoteState(shouldPersist)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title className="mb-2">When opening the app, show...</Title>
|
||||
<label className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<StyledRadioInput
|
||||
name="state-persistence"
|
||||
checked={!shouldPersistNoteState}
|
||||
onChange={(event) => {
|
||||
toggleStatePersistence(!event.target.checked)
|
||||
}}
|
||||
/>
|
||||
The first note in the list
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<StyledRadioInput
|
||||
name="state-persistence"
|
||||
checked={!!shouldPersistNoteState}
|
||||
onChange={(event) => {
|
||||
toggleStatePersistence(event.target.checked)
|
||||
}}
|
||||
/>
|
||||
The last viewed note
|
||||
</label>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default Persistence
|
||||
@@ -16,7 +16,7 @@ import FocusModeSwitch from './FocusModeSwitch'
|
||||
import ThemesMenuButton from './ThemesMenuButton'
|
||||
import { ThemeItem } from './ThemeItem'
|
||||
import { sortThemes } from '@/Utils/SortThemes'
|
||||
import RadioIndicator from '../RadioIndicator/RadioIndicator'
|
||||
import RadioIndicator from '../Radio/RadioIndicator'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
|
||||
import PanelSettingsSection from './PanelSettingsSection'
|
||||
|
||||
@@ -5,7 +5,7 @@ import Icon from '@/Components/Icon/Icon'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { ThemeItem } from './ThemeItem'
|
||||
import RadioIndicator from '../RadioIndicator/RadioIndicator'
|
||||
import RadioIndicator from '../Radio/RadioIndicator'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||
import { isMobileScreen } from '@/Utils'
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { ComponentPropsWithoutRef } from 'react'
|
||||
import RadioIndicator from './RadioIndicator'
|
||||
|
||||
type Props = ComponentPropsWithoutRef<'input'>
|
||||
|
||||
const StyledRadioInput = (props: Props) => {
|
||||
return (
|
||||
<div className="flex">
|
||||
<input type="radio" className={classNames('h-0 w-0 opacity-0', props.className)} {...props} />
|
||||
<RadioIndicator checked={!!props.checked} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StyledRadioInput
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { FunctionComponent, ReactNode } from 'react'
|
||||
import RadioIndicator from '../RadioIndicator/RadioIndicator'
|
||||
import RadioIndicator from '../Radio/RadioIndicator'
|
||||
|
||||
type HistoryListItemProps = {
|
||||
isSelected: boolean
|
||||
|
||||
@@ -63,7 +63,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
|
||||
}, [setTitle, view])
|
||||
|
||||
const selectCurrentTag = useCallback(async () => {
|
||||
await tagsState.setSelectedTag(view)
|
||||
await tagsState.setSelectedTag(view, {
|
||||
userTriggered: true,
|
||||
})
|
||||
toggleAppPane(AppPaneId.Items)
|
||||
}, [tagsState, toggleAppPane, view])
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
)
|
||||
|
||||
const selectCurrentTag = useCallback(async () => {
|
||||
await tagsState.setSelectedTag(tag)
|
||||
await tagsState.setSelectedTag(tag, {
|
||||
userTriggered: true,
|
||||
})
|
||||
toggleAppPane(AppPaneId.Items)
|
||||
}, [tagsState, tag, toggleAppPane])
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Persistable<T> {
|
||||
getPersistableValue(): T
|
||||
hydrateFromPersistedValue(value: T | undefined): void
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ShouldPersistNoteStateKey } from '@/Components/Preferences/Panes/General/Persistence'
|
||||
import { ApplicationEvent, InternalEventBus } from '@standardnotes/snjs'
|
||||
import { CrossControllerEvent } from '../CrossControllerEvent'
|
||||
|
||||
const MasterPersistenceKey = 'master-persistence-key'
|
||||
|
||||
export enum PersistenceKey {
|
||||
SelectedItemsController = 'selected-items-controller',
|
||||
NavigationController = 'navigation-controller',
|
||||
ItemListController = 'item-list-controller',
|
||||
}
|
||||
|
||||
export type MasterPersistedValue = Record<PersistenceKey, unknown>
|
||||
|
||||
export class PersistenceService {
|
||||
private unsubAppEventObserver: () => void
|
||||
|
||||
constructor(private application: WebApplication, private eventBus: InternalEventBus) {
|
||||
this.unsubAppEventObserver = this.application.addEventObserver(async (eventName) => {
|
||||
if (!this.application) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.onAppEvent(eventName)
|
||||
})
|
||||
}
|
||||
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
let shouldHydrateState = this.application.getValue(ShouldPersistNoteStateKey)
|
||||
|
||||
if (typeof shouldHydrateState === 'undefined') {
|
||||
this.application.setValue(ShouldPersistNoteStateKey, true)
|
||||
shouldHydrateState = true
|
||||
}
|
||||
|
||||
this.eventBus.publish({
|
||||
type: CrossControllerEvent.HydrateFromPersistedValues,
|
||||
payload: shouldHydrateState ? this.getPersistedValues() : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
persistValues(values: MasterPersistedValue): void {
|
||||
if (!this.application.isDatabaseLoaded()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.application.setValue(MasterPersistenceKey, values)
|
||||
}
|
||||
|
||||
getPersistedValues(): MasterPersistedValue {
|
||||
return this.application.getValue(MasterPersistenceKey) as MasterPersistedValue
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.unsubAppEventObserver()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export enum CrossControllerEvent {
|
||||
TagChanged = 'TagChanged',
|
||||
ActiveEditorChanged = 'ActiveEditorChanged',
|
||||
HydrateFromPersistedValues = 'HydrateFromPersistedValues',
|
||||
RequestValuePersistence = 'RequestValuePersistence',
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../../Application/Application'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
import { WebDisplayOptions } from './WebDisplayOptions'
|
||||
import { NavigationController } from '../Navigation/NavigationController'
|
||||
import { CrossControllerEvent } from '../CrossControllerEvent'
|
||||
@@ -33,6 +32,8 @@ import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import dayjs from 'dayjs'
|
||||
import { LinkingController } from '../LinkingController'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
import { Persistable } from '../Abstract/Persistable'
|
||||
|
||||
const MinNoteCellHeight = 51.0
|
||||
const DefaultListNumNotes = 20
|
||||
@@ -45,10 +46,18 @@ enum ItemsReloadSource {
|
||||
DisplayOptionsChange,
|
||||
Pagination,
|
||||
TagChange,
|
||||
UserTriggeredTagChange,
|
||||
FilterTextChange,
|
||||
}
|
||||
|
||||
export class ItemListController extends AbstractViewController implements InternalEventHandlerInterface {
|
||||
export type ItemListControllerPersistableValue = {
|
||||
displayOptions: DisplayOptions
|
||||
}
|
||||
|
||||
export class ItemListController
|
||||
extends AbstractViewController
|
||||
implements Persistable<ItemListControllerPersistableValue>, InternalEventHandlerInterface
|
||||
{
|
||||
completedFullSync = false
|
||||
noteFilterText = ''
|
||||
notes: SNNote[] = []
|
||||
@@ -204,6 +213,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
handleFilterTextChanged: action,
|
||||
|
||||
optionsSubtitle: computed,
|
||||
activeControllerItem: computed,
|
||||
})
|
||||
|
||||
window.onresize = () => {
|
||||
@@ -211,9 +221,25 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
}
|
||||
}
|
||||
|
||||
getPersistableValue = (): ItemListControllerPersistableValue => {
|
||||
return {
|
||||
displayOptions: this.displayOptions,
|
||||
}
|
||||
}
|
||||
|
||||
hydrateFromPersistedValue = (state: ItemListControllerPersistableValue | undefined) => {
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (state.displayOptions) {
|
||||
this.displayOptions = state.displayOptions
|
||||
}
|
||||
}
|
||||
|
||||
async handleEvent(event: InternalEventInterface): Promise<void> {
|
||||
if (event.type === CrossControllerEvent.TagChanged) {
|
||||
this.handleTagChange()
|
||||
const payload = event.payload as { userTriggered: boolean }
|
||||
this.handleTagChange(payload.userTriggered)
|
||||
} else if (event.type === CrossControllerEvent.ActiveEditorChanged) {
|
||||
this.handleEditorChange().catch(console.error)
|
||||
}
|
||||
@@ -335,8 +361,10 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
)
|
||||
}
|
||||
|
||||
private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource, activeItem: SNNote | FileItem | undefined) => {
|
||||
return itemsReloadSource === ItemsReloadSource.TagChange || !activeItem
|
||||
private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => {
|
||||
return (
|
||||
itemsReloadSource === ItemsReloadSource.UserTriggeredTagChange || !this.selectionController.selectedUuids.size
|
||||
)
|
||||
}
|
||||
|
||||
private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => {
|
||||
@@ -359,7 +387,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
}
|
||||
|
||||
private shouldSelectActiveItem = (activeItem: SNNote | FileItem | undefined) => {
|
||||
return activeItem && !this.selectionController.selectedItems[activeItem.uuid]
|
||||
return activeItem && !this.selectionController.isItemSelected(activeItem)
|
||||
}
|
||||
|
||||
private async recomputeSelectionAfterItemsReload(itemsReloadSource: ItemsReloadSource) {
|
||||
@@ -371,7 +399,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
|
||||
const activeItem = activeController?.item
|
||||
|
||||
if (this.shouldSelectFirstItem(itemsReloadSource, activeItem)) {
|
||||
if (this.shouldSelectFirstItem(itemsReloadSource)) {
|
||||
await this.selectFirstItem()
|
||||
} else if (this.shouldCloseActiveItem(activeItem) && activeController) {
|
||||
this.closeItemController(activeController)
|
||||
@@ -500,6 +528,11 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
if (newDisplayOptions.sortBy !== currentSortBy) {
|
||||
await this.selectFirstItem()
|
||||
}
|
||||
|
||||
this.eventBus.publish({
|
||||
type: CrossControllerEvent.RequestValuePersistence,
|
||||
payload: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async createNewNoteController(title?: string) {
|
||||
@@ -664,7 +697,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
this.application.itemControllerGroup.closeItemController(controller)
|
||||
}
|
||||
|
||||
handleTagChange = () => {
|
||||
handleTagChange = (userTriggered: boolean) => {
|
||||
const activeNoteController = this.getActiveItemController()
|
||||
if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) {
|
||||
this.closeItemController(activeNoteController)
|
||||
@@ -682,7 +715,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
|
||||
this.reloadNotesDisplayOptions()
|
||||
|
||||
void this.reloadItems(ItemsReloadSource.TagChange)
|
||||
void this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange)
|
||||
}
|
||||
|
||||
onFilterEnter = () => {
|
||||
|
||||
@@ -15,19 +15,28 @@ import {
|
||||
InternalEventBus,
|
||||
InternalEventPublishStrategy,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeAutoObservable, makeObservable, observable, runInAction } from 'mobx'
|
||||
import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../../Application/Application'
|
||||
import { FeaturesController } from '../FeaturesController'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
import { destroyAllObjectProperties } from '@/Utils'
|
||||
import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils'
|
||||
import { AnyTag } from './AnyTagType'
|
||||
import { CrossControllerEvent } from '../CrossControllerEvent'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
import { Persistable } from '../Abstract/Persistable'
|
||||
|
||||
export class NavigationController extends AbstractViewController {
|
||||
export type NavigationControllerPersistableValue = {
|
||||
selectedTagUuid: AnyTag['uuid']
|
||||
}
|
||||
|
||||
export class NavigationController
|
||||
extends AbstractViewController
|
||||
implements Persistable<NavigationControllerPersistableValue>
|
||||
{
|
||||
tags: SNTag[] = []
|
||||
smartViews: SmartView[] = []
|
||||
allNotesCount_ = 0
|
||||
selectedUuid: AnyTag['uuid'] | undefined = undefined
|
||||
selected_: AnyTag | undefined
|
||||
previouslySelected_: AnyTag | undefined
|
||||
editing_: SNTag | SmartView | undefined
|
||||
@@ -63,12 +72,12 @@ export class NavigationController extends AbstractViewController {
|
||||
allNotesCount: computed,
|
||||
setAllNotesCount: action,
|
||||
|
||||
selected_: observable.ref,
|
||||
selected_: observable,
|
||||
previouslySelected_: observable.ref,
|
||||
previouslySelected: computed,
|
||||
editing_: observable.ref,
|
||||
selected: computed,
|
||||
selectedUuid: computed,
|
||||
selectedUuid: observable,
|
||||
editingTag: computed,
|
||||
|
||||
addingSubtagTo: observable,
|
||||
@@ -94,6 +103,8 @@ export class NavigationController extends AbstractViewController {
|
||||
setContextMenuMaxHeight: action,
|
||||
|
||||
isInFilesView: computed,
|
||||
|
||||
hydrateFromPersistedValue: action,
|
||||
})
|
||||
|
||||
this.disposers.push(
|
||||
@@ -103,25 +114,23 @@ export class NavigationController extends AbstractViewController {
|
||||
|
||||
this.smartViews = this.application.items.getSmartViews()
|
||||
|
||||
const currrentSelectedTag = this.selected_
|
||||
|
||||
if (!currrentSelectedTag) {
|
||||
this.setSelectedTagInstance(this.smartViews[0])
|
||||
const currentSelectedTag = this.selected_
|
||||
|
||||
if (!currentSelectedTag) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedReference =
|
||||
FindItem(changed, currrentSelectedTag.uuid) || FindItem(this.smartViews, currrentSelectedTag.uuid)
|
||||
FindItem(changed, currentSelectedTag.uuid) || FindItem(this.smartViews, currentSelectedTag.uuid)
|
||||
if (updatedReference) {
|
||||
this.setSelectedTagInstance(updatedReference as AnyTag)
|
||||
}
|
||||
|
||||
if (isSystemView(currrentSelectedTag as SmartView)) {
|
||||
if (isSystemView(currentSelectedTag as SmartView)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (FindItem(removed, currrentSelectedTag.uuid)) {
|
||||
if (FindItem(removed, currentSelectedTag.uuid)) {
|
||||
this.setSelectedTagInstance(this.smartViews[0])
|
||||
}
|
||||
})
|
||||
@@ -140,6 +149,52 @@ export class NavigationController extends AbstractViewController {
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => this.selectedUuid,
|
||||
() => {
|
||||
eventBus.publish({
|
||||
type: CrossControllerEvent.RequestValuePersistence,
|
||||
payload: undefined,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
findAndSetTag = (uuid: UuidString) => {
|
||||
const tagToSelect = [...this.tags, ...this.smartViews].find((tag) => tag.uuid === uuid)
|
||||
if (tagToSelect) {
|
||||
void this.setSelectedTag(tagToSelect)
|
||||
}
|
||||
}
|
||||
|
||||
selectHydratedTagOrDefault = () => {
|
||||
if (this.selectedUuid && !this.selected_) {
|
||||
this.findAndSetTag(this.selectedUuid)
|
||||
}
|
||||
|
||||
if (!this.selectedUuid) {
|
||||
void this.selectHomeNavigationView()
|
||||
}
|
||||
}
|
||||
|
||||
getPersistableValue = (): NavigationControllerPersistableValue => {
|
||||
return {
|
||||
selectedTagUuid: this.selectedUuid ? this.selectedUuid : SystemViewId.AllNotes,
|
||||
}
|
||||
}
|
||||
|
||||
hydrateFromPersistedValue = (state: NavigationControllerPersistableValue | undefined) => {
|
||||
if (!state) {
|
||||
void this.selectHomeNavigationView()
|
||||
return
|
||||
}
|
||||
if (state.selectedTagUuid) {
|
||||
this.selectedUuid = state.selectedTagUuid
|
||||
this.selectHydratedTagOrDefault()
|
||||
}
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
@@ -358,7 +413,7 @@ export class NavigationController extends AbstractViewController {
|
||||
return this.selected_
|
||||
}
|
||||
|
||||
public async setSelectedTag(tag: AnyTag | undefined) {
|
||||
public async setSelectedTag(tag: AnyTag | undefined, { userTriggered } = { userTriggered: false }) {
|
||||
if (tag && tag.conflictOf) {
|
||||
this.application.mutator
|
||||
.changeAndSaveItem(tag, (mutator) => {
|
||||
@@ -384,7 +439,7 @@ export class NavigationController extends AbstractViewController {
|
||||
await this.eventBus.publishSync(
|
||||
{
|
||||
type: CrossControllerEvent.TagChanged,
|
||||
payload: { tag, previousTag: this.previouslySelected_ },
|
||||
payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered },
|
||||
},
|
||||
InternalEventPublishStrategy.SEQUENCE,
|
||||
)
|
||||
@@ -407,7 +462,10 @@ export class NavigationController extends AbstractViewController {
|
||||
}
|
||||
|
||||
private setSelectedTagInstance(tag: AnyTag | undefined): void {
|
||||
runInAction(() => (this.selected_ = tag))
|
||||
runInAction(() => {
|
||||
this.selected_ = tag
|
||||
this.selectedUuid = tag ? tag.uuid : undefined
|
||||
})
|
||||
}
|
||||
|
||||
public setExpanded(tag: SNTag, expanded: boolean) {
|
||||
@@ -418,10 +476,6 @@ export class NavigationController extends AbstractViewController {
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
public get selectedUuid(): UuidString | undefined {
|
||||
return this.selected_?.uuid
|
||||
}
|
||||
|
||||
public get editingTag(): SNTag | SmartView | undefined {
|
||||
return this.editing_
|
||||
}
|
||||
|
||||
@@ -63,20 +63,6 @@ export class NotesController extends AbstractViewController {
|
||||
this.itemListController = itemListController
|
||||
|
||||
this.disposers.push(
|
||||
this.application.streamItems<SNNote>(ContentType.Note, ({ changed, inserted, removed }) => {
|
||||
runInAction(() => {
|
||||
for (const removedNote of removed) {
|
||||
this.selectionController.deselectItem(removedNote)
|
||||
}
|
||||
|
||||
for (const note of [...changed, ...inserted]) {
|
||||
if (this.selectionController.isItemSelected(note)) {
|
||||
this.selectionController.updateReferenceOfSelectedItem(note)
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
this.application.itemControllerGroup.addActiveControllerChangeObserver(() => {
|
||||
const controllers = this.application.itemControllerGroup.itemControllers
|
||||
|
||||
@@ -278,7 +264,7 @@ export class NotesController extends AbstractViewController {
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.selectionController.setSelectedItems({})
|
||||
this.selectionController.deselectAll()
|
||||
this.contextMenuOpen = false
|
||||
})
|
||||
}
|
||||
@@ -295,7 +281,7 @@ export class NotesController extends AbstractViewController {
|
||||
}
|
||||
|
||||
unselectNotes(): void {
|
||||
this.selectionController.setSelectedItems({})
|
||||
this.selectionController.deselectAll()
|
||||
}
|
||||
|
||||
getSpellcheckStateForNote(note: SNNote) {
|
||||
|
||||
@@ -7,17 +7,25 @@ import {
|
||||
SNNote,
|
||||
UuidString,
|
||||
InternalEventBus,
|
||||
isFile,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../Application/Application'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
import { Persistable } from './Abstract/Persistable'
|
||||
import { CrossControllerEvent } from './CrossControllerEvent'
|
||||
import { ItemListController } from './ItemList/ItemListController'
|
||||
|
||||
type SelectedItems = Record<UuidString, ListableContentItem>
|
||||
export type SelectionControllerPersistableValue = {
|
||||
selectedUuids: UuidString[]
|
||||
}
|
||||
|
||||
export class SelectedItemsController extends AbstractViewController {
|
||||
export class SelectedItemsController
|
||||
extends AbstractViewController
|
||||
implements Persistable<SelectionControllerPersistableValue>
|
||||
{
|
||||
lastSelectedItem: ListableContentItem | undefined
|
||||
selectedItems: SelectedItems = {}
|
||||
selectedUuids: Set<UuidString> = observable(new Set<UuidString>())
|
||||
private itemListController!: ItemListController
|
||||
|
||||
override deinit(): void {
|
||||
@@ -29,7 +37,7 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
selectedItems: observable,
|
||||
selectedUuids: observable,
|
||||
|
||||
selectedItemsCount: computed,
|
||||
selectedFiles: computed,
|
||||
@@ -37,30 +45,50 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
firstSelectedItem: computed,
|
||||
|
||||
selectItem: action,
|
||||
setSelectedItems: action,
|
||||
setSelectedUuids: action,
|
||||
|
||||
hydrateFromPersistedValue: action,
|
||||
})
|
||||
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => this.selectedUuids,
|
||||
() => {
|
||||
eventBus.publish({
|
||||
type: CrossControllerEvent.RequestValuePersistence,
|
||||
payload: undefined,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
getPersistableValue = (): SelectionControllerPersistableValue => {
|
||||
return {
|
||||
selectedUuids: Array.from(this.selectedUuids),
|
||||
}
|
||||
}
|
||||
|
||||
hydrateFromPersistedValue = (state: SelectionControllerPersistableValue | undefined): void => {
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (!this.selectedUuids.size && state.selectedUuids.length > 0) {
|
||||
void this.selectUuids(state.selectedUuids)
|
||||
}
|
||||
}
|
||||
|
||||
public setServicesPostConstruction(itemListController: ItemListController) {
|
||||
this.itemListController = itemListController
|
||||
|
||||
this.disposers.push(
|
||||
this.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
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
),
|
||||
this.application.streamItems<SNNote | FileItem>([ContentType.Note, ContentType.File], ({ removed }) => {
|
||||
runInAction(() => {
|
||||
for (const removedNote of removed) {
|
||||
this.removeFromSelectedUuids(removedNote.uuid)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,7 +97,7 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
}
|
||||
|
||||
get selectedItemsCount(): number {
|
||||
return Object.keys(this.selectedItems).length
|
||||
return this.selectedUuids.size
|
||||
}
|
||||
|
||||
get selectedFiles(): FileItem[] {
|
||||
@@ -81,21 +109,31 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
}
|
||||
|
||||
get firstSelectedItem() {
|
||||
return this.getSelectedItems()[0]
|
||||
return this.application.items.findSureItem(Array.from(this.selectedUuids)[0]) as ListableContentItem
|
||||
}
|
||||
|
||||
getSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: ContentType): T[] => {
|
||||
return Object.values(this.selectedItems).filter((item) => {
|
||||
return !contentType ? true : item.content_type === contentType
|
||||
}) as T[]
|
||||
const uuids = Array.from(this.selectedUuids)
|
||||
return uuids.length > 0
|
||||
? (uuids
|
||||
.map((uuid) => this.application.items.findSureItem(uuid))
|
||||
.filter((item) => {
|
||||
return !contentType ? true : item.content_type === contentType
|
||||
}) as T[])
|
||||
: []
|
||||
}
|
||||
|
||||
setSelectedItems = (selectedItems: SelectedItems) => {
|
||||
this.selectedItems = selectedItems
|
||||
setSelectedUuids = (selectedUuids: Set<UuidString>) => {
|
||||
this.selectedUuids = new Set(selectedUuids)
|
||||
}
|
||||
|
||||
private removeFromSelectedUuids = (uuid: UuidString) => {
|
||||
this.selectedUuids.delete(uuid)
|
||||
this.setSelectedUuids(this.selectedUuids)
|
||||
}
|
||||
|
||||
public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => {
|
||||
delete this.selectedItems[item.uuid]
|
||||
this.removeFromSelectedUuids(item.uuid)
|
||||
|
||||
if (item.uuid === this.lastSelectedItem?.uuid) {
|
||||
this.lastSelectedItem = undefined
|
||||
@@ -103,11 +141,7 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
}
|
||||
|
||||
public isItemSelected = (item: ListableContentItem): boolean => {
|
||||
return this.selectedItems[item.uuid] != undefined
|
||||
}
|
||||
|
||||
public updateReferenceOfSelectedItem = (item: ListableContentItem): void => {
|
||||
this.selectedItems[item.uuid] = item
|
||||
return this.selectedUuids.has(item.uuid)
|
||||
}
|
||||
|
||||
private selectItemsRange = async ({
|
||||
@@ -138,7 +172,7 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
|
||||
for (const item of authorizedItems) {
|
||||
runInAction(() => {
|
||||
this.selectedItems[item.uuid] = item
|
||||
this.setSelectedUuids(this.selectedUuids.add(item.uuid))
|
||||
this.lastSelectedItem = item
|
||||
})
|
||||
}
|
||||
@@ -147,7 +181,7 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
cancelMultipleSelection = () => {
|
||||
this.io.cancelAllKeyboardModifiers()
|
||||
|
||||
const firstSelectedItem = this.getSelectedItems()[0]
|
||||
const firstSelectedItem = this.firstSelectedItem
|
||||
|
||||
if (firstSelectedItem) {
|
||||
this.replaceSelection(firstSelectedItem)
|
||||
@@ -157,9 +191,8 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
}
|
||||
|
||||
private replaceSelection = (item: ListableContentItem): void => {
|
||||
this.setSelectedItems({
|
||||
[item.uuid]: item,
|
||||
})
|
||||
this.deselectAll()
|
||||
this.setSelectedUuids(this.selectedUuids.add(item.uuid))
|
||||
|
||||
this.lastSelectedItem = item
|
||||
}
|
||||
@@ -171,12 +204,25 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
})
|
||||
}
|
||||
|
||||
private deselectAll = (): void => {
|
||||
this.setSelectedItems({})
|
||||
deselectAll = (): void => {
|
||||
this.selectedUuids.clear()
|
||||
this.setSelectedUuids(this.selectedUuids)
|
||||
|
||||
this.lastSelectedItem = undefined
|
||||
}
|
||||
|
||||
openSingleSelectedItem = async () => {
|
||||
if (this.selectedItemsCount === 1) {
|
||||
const item = this.firstSelectedItem
|
||||
|
||||
if (item.content_type === ContentType.Note) {
|
||||
await this.itemListController.openNote(item.uuid)
|
||||
} else if (item.content_type === ContentType.File) {
|
||||
await this.itemListController.openFile(item.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectItem = async (
|
||||
uuid: UuidString,
|
||||
userTriggered?: boolean,
|
||||
@@ -197,33 +243,25 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item)
|
||||
|
||||
if (userTriggered && (hasMeta || hasCtrl)) {
|
||||
if (this.selectedItems[uuid] && hasMoreThanOneSelected) {
|
||||
delete this.selectedItems[uuid]
|
||||
if (this.selectedUuids.has(uuid) && hasMoreThanOneSelected) {
|
||||
this.removeFromSelectedUuids(uuid)
|
||||
} else if (isAuthorizedForAccess) {
|
||||
this.selectedItems[uuid] = item
|
||||
this.setSelectedUuids(this.selectedUuids.add(uuid))
|
||||
this.lastSelectedItem = item
|
||||
}
|
||||
} else if (userTriggered && hasShift) {
|
||||
await this.selectItemsRange({ selectedItem: item })
|
||||
} else {
|
||||
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedItems[uuid]
|
||||
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedUuids.has(uuid)
|
||||
if (shouldSelectNote && isAuthorizedForAccess) {
|
||||
this.replaceSelection(item)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selectedItemsCount === 1) {
|
||||
const item = Object.values(this.selectedItems)[0]
|
||||
|
||||
if (item.content_type === ContentType.Note) {
|
||||
await this.itemListController.openNote(item.uuid)
|
||||
} else if (item.content_type === ContentType.File) {
|
||||
await this.itemListController.openFile(item.uuid)
|
||||
}
|
||||
}
|
||||
await this.openSingleSelectedItem()
|
||||
|
||||
return {
|
||||
didSelect: this.selectedItems[uuid] != undefined,
|
||||
didSelect: this.selectedUuids.has(uuid),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +281,20 @@ export class SelectedItemsController extends AbstractViewController {
|
||||
}
|
||||
}
|
||||
|
||||
selectUuids = async (uuids: UuidString[], userTriggered = false) => {
|
||||
const itemsForUuids = this.application.items.findItems(uuids)
|
||||
if (itemsForUuids.length < 1) {
|
||||
return
|
||||
}
|
||||
if (!userTriggered && itemsForUuids.some((item) => item.protected && isFile(item))) {
|
||||
return
|
||||
}
|
||||
this.setSelectedUuids(new Set(uuids))
|
||||
if (itemsForUuids.length === 1) {
|
||||
void this.openSingleSelectedItem()
|
||||
}
|
||||
}
|
||||
|
||||
selectNextItem = () => {
|
||||
const displayableItems = this.itemListController.items
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
ItemCounterInterface,
|
||||
ItemCounter,
|
||||
SubscriptionClientInterface,
|
||||
InternalEventHandlerInterface,
|
||||
InternalEventInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
import { ActionsMenuController } from './ActionsMenuController'
|
||||
import { FeaturesController } from './FeaturesController'
|
||||
import { FilesController } from './FilesController'
|
||||
import { NotesController } from './NotesController'
|
||||
import { ItemListController } from './ItemList/ItemListController'
|
||||
import { ItemListController, ItemListControllerPersistableValue } from './ItemList/ItemListController'
|
||||
import { NoAccountWarningController } from './NoAccountWarningController'
|
||||
import { PreferencesController } from './PreferencesController'
|
||||
import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController'
|
||||
@@ -25,14 +27,16 @@ import { QuickSettingsController } from './QuickSettingsController'
|
||||
import { SearchOptionsController } from './SearchOptionsController'
|
||||
import { SubscriptionController } from './Subscription/SubscriptionController'
|
||||
import { SyncStatusController } from './SyncStatusController'
|
||||
import { NavigationController } from './Navigation/NavigationController'
|
||||
import { NavigationController, NavigationControllerPersistableValue } from './Navigation/NavigationController'
|
||||
import { FilePreviewModalController } from './FilePreviewModalController'
|
||||
import { SelectedItemsController } from './SelectedItemsController'
|
||||
import { SelectedItemsController, SelectionControllerPersistableValue } from './SelectedItemsController'
|
||||
import { HistoryModalController } from './NoteHistory/HistoryModalController'
|
||||
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
|
||||
import { LinkingController } from './LinkingController'
|
||||
import { MasterPersistedValue, PersistenceKey, PersistenceService } from './Abstract/PersistenceService'
|
||||
import { CrossControllerEvent } from './CrossControllerEvent'
|
||||
|
||||
export class ViewControllerManager {
|
||||
export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
|
||||
|
||||
private unsubAppEventObserver!: () => void
|
||||
@@ -65,10 +69,16 @@ export class ViewControllerManager {
|
||||
private eventBus: InternalEventBus
|
||||
private itemCounter: ItemCounterInterface
|
||||
private subscriptionManager: SubscriptionClientInterface
|
||||
private persistenceService: PersistenceService
|
||||
|
||||
constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) {
|
||||
this.eventBus = new InternalEventBus()
|
||||
|
||||
this.persistenceService = new PersistenceService(application, this.eventBus)
|
||||
|
||||
this.eventBus.addEventHandler(this, CrossControllerEvent.HydrateFromPersistedValues)
|
||||
this.eventBus.addEventHandler(this, CrossControllerEvent.RequestValuePersistence)
|
||||
|
||||
this.itemCounter = new ItemCounter()
|
||||
|
||||
this.subscriptionManager = application.subscriptions
|
||||
@@ -173,6 +183,9 @@ export class ViewControllerManager {
|
||||
;(this.quickSettingsMenuController as unknown) = undefined
|
||||
;(this.syncStatusController as unknown) = undefined
|
||||
|
||||
this.persistenceService.deinit()
|
||||
;(this.persistenceService as unknown) = undefined
|
||||
|
||||
this.actionsMenuController.reset()
|
||||
;(this.actionsMenuController as unknown) = undefined
|
||||
|
||||
@@ -264,4 +277,33 @@ export class ViewControllerManager {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
persistValues = (): void => {
|
||||
const values: MasterPersistedValue = {
|
||||
[PersistenceKey.SelectedItemsController]: this.selectionController.getPersistableValue(),
|
||||
[PersistenceKey.NavigationController]: this.navigationController.getPersistableValue(),
|
||||
[PersistenceKey.ItemListController]: this.itemListController.getPersistableValue(),
|
||||
}
|
||||
|
||||
this.persistenceService.persistValues(values)
|
||||
}
|
||||
|
||||
hydrateFromPersistedValues = (values: MasterPersistedValue | undefined): void => {
|
||||
const itemListState = values?.[PersistenceKey.ItemListController] as ItemListControllerPersistableValue
|
||||
this.itemListController.hydrateFromPersistedValue(itemListState)
|
||||
|
||||
const selectedItemsState = values?.[PersistenceKey.SelectedItemsController] as SelectionControllerPersistableValue
|
||||
this.selectionController.hydrateFromPersistedValue(selectedItemsState)
|
||||
|
||||
const navigationState = values?.[PersistenceKey.NavigationController] as NavigationControllerPersistableValue
|
||||
this.navigationController.hydrateFromPersistedValue(navigationState)
|
||||
}
|
||||
|
||||
async handleEvent(event: InternalEventInterface): Promise<void> {
|
||||
if (event.type === CrossControllerEvent.HydrateFromPersistedValues) {
|
||||
this.hydrateFromPersistedValues(event.payload as MasterPersistedValue | undefined)
|
||||
} else if (event.type === CrossControllerEvent.RequestValuePersistence) {
|
||||
this.persistValues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user