import { confirmDialog } from '@/Services/AlertService' import { STRING_DELETE_TAG } from '@/Constants/Strings' import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER, SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants' import { ComponentAction, ContentType, MessageData, SmartView, SNTag, TagMutator, UuidString, isSystemView, FindItem, SystemViewId, InternalEventBus, InternalEventPublishStrategy, } from '@standardnotes/snjs' import { action, computed, makeAutoObservable, makeObservable, observable, 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' export class NavigationController extends AbstractViewController { 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(application: WebApplication, private featuresController: FeaturesController, eventBus: InternalEventBus) { super(application, eventBus) this.tagsCountsState = new TagsCountsState(this.application) this.selected_ = undefined this.previouslySelected_ = undefined this.editing_ = undefined this.addingSubtagTo = undefined this.smartViews = this.application.items.getSmartViews() 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, }) this.disposers.push( this.application.streamItems([ContentType.Tag, ContentType.SmartView], ({ changed, removed }) => { runInAction(() => { this.tags = this.application.items.getDisplayableTags() this.smartViews = this.application.items.getSmartViews() const currrentSelectedTag = this.selected_ if (!currrentSelectedTag) { this.setSelectedTagInstance(this.smartViews[0]) return } const updatedReference = FindItem(changed, currrentSelectedTag.uuid) || FindItem(this.smartViews, currrentSelectedTag.uuid) if (updatedReference) { this.setSelectedTagInstance(updatedReference as AnyTag) } if (isSystemView(currrentSelectedTag as SmartView)) { return } if (FindItem(removed, currrentSelectedTag.uuid)) { this.setSelectedTagInstance(this.smartViews[0]) } }) }), ) this.disposers.push( this.application.items.addNoteCountChangeObserver((tagUuid) => { if (!tagUuid) { this.setAllNotesCount(this.application.items.allCountableNotesCount()) } else { const tag = this.application.items.findItem(tagUuid) if (tag) { this.tagsCountsState.update([tag]) } } }), ) } override deinit() { super.deinit() ;(this.featuresController as unknown) = undefined ;(this.tags as unknown) = undefined ;(this.smartViews as unknown) = undefined ;(this.selected_ as unknown) = undefined ;(this.previouslySelected_ as unknown) = undefined ;(this.editing_ as unknown) = undefined ;(this.addingSubtagTo as unknown) = undefined ;(this.featuresController as unknown) = undefined destroyAllObjectProperties(this) } 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(() => { void this.setSelectedTag(createdTag as SNTag) }) this.setAddingSubtagTo(undefined) } public isInSmartView(): boolean { return this.selected instanceof SmartView } public isInHomeView(): boolean { return this.selected instanceof SmartView && this.selected.uuid === SystemViewId.AllNotes } 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 let openUpBottom = true if (footerHeightInPx) { const bottomSpace = clientHeight - footerHeightInPx - this.contextMenuClickLocation.y const upSpace = this.contextMenuClickLocation.y const notEnoughSpaceToOpenUpBottom = maxContextMenuHeight > bottomSpace if (notEnoughSpaceToOpenUpBottom) { const enoughSpaceToOpenBottomUp = upSpace > maxContextMenuHeight if (enoughSpaceToOpenBottomUp) { openUpBottom = false this.setContextMenuMaxHeight('auto') } else { const hasMoreUpSpace = upSpace > bottomSpace if (hasMoreUpSpace) { 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 { 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 async setSelectedTag(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.setSelectedTagInstance(tag) if (tag && this.application.items.isTemplateItem(tag)) { return } await this.eventBus.publishSync( { type: CrossControllerEvent.TagChanged, payload: { tag, previousTag: this.previouslySelected_ }, }, InternalEventPublishStrategy.SEQUENCE, ) } public async selectHomeNavigationView(): Promise { await this.setSelectedTag(this.homeNavigationView) } get homeNavigationView(): SmartView { return this.smartViews[0] } private setSelectedTagInstance(tag: AnyTag | undefined): void { this.selected_ = tag } public setExpanded(tag: SNTag, expanded: boolean) { this.application.mutator .changeAndSaveItem(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 void this.setSelectedTag(editingTag) } public createNewTemplate() { const isAlreadyEditingATemplate = this.editing_ && this.application.items.isTemplateItem(this.editing_) if (isAlreadyEditingATemplate) { return } const newTag = this.application.mutator.createTemplateItem(ContentType.Tag) as SNTag runInAction(() => { this.editing_ = newTag }) } public undoCreateNewTag() { this.editing_ = undefined const previousTag = this.previouslySelected_ || this.smartViews[0] void this.setSelectedTag(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) await this.setSelectedTag(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.featuresController.hasSmartViews) { await this.featuresController.showPremiumAlert(SMART_TAGS_FEATURE_NAME) return } } const insertedTag = await this.application.mutator.createTagOrSmartView(newTitle) this.application.sync.sync().catch(console.error) runInAction(() => { void this.setSelectedTag(insertedTag as SNTag) }) } else { await this.application.mutator.changeAndSaveItem(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) { void this.setSelectedTag(matchingTag as AnyTag) return } } } else if (action === ComponentAction.ClearSelection) { void this.setSelectedTag(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 } }