diff --git a/LICENSE b/LICENSE index 832bae0..f9c2fa1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Nick (Reifat) +Copyright (c) 2026 Nick (Reifat) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/manifest.json b/manifest.json index d6bf77b..3680895 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "Daily notes sorter", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Automatically sort daily notes and files by date in the file explorer. Supports multiple date formats (YYYY-MM-DD, DD.MM.YYYY, DD.MM.YY, MM/DD/YYYY) and flexible folder configuration.", + "description": "Automatically sort daily notes and files by date in the file explorer. Supports multiple date formats and flexible folder configuration.", "author": "Nick", "authorUrl": "https://github.com/Reifat", "isDesktopOnly": false diff --git a/src/file-explorer-utils.ts b/src/file-explorer-utils.ts index 6ae6d83..bf39e58 100644 --- a/src/file-explorer-utils.ts +++ b/src/file-explorer-utils.ts @@ -14,6 +14,8 @@ export class FileExplorerUtils { private sorter: Sorter; private settings: { items: FolderItem[]; sortAscending?: boolean }; private pluginInstance: { saveSettings: () => Promise } | undefined; // Reference to plugin instance for saving settings + private fileExplorerInitialized = false; + private fileExplorerInitializationInProgress = false; public sortAscending: boolean = true; // Public for access from closure constructor( @@ -102,10 +104,12 @@ export class FileExplorerUtils { * Gets FileExplorerView instance from workspace */ getFileExplorer(): FileExplorerView | undefined { - const fileExplorer: FileExplorerView | undefined = this.app.workspace - .getLeavesOfType('file-explorer') - .first()?.view as unknown as FileExplorerView; - return fileExplorer; + const view = this.app.workspace.getLeavesOfType('file-explorer').first()?.view; + if (view && typeof (view as Partial).requestSort === 'function') { + return view as FileExplorerView; + } + + return undefined; } /** @@ -125,18 +129,55 @@ export class FileExplorerUtils { const checkFileExplorer = (): void => { const fileExplorer = this.getFileExplorer(); if (fileExplorer) { - clearInterval(timer); + window.clearInterval(timer); callback(fileExplorer); resolve(); } else if (Date.now() - startTime > timeout) { - clearInterval(timer); + window.clearInterval(timer); reject(new Error('[FileExplorerUtils] FileExplorer was not loaded, reason is time up!')); } }; - const timer = setInterval(checkFileExplorer, interval); + const timer = window.setInterval(checkFileExplorer, interval); }); } + async ensureFileExplorerInitialized( + onInitialized: (fileExplorer: FileExplorerView) => void, + ): Promise { + if (this.fileExplorerInitialized || this.fileExplorerInitializationInProgress) { + return; + } + + this.fileExplorerInitializationInProgress = true; + return this.waitForFileExplorer( + (fileExplorer) => { + if (this.fileExplorerInitialized) { + return; + } + + this.patchFileExplorerFolder(fileExplorer); + onInitialized(fileExplorer); + this.fileExplorerInitialized = true; + + // Use a small delay to ensure the patch is applied. + window.setTimeout(() => { + this.applySort(); + }, 100); + }, + 100, + 1500, + ) + .catch(() => { + // File Explorer may still be deferred/not visible yet. We'll retry on next layout change. + console.debug( + "[FileExplorerUtils] File Explorer may still be deferred/not visible yet. We'll retry on next layout change.", + ); + }) + .finally(() => { + this.fileExplorerInitializationInProgress = false; + }); + } + /** * Checks availability and patchability of FileExplorer */ diff --git a/src/main.ts b/src/main.ts index 44b6f92..91a78e6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,22 +46,16 @@ export default class DailyNotesSorter extends Plugin { // Initialize UI for FileExplorer this.explorerUI = new ExplorerUI(this.app, this.fileExplorerUtils); - this.fileExplorerUtils - .waitForFileExplorer((fileExplorer) => { - this.fileExplorerUtils.patchFileExplorerFolder(fileExplorer); - this.explorerUI.initialize(fileExplorer); - - // Apply sorting after plugin load - // Use a small delay to ensure the patch is applied - setTimeout(() => { - this.fileExplorerUtils.applySort(); - }, 100); - }) - .catch((err: unknown) => { - console.error('[DailyNotesSorter] Error initializing FileExplorer:', err); - // Note: We don't show a Notice here as the plugin might still work - // if FileExplorer loads later - }); + void this.fileExplorerUtils.ensureFileExplorerInitialized((fileExplorer) => { + this.explorerUI.initialize(fileExplorer); + }); + this.registerEvent( + this.app.workspace.on('layout-change', () => { + void this.fileExplorerUtils.ensureFileExplorerInitialized((fileExplorer) => { + this.explorerUI.initialize(fileExplorer); + }); + }), + ); this.addSettingTab(new SorterSettings(this.app, this)); } diff --git a/src/ui/autocomplete-input.ts b/src/ui/autocomplete-input.ts index f151883..2c1b2d3 100644 --- a/src/ui/autocomplete-input.ts +++ b/src/ui/autocomplete-input.ts @@ -6,6 +6,8 @@ export class AutocompleteInput { private inputEl: HTMLInputElement; private onValueChange: (value: string) => void; private getSuggestions: () => string[]; + private blurTimeoutId: number | null = null; + private disconnectObserver: MutationObserver | null = null; constructor( container: HTMLElement, @@ -18,13 +20,14 @@ export class AutocompleteInput { this.getSuggestions = getSuggestions; this.suggestionsList = this.createSuggestionsList(container); this.attachEventListeners(); + this.setupAutoCleanupOnDetach(); } /** * Updates suggestion list if it's visible */ public refresh(): void { - if (!this.suggestionsList.hasClass('hidden')) { + if (!this.suggestionsList.hasClass('dns-hidden')) { const query = this.inputEl.value.trim(); if (query) { this.handleInput(); @@ -39,35 +42,58 @@ export class AutocompleteInput { * Checks if suggestion list is visible */ public isVisible(): boolean { - return !this.suggestionsList.hasClass('hidden'); + return !this.suggestionsList.hasClass('dns-hidden'); } private createSuggestionsList(container: HTMLElement): HTMLElement { return container.createDiv({ - cls: 'suggestions-list_1 hidden', + cls: 'dns-suggestions-list dns-hidden', }); } private attachEventListeners(): void { - this.inputEl.addEventListener('input', () => { - this.handleInput(); - }); - this.inputEl.addEventListener('focus', () => { - this.handleFocus(); - }); - this.inputEl.addEventListener('blur', () => { - this.handleBlur(); - }); - - // Recalculate height on window resize - window.addEventListener('resize', () => { - if (!this.suggestionsList.hasClass('hidden')) { - this.adjustListHeight(); - } - }); + this.inputEl.addEventListener('input', this.handleInput); + this.inputEl.addEventListener('focus', this.handleFocus); + this.inputEl.addEventListener('blur', this.handleBlur); + window.addEventListener('resize', this.handleResize); } - private handleInput(): void { + private setupAutoCleanupOnDetach(): void { + const rootNode = this.inputEl.ownerDocument.body; + this.disconnectObserver = new MutationObserver(() => { + if (!this.inputEl.isConnected) { + this.dispose(); + } + }); + this.disconnectObserver.observe(rootNode, { childList: true, subtree: true }); + } + + private dispose(): void { + if (!this.disconnectObserver) { + return; + } + + this.inputEl.removeEventListener('input', this.handleInput); + this.inputEl.removeEventListener('focus', this.handleFocus); + this.inputEl.removeEventListener('blur', this.handleBlur); + window.removeEventListener('resize', this.handleResize); + + if (this.blurTimeoutId !== null) { + window.clearTimeout(this.blurTimeoutId); + this.blurTimeoutId = null; + } + + this.disconnectObserver.disconnect(); + this.disconnectObserver = null; + } + + private handleResize = (): void => { + if (!this.suggestionsList.hasClass('dns-hidden')) { + this.adjustListHeight(); + } + }; + + private handleInput = (): void => { const query = this.inputEl.value.trim(); this.suggestionsList.empty(); @@ -82,9 +108,9 @@ export class AutocompleteInput { } else { this.hideSuggestions(); } - } + }; - private handleFocus(): void { + private handleFocus = (): void => { if (this.inputEl.value.trim()) { const query = this.inputEl.value.trim(); const filtered = this.filterSuggestions(query); @@ -94,14 +120,18 @@ export class AutocompleteInput { this.showSuggestions(filtered); } } - } + }; - private handleBlur(): void { + private handleBlur = (): void => { // Delay for handling click on suggestion - setTimeout(() => { + if (this.blurTimeoutId !== null) { + window.clearTimeout(this.blurTimeoutId); + } + this.blurTimeoutId = window.setTimeout(() => { this.hideSuggestions(); + this.blurTimeoutId = null; }, 100); - } + }; private filterSuggestions(query: string): string[] { const lowerQuery = query.toLowerCase(); @@ -112,7 +142,7 @@ export class AutocompleteInput { private showSuggestions(suggestions: string[]): void { // Always clear list before adding new elements this.suggestionsList.empty(); - this.suggestionsList.removeClass('hidden'); + this.suggestionsList.removeClass('dns-hidden'); // Limit number of displayed suggestions for performance const maxSuggestions = 20; @@ -132,7 +162,7 @@ export class AutocompleteInput { // Show message if there are more suggestions if (suggestions.length > maxSuggestions) { this.suggestionsList.createDiv({ - cls: 'suggestion-item_1 suggestion-more', + cls: 'dns-suggestion-item dns-suggestion-more', text: `... and ${suggestions.length - maxSuggestions} more`, }); } @@ -149,7 +179,7 @@ export class AutocompleteInput { */ private adjustListHeight(): void { // Check if list is visible - if (this.suggestionsList.hasClass('hidden')) { + if (this.suggestionsList.hasClass('dns-hidden')) { return; } @@ -158,7 +188,7 @@ export class AutocompleteInput { '--suggestions-list-height': 'auto', '--suggestions-list-overflow': 'auto', }); - this.suggestionsList.removeClass('no-scroll'); + this.suggestionsList.removeClass('dns-no-scroll'); // Get actual content height const contentHeight = this.suggestionsList.scrollHeight; @@ -176,14 +206,14 @@ export class AutocompleteInput { '--suggestions-list-height': `${contentHeight}px`, '--suggestions-list-overflow': 'hidden', }); - this.suggestionsList.addClass('no-scroll'); + this.suggestionsList.addClass('dns-no-scroll'); } else if (contentHeight > 0) { // Content doesn't fit, set maximum height with scrolling this.suggestionsList.setCssProps({ '--suggestions-list-height': `${maxAllowedHeight}px`, '--suggestions-list-overflow': 'auto', }); - this.suggestionsList.removeClass('no-scroll'); + this.suggestionsList.removeClass('dns-no-scroll'); } } @@ -199,7 +229,7 @@ export class AutocompleteInput { } const item = this.suggestionsList.createDiv({ - cls: 'suggestion-item_1', + cls: 'dns-suggestion-item', text: suggestion, }); @@ -211,6 +241,6 @@ export class AutocompleteInput { } private hideSuggestions(): void { - this.suggestionsList.addClass('hidden'); + this.suggestionsList.addClass('dns-hidden'); } } diff --git a/src/ui/explorer-ui.ts b/src/ui/explorer-ui.ts index 1b77c5b..6b4bb8a 100644 --- a/src/ui/explorer-ui.ts +++ b/src/ui/explorer-ui.ts @@ -37,10 +37,10 @@ export class ExplorerUI { ); // Style the button using CSS class and custom color - setTimeout(() => { + window.setTimeout(() => { const button = fileExplorer.headerDom.navButtonsEl?.lastElementChild as HTMLElement | null; if (button) { - button.addClass('file-explorer-sort-button'); + button.addClass('dns-file-explorer-sort-button'); const svg = button.querySelector('svg') as { setCssProps?: (props: Record) => void; } | null; @@ -72,7 +72,7 @@ export class ExplorerUI { // Reopen FileExplorer leaves with saved state // Use setTimeout to ensure detach completes before reopening - setTimeout(() => { + window.setTimeout(() => { // Choose the view state to restore: use the first saved state const preferred = leavesData[0]; diff --git a/src/ui/settings.ts b/src/ui/settings.ts index 0ac60aa..6e2c2a0 100644 --- a/src/ui/settings.ts +++ b/src/ui/settings.ts @@ -154,7 +154,7 @@ export class SorterSettings extends PluginSettingTab { } const itemDiv = this.itemsContainer.createDiv({ - cls: 'setting-item-custom-plugin', + cls: 'dns-setting-item', }); const inputSetting = this.createInputSetting(itemDiv, item.path || '', index); @@ -213,12 +213,12 @@ export class SorterSettings extends PluginSettingTab { if (inputEl) { if (!isValidPath) { - inputEl.addClass('input-error'); - inputEl.removeClass('input-valid'); + inputEl.addClass('dns-input-error'); + inputEl.removeClass('dns-input-valid'); isValid = false; } else { - inputEl.removeClass('input-error'); - inputEl.addClass('input-valid'); + inputEl.removeClass('dns-input-error'); + inputEl.addClass('dns-input-valid'); } } else { if (!isValidPath) { @@ -239,11 +239,11 @@ export class SorterSettings extends PluginSettingTab { if (inputEl) { if (!isValid) { - inputEl.addClass('input-error'); - inputEl.removeClass('input-valid'); + inputEl.addClass('dns-input-error'); + inputEl.removeClass('dns-input-valid'); } else { - inputEl.removeClass('input-error'); - inputEl.addClass('input-valid'); + inputEl.removeClass('dns-input-error'); + inputEl.addClass('dns-input-valid'); } } @@ -379,11 +379,11 @@ export class SorterSettings extends PluginSettingTab { */ private renderApplyButton(container: HTMLElement): void { const buttonContainer = container.createDiv({ - cls: 'apply-button-container', + cls: 'dns-apply-button-container', }); const applyButton = buttonContainer.createEl('button', { - cls: 'apply-button', + cls: 'dns-apply-button', text: APPLY_BUTTON_TEXT, }); @@ -415,8 +415,8 @@ export class SorterSettings extends PluginSettingTab { // Remove all validation indicators after successful application this.inputElements.forEach((inputEl) => { - inputEl.removeClass('input-error'); - inputEl.removeClass('input-valid'); + inputEl.removeClass('dns-input-error'); + inputEl.removeClass('dns-input-valid'); }); new Notice('Settings applied successfully!'); diff --git a/styles.css b/styles.css index f52e7e6..5adb52d 100644 --- a/styles.css +++ b/styles.css @@ -1,12 +1,12 @@ -.setting-item-custom-plugin { +.dns-setting-item { position: relative; /* Child elements are now positioned relative to this container */ width: 100%; /* Ensure the container takes the required width */ box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); border-radius: 5px; } -.suggestions-list_1 { - position: absolute; /* Positioned relative to setting-item-custom-plugin */ +.dns-suggestions-list { + position: absolute; /* Positioned relative to dns-setting-item */ top: 100%; /* Starts immediately below the input field */ left: 0; /* Aligned to the left edge of the parent */ width: 100%; /* Matches the width of the input field */ @@ -22,7 +22,7 @@ min-height: 0; } -.suggestion-item_1 { +.dns-suggestion-item { padding: 10px; cursor: pointer; color: var(--text-normal); @@ -30,38 +30,38 @@ border-bottom: 1px solid var(--background-modifier-border); } -.suggestion-item_1:hover { +.dns-suggestion-item:hover { background-color: var(--background-modifier-hover); } -.suggestion-item_1:last-child { +.dns-suggestion-item:last-child { border-bottom: none; } -.suggestion-more { +.dns-suggestion-more { font-style: italic; opacity: 0.7; } -.suggestions-list_1.no-scroll { +.dns-suggestions-list.dns-no-scroll { overflow-y: hidden; } -.hidden { +.dns-hidden { display: none; } /* Validation styles */ -.input-error { - border: 2px solid var(--text-error) !important; - background-color: var(--background-modifier-error) !important; +.dns-setting-item input.dns-input-error { + border: 2px solid var(--text-error); + background-color: var(--background-modifier-error); } -.input-valid { - border: 2px solid var(--text-success) !important; +.dns-setting-item input.dns-input-valid { + border: 2px solid var(--text-success); } -.apply-button-container { +.dns-apply-button-container { display: flex; justify-content: flex-end; margin-top: 20px; @@ -69,7 +69,7 @@ border-top: 1px solid var(--background-modifier-border); } -.apply-button { +.dns-apply-button { padding: 8px 20px; background-color: var(--interactive-accent); color: var(--text-on-accent); @@ -79,16 +79,16 @@ font-weight: 500; } -.apply-button:hover { +.dns-apply-button:hover { background-color: var(--interactive-accent-hover); } -.apply-button:disabled { +.dns-apply-button:disabled { opacity: 0.5; cursor: not-allowed; } /* File Explorer sort button styles */ -.file-explorer-sort-button svg { +.dns-file-explorer-sort-button svg { color: var(--sort-button-icon-color, #fff); }