import { type App, type TAbstractFile, type MarkdownPostProcessorContext, type WorkspaceLeaf, Plugin, TFile, TFolder, parseYaml, Notice, Keymap, requireApiVersion, Platform, debounce, } from 'obsidian'; import { type FolderNotesSettings, DEFAULT_SETTINGS, SettingsTab, } from './settings/SettingsTab'; import { FolderOverview, type defaultOverviewSettings } from './obsidian-folder-overview/src/FolderOverview'; import { Commands } from './Commands'; import type { FileExplorerWorkspaceLeaf } from './globals'; import { registerFileExplorerObserver, unregisterFileExplorerObserver, } from './events/MutationObserver'; import { handleRename } from './events/handleRename'; import { getFolderNote, getFolder, openFolderNote, createFolderNote, } from './functions/folderNoteFunctions'; import { handleCreate } from './events/handleCreate'; import { FrontMatterTitlePluginHandler } from './events/FrontMatterTitle'; import { FolderOverviewSettings } from './obsidian-folder-overview/src/modals/Settings'; import { TabManager } from './events/TabManager'; import './functions/ListComponent'; import { handleDelete } from './events/handleDelete'; import { addCSSClassToFileExplorerEl, getFileExplorerElement, removeCSSClassFromFileExplorerEL, refreshAllFolderStyles, setActiveFolder, removeActiveFolder, } from './functions/styleFunctions'; import { getExcludedFolder } from './ExcludeFolders/functions/folderFunctions'; import { getFileExplorer } from './functions/utils'; import { FOLDER_OVERVIEW_VIEW, FolderOverviewView } from './obsidian-folder-overview/src/view'; import { registerOverviewCommands } from './obsidian-folder-overview/src/Commands'; import { updateOverviewView, updateViewDropdown } from './obsidian-folder-overview/src/main'; import { FvIndexDB } from './obsidian-folder-overview/src/utils/IndexDB'; import { updateAllOverviews } from './obsidian-folder-overview/src/utils/functions'; interface FileExplorerPluginLike extends Plugin { revealInFolder: (file: TAbstractFile) => void; } interface DragManagerLike { draggable?: { file?: TAbstractFile; type?: string; } | null; setAction(action: string): void; } interface ClipboardManagerLike { app: App & { dragManager?: DragManagerLike; }; handleDragOver: (evt: DragEvent, ...args: unknown[]) => void; handleDrop: (evt: DragEvent, ...args: unknown[]) => void; } interface EditModeLike { clipboardManager: ClipboardManagerLike; } interface ViewWithEditModes { editMode?: EditModeLike; sourceMode?: EditModeLike; } interface ActiveEditorLike { editMode?: EditModeLike; } type LegacySettingsData = Partial & { allowWhitespaceCollapsing?: boolean; defaultOverview?: defaultOverviewSettings; }; export default class FolderNotesPlugin extends Plugin { settings!: FolderNotesSettings; settingsTab!: SettingsTab; activeFolderDom!: HTMLElement | null; activeFileExplorer!: FileExplorerWorkspaceLeaf; fmtpHandler: FrontMatterTitlePluginHandler | null = null; hoveredElement: HTMLElement | null = null; mouseEvent: MouseEvent | null = null; hoverLinkTriggered = false; tabManager!: TabManager; settingsOpened = false; askModalCurrentlyOpen = false; fvIndexDB!: FvIndexDB; async onload(): Promise { console.debug('loading folder notes plugin'); await this.loadSettings(); this.settingsTab = new SettingsTab(this.app, this); this.addSettingTab(this.settingsTab); await this.saveSettings(); this.fvIndexDB = new FvIndexDB(this); // Add CSS Classes this.addSettingCssClasses(); new Commands(this.app, this).registerCommands(); registerOverviewCommands(this); this.app.workspace.onLayoutReady(() => this.onLayoutReady()); if (!this.settings.persistentSettingsTab.afterRestart) { this.settings.settingsTab = 'general'; } this.registerDomEvent(window, 'keydown', (event: KeyboardEvent) => { const { hoveredElement } = this; if (this.hoverLinkTriggered) return; if (!hoveredElement) return; if (!Keymap.isModEvent(event)) return; const folderPath = hoveredElement?.parentElement?.getAttribute('data-path') || ''; const folderNote = getFolderNote(this, folderPath); if (!folderNote) return; this.app.workspace.trigger('hover-link', { event: this.mouseEvent, source: 'preview', hoverParent: { file: folderNote, }, targetEl: hoveredElement, linktext: folderNote?.basename, sourcePath: folderNote?.path, }); this.hoverLinkTriggered = true; }); this.registerEvent(this.app.workspace.on('file-open', (openFile: TFile | null) => { removeActiveFolder(this); if (!openFile || !openFile.basename) { return; } const folder = getFolder(this, openFile); if (!folder) { return; } const excludedFolder = getExcludedFolder(this, folder.path, true); if (excludedFolder?.disableFolderNote) return; const folderNote = getFolderNote(this, folder.path); if (!folderNote) { return; } if (folderNote.path !== openFile.path) { return; } setActiveFolder(folder.path, this); })); this.registerEvent(this.app.vault.on('create', (file: TAbstractFile) => { handleCreate(file, this).catch((err) => { console.error(err); new Notice('Error handling file creation'); }); this.handleVaultChange(); })); this.registerEvent(this.app.vault.on('rename', (file: TAbstractFile, oldPath: string) => { handleRename(file, oldPath, this); this.handleVaultChange(); })); this.registerEvent(this.app.vault.on('delete', (file: TAbstractFile) => { handleDelete(file, this); this.handleVaultChange(); })); this.registerMarkdownCodeBlockProcessor( 'folder-overview', (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { this.handleOverviewBlock(source, el, ctx); }, ); } addSettingCssClasses(): void { activeDocument.body.classList.add('folder-notes-plugin'); if (this.settings.hideFolderNote) { activeDocument.body.classList.add('hide-folder-note'); } if (this.settings.hideCollapsingIconForEmptyFolders) { activeDocument.body.classList.add('fn-hide-empty-collapse-icon'); } if (this.settings.hideFolderNoteNameInPath) { activeDocument.body.classList.add('folder-note-hide-name-path'); } if (this.settings.underlineFolder) { activeDocument.body.classList.add('folder-note-underline'); } if (this.settings.boldName) { activeDocument.body.classList.add('folder-note-bold'); } if (this.settings.cursiveName) { activeDocument.body.classList.add('folder-note-cursive'); } if (this.settings.boldNameInPath) { activeDocument.body.classList.add('folder-note-bold-path'); } if (this.settings.cursiveNameInPath) { activeDocument.body.classList.add('folder-note-cursive-path'); } if (this.settings.underlineFolderInPath) { activeDocument.body.classList.add('folder-note-underline-path'); } if (this.settings.stopWhitespaceCollapsing) { activeDocument.body.classList.add('fn-whitespace-stop-collapsing'); } if (this.settings.hideCollapsingIcon) { activeDocument.body.classList.add('fn-hide-collapse-icon'); } if (this.settings.ignoreAttachmentFolder) { activeDocument.body.classList.add('fn-ignore-attachment-folder'); } if (!this.settings.highlightFolder) { activeDocument.body.classList.add('disable-folder-highlight'); } if (requireApiVersion('1.7.2')) { activeDocument.body.classList.add('version-1-7-2'); } } onLayoutReady(): void { if (!this._loaded) { return; } registerFileExplorerObserver(this); const fileExplorer = getFileExplorer(this); // @ts-expect-error use internal API const infinityScroll = fileExplorer?.view?.tree?.infinityScroll; if (infinityScroll) { // increase infinity scroll buffer to show hidden folder notes // Issue: https://github.com/LostPaul/obsidian-folder-notes/issues/274 infinityScroll.rootMargin = 1.5; } this.registerView(FOLDER_OVERVIEW_VIEW, (leaf: WorkspaceLeaf) => { return new FolderOverviewView(leaf, this); }); this.app.workspace.on('layout-change', () => { this.tabManager.updateTabs(); }); if (this.app.plugins.getPlugin('obsidian-front-matter-title-plugin')) { this.fmtpHandler = new FrontMatterTitlePluginHandler(this); } this.tabManager = new TabManager(this); this.tabManager.updateTabs(); this.registerDomEvent(activeDocument, 'click', (evt: MouseEvent) => { this.handleFileExplorerClick(evt); }, true); // Handle middle mouse button clicks this.registerDomEvent(activeDocument, 'auxclick', (evt: MouseEvent) => { const rightClick = 2; if (evt.button === rightClick) return; this.handleFileExplorerClick(evt); }, true); const fileExplorerPlugin = this.app.internalPlugins.getEnabledPluginById('file-explorer'); if (fileExplorerPlugin) { const fileExplorer = fileExplorerPlugin as unknown as FileExplorerPluginLike; const originalRevealInFolder = (fileExplorer.revealInFolder as unknown as FileExplorerPluginLike['revealInFolder']) .bind(fileExplorer); fileExplorer.revealInFolder = (file: TAbstractFile): void => { if (file instanceof TFile) { const folder = getFolder(this, file); if (folder instanceof TFolder) { const folderNote = getFolderNote(this, folder.path); if (!folderNote || folderNote.path !== file.path) { originalRevealInFolder(file); return; } activeDocument.body.classList.remove('hide-folder-note'); originalRevealInFolder(folder); const FOLDER_REVEAL_DELAY = 100; window.setTimeout(() => { activeDocument.body.classList.add('hide-folder-note'); }, FOLDER_REVEAL_DELAY); return; } } if (file instanceof TFolder || file instanceof TFile) { originalRevealInFolder(file); return; } }; } const leaf = this.app.workspace.getLeavesOfType('markdown').first(); const view = leaf?.view; if (!view) { return; } const viewWithEditModes = view as ViewWithEditModes; const activeEditor = this.app.workspace.activeEditor as ActiveEditorLike | undefined; const editMode = viewWithEditModes.editMode ?? viewWithEditModes.sourceMode ?? activeEditor?.editMode; if (!editMode) { return; } const { clipboardManager } = editMode; const clipboardProto = Object.getPrototypeOf(clipboardManager) as ClipboardManagerLike; const originalHandleDragOver = clipboardProto.handleDragOver as unknown as ClipboardManagerLike['handleDragOver']; const originalHandleDrop = clipboardProto.handleDrop as unknown as ClipboardManagerLike['handleDrop']; const folderNotePlugin = this; clipboardProto.handleDragOver = function (evt: DragEvent, ...args: unknown[]): void { const { dragManager } = (this as ClipboardManagerLike).app; const draggable = dragManager?.draggable; if (draggable?.file instanceof TFolder) { const folderNote = getFolderNote(folderNotePlugin, draggable.file.path); if (folderNote) { dragManager.setAction( window.i18next.t('interface.drag-and-drop.insert-link-here'), ); return; } } return originalHandleDragOver.call(this, evt, ...args); }; clipboardProto.handleDrop = function (evt: DragEvent, ...args: unknown[]): void { const { dragManager } = (this as ClipboardManagerLike).app; const draggable = dragManager?.draggable; if (draggable?.file instanceof TFolder) { const folderNote = getFolderNote(folderNotePlugin, draggable.file.path); if (folderNote) { draggable.file = folderNote; draggable.type = 'file'; } } return originalHandleDrop.call(this, evt, ...args); }; if (this.settings.fvGlobalSettings.autoUpdateLinks) { this.fvIndexDB.init(false); } } handleVaultChange(): void { if (!this.settings.fvGlobalSettings.autoUpdateLinks) return; const DEBOUNCE_DELAY = 2000; debounce(() => { void updateAllOverviews(this); }, DEBOUNCE_DELAY, true)(); } handleFileExplorerClick(evt: MouseEvent): void { const target = evt.target as HTMLElement; if (evt.shiftKey) return; if (this.isMobileClickDisabled()) return; const { folderTitleEl, onlyClickedOnFolderTitle } = this.getFolderTitleInfo(target); if (!folderTitleEl) return; if (this.shouldIgnoreClickByWhitespaceOrCollapse(target, onlyClickedOnFolderTitle)) return; const folderPath = this.getValidFolderPath(folderTitleEl); if (!folderPath) return; const usedCtrl = this.isCtrlUsed(evt); const folderNote = getFolderNote(this, folderPath); if (!folderNote && this.shouldCreateNote(evt, usedCtrl)) { this.createNoteAndMark(folderPath); return; } if (!(folderNote instanceof TFile)) return; if (!this.shouldOpenNote(usedCtrl, evt)) return; if (!this.settings.enableCollapsing || usedCtrl) { evt.preventDefault(); evt.stopImmediatePropagation(); } void openFolderNote(this, folderNote, evt); } private isMobileClickDisabled(): boolean { return Platform.isMobile && this.settings.disableOpenFolderNoteOnClick; } private getFolderTitleInfo(target: HTMLElement): { folderTitleEl: HTMLElement | null; onlyClickedOnFolderTitle: boolean; } { const folderTitleEl = target.closest('.nav-folder-title'); const onlyClickedOnFolderTitle = !!target.closest('.nav-folder-title-content'); return { folderTitleEl: folderTitleEl instanceof HTMLElement ? folderTitleEl : null, onlyClickedOnFolderTitle, }; } private shouldIgnoreClickByWhitespaceOrCollapse( target: HTMLElement, onlyClickedOnFolderTitle: boolean, ): boolean { if (!this.settings.stopWhitespaceCollapsing && !onlyClickedOnFolderTitle) return true; if (target.closest('.collapse-icon')) return true; return false; } private getValidFolderPath(folderTitleEl: HTMLElement): string | null { const folderPath = folderTitleEl.getAttribute('data-path'); if (!folderPath) return null; const excludedFolder = getExcludedFolder(this, folderPath, true); if (excludedFolder?.disableFolderNote) return null; return folderPath; } private isCtrlUsed(evt: MouseEvent): boolean { return Platform.isMacOS ? evt.metaKey : evt.ctrlKey; } private shouldCreateNote(evt: MouseEvent, usedCtrl: boolean): boolean { const isTabMod = Keymap.isModEvent(evt) === 'tab'; if (!(evt.altKey || isTabMod)) return false; return (this.settings.altKey && evt.altKey) || (usedCtrl && this.settings.ctrlKey); } private createNoteAndMark(folderPath: string): void { void createFolderNote(this, folderPath, true, undefined, true); void addCSSClassToFileExplorerEl(folderPath, 'has-folder-note', false, this); void removeCSSClassFromFileExplorerEL(folderPath, 'has-not-folder-note', false, this); } private shouldOpenNote(usedCtrl: boolean, evt: MouseEvent): boolean { if (this.settings.openWithCtrl && !usedCtrl) return false; if (this.settings.openWithAlt && !evt.altKey) return false; return true; } handleOverviewBlock(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): void { const observer = new MutationObserver(() => { const editButton = el.parentElement?.childNodes.item(1); if (editButton) { editButton.addEventListener('click', (e) => { e.stopImmediatePropagation(); e.preventDefault(); e.stopPropagation(); new FolderOverviewSettings( this.app, this, parseYaml(source) as defaultOverviewSettings, ctx, el, this.settings.defaultOverview, ).open(); }, { capture: true }); } }); observer.observe(el, { childList: true, subtree: true, }); try { if (this.app.workspace.layoutReady) { const { defaultOverview } = this.settings; const folderOverview = new FolderOverview( this, ctx, source, el, defaultOverview, ); void folderOverview.create(this, el, ctx); } else { this.app.workspace.onLayoutReady(() => { const folderOverview = new FolderOverview( this, ctx, source, el, this.settings.defaultOverview, ); void folderOverview.create(this, el, ctx); }); } } catch (e) { // eslint-disable-next-line max-len new Notice('Error creating folder overview (folder notes plugin) - check console for more details'); console.error(e); } } async activateOverviewView(): Promise { const { workspace } = this.app; let leaf: WorkspaceLeaf | null = null; const leaves = workspace.getLeavesOfType(FOLDER_OVERVIEW_VIEW); if (leaves.length > 0) { leaf = leaves[0]; } else { leaf = workspace.getRightLeaf(false); await leaf?.setViewState({ type: FOLDER_OVERVIEW_VIEW, active: true }); } if (!leaf) return; void workspace.revealLeaf(leaf); } updateOverviewView: typeof updateOverviewView = updateOverviewView; updateViewDropdown: typeof updateViewDropdown = updateViewDropdown; isEmptyFolderNoteFolder(folder: TFolder): boolean { let attachmentFolderPath = this.app.vault.getConfig('attachmentFolderPath') as string; const cleanAttachmentFolderPath = attachmentFolderPath?.replace('./', '') || ''; const attachmentsAreInRootFolder = attachmentFolderPath === './' || attachmentFolderPath === ''; const threshold = this.settings.storageLocation === 'insideFolder' ? 1 : 0; if (folder.children.length === 0) { void addCSSClassToFileExplorerEl(folder.path, 'fn-empty-folder', false, this); } attachmentFolderPath = `${folder.path}/${cleanAttachmentFolderPath}`; if (folder.children.length === threshold) { void addCSSClassToFileExplorerEl(folder.path, 'fn-empty-folder', false, this); return true; } else if (folder.children.length > threshold) { if (attachmentsAreInRootFolder) { return false; } else if ( this.app.vault.getAbstractFileByPath(attachmentFolderPath) instanceof TFolder ) { const attachmentFolder = this.app.vault.getAbstractFileByPath(attachmentFolderPath); if ( attachmentFolder instanceof TFolder && folder.children.length <= threshold + 1 ) { void addCSSClassToFileExplorerEl(folder.path, 'fn-empty-folder', false, this); void addCSSClassToFileExplorerEl( folder.path, 'fn-has-attachment-folder', false, this, ); void addCSSClassToFileExplorerEl( folder.path, 'fn-has-attachment-folder', true, this, ); } return folder.children.length <= threshold + 1; } return false; } return true; } async changeFolderNameInExplorer( folder: TFolder, newName: string | null | undefined, waitForCreate = false, count = 0, ): Promise { const MAX_RETRY_COUNT = 5; const RETRY_DELAY_MS = 500; if (!newName) newName = folder.name; let fileExplorerItem = getFileExplorerElement(folder.path, this); if (!fileExplorerItem) { if (waitForCreate && count < MAX_RETRY_COUNT) { await new Promise((resolve) => window.setTimeout(resolve, RETRY_DELAY_MS)); void this.changeFolderNameInExplorer(folder, newName, waitForCreate, count + 1); return; } return; } fileExplorerItem = fileExplorerItem?.querySelector('div.nav-folder-title-content'); if (!fileExplorerItem) { return; } if (this.settings.frontMatterTitle.explorer && this.settings.frontMatterTitle.enabled) { (fileExplorerItem).innerText = newName; (fileExplorerItem).setAttribute('old-name', folder.name); } else { (fileExplorerItem).innerText = folder.name; (fileExplorerItem).removeAttribute('old-name'); } } async changeFolderNameInPath( folder: TFolder, newName: string | null | undefined, breadcrumb: HTMLElement, ): Promise { if (!newName) newName = folder.name; breadcrumb.textContent = folder.newName || folder.name; breadcrumb.setAttribute('old-name', folder.name); breadcrumb.setAttribute('data-path', folder.path); } /** * Updates all folder names in the path above the note editor */ updateAllBreadcrumbs(remove?: boolean): void { if (!this.settings.frontMatterTitle.path && !remove) { return; } const viewHeaderItems = activeDocument.querySelectorAll('span.view-header-breadcrumb'); const files = this.app.vault.getAllLoadedFiles().filter((file) => file instanceof TFolder); viewHeaderItems.forEach((item) => { if (!item.hasAttribute('data-path')) { return; } const path = item.getAttribute('data-path'); const folder = files.find((file) => file.path === path); if (!(folder instanceof TFolder)) { return; } if (remove) { item.textContent = folder.name; item.removeAttribute('old-name'); } else { item.textContent = folder.newName || folder.name; item.setAttribute('old-name', folder.name); item.setAttribute('data-path', folder.path); } }); } onunload(): void { unregisterFileExplorerObserver(); activeDocument.body.classList.remove('folder-notes-plugin'); activeDocument.body.classList.remove('folder-note-underline'); activeDocument.body.classList.remove('hide-folder-note'); activeDocument.body.classList.remove('fn-whitespace-stop-collapsing'); removeActiveFolder(this); if (this.fmtpHandler) { this.fmtpHandler.deleteEvent(); } } async loadSettings(): Promise { const data = await this.loadData() as LegacySettingsData | null; if (data) { if (data.allowWhitespaceCollapsing === true) { data.stopWhitespaceCollapsing = false; delete data.allowWhitespaceCollapsing; } else if (data.allowWhitespaceCollapsing === false) { data.stopWhitespaceCollapsing = true; delete data.allowWhitespaceCollapsing; } } this.settings = Object.assign({}, DEFAULT_SETTINGS, data); if (!this.settings.oldFolderNoteName) { this.settings.oldFolderNoteName = this.settings.folderNoteName; } if (!data) { return; } const overview = data.defaultOverview; if (!overview) { return; } this.settings.defaultOverview = Object.assign( {}, DEFAULT_SETTINGS.defaultOverview, overview, ); } async saveSettings(reloadStyles?: boolean): Promise { await this.saveData(this.settings); // cleanup any css if we need too if ((!this.settingsOpened || reloadStyles === true) && reloadStyles !== false) { refreshAllFolderStyles(true, this); } } }