diff --git a/.gitignore b/.gitignore index e09a007..2c116ac 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store +styles.css diff --git a/README.md b/README.md index e13cb47..8afa876 100644 --- a/README.md +++ b/README.md @@ -223,13 +223,35 @@ note-status/ │ │ └── file-utils.ts # File-related helpers │ └── settings/ # Settings UI │ └── settings-tab.ts # Settings tab definition -└── styles.css # CSS styles +├── styles/ # Modular CSS files +│ ├── base.css # Variables and base styles +│ ├── utils.css # Utility classes +│ ├── components/ # Component-specific styles +│ │ ├── status-bar.css # Status bar styles +│ │ ├── status-pane.css # Status pane styles +│ │ ├── dropdown.css # Dropdown component styles +│ │ ├── explorer.css # File explorer styles +│ │ └── settings.css # Settings UI styles +│ └── index.css # Main CSS import file +└── styles.css # Auto-generated final CSS ``` ### Prerequisites - Node.js and npm installed. - Obsidian API knowledge (TypeScript-based). +### CSS Modularization +The plugin's CSS has been modularized to improve maintainability: + +- **Component-based**: Each UI component has its own CSS file +- **Auto-bundling**: The build process automatically bundles all CSS files into `styles.css` +- **Development workflow**: CSS changes are hot-reloaded during development + +When making CSS changes: +1. Modify the appropriate file in the `styles/` directory +2. For new components, create a dedicated CSS file and import it in `styles/index.css` +3. The build process will automatically bundle everything into `styles.css` + ### Building the Plugin 1. Clone this repository: ```bash diff --git a/components/status-bar/index.ts b/components/status-bar/index.ts new file mode 100644 index 0000000..64d38cb --- /dev/null +++ b/components/status-bar/index.ts @@ -0,0 +1,4 @@ +import { StatusBarController } from './status-bar-controller'; + +export { StatusBarController as StatusBar}; +export default StatusBarController; \ No newline at end of file diff --git a/components/status-bar/status-bar-controller.ts b/components/status-bar/status-bar-controller.ts new file mode 100644 index 0000000..c6838f2 --- /dev/null +++ b/components/status-bar/status-bar-controller.ts @@ -0,0 +1,94 @@ +import { NoteStatusSettings } from '../../models/types'; +import { StatusService } from '../../services/status-service'; +import { StatusBarView } from './status-bar-view'; + +/** + * Controller for the status bar + */ +export class StatusBarController { + private view: StatusBarView; + private settings: NoteStatusSettings; + private statusService: StatusService; + private currentStatuses: string[] = ['unknown']; + + constructor(statusBarContainer: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) { + this.view = new StatusBarView(statusBarContainer); + this.settings = settings; + this.statusService = statusService; + + this.update(['unknown']); + } + + /** + * Update the status bar with new statuses + */ + public update(statuses: string[]): void { + this.currentStatuses = statuses; + this.render(); + } + + /** + * Render the status bar + */ + private render(): void { + this.view.reset(); + + if (!this.settings.showStatusBar) { + this.view.hide(); + return; + } + + if (!this.settings.useMultipleStatuses) { + this.renderStatuses([this.currentStatuses[0]]); + } else { + this.renderStatuses(this.currentStatuses); + } + + this.handleAutoHide(); + } + + /** + * Render statuses - handles both single and multiple status cases + */ + private renderStatuses(statuses: string[]): void { + const statusDetails = statuses.map(status => { + const statusObj = this.statusService.getAllStatuses().find(s => s.name === status); + return { + name: status, + icon: this.statusService.getStatusIcon(status), + tooltipText: statusObj?.description ? `${status} - ${statusObj.description}` : status + }; + }); + + this.view.renderStatuses(statusDetails); + } + + /** + * Handle auto-hide behavior + */ + private handleAutoHide(): void { + const onlyUnknown = this.currentStatuses.length === 1 && + this.currentStatuses[0] === 'unknown'; + + if (this.settings.autoHideStatusBar && onlyUnknown) { + this.view.hide(); + } else { + this.view.show(); + } + } + + /** + * Update settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.render(); + } + + /** + * Clean up when plugin is unloaded + */ + public unload(): void { + this.view.destroy(); + } +} \ No newline at end of file diff --git a/components/status-bar/status-bar-view.ts b/components/status-bar/status-bar-view.ts new file mode 100644 index 0000000..8dc46f3 --- /dev/null +++ b/components/status-bar/status-bar-view.ts @@ -0,0 +1,107 @@ +import { setTooltip } from 'obsidian'; + +/** + * Renders the status bar UI + */ +export class StatusBarView { + private element: HTMLElement; + + constructor(element: HTMLElement) { + this.element = element; + this.element.addClass('note-status-bar'); + } + + /** + * Clears the element and resets CSS classes + */ + reset(): void { + this.element.empty(); + this.element.removeClass('left', 'hidden', 'auto-hide', 'visible'); + this.element.addClass('note-status-bar'); + } + + /** + * Hide the status bar + */ + hide(): void { + this.element.addClass('hidden'); + this.element.removeClass('visible'); + } + + /** + * Show the status bar + */ + show(): void { + this.element.removeClass('hidden'); + this.element.addClass('visible'); + } + + + renderStatuses(statuses: Array<{name: string, icon: string, tooltipText: string}>): void { + if (statuses.length === 1) { + this.renderSingleStatus(statuses[0].name, statuses[0].icon, statuses[0].tooltipText); + } else { + this.renderMultipleStatuses(statuses); + } + } + + /** + * Render a single status + */ + private renderSingleStatus(status: string, icon: string, tooltipText: string): void { + const statusText = this.element.createEl('span', { + text: `Status: ${status}`, + cls: `note-status-${status}` + }); + setTooltip(statusText, tooltipText); + + const statusIcon = this.element.createEl('span', { + text: icon, + cls: `note-status-icon status-${status}` + }); + setTooltip(statusIcon, tooltipText); + } + + /** + * Render multiple statuses + */ + private renderMultipleStatuses(statuses: Array<{name: string, icon: string, tooltipText: string}>): void { + this.element.createEl('span', { + text: 'Statuses: ', + cls: 'note-status-label' + }); + + const badgesContainer = this.element.createEl('span', { + cls: 'note-status-badges' + }); + + statuses.forEach(status => this.createStatusBadge(badgesContainer, status)); + } + + /** + * Create a status badge for multiple status display + */ + private createStatusBadge(container: HTMLElement, status: {name: string, icon: string, tooltipText: string}): void { + const badge = container.createEl('span', { + cls: `note-status-badge status-${status.name}` + }); + setTooltip(badge, status.tooltipText); + + badge.createEl('span', { + text: status.icon, + cls: 'note-status-badge-icon' + }); + + badge.createEl('span', { + text: status.name, + cls: 'note-status-badge-text' + }); + } + + /** + * Clean up the element + */ + destroy(): void { + this.element.empty(); + } +} \ No newline at end of file diff --git a/components/status-dropdown/dropdown-events.ts b/components/status-dropdown/dropdown-events.ts new file mode 100644 index 0000000..d0468f4 --- /dev/null +++ b/components/status-dropdown/dropdown-events.ts @@ -0,0 +1,29 @@ +/** + * Event handlers setup and cleanup for the dropdown + */ + +/** + * Setup event handlers for the dropdown + */ +export function setupDropdownEvents(options: { + clickOutsideHandler: (e: MouseEvent) => void, + escapeKeyHandler: (e: KeyboardEvent) => void +}): void { + const { clickOutsideHandler, escapeKeyHandler } = options; + + document.addEventListener('click', clickOutsideHandler); + document.addEventListener('keydown', escapeKeyHandler); +} + +/** + * Remove dropdown event handlers + */ +export function removeDropdownEvents(options: { + clickOutsideHandler: (e: MouseEvent) => void, + escapeKeyHandler: (e: KeyboardEvent) => void +}): void { + const { clickOutsideHandler, escapeKeyHandler } = options; + + document.removeEventListener('click', clickOutsideHandler); + document.removeEventListener('keydown', escapeKeyHandler); +} \ No newline at end of file diff --git a/components/status-dropdown/dropdown-manager.ts b/components/status-dropdown/dropdown-manager.ts new file mode 100644 index 0000000..49cd5ce --- /dev/null +++ b/components/status-dropdown/dropdown-manager.ts @@ -0,0 +1,255 @@ +import { MarkdownView, Editor, Notice, TFile, App } from 'obsidian'; +import { DropdownUI } from './dropdown-ui'; +import { DropdownOptions, DropdownDependencies } from './types'; +import { createDummyTarget } from './dropdown-position'; +import { StatusService } from 'services/status-service'; +import { NoteStatusSettings } from 'models/types'; + +/** + * High-level manager for status dropdown interactions + */ +export class DropdownManager { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private currentStatuses: string[] = ['unknown']; + private toolbarButton?: HTMLElement; + private dropdownUI: DropdownUI; + + constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + + const deps: DropdownDependencies = { app, settings, statusService }; + this.dropdownUI = new DropdownUI(deps); + this.setupDropdownCallbacks(); + } + + /** + * Set up dropdown callbacks + */ + private setupDropdownCallbacks(): void { + + this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => { + if (!targetFile) return; + + const isMultiple = Array.isArray(targetFile); + + await this.statusService.handleStatusChange({ + files: targetFile, + statuses: status, + operation: 'remove', + showNotice: isMultiple + }); + }); + + this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => { + // Check if handling multiple files + const isMultipleFiles = Array.isArray(targetFile) && targetFile.length > 1; + + if (isMultipleFiles) { + const files = targetFile as TFile[]; + // Count how many files already have this status + const filesWithStatus = files.filter(file => + this.statusService.getFileStatuses(file).includes(status) + ); + + // If ALL have the status, remove it. Otherwise, add it + const operation = filesWithStatus.length === files.length ? 'remove' : (!this.settings.useMultipleStatuses) ? 'set':'add'; + + await this.statusService.handleStatusChange({ + files: targetFile, + statuses: status, + isMultipleSelection: true, + operation: operation + }); + } else { + // For individual files, maintain default behavior + await this.statusService.handleStatusChange({ + files: targetFile, + statuses: status + }); + } + }); + } + + /** + * Updates the dropdown UI based on current statuses + */ + public update(currentStatuses: string[] | string, _file?: TFile): void { + this.currentStatuses = Array.isArray(currentStatuses) ? + [...currentStatuses] : [currentStatuses]; + + this.dropdownUI.updateStatuses(this.currentStatuses); + } + + /** + * Updates settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.dropdownUI.updateSettings(settings); + } + + /** + * Get position from cursor or fallback + */ + private getCursorPosition(editor: Editor, view: MarkdownView): { x: number, y: number } { + try { + const cursor = editor.getCursor('head'); + const lineElement = view.contentEl.querySelector(`.cm-line:nth-child(${cursor.line + 1})`); + + if (lineElement) { + const rect = lineElement.getBoundingClientRect(); + return { x: rect.left + 20, y: rect.bottom + 5 }; + } + + const editorEl = view.contentEl.querySelector('.cm-editor'); + if (editorEl) { + const rect = editorEl.getBoundingClientRect(); + return { x: rect.left + 100, y: rect.top + 100 }; + } + } catch (error) { + console.error('Error getting position for dropdown:', error); + } + + return { x: window.innerWidth / 2, y: window.innerHeight / 3 }; + } + + + /** + * Universal function to open the status dropdown + */ + public openStatusDropdown(options: DropdownOptions): void { + const activeFile = this.app.workspace.getActiveFile(); + const files = options.files || (activeFile ? [activeFile] : []); + if (!files.length) { + new Notice('No files selected'); + return; + } + if (!files.length || !files[0]) { + new Notice('No files selected'); + return; + } + + if (this.dropdownUI.isOpen) { + this.resetDropdown(); + return + } + + const isSingleFile = files.length === 1; + + // Set up target files appropriately + if (isSingleFile) { + const targetFile = files[0]; + this.dropdownUI.setTargetFile(targetFile); + const currentStatuses = this.statusService.getFileStatuses(targetFile); + this.dropdownUI.updateStatuses(currentStatuses); + } else { + // For multiple files, set the whole collection + this.dropdownUI.setTargetFiles(files); + const commonStatuses = this.findCommonStatuses(files); + this.dropdownUI.updateStatuses(commonStatuses); + } + + this.positionAndOpenDropdown(options); + } + + + /** + * Reset dropdown state before opening + */ + public resetDropdown(): void { + this.dropdownUI.close(); + this.dropdownUI.setTargetFile(null); + } + + /** + * Position and open the dropdown + */ + private positionAndOpenDropdown(options: { + target?: HTMLElement; + position?: { x: number, y: number }; + editor?: Editor; + view?: MarkdownView; + }): void { + if (options.editor && options.view) { + const position = this.getCursorPosition(options.editor, options.view); + this.openWithPosition(position); + return; + } + + if (options.target) { + if (options.position) { + this.dropdownUI.open(options.target, options.position); + } else { + const rect = options.target.getBoundingClientRect(); + this.dropdownUI.open(options.target, { + x: rect.left, + y: rect.bottom + 5 + }); + } + return; + } + + if (options.position) { + this.openWithPosition(options.position); + return; + } + + this.openWithPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 3 + }); + } + + /** + * Open dropdown at a specific position using dummy target + */ + private openWithPosition(position: { x: number, y: number }): void { + const dummyTarget = createDummyTarget(position); + this.dropdownUI.open(dummyTarget, position); + + setTimeout(() => { + if (dummyTarget.parentNode) { + dummyTarget.parentNode.removeChild(dummyTarget); + } + }, 100); + } + + /** + * Find common statuses across multiple files + */ + private findCommonStatuses(files: TFile[]): string[] { + if (files.length === 0) return ['unknown']; + + const firstFileStatuses = this.statusService.getFileStatuses(files[0]); + + return firstFileStatuses.filter(status => + status !== 'unknown' && + files.every(file => this.statusService.getFileStatuses(file).includes(status)) + ); + } + + + /** + * Render method (kept for compatibility) + */ + public render(): void { + // No-op - dropdown component handles rendering internally + } + + /** + * Remove dropdown when plugin is unloaded + */ + public unload(): void { + this.dropdownUI.dispose(); + + if (this.toolbarButton) { + this.toolbarButton.remove(); + this.toolbarButton = undefined; + } + } + +} diff --git a/components/status-dropdown/dropdown-position.ts b/components/status-dropdown/dropdown-position.ts new file mode 100644 index 0000000..1601581 --- /dev/null +++ b/components/status-dropdown/dropdown-position.ts @@ -0,0 +1,105 @@ +/** + * Dropdown positioning utilities + */ + +/** + * Position the dropdown + */ +export function positionDropdown(options: { + dropdownElement: HTMLElement, + targetEl: HTMLElement, + position?: { x: number, y: number } +}): void { + const { dropdownElement, targetEl, position } = options; + + if (position) { + positionAt(dropdownElement, position.x, position.y); + } else { + positionRelativeTo(dropdownElement, targetEl); + } +} + +/** + * Position the dropdown at specific coordinates + */ +function positionAt(dropdownElement: HTMLElement, x: number, y: number): void { + dropdownElement.addClass('note-status-popover-fixed'); + dropdownElement.style.setProperty('--pos-x-px', `${x}px`); + dropdownElement.style.setProperty('--pos-y-px', `${y}px`); + + setTimeout(() => adjustPositionToViewport(dropdownElement), 0); +} + +/** + * Adjust the dropdown position to ensure it's visible in the viewport + */ +function adjustPositionToViewport(dropdownElement: HTMLElement): void { + const rect = dropdownElement.getBoundingClientRect(); + + if (rect.right > window.innerWidth) { + dropdownElement.addClass('note-status-popover-right'); + dropdownElement.style.setProperty('--right-offset-px', '10px'); + } else { + dropdownElement.removeClass('note-status-popover-right'); + } + + if (rect.bottom > window.innerHeight) { + dropdownElement.addClass('note-status-popover-bottom'); + dropdownElement.style.setProperty('--bottom-offset-px', '10px'); + } else { + dropdownElement.removeClass('note-status-popover-bottom'); + } + + const maxHeight = window.innerHeight - rect.top - 20; + dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`); +} + +/** + * Position the dropdown relative to a target element + */ +function positionRelativeTo(dropdownElement: HTMLElement, targetEl: HTMLElement): void { + dropdownElement.addClass('note-status-popover-fixed'); + + const targetRect = targetEl.getBoundingClientRect(); + + dropdownElement.style.setProperty('--pos-y-px', `${targetRect.bottom + 5}px`); + dropdownElement.style.setProperty('--pos-x-px', `${targetRect.left}px`); + + setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0); +} + +/** + * Adjust position when positioned relative to an element + */ +function adjustRelativePosition(dropdownElement: HTMLElement, targetRect: DOMRect): void { + const rect = dropdownElement.getBoundingClientRect(); + + if (rect.right > window.innerWidth) { + dropdownElement.addClass('note-status-popover-right'); + dropdownElement.style.setProperty('--right-offset-px', `${window.innerWidth - targetRect.right}px`); + } else { + dropdownElement.removeClass('note-status-popover-right'); + } + + if (rect.bottom > window.innerHeight) { + dropdownElement.addClass('note-status-popover-bottom'); + dropdownElement.style.setProperty('--bottom-offset-px', `${window.innerHeight - targetRect.top + 5}px`); + } else { + dropdownElement.removeClass('note-status-popover-bottom'); + } + + const maxHeight = window.innerHeight - rect.top - 20; + dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`); +} + +/** + * Create a dummy target element for positioning + */ +export function createDummyTarget(position: { x: number, y: number }): HTMLElement { + const dummyTarget = document.createElement('div'); + dummyTarget.addClass('note-status-dummy-target'); + dummyTarget.style.setProperty('--pos-x-px', `${position.x}px`); + dummyTarget.style.setProperty('--pos-y-px', `${position.y}px`); + document.body.appendChild(dummyTarget); + return dummyTarget; +} \ No newline at end of file diff --git a/components/status-dropdown/dropdown-render.ts b/components/status-dropdown/dropdown-render.ts new file mode 100644 index 0000000..de65496 --- /dev/null +++ b/components/status-dropdown/dropdown-render.ts @@ -0,0 +1,314 @@ +import { setIcon, TFile, setTooltip } from 'obsidian'; +import { StatusRemoveHandler, StatusSelectHandler } from './types'; +import { NoteStatusSettings, Status } from 'models/types'; +import { StatusService } from 'services/status-service'; + +/** + * Render the dropdown content + */ +export function renderDropdownContent(options: { + dropdownElement: HTMLElement, + settings: NoteStatusSettings, + statusService: StatusService, + currentStatuses: string[], + targetFile: TFile | null, + targetFiles: TFile[], + onRemoveStatus: StatusRemoveHandler, + onSelectStatus: StatusSelectHandler +}): void { + const { + dropdownElement, + settings, + statusService, + currentStatuses, + targetFile, + targetFiles, + onRemoveStatus, + onSelectStatus + } = options; + + dropdownElement.empty(); + + // Create UI sections + createHeader(dropdownElement, targetFiles); + + const target = targetFiles.length > 1 ? targetFiles : targetFile; + createStatusChips(dropdownElement, currentStatuses, statusService, target ?? [], onRemoveStatus); + const searchInput = createSearchFilter(dropdownElement); + + // Create status options container + const statusOptionsContainer = dropdownElement.createDiv({ + cls: 'note-status-options-container' + }); + + // Get all available statuses (excluding 'unknown') + const allStatuses = statusService.getAllStatuses() + .filter(status => status.name !== 'unknown'); + + // Function to populate options with filtering + const populateOptions = (filter = '') => { + populateStatusOptions({ + container: statusOptionsContainer, + statuses: allStatuses, + currentStatuses, + settings, + targetFiles, + onSelectStatus, + filter + }); + }; + + // Initial population + populateOptions(); + + // Add search functionality + searchInput.addEventListener('input', () => { + populateOptions(searchInput.value); + }); + + // Focus search input after a short delay + setTimeout(() => searchInput.focus(), 50); +} + +/** + * Create the dropdown header + */ +function createHeader(dropdownElement: HTMLElement, targetFiles: TFile[]): void { + const headerEl = dropdownElement.createDiv({ cls: 'note-status-popover-header' }); + const titleEl = headerEl.createDiv({ cls: 'note-status-popover-title' }); + + const iconContainer = titleEl.createDiv({ cls: 'note-status-popover-icon' }); + setIcon(iconContainer, 'tag'); + + titleEl.createSpan({ text: 'Note status', cls: 'note-status-popover-label' }); + + // If multiple files are selected, show count + if (targetFiles.length > 1) { + titleEl.createSpan({ + text: ` (${targetFiles.length} files)`, + cls: 'note-status-popover-count' + }); + } +} + +/** + * Create the status chips section + */ +function createStatusChips( + dropdownElement: HTMLElement, + currentStatuses: string[], + statusService: StatusService, + targetFile: TFile | TFile[], + onRemoveStatus: StatusRemoveHandler +): void { + const chipsContainer = dropdownElement.createDiv({ cls: 'note-status-popover-chips' }); + + const hasNoValidStatus = currentStatuses.length === 0 || + (currentStatuses.length === 1 && currentStatuses[0] === 'unknown'); + + if (hasNoValidStatus) { + chipsContainer.createDiv({ + cls: 'note-status-empty-indicator', + text: 'No status assigned' + }); + } else { + createStatusChipElements(chipsContainer, currentStatuses, statusService, targetFile, onRemoveStatus); + } +} + +/** + * Create chips for all current statuses + */ +function createStatusChipElements( + container: HTMLElement, + currentStatuses: string[], + statusService: StatusService, + target: TFile | TFile[], + onRemoveStatus: StatusRemoveHandler +): void { + currentStatuses.forEach(status => { + if (status === 'unknown') return; + + const statusObj = statusService.getAllStatuses().find(s => s.name === status); + if (!statusObj) return; + + createSingleStatusChip(container, status, statusObj, target, onRemoveStatus); + }); +} + +/** + * Create a single status chip + */ +function createSingleStatusChip( + container: HTMLElement, + status: string, + statusObj: Status, + target: TFile | TFile[], + onRemoveStatus: StatusRemoveHandler +): void { + const chipEl = container.createDiv({ + cls: `note-status-chip status-${status}` + }); + + // Status icon and name + chipEl.createSpan({ + text: statusObj.icon, + cls: 'note-status-chip-icon' + }); + + chipEl.createSpan({ + text: statusObj.name, + cls: 'note-status-chip-text' + }); + + addRemoveButton(chipEl, status, statusObj, target, onRemoveStatus); +} + +/** + * Add a remove button to a status chip + */ +function addRemoveButton( + chipEl: HTMLElement, + status: string, + statusObj: Status, + targetFile: TFile | TFile[] | null, + onRemoveStatus: StatusRemoveHandler +): void { + const tooltipValue = statusObj.description ? `${status} - ${statusObj.description}`: status; + + setTooltip(chipEl, tooltipValue); + + const removeBtn = chipEl.createDiv({ + cls: 'note-status-chip-remove', + attr: { + 'aria-label': `Remove ${status} status`, + 'title': `Remove ${status} status` + } + }); + setIcon(removeBtn, 'x'); + + removeBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + + chipEl.addClass('note-status-chip-removing'); + + setTimeout(async () => { + if (targetFile) { + await onRemoveStatus(status, targetFile); + } + }, 150); + }); +} + +/** + * Create the search filter input + */ +function createSearchFilter(dropdownElement: HTMLElement): HTMLInputElement { + const searchContainer = dropdownElement.createDiv({ cls: 'note-status-popover-search' }); + + return searchContainer.createEl('input', { + type: 'text', + placeholder: 'Filter statuses...', + cls: 'note-status-popover-search-input' + }); +} + +/** + * Populate status options with optional filtering + */ +function populateStatusOptions(options: { + container: HTMLElement, + statuses: Status[], + currentStatuses: string[], + settings: NoteStatusSettings, + targetFiles: TFile[], + onSelectStatus: StatusSelectHandler, + filter?: string +}): void { + const { + container, + statuses, + currentStatuses, + settings, + targetFiles, + onSelectStatus, + filter = '' + } = options; + + container.empty(); + + const filteredStatuses = filter ? + statuses.filter(status => + status.name.toLowerCase().includes(filter.toLowerCase()) || + status.icon.includes(filter) + ) : + statuses; + + if (filteredStatuses.length === 0) { + container.createDiv({ + cls: 'note-status-empty-options', + text: filter ? `No statuses match "${filter}"` : 'No statuses found' + }); + return; + } + + filteredStatuses.forEach(status => { + createStatusOption({ + container, + status, + isSelected: currentStatuses.includes(status.name), + settings, + targetFiles, + onSelectStatus + }); + }); +} + +/** + * Create a single status option element + */ +function createStatusOption(options: { + container: HTMLElement, + status: Status, + isSelected: boolean, + settings: NoteStatusSettings, + targetFiles: TFile[], + onSelectStatus: StatusSelectHandler +}): void { + const { container, status, isSelected, settings, targetFiles, onSelectStatus } = options; + + const optionEl = container.createDiv({ + cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}` + }); + + // Status icon and name + optionEl.createSpan({ + text: status.icon, + cls: 'note-status-option-icon' + }); + + optionEl.createSpan({ + text: status.name, + cls: 'note-status-option-text' + }); + + // Add tooltip if description available + if (status.description) { + setTooltip(optionEl, `${status.name} - ${status.description}`); + } + + // Check icon for selected status + if (isSelected) { + const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' }); + setIcon(checkIcon, 'check'); + } + + optionEl.addEventListener('click', () => { + optionEl.addClass('note-status-option-selecting'); + setTimeout(async () => { + if (targetFiles.length > 0) { + await onSelectStatus(status.name, targetFiles); + } + }, 150); + }); +} diff --git a/components/status-dropdown/dropdown-ui.ts b/components/status-dropdown/dropdown-ui.ts new file mode 100644 index 0000000..d3b9deb --- /dev/null +++ b/components/status-dropdown/dropdown-ui.ts @@ -0,0 +1,220 @@ +import { App, TFile } from 'obsidian'; +import { DropdownDependencies, StatusRemoveHandler, StatusSelectHandler } from './types'; +import { positionDropdown } from './dropdown-position'; +import { renderDropdownContent } from './dropdown-render'; +import { setupDropdownEvents } from './dropdown-events'; +import { StatusService } from 'services/status-service'; +import { NoteStatusSettings } from 'models/types'; + +/** + * Core UI component for the status dropdown + */ +export class DropdownUI { + private app: App; + private statusService: StatusService; + private settings: NoteStatusSettings; + + private dropdownElement: HTMLElement | null = null; + private currentStatuses: string[] = ['unknown']; + private targetFile: TFile | null = null; + private targetFiles: TFile[] = []; + private animationDuration = 220; + + public isOpen = false; + private onRemoveStatus: StatusRemoveHandler = async () => {}; + private onSelectStatus: StatusSelectHandler = async () => {}; + + // Event handlers + private clickOutsideHandler: (e: MouseEvent) => void; + private escapeKeyHandler: (e: KeyboardEvent) => void; + + constructor({ app, settings, statusService }: DropdownDependencies) { + this.app = app; + this.statusService = statusService; + this.settings = settings; + + // Bind methods + this.clickOutsideHandler = this.handleClickOutside.bind(this); + this.escapeKeyHandler = this.handleEscapeKey.bind(this); + } + + /** + * Set the target file for status updates + */ + public setTargetFile(file: TFile | null): void { + this.targetFile = file; + this.targetFiles = file ? [file] : []; + } + + /** + * Set multiple target files for status updates + */ + public setTargetFiles(files: TFile[]): void { + this.targetFiles = [...files]; + this.targetFile = files.length === 1 ? files[0] : null; + } + + /** + * Register handler for removing a status + */ + public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void { + this.onRemoveStatus = handler; + } + + /** + * Register handler for selecting a status + */ + public setOnSelectStatusHandler(handler: StatusSelectHandler): void { + this.onSelectStatus = handler; + } + + /** + * Updates the current statuses + */ + public updateStatuses(statuses: string[] | string): void { + this.currentStatuses = Array.isArray(statuses) ? [...statuses] : [statuses]; + + if (this.isOpen && this.dropdownElement) { + this.refreshDropdownContent(); + } + } + + /** + * Updates settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + + if (this.isOpen && this.dropdownElement) { + this.refreshDropdownContent(); + } + } + + /** + * Toggle the dropdown visibility + */ + public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void { + if (this.isOpen) { + this.close(); + setTimeout(() => { + if (!this.isOpen && !this.dropdownElement) { + this.open(targetEl, position); + } + }, 50); + } else { + this.open(targetEl, position); + } + } + + /** + * Open the dropdown + */ + public open(targetEl: HTMLElement, position?: { x: number, y: number }): void { + if (this.isOpen || this.dropdownElement) { + this.close(); + setTimeout(() => this.actuallyOpen(targetEl, position), 10); + return; + } + + this.actuallyOpen(targetEl, position); + } + + /** + * Actually open the dropdown (internal method) + */ + private actuallyOpen(targetEl: HTMLElement, position?: { x: number, y: number }): void { + this.isOpen = true; + + // Create dropdown element + this.createDropdownElement(); + this.refreshDropdownContent(); + + // Position the dropdown + positionDropdown({ + dropdownElement: this.dropdownElement!, + targetEl, + position + }); + + this.dropdownElement?.addClass('note-status-popover-animate-in'); + + // Add event listeners with slight delay to prevent immediate triggering + setTimeout(() => { + setupDropdownEvents({ + clickOutsideHandler: this.clickOutsideHandler, + escapeKeyHandler: this.escapeKeyHandler + }); + }, 10); + } + + /** + * Create the dropdown element + */ + private createDropdownElement(): void { + this.dropdownElement = document.createElement('div'); + this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown'); + document.body.appendChild(this.dropdownElement); + } + + /** + * Close the dropdown + */ + public close(): void { + if (!this.dropdownElement || !this.isOpen) return; + + this.dropdownElement.addClass('note-status-popover-animate-out'); + + document.removeEventListener('click', this.clickOutsideHandler); + document.removeEventListener('keydown', this.escapeKeyHandler); + + this.isOpen = false; + + if (this.dropdownElement) { + this.dropdownElement.remove(); + this.dropdownElement = null; + } + } + + /** + * Refresh the dropdown content + */ + private refreshDropdownContent(): void { + if (!this.dropdownElement) return; + + renderDropdownContent({ + dropdownElement: this.dropdownElement, + settings: this.settings, + statusService: this.statusService, + currentStatuses: this.currentStatuses, + targetFile: this.targetFile, + targetFiles: this.targetFiles, + onRemoveStatus: this.onRemoveStatus, + onSelectStatus: this.onSelectStatus + }); + } + + /** + * Handle click outside the dropdown + */ + private handleClickOutside(e: MouseEvent): void { + if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) { + this.close(); + } + } + + /** + * Handle escape key to close dropdown + */ + private handleEscapeKey(e: KeyboardEvent): void { + if (e.key === 'Escape') { + this.close(); + } + } + + /** + * Dispose of resources + */ + public dispose(): void { + this.close(); + } +} \ No newline at end of file diff --git a/components/status-dropdown/index.ts b/components/status-dropdown/index.ts new file mode 100644 index 0000000..2e49ef2 --- /dev/null +++ b/components/status-dropdown/index.ts @@ -0,0 +1,6 @@ +import { DropdownManager } from './dropdown-manager'; +import { DropdownOptions } from './types'; + +export type { DropdownOptions }; +export { DropdownManager as StatusDropdown }; +export default DropdownManager; \ No newline at end of file diff --git a/components/status-dropdown/types.ts b/components/status-dropdown/types.ts new file mode 100644 index 0000000..da57db6 --- /dev/null +++ b/components/status-dropdown/types.ts @@ -0,0 +1,36 @@ +// # Tipos comunes +import { NoteStatusSettings } from 'models/types'; +import { App, TFile, Editor, MarkdownView } from 'obsidian'; +import { StatusService } from 'services/status-service'; + +/** + * Options for opening status dropdown + */ +export interface DropdownOptions { + target?: HTMLElement; + position?: { x: number, y: number }; + files?: TFile[]; + editor?: Editor; + view?: MarkdownView; + mode?: 'replace' | 'add' | 'remove' | 'toggle'; + onStatusChange?: (statuses: string[]) => void; +} + +/** + * Status removal handler function type + */ +export type StatusRemoveHandler = (status: string, targetFile?: TFile | TFile[]) => Promise; + +/** + * Status selection handler function type + */ +export type StatusSelectHandler = (status: string, targetFile: TFile | TFile[]) => Promise; + +/** + * Common dependencies for dropdown components + */ +export interface DropdownDependencies { + app: App; + settings: NoteStatusSettings; + statusService: StatusService; +} diff --git a/components/toolbar-button.ts b/components/toolbar-button.ts new file mode 100644 index 0000000..af204bb --- /dev/null +++ b/components/toolbar-button.ts @@ -0,0 +1,75 @@ +import { NoteStatusSettings } from "models/types"; +import { StatusService } from "services/status-service"; + +export class ToolbarButton { + private element: HTMLElement | null = null; + private settings: NoteStatusSettings; + private statusService: StatusService; + + constructor(settings: NoteStatusSettings, statusService: StatusService) { + this.settings = settings; + this.statusService = statusService; + } + + public createElement(): HTMLElement { + const button = document.createElement('button'); + button.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action'); + button.setAttribute('aria-label', 'Note status'); + + this.element = button; + return button; + } + + public updateDisplay(statuses: string[]): void { + if (!this.element) return; + + this.element.empty(); + + const hasValidStatus = statuses.length > 0 && statuses[0] !== 'unknown'; + const badgeContainer = document.createElement('div'); + badgeContainer.addClass('note-status-toolbar-badge-container'); + + if (hasValidStatus) { + this.renderStatusBadge(badgeContainer, statuses); + } else { + this.renderUnknownBadge(badgeContainer); + } + + this.element.appendChild(badgeContainer); + } + + private renderStatusBadge(container: HTMLElement, statuses: string[]): void { + const primaryStatus = statuses[0]; + const icon = this.statusService.getStatusIcon(primaryStatus); + + const iconSpan = document.createElement('span'); + iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`); + iconSpan.textContent = icon; + container.appendChild(iconSpan); + + if (this.settings.useMultipleStatuses && statuses.length > 1) { + const countBadge = document.createElement('span'); + countBadge.addClass('note-status-count-badge'); + countBadge.textContent = `${statuses.length}`; + container.appendChild(countBadge); + } + } + + private renderUnknownBadge(container: HTMLElement): void { + const iconSpan = document.createElement('span'); + iconSpan.addClass('note-status-toolbar-icon', 'status-unknown'); + iconSpan.textContent = this.statusService.getStatusIcon('unknown'); + container.appendChild(iconSpan); + } + + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + public destroy(): void { + if (this.element) { + this.element.remove(); + this.element = null; + } + } +} \ No newline at end of file diff --git a/constants/defaults.ts b/constants/defaults.ts index d98016f..cbe487f 100644 --- a/constants/defaults.ts +++ b/constants/defaults.ts @@ -23,6 +23,7 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = { useCustomStatusesOnly: false, useMultipleStatuses: true, tagPrefix: 'obsidian-note-status', + strictStatuses: false, // Default to show all statuses from frontmatter excludeUnknownStatus: true, // Default to exclude unknown status files for better performance }; @@ -35,4 +36,4 @@ export const DEFAULT_COLORS: Record = { completed: '#00aaff', // Blue for accent dropped: '#ff0000', // Red for error unknown: '#888888' // Gray for muted -}; \ No newline at end of file +}; diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a5de8b8..11eb737 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -11,6 +11,17 @@ if you want to view the source, please visit the github repository of this plugi const prod = (process.argv[2] === "production"); +// CSS bundling function +const buildStyles = async () => { + await esbuild.build({ + entryPoints: ["styles/index.css"], + bundle: true, + outfile: "styles.css", + minify: prod, + logLevel: "info", + }); +}; + const context = await esbuild.context({ banner: { js: banner, @@ -31,7 +42,8 @@ const context = await esbuild.context({ "@lezer/common", "@lezer/highlight", "@lezer/lr", - ...builtins], + ...builtins + ], format: "cjs", target: "es2018", logLevel: "info", @@ -41,9 +53,20 @@ const context = await esbuild.context({ minify: prod, }); +// CSS context for watch mode +const cssContext = await esbuild.context({ + entryPoints: ["styles/index.css"], + bundle: true, + outfile: "styles.css", + minify: prod, + logLevel: "info", +}); + if (prod) { await context.rebuild(); + await buildStyles(); // Build CSS in production mode process.exit(0); } else { await context.watch(); -} + await cssContext.watch(); // Watch CSS files in development mode +} \ No newline at end of file diff --git a/integrations/commands/command-integration.ts b/integrations/commands/command-integration.ts new file mode 100644 index 0000000..def45b5 --- /dev/null +++ b/integrations/commands/command-integration.ts @@ -0,0 +1,227 @@ +// integrations/commands/command-integration.ts +import { App, Editor, MarkdownView, Notice, TFile } from 'obsidian'; +import { NoteStatusSettings } from '../../models/types'; +import { StatusService } from 'services/status-service'; +import { StatusDropdown } from 'components/status-dropdown'; +import { StatusPaneViewController } from 'views/status-pane-view'; +import NoteStatus from 'main'; + +export class CommandIntegration { + private app: App; + private plugin: NoteStatus; + private settings: NoteStatusSettings; + private statusService: StatusService; + private statusDropdown: StatusDropdown; + + constructor( + app: App, + plugin: NoteStatus, + settings: NoteStatusSettings, + statusService: StatusService, + statusDropdown: StatusDropdown + ) { + this.app = app; + this.plugin = plugin; + this.settings = settings; + this.statusService = statusService; + this.statusDropdown = statusDropdown; + } + + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + public registerCommands(): void { + // Open status pane + this.plugin.addCommand({ + id: 'open-status-pane', + name: 'Open status pane', + callback: () => StatusPaneViewController.open(this.app) + }); + + // Change status of current note + this.plugin.addCommand({ + id: 'change-status', + name: 'Change status of current note', + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + this.statusDropdown.openStatusDropdown({ files: [file] }); + } + return true; + } + }); + + // Insert status metadata + this.plugin.addCommand({ + id: 'insert-status-metadata', + name: 'Insert status metadata', + editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView) => { + if (!view.file) return false; + + const statuses = this.statusService.getFileStatuses(view.file); + const hasNoStatus = statuses.length === 1 && statuses[0] === 'unknown'; + + if (!checking && hasNoStatus) { + this.statusService.insertStatusMetadataInEditor(editor); + new Notice('Status metadata inserted'); + } + return hasNoStatus; + } + }); + + // Cycle through statuses + this.plugin.addCommand({ + id: 'cycle-status', + name: 'Cycle to next status', + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + this.cycleStatus(file); + } + return true; + } + }); + + // Quick status commands for common statuses + const quickStatuses = ['active', 'completed', 'onHold', 'dropped']; + quickStatuses.forEach(status => { + this.plugin.addCommand({ + id: `set-status-${status}`, + name: `Set status to ${status}`, + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + this.statusService.handleStatusChange({ + files: file, + statuses: status, + operation: 'set' + }); + new Notice(`Status set to ${status}`); + } + return true; + } + }); + }); + + // Clear status + this.plugin.addCommand({ + id: 'clear-status', + name: 'Clear status (set to unknown)', + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + this.statusService.handleStatusChange({ + files: file, + statuses: 'unknown', + operation: 'set' + }); + new Notice('Status cleared'); + } + return true; + } + }); + + // Copy status from current note + this.plugin.addCommand({ + id: 'copy-status', + name: 'Copy status from current note', + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + const statuses = this.statusService.getFileStatuses(file); + (this.app as any).clipboard = statuses; + new Notice(`Copied status: ${statuses.join(', ')}`); + } + return true; + } + }); + + // Paste status to current note + this.plugin.addCommand({ + id: 'paste-status', + name: 'Paste status to current note', + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + const clipboard = (this.app as any).clipboard; + if (!file || !clipboard || !Array.isArray(clipboard)) return false; + + if (!checking) { + this.statusService.handleStatusChange({ + files: file, + statuses: clipboard, + operation: 'set' + }); + new Notice(`Pasted status: ${clipboard.join(', ')}`); + } + return true; + } + }); + + // Toggle multiple statuses mode + this.plugin.addCommand({ + id: 'toggle-multiple-statuses', + name: 'Toggle multiple statuses mode', + callback: () => { + this.settings.useMultipleStatuses = !this.settings.useMultipleStatuses; + this.plugin.saveSettings(); + new Notice(`Multiple statuses mode ${this.settings.useMultipleStatuses ? 'enabled' : 'disabled'}`); + } + }); + + // Search notes by status + this.plugin.addCommand({ + id: 'search-by-status', + name: 'Search notes by current status', + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + const statuses = this.statusService.getFileStatuses(file); + const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`; + (this.app as any).internalPlugins.getPluginById('global-search').instance.openGlobalSearch(query); + } + return true; + } + }); + } + + private cycleStatus(file: TFile): void { + const allStatuses = this.statusService.getAllStatuses() + .filter(s => s.name !== 'unknown') + .map(s => s.name); + + if (allStatuses.length === 0) { + new Notice('No statuses available'); + return; + } + + const currentStatuses = this.statusService.getFileStatuses(file); + const currentStatus = currentStatuses[0]; + + let nextIndex = 0; + if (currentStatus !== 'unknown') { + const currentIndex = allStatuses.indexOf(currentStatus); + nextIndex = (currentIndex + 1) % allStatuses.length; + } + + this.statusService.handleStatusChange({ + files: file, + statuses: allStatuses[nextIndex], + operation: 'set' + }); + + new Notice(`Status changed to ${allStatuses[nextIndex]}`); + } +} diff --git a/integrations/context-menu/file-context-menu-integration.ts b/integrations/context-menu/file-context-menu-integration.ts new file mode 100644 index 0000000..7c3a0b8 --- /dev/null +++ b/integrations/context-menu/file-context-menu-integration.ts @@ -0,0 +1,95 @@ +import { App, Menu, TFile } from 'obsidian'; +import { NoteStatusSettings } from 'models/types'; +import { StatusService } from 'services/status-service'; +import { ExplorerIntegration } from 'integrations/explorer/explorer-integration'; +import { StatusContextMenu } from 'integrations/context-menu/status-context-menu'; + +/** + * Gestiona la integración de menús contextuales con el explorador de archivos + */ +export class FileContextMenuIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private explorerIntegration: ExplorerIntegration; + private statusContextMenu: StatusContextMenu; + private fileMenuEventRef: any; + private filesMenuEventRef: any; + + constructor( + app: App, + settings: NoteStatusSettings, + statusService: StatusService, + explorerIntegration: ExplorerIntegration, + statusContextMenu: StatusContextMenu + ) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.explorerIntegration = explorerIntegration; + this.statusContextMenu = statusContextMenu; + } + + /** + * Actualiza la configuración + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + /** + * Registra los eventos del menú contextual de archivos + */ +public registerFileContextMenuEvents(): void { + this.fileMenuEventRef = (menu: Menu, file: any, source: string) => { + if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') { + this.addStatusChangeMenu(menu, file); + } + }; + + this.filesMenuEventRef = (menu: Menu, files: any[]) => { + const mdFiles = files.filter(file => + file instanceof TFile && file.extension === 'md' + ) as TFile[]; + + if (mdFiles.length > 0) { + this.addBatchStatusChangeMenu(menu, mdFiles); + } + }; + + this.app.workspace.on('file-menu', this.fileMenuEventRef); + this.app.workspace.on('files-menu', this.filesMenuEventRef); + } + + /** + * Añade opción de cambio de estado al menú contextual de un archivo + */ + private addStatusChangeMenu(menu: Menu, file: TFile): void { + this.statusContextMenu.addStatusMenuItemToSingleFile(menu, file, (file) => { + const selectedFiles = this.explorerIntegration.getSelectedFiles(); + if (selectedFiles.length > 1) { + this.statusContextMenu.showForFiles(selectedFiles); + } else { + this.statusContextMenu.showForFiles([file]); + } + }); + } + + /** + * Añade opción de cambio de estado para múltiples archivos + */ + private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void { + this.statusContextMenu.addStatusMenuItemToBatch(menu, files, (files) => { + this.statusContextMenu.showForFiles(files); + }); + } + + public unload(): void { + if (this.fileMenuEventRef) { + this.app.workspace.off('file-menu', this.fileMenuEventRef); + } + if (this.filesMenuEventRef) { + this.app.workspace.off('files-menu', this.filesMenuEventRef); + } + } +} diff --git a/integrations/context-menu/status-context-menu.ts b/integrations/context-menu/status-context-menu.ts new file mode 100644 index 0000000..0dbc150 --- /dev/null +++ b/integrations/context-menu/status-context-menu.ts @@ -0,0 +1,130 @@ +import { App, Menu, TFile } from 'obsidian'; +import { NoteStatusSettings } from 'models/types'; +import { StatusService } from 'services/status-service'; +import { StatusDropdown } from 'components/status-dropdown'; +import { ExplorerIntegration } from 'integrations/explorer/explorer-integration'; + +/** + * Gestiona los menús contextuales para cambios de estado + */ +export class StatusContextMenu { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private statusDropdown: StatusDropdown; + private explorerIntegration: ExplorerIntegration; + + constructor( + app: App, + settings: NoteStatusSettings, + statusService: StatusService, + statusDropdown: StatusDropdown, + explorerIntegration: ExplorerIntegration + ) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.statusDropdown = statusDropdown; + this.explorerIntegration = explorerIntegration; + } + + /** + * Actualiza la configuración + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + /** + * Añade ítem de menú para cambiar estado de un archivo + */ + public addStatusMenuItemToSingleFile(menu: Menu, file: TFile, onClick: (file: TFile) => void): void { + menu.addItem(item => + item + .setTitle('Change status') + .setIcon('tag') + .onClick(() => onClick(file)) + ); + } + + /** + * Añade ítem de menú para cambiar estado de múltiples archivos + */ + public addStatusMenuItemToBatch(menu: Menu, files: TFile[], onClick: (files: TFile[]) => void): void { + menu.addItem(item => + item + .setTitle('Change status') + .setIcon('tag') + .onClick(() => onClick(files)) + ); + } + + /** + * Muestra el menú contextual para cambiar estado de uno o más archivos + * @param files Archivos a los que cambiar el estado + * @param position Posición opcional para mostrar el menú + */ + public showForFiles(files: TFile[], position?: { x: number; y: number }): void { + if (files.length === 0) return; + + if (files.length === 1) { + this.showForSingleFile(files[0], position); + } else { + this.showForMultipleFiles(files, position); + } + } + + /** + * Muestra el menú contextual para un solo archivo + * @param file Archivo al que cambiar el estado + * @param position Posición opcional para mostrar el menú + */ + private showForSingleFile(file: TFile, position?: { x: number; y: number }): void { + if (!(file instanceof TFile) || file.extension !== 'md') return; + + this.statusDropdown.openStatusDropdown({ + position, + files: [file] + }); + } + + /** + * Muestra el menú contextual para múltiples archivos + * @param files Archivos a los que cambiar el estado + * @param position Posición opcional para mostrar el menú + */ + private showForMultipleFiles(files: TFile[], position?: { x: number; y: number }): void { + const menu = new Menu(); + + // Elemento de información (deshabilitado) + menu.addItem((item) => { + item.setTitle(`Update ${files.length} files`) + .setDisabled(true); + return item; + }); + + // Opción para gestionar estados + menu.addItem((item) => + item + .setTitle('Manage statuses...') + .setIcon('tag') + .onClick(() => { + this.statusDropdown.openStatusDropdown({ + position, + files + }); + }) + ); + + // Mostrar el menú en la posición adecuada + if (position) { + menu.showAtPosition(position); + } else { + // Use a centered position + menu.showAtPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 3 + }); + } + } +} \ No newline at end of file diff --git a/integrations/editor/editor-integration.ts b/integrations/editor/editor-integration.ts new file mode 100644 index 0000000..0e86fb0 --- /dev/null +++ b/integrations/editor/editor-integration.ts @@ -0,0 +1,80 @@ +import { App, Editor, MarkdownView, Menu, TFile } from 'obsidian'; +import { NoteStatusSettings } from '../../models/types'; +import { StatusService } from 'services/status-service'; +import { StatusDropdown } from 'components/status-dropdown'; + +export class EditorIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private statusDropdown: StatusDropdown; + private editorMenuRef: any; + + constructor( + app: App, + settings: NoteStatusSettings, + statusService: StatusService, + statusDropdown: StatusDropdown + ) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.statusDropdown = statusDropdown; + } + + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + public registerEditorMenus(): void { + this.editorMenuRef = (menu: Menu, editor: Editor, view: MarkdownView) => { + if (view.file) { + this.addStatusMenuItems(menu, editor, view); + } + }; + + this.app.workspace.on('editor-menu', this.editorMenuRef); + } + +private addStatusMenuItems(menu: Menu, editor: Editor, view: MarkdownView): void { + menu.addItem(item => + item + .setTitle('Change note status') + .setIcon('tag') + .onClick(() => { + if (view.file) { + this.statusDropdown.openStatusDropdown({ + files: [view.file], + editor, + view + }); + } + }) + ); + + // Only show insert metadata if it doesn't exist + if (view.file) { + const statuses = this.statusService.getFileStatuses(view.file); + if (statuses.length === 1 && statuses[0] === 'unknown') { + menu.addItem(item => + item + .setTitle('Insert status metadata') + .setIcon('plus-circle') + .onClick(() => { + this.insertStatusMetadata(editor); + }) + ); + } + } +} + + public insertStatusMetadata(editor: Editor): void { + this.statusService.insertStatusMetadataInEditor(editor); + } + + public unload(): void { + if (this.editorMenuRef) { + this.app.workspace.off('editor-menu', this.editorMenuRef); + } + } +} diff --git a/integrations/editor/index.ts b/integrations/editor/index.ts new file mode 100644 index 0000000..176fa1c --- /dev/null +++ b/integrations/editor/index.ts @@ -0,0 +1,2 @@ +export { EditorIntegration } from './editor-integration'; +export { ToolbarIntegration } from './toolbar-integration'; \ No newline at end of file diff --git a/integrations/editor/toolbar-integration.ts b/integrations/editor/toolbar-integration.ts new file mode 100644 index 0000000..d712fe4 --- /dev/null +++ b/integrations/editor/toolbar-integration.ts @@ -0,0 +1,118 @@ +import { App, MarkdownView } from 'obsidian'; +import { NoteStatusSettings } from '../../models/types'; +import { StatusService } from '../../services/status-service'; +import { StatusDropdown } from '../../components/status-dropdown'; +import { ToolbarButton } from 'components/toolbar-button'; + +/** + * Gestiona la integración con la barra de herramientas del editor + */ +export class ToolbarIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private statusDropdown: StatusDropdown; + private buttonView: ToolbarButton; + private buttonElement: HTMLElement | null = null; + private currentLeafId: string | null = null; + + constructor( + app: App, + settings: NoteStatusSettings, + statusService: StatusService, + statusDropdown: StatusDropdown + ) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.statusDropdown = statusDropdown; + this.buttonView = new ToolbarButton(settings, statusService); + } + + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.buttonView.updateSettings(settings); + this.updateStatusDisplay([]); + } + + public addToolbarButtonToActiveLeaf(statuses?: string[]): void { + const activeLeaf = this.app.workspace.activeLeaf; + if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView)) return; + + const leafId = (activeLeaf as any).id || activeLeaf.view.containerEl.id; + + // Only recreate button if we're on a different leaf or button doesn't exist + if (this.currentLeafId !== leafId || !this.buttonElement || !this.isButtonInDOM()) { + this.recreateButton(activeLeaf.view, leafId); + } + + this.updateButtonDisplay(statuses); + } + + private recreateButton(view: MarkdownView, leafId: string): void { + const toolbarContainer = view.containerEl.querySelector('.view-header .view-actions'); + if (!toolbarContainer) return; + + // Remove old button if it exists + this.removeToolbarButton(); + + // Create new button + this.buttonElement = this.buttonView.createElement(); + this.buttonElement.addEventListener('click', this.handleButtonClick.bind(this)); + + if (toolbarContainer.firstChild) { + toolbarContainer.insertBefore(this.buttonElement, toolbarContainer.firstChild); + } else { + toolbarContainer.appendChild(this.buttonElement); + } + + this.currentLeafId = leafId; + } + + private isButtonInDOM(): boolean { + return this.buttonElement?.isConnected === true; + } + +private removeToolbarButton(): void { + this.app.workspace.iterateAllLeaves(leaf => { + if (leaf.view instanceof MarkdownView) { + const buttons = leaf.view.containerEl.querySelectorAll('.note-status-toolbar-button'); + buttons.forEach(button => button.remove()); + } + }); + + this.buttonElement = null; + this.currentLeafId = null; +} + + private updateButtonDisplay(overrideStatuses?: string[]): void { + if (!this.buttonElement) return; + + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) return; + + const statuses = overrideStatuses?.length ? overrideStatuses : this.statusService.getFileStatuses(activeFile); + this.buttonView.updateDisplay(statuses); + } + + private handleButtonClick(e: MouseEvent): void { + e.stopPropagation(); + e.preventDefault(); + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) return; + + this.statusDropdown.openStatusDropdown({ + target: this.buttonElement || undefined, + files: [activeFile] + }); + } + + public updateStatusDisplay(statuses: string[]): void { + this.updateButtonDisplay(statuses); + } + + public unload(): void { + // this.buttonView.destroy(); + this.removeToolbarButton(); + } +} diff --git a/integrations/explorer/explorer-integration.ts b/integrations/explorer/explorer-integration.ts new file mode 100644 index 0000000..3f80dff --- /dev/null +++ b/integrations/explorer/explorer-integration.ts @@ -0,0 +1,350 @@ +import { App, TFile, setTooltip, debounce } from 'obsidian'; +import { NoteStatusSettings, FileExplorerView } from '../../models/types'; +import { StatusService } from 'services/status-service'; + + +/** + * Manages the logic for file explorer status integration + */ +export class ExplorerIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private ui: ExplorerIntegrationUI; + private iconUpdateQueue = new Set(); + private isProcessingQueue = false; + private debouncedUpdateAll: ReturnType; + private fileItemsWasUndefined = false; + + constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.ui = new ExplorerIntegrationUI(app, settings, statusService, () => { + this.fileItemsWasUndefined = true; + }); + this.debouncedUpdateAll = debounce(this.processUpdateQueue.bind(this), 100, true); + + // Listen for layout changes to detect when file explorer becomes available + this.app.workspace.on('layout-change', () => { + this.checkFileExplorerAvailability(); + }); + } + + /** + * Check if file explorer became available after being unavailable + */ + private checkFileExplorerAvailability(): void { + if (!this.settings.showStatusIconsInExplorer) return; + + const fileExplorer = this.ui.findFileExplorerView(); + if (fileExplorer?.fileItems && this.fileItemsWasUndefined) { + this.fileItemsWasUndefined = false; + setTimeout(() => this.updateAllFileExplorerIcons(), 100); + } + } + + /** + * Updates settings and refreshes UI if necessary + */ + public updateSettings(settings: NoteStatusSettings): void { + const shouldRefreshIcons = + this.settings.showStatusIconsInExplorer !== settings.showStatusIconsInExplorer || + this.settings.hideUnknownStatusInExplorer !== settings.hideUnknownStatusInExplorer; + + this.settings = {...settings}; + this.ui.updateSettings(settings); + + if (shouldRefreshIcons) { + this.ui.removeAllFileExplorerIcons(); + + if (settings.showStatusIconsInExplorer) { + setTimeout(() => this.updateAllFileExplorerIcons(), 50); + } + } else if (settings.showStatusIconsInExplorer) { + this.updateAllFileExplorerIcons(); + } + } + + /** + * Updates icons for a specific file + */ + public updateFileExplorerIcons(file: TFile): void { + if (!file || !this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; + + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile?.path === file.path) { + this.ui.updateSingleFileIconDirectly(file, this.statusService); + } + + this.queueFileUpdate(file); + } + + /** + * Updates all icons in the explorer + */ + public updateAllFileExplorerIcons(): void { + if (!this.settings.showStatusIconsInExplorer) { + this.ui.removeAllFileExplorerIcons(); + return; + } + + this.processFilesInBatches(); + } + + /** + * Gets selected files from the explorer + */ + public getSelectedFiles(): TFile[] { + return this.ui.getSelectedFiles(); + } + + /** + * Queue a file for icon update + */ + private queueFileUpdate(file: TFile): void { + if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; + + this.iconUpdateQueue.add(file.path); + this.debouncedUpdateAll(); + } + + /** + * Process the update queue + */ + private async processUpdateQueue(): Promise { + if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return; + + this.isProcessingQueue = true; + + try { + const fileExplorerView = this.ui.findFileExplorerView(); + if (!fileExplorerView || !fileExplorerView.fileItems) { + this.fileItemsWasUndefined = true; + setTimeout(() => this.debouncedUpdateAll(), 200); + return; + } + + const allPaths = Array.from(this.iconUpdateQueue); + const batchSize = 50; + + for (let i = 0; i < allPaths.length; i += batchSize) { + await this.processBatch(allPaths.slice(i, i + batchSize), fileExplorerView); + + if (i + batchSize < allPaths.length) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + } catch (error) { + console.error('Note Status: Error processing file update queue', error); + } finally { + this.isProcessingQueue = false; + + if (this.iconUpdateQueue.size > 0) { + this.debouncedUpdateAll(); + } + } + } + + /** + * Process a batch of files + */ + private async processBatch(paths: string[], fileExplorerView: any): Promise { + for (const path of paths) { + const file = this.app.vault.getFileByPath(path); + if (file instanceof TFile) { + this.ui.updateSingleFileIcon(file, fileExplorerView, this.statusService); + } + this.iconUpdateQueue.delete(path); + } + } + + /** + * Process files in batches + */ + private async processFilesInBatches(): Promise { + const files = this.app.vault.getMarkdownFiles(); + const batchSize = 100; + + for (let i = 0; i < files.length; i += batchSize) { + files.slice(i, i + batchSize).forEach(file => this.queueFileUpdate(file)); + + if (i + batchSize < files.length) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + } + + /** + * Cleanup when unloading the plugin + */ + public unload(): void { + this.ui.removeAllFileExplorerIcons(); + this.debouncedUpdateAll.cancel(); + } +} + +/** + * Manages UI operations for file explorer icons + */ +export class ExplorerIntegrationUI { + private app: App; + private settings: NoteStatusSettings; + private onFileItemsUndefined: () => void; + + constructor(app: App, settings: NoteStatusSettings, statusService: StatusService, onFileItemsUndefined: () => void) { + this.app = app; + this.settings = settings; + this.onFileItemsUndefined = onFileItemsUndefined; + } + + /** + * Updates the settings + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + /** + * Finds the file explorer view + */ + public findFileExplorerView(): FileExplorerView | null { + const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0]; + if (leaf?.view) return leaf.view as FileExplorerView; + + for (const leaf of this.app.workspace.getLeavesOfType('')) { + if (leaf.view && 'fileItems' in leaf.view) { + return leaf.view as FileExplorerView; + } + } + + return null; + } + + /** + * Updates a single file icon + */ + public updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView, statusService: StatusService): void { + if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; + + try { + // Check if fileItems is initialized + if (!fileExplorerView.fileItems) { + this.onFileItemsUndefined(); + return; + } + + const fileItem = fileExplorerView.fileItems[file.path]; + if (!fileItem) return; + + const titleEl = fileItem.titleEl || fileItem.selfEl; + if (!titleEl) return; + + const statuses = statusService.getFileStatuses(file); + + this.removeExistingIcons(titleEl); + + if (this.shouldSkipIcon(statuses)) return; + + this.addStatusIcons(titleEl, statuses, statusService); + } catch (error) { + console.error(`Note Status: Error updating icon for ${file.path}`, error); + } + } + + /** + * Updates the icon for the active file directly + */ + public updateSingleFileIconDirectly(file: TFile, statusService: StatusService): void { + const fileExplorer = this.findFileExplorerView(); + if (fileExplorer) { + this.updateSingleFileIcon(file, fileExplorer, statusService); + } + } + + /** + * Removes all file explorer icons + */ + public removeAllFileExplorerIcons(): void { + const fileExplorer = this.findFileExplorerView(); + if (!fileExplorer?.fileItems) return; + + Object.values(fileExplorer.fileItems).forEach(fileItem => { + const titleEl = fileItem.titleEl || fileItem.selfEl; + if (titleEl) this.removeExistingIcons(titleEl); + }); + } + + /** + * Gets the currently selected files + */ + public getSelectedFiles(): TFile[] { + const fileExplorer = this.findFileExplorerView(); + if (!fileExplorer?.fileItems) return []; + + return Object.values(fileExplorer.fileItems) + .filter(item => + item.el?.classList.contains('is-selected') && + item.file instanceof TFile && + item.file.extension === 'md') + .map(item => item.file as TFile); + } + + /** + * Checks if the icon should be skipped based on status + */ + private shouldSkipIcon(statuses: string[]): boolean { + return this.settings.hideUnknownStatusInExplorer && + statuses.length === 1 && + statuses[0] === 'unknown'; + } + + /** + * Removes existing status icons + */ + private removeExistingIcons(element: HTMLElement): void { + const iconSelectors = '.note-status-icon, .note-status-icon-container'; + element.querySelectorAll(iconSelectors).forEach(icon => { + if (icon.classList.contains('note-status-icon') || + icon.classList.contains('note-status-icon-container')) { + icon.remove(); + } + }); + } + + /** + * Adds status icons to an element + */ + private addStatusIcons(titleEl: HTMLElement, statuses: string[], statusService: StatusService): void { + const iconContainer = document.createElement('span'); + iconContainer.className = 'note-status-icon-container'; + + if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') { + statuses.forEach(status => this.addSingleStatusIcon(iconContainer, status, statusService)); + } else { + const primaryStatus = statuses[0] || 'unknown'; + if (primaryStatus !== 'unknown' || !this.settings.hideUnknownStatusInExplorer) { + this.addSingleStatusIcon(iconContainer, primaryStatus, statusService); + } + } + + if (iconContainer.childElementCount > 0) { + titleEl.appendChild(iconContainer); + } + } + + /** + * Adds a single status icon + */ + private addSingleStatusIcon(container: HTMLElement, status: string, statusService: StatusService): void { + const iconEl = document.createElement('span'); + iconEl.className = `note-status-icon nav-file-tag status-${status}`; + iconEl.textContent = statusService.getStatusIcon(status); + + const statusObj = statusService.getAllStatuses().find(s => s.name === status); + const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status; + setTooltip(iconEl, tooltipValue); + + container.appendChild(iconEl); + } +} diff --git a/integrations/explorer/index.ts b/integrations/explorer/index.ts new file mode 100644 index 0000000..6720b6d --- /dev/null +++ b/integrations/explorer/index.ts @@ -0,0 +1 @@ +export { ExplorerIntegration } from './explorer-integration'; \ No newline at end of file diff --git a/integrations/metadata-cache/index.ts b/integrations/metadata-cache/index.ts new file mode 100644 index 0000000..d9eb2a9 --- /dev/null +++ b/integrations/metadata-cache/index.ts @@ -0,0 +1 @@ +export { MetadataIntegration } from './metadata-integration'; \ No newline at end of file diff --git a/integrations/metadata-cache/metadata-integration.ts b/integrations/metadata-cache/metadata-integration.ts new file mode 100644 index 0000000..6f4404a --- /dev/null +++ b/integrations/metadata-cache/metadata-integration.ts @@ -0,0 +1,71 @@ +import { App, TFile } from 'obsidian'; +import { NoteStatusSettings } from '../../models/types'; +import { ExplorerIntegration } from '../explorer/explorer-integration'; +import { StatusService } from 'services/status-service'; + +export class MetadataIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private explorerIntegration: ExplorerIntegration; + private metadataChangedRef: any; + private metadataResolvedRef: any; + + constructor( + app: App, + settings: NoteStatusSettings, + statusService: StatusService, + explorerIntegration: ExplorerIntegration + ) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.explorerIntegration = explorerIntegration; + } + + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + public registerMetadataEvents(): void { + this.metadataChangedRef = (file: TFile) => { + if (file instanceof TFile && file.extension === 'md') { + this.handleMetadataChanged(file); + } + }; + + this.metadataResolvedRef = () => { + setTimeout(() => { + if (this.settings.showStatusIconsInExplorer) { + this.explorerIntegration.updateAllFileExplorerIcons(); + } + }, 500); + }; + + this.app.metadataCache.on('changed', this.metadataChangedRef); + this.app.metadataCache.on('resolved', this.metadataResolvedRef); + } + + private handleMetadataChanged(file: TFile): void { + if (this.settings.showStatusIconsInExplorer) { + this.explorerIntegration.updateFileExplorerIcons(file); + } + + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile?.path === file.path) { + const statuses = this.statusService.getFileStatuses(file); + window.dispatchEvent(new CustomEvent('note-status:status-changed', { + detail: { statuses, file: file.path } + })); + } + } + + public unload(): void { + if (this.metadataChangedRef) { + this.app.metadataCache.off('changed', this.metadataChangedRef); + } + if (this.metadataResolvedRef) { + this.app.metadataCache.off('resolved', this.metadataResolvedRef); + } + } +} diff --git a/integrations/settings/settings-controller.ts b/integrations/settings/settings-controller.ts new file mode 100644 index 0000000..addcbd8 --- /dev/null +++ b/integrations/settings/settings-controller.ts @@ -0,0 +1,119 @@ +import { App } from 'obsidian'; +import { Status } from '../../models/types'; +import NoteStatus from 'main'; +import { StatusService } from 'services/status-service'; +import { NoteStatusSettingsUI, SettingsUICallbacks } from './settings-ui'; + +/** + * Controller for settings UI, handles business logic and plugin state updates + */ +export class NoteStatusSettingsController implements SettingsUICallbacks { + private app: App; + private plugin: NoteStatus; + private statusService: StatusService; + private ui: NoteStatusSettingsUI; + + constructor(app: App, plugin: NoteStatus, statusService: StatusService) { + this.app = app; + this.plugin = plugin; + this.statusService = statusService; + this.ui = new NoteStatusSettingsUI(this); + } + + /** + * Renders the settings interface + */ + display(containerEl: HTMLElement): void { + this.ui.render(containerEl, this.plugin.settings); + } + + /** + * Handles template enable/disable toggle + */ + async onTemplateToggle(templateId: string, enabled: boolean): Promise { + if (enabled) { + if (!this.plugin.settings.enabledTemplates.includes(templateId)) { + this.plugin.settings.enabledTemplates.push(templateId); + } + } else { + this.plugin.settings.enabledTemplates = this.plugin.settings.enabledTemplates.filter( + (id: string) => id !== templateId + ); + } + + await this.plugin.saveSettings(); + } + + /** + * Handles general setting changes + */ + async onSettingChange(key: string, value: any): Promise { + (this.plugin.settings as any)[key] = value; + await this.plugin.saveSettings(); + } + + /** + * Handles custom status field changes + */ + async onCustomStatusChange(index: number, field: string, value: any): Promise { + const status = this.plugin.settings.customStatuses[index]; + if (!status) return; + + if (field === 'name') { + const oldName = status.name; + status.name = value; + + if (oldName !== status.name) { + this.plugin.settings.statusColors[status.name] = + this.plugin.settings.statusColors[oldName]; + delete this.plugin.settings.statusColors[oldName]; + } + } else if (field === 'color') { + this.plugin.settings.statusColors[status.name] = value; + } else { + (status as any)[field] = value; + } + + await this.plugin.saveSettings(); + } + + /** + * Handles custom status removal + */ + async onCustomStatusRemove(index: number): Promise { + const status = this.plugin.settings.customStatuses[index]; + if (!status) return; + + this.plugin.settings.customStatuses.splice(index, 1); + delete this.plugin.settings.statusColors[status.name]; + + await this.plugin.saveSettings(); + + // Re-render the custom statuses section + const statusList = document.querySelector('.custom-status-list') as HTMLElement; + if (statusList) { + this.ui.renderCustomStatuses(statusList, this.plugin.settings); + } + } + + /** + * Handles adding new custom status + */ + async onCustomStatusAdd(): Promise { + const newStatus: Status = { + name: `status${this.plugin.settings.customStatuses.length + 1}`, + icon: '⭐' + }; + + this.plugin.settings.customStatuses.push(newStatus); + this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; + + await this.plugin.saveSettings(); + + // Re-render the custom statuses section + const statusList = document.querySelector('.custom-status-list') as HTMLElement; + if (statusList) { + this.ui.renderCustomStatuses(statusList, this.plugin.settings); + } + } +} diff --git a/integrations/settings/settings-tab.ts b/integrations/settings/settings-tab.ts new file mode 100644 index 0000000..f59f470 --- /dev/null +++ b/integrations/settings/settings-tab.ts @@ -0,0 +1,23 @@ +import { App, PluginSettingTab } from 'obsidian'; +import NoteStatus from 'main'; +import { StatusService } from 'services/status-service'; +import { NoteStatusSettingsController } from './settings-controller'; + +/** + * Settings tab for the Note Status plugin - delegates to controller + */ +export class NoteStatusSettingTab extends PluginSettingTab { + private controller: NoteStatusSettingsController; + + constructor(app: App, plugin: NoteStatus, statusService: StatusService) { + super(app, plugin); + this.controller = new NoteStatusSettingsController(app, plugin, statusService); + } + + /** + * Displays the settings interface + */ + display(): void { + this.controller.display(this.containerEl); + } +} diff --git a/integrations/settings/settings-ui.ts b/integrations/settings/settings-ui.ts new file mode 100644 index 0000000..33ae528 --- /dev/null +++ b/integrations/settings/settings-ui.ts @@ -0,0 +1,235 @@ +import { Setting } from 'obsidian'; +import { Status } from '../../models/types'; +import { PREDEFINED_TEMPLATES } from '../../constants/status-templates'; + +/** + * Callbacks interface for settings UI interactions + */ +export interface SettingsUICallbacks { + /** Handle template enable/disable toggle */ + onTemplateToggle: (templateId: string, enabled: boolean) => Promise; + /** Handle general setting changes */ + onSettingChange: (key: string, value: any) => Promise; + /** Handle custom status field changes */ + onCustomStatusChange: (index: number, field: string, value: any) => Promise; + /** Handle custom status removal */ + onCustomStatusRemove: (index: number) => Promise; + /** Handle adding new custom status */ + onCustomStatusAdd: () => Promise; +} + +/** + * Pure UI component for rendering settings interface + */ +export class NoteStatusSettingsUI { + private callbacks: SettingsUICallbacks; + + constructor(callbacks: SettingsUICallbacks) { + this.callbacks = callbacks; + } + + /** + * Renders the complete settings interface + */ + render(containerEl: HTMLElement, settings: any): void { + containerEl.empty(); + + this.renderTemplateSettings(containerEl, settings); + this.renderUISettings(containerEl, settings); + this.renderTagSettings(containerEl, settings); + this.renderCustomStatusSettings(containerEl, settings); + } + + /** + * Renders the status templates section + */ + private renderTemplateSettings(containerEl: HTMLElement, settings: any): void { + new Setting(containerEl).setName('Status templates').setHeading(); + + containerEl.createEl('p', { + text: 'Enable predefined templates to quickly add common status workflows', + cls: 'setting-item-description' + }); + + const templatesContainer = containerEl.createDiv({ cls: 'templates-container' }); + + PREDEFINED_TEMPLATES.forEach(template => { + const templateEl = templatesContainer.createDiv({ cls: 'template-item' }); + const headerEl = templateEl.createDiv({ cls: 'template-header' }); + + const isEnabled = settings.enabledTemplates.includes(template.id); + const checkbox = headerEl.createEl('input', { + type: 'checkbox', + cls: 'template-checkbox' + }); + checkbox.checked = isEnabled; + + checkbox.addEventListener('change', () => { + this.callbacks.onTemplateToggle(template.id, checkbox.checked); + }); + + headerEl.createEl('span', { + text: template.name, + cls: 'template-name' + }); + + templateEl.createEl('div', { + text: template.description, + cls: 'template-description' + }); + + const statusesEl = templateEl.createDiv({ cls: 'template-statuses' }); + template.statuses.forEach(status => { + const statusEl = statusesEl.createEl('div', { cls: 'template-status-chip' }); + const colorDot = statusEl.createEl('span', { cls: 'status-color-dot' }); + colorDot.style.setProperty('--dot-color', status.color || '#ffffff'); + statusEl.createSpan({ text: `${status.icon} ${status.name}` }); + }); + }); + } + + /** + * Renders the UI display settings section + */ + private renderUISettings(containerEl: HTMLElement, settings: any): void { + new Setting(containerEl).setName('User interface').setHeading(); + + new Setting(containerEl) + .setName('Show status bar') + .setDesc('Display the status bar') + .addToggle(toggle => toggle + .setValue(settings.showStatusBar) + .onChange(value => this.callbacks.onSettingChange('showStatusBar', value))); + + new Setting(containerEl) + .setName('Auto-hide status bar') + .setDesc('Hide the status bar when status is unknown') + .addToggle(toggle => toggle + .setValue(settings.autoHideStatusBar) + .onChange(value => this.callbacks.onSettingChange('autoHideStatusBar', value))); + + new Setting(containerEl) + .setName('Show status icons in file explorer') + .setDesc('Display status icons in the file explorer') + .addToggle(toggle => toggle + .setValue(settings.showStatusIconsInExplorer) + .onChange(value => this.callbacks.onSettingChange('showStatusIconsInExplorer', value))); + + new Setting(containerEl) + .setName('Hide unknown status in file explorer') + .setDesc('Hide status icons for files with unknown status in the file explorer') + .addToggle(toggle => toggle + .setValue(settings.hideUnknownStatusInExplorer || false) + .onChange(value => this.callbacks.onSettingChange('hideUnknownStatusInExplorer', value))); + + new Setting(containerEl) + .setName('Default to compact view') + .setDesc('Start the status pane in compact view by default') + .addToggle(toggle => toggle + .setValue(settings.compactView || false) + .onChange(value => this.callbacks.onSettingChange('compactView', value))); + + new Setting(containerEl) + .setName('Exclude unassigned notes from status pane') + .setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.') + .addToggle(toggle => toggle + .setValue(settings.excludeUnknownStatus || false) + .onChange(value => this.callbacks.onSettingChange('excludeUnknownStatus', value))); + } + + /** + * Renders the status tag configuration section + */ + private renderTagSettings(containerEl: HTMLElement, settings: any): void { + new Setting(containerEl).setName('Status tag').setHeading(); + + new Setting(containerEl) + .setName('Enable multiple statuses') + .setDesc('Allow notes to have multiple statuses at the same time') + .addToggle(toggle => toggle + .setValue(settings.useMultipleStatuses) + .onChange(value => this.callbacks.onSettingChange('useMultipleStatuses', value))); + + new Setting(containerEl) + .setName('Status tag prefix') + .setDesc('The YAML frontmatter tag name used for status (default: obsidian-note-status)') + .addText(text => text + .setValue(settings.tagPrefix) + .onChange(value => { + if (value.trim()) { + this.callbacks.onSettingChange('tagPrefix', value.trim()); + } + })); + + + new Setting(containerEl) + .setName('Strict status validation') + .setDesc('Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses.') + .addToggle(toggle => toggle + .setValue(settings.strictStatuses || false) + .onChange(value => this.callbacks.onSettingChange('strictStatuses', value))); + } + + /** + * Renders the custom status management section + */ + private renderCustomStatusSettings(containerEl: HTMLElement, settings: any): void { + new Setting(containerEl).setName('Custom statuses').setHeading(); + + new Setting(containerEl) + .setName('Use only custom statuses') + .setDesc('Ignore template statuses and use only the custom statuses defined below') + .addToggle(toggle => toggle + .setValue(settings.useCustomStatusesOnly || false) + .onChange(value => this.callbacks.onSettingChange('useCustomStatusesOnly', value))); + + const statusList = containerEl.createDiv({ cls: 'custom-status-list' }); + this.renderCustomStatuses(statusList, settings); + + new Setting(containerEl) + .setName('Add new status') + .setDesc('Add a custom status with a name, icon, and color') + .addButton(button => button + .setButtonText('Add Status') + .setCta() + .onClick(() => this.callbacks.onCustomStatusAdd())); + } + + /** + * Renders the list of custom statuses with edit controls + */ + renderCustomStatuses(statusList: HTMLElement, settings: any): void { + statusList.empty(); + + settings.customStatuses.forEach((status: Status, index: number) => { + const setting = new Setting(statusList) + .setName(status.name) + .setClass('status-item'); + + setting.addText(text => text + .setPlaceholder('Name') + .setValue(status.name) + .onChange(value => this.callbacks.onCustomStatusChange(index, 'name', value || 'unnamed'))); + + setting.addText(text => text + .setPlaceholder('Icon') + .setValue(status.icon) + .onChange(value => this.callbacks.onCustomStatusChange(index, 'icon', value || '❓'))); + + setting.addColorPicker(colorPicker => colorPicker + .setValue(settings.statusColors[status.name] || '#ffffff') + .onChange(value => this.callbacks.onCustomStatusChange(index, 'color', value))); + + setting.addText(text => text + .setPlaceholder('Description') + .setValue(status.description || '') + .onChange(value => this.callbacks.onCustomStatusChange(index, 'description', value))); + + setting.addButton(button => button + .setButtonText('Remove') + .setClass('status-remove-button') + .setWarning() + .onClick(() => this.callbacks.onCustomStatusRemove(index))); + }); + } +} diff --git a/integrations/workspace/index.ts b/integrations/workspace/index.ts new file mode 100644 index 0000000..6591713 --- /dev/null +++ b/integrations/workspace/index.ts @@ -0,0 +1 @@ +export { WorkspaceIntegration } from './workspace-integration'; \ No newline at end of file diff --git a/integrations/workspace/workspace-integration.ts b/integrations/workspace/workspace-integration.ts new file mode 100644 index 0000000..dae584e --- /dev/null +++ b/integrations/workspace/workspace-integration.ts @@ -0,0 +1,124 @@ +import { App, TFile, WorkspaceLeaf } from 'obsidian'; +import { NoteStatusSettings } from '../../models/types'; +import { ToolbarIntegration } from '../editor/toolbar-integration'; +import { StatusService } from 'services/status-service'; + +/** + * Gestiona la integración con el workspace de Obsidian + */ +export class WorkspaceIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + private toolbarIntegration: ToolbarIntegration; + private lastActiveFile: TFile | null = null; + private fileOpenEventRef: any; + private activeLeafChangeEventRef: any; + private layoutChangeEventRef: any; + + constructor( + app: App, + settings: NoteStatusSettings, + statusService: StatusService, + toolbarIntegration: ToolbarIntegration + ) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + this.toolbarIntegration = toolbarIntegration; + } + + /** + * Actualiza la configuración + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + /** + * Registra eventos del workspace + */ + public registerWorkspaceEvents(): void { + this.fileOpenEventRef = (file: any) => { + if (file instanceof TFile) { + this.handleFileOpen(file); + } + }; + + this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => { + this.handleActiveLeafChange(leaf); + }; + + this.layoutChangeEventRef = () => { + this.handleLayoutChange(); + }; + + this.app.workspace.on('file-open', this.fileOpenEventRef); + this.app.workspace.on('active-leaf-change', this.activeLeafChangeEventRef); + this.app.workspace.on('layout-change', this.layoutChangeEventRef); + } + + /** + * Maneja apertura de archivo + */ + private handleFileOpen(file: TFile): void { + // Añade el botón de la barra de herramientas + this.toolbarIntegration.addToolbarButtonToActiveLeaf(); + + // Actualiza estado + this.propagateNoteStatusChange(file); + } + + /** + * Maneja cambio de hoja activa + */ + private handleActiveLeafChange(leaf: WorkspaceLeaf): void { + // Añade el botón de la barra de herramientas + this.toolbarIntegration.addToolbarButtonToActiveLeaf(); + + const activeFile = this.app.workspace.getActiveFile(); + + // Solo actualiza si el archivo realmente cambió + // if (this.lastActiveFile?.path !== activeFile?.path) { + // this.lastActiveFile = activeFile; + // this.propagateNoteStatusChange(); + // } + } + + /** + * Maneja y propaga el cambio de layout + */ + private handleLayoutChange(): void { + window.dispatchEvent(new CustomEvent('note-status:update-pane')); + } + + /** + * Verifica y propaga el estado de la nota activa + */ + private propagateNoteStatusChange(file: TFile): void { + try { + const activeFile = this.app.workspace.getActiveFile(); + let fileStatuses: string[] = []; + if (activeFile && activeFile.extension === 'md') { + fileStatuses = this.statusService.getFileStatuses(activeFile) + } + // Dispara evento para que otros componentes se actualicen + window.dispatchEvent(new CustomEvent('note-status:status-changed', { + detail: { statuses: fileStatuses, file: file } + })); + } catch (error) { + console.error('Error checking note status:', error); + } + } + public unload(): void { + if (this.fileOpenEventRef) { + this.app.workspace.off('file-open', this.fileOpenEventRef); + } + if (this.activeLeafChangeEventRef) { + this.app.workspace.off('active-leaf-change', this.activeLeafChangeEventRef); + } + if (this.layoutChangeEventRef) { + this.app.workspace.off('layout-change', this.layoutChangeEventRef); + } + } +} diff --git a/main.ts b/main.ts index bbe0189..7dd7fd6 100644 --- a/main.ts +++ b/main.ts @@ -1,753 +1,218 @@ -import { Editor, MarkdownView, Notice, Plugin, TFile, addIcon, WorkspaceLeaf, debounce } from 'obsidian'; - -// Constants -import { ICONS } from './constants/icons'; -import { DEFAULT_SETTINGS, DEFAULT_COLORS } from './constants/defaults'; - -// Types +import { Plugin, Notice } from 'obsidian'; +import { DEFAULT_SETTINGS } from './constants/defaults'; import { NoteStatusSettings } from './models/types'; +import { StatusService } from 'services/status-service'; +import { StyleService } from 'services/style-service'; -// Services -import { StatusService } from './services/status-service'; -import { StyleService } from './services/style-service'; +// Importar integraciones +import { ExplorerIntegration } from './integrations/explorer'; +import { EditorIntegration, ToolbarIntegration } from './integrations/editor'; +import { MetadataIntegration } from './integrations/metadata-cache'; +import { WorkspaceIntegration } from './integrations/workspace'; +import { FileContextMenuIntegration } from 'integrations/context-menu/file-context-menu-integration'; +import { NoteStatusSettingTab } from 'integrations/settings/settings-tab'; +import { CommandIntegration } from 'integrations/commands/command-integration'; -// UI Components -import { StatusBar } from './ui/components/status-bar'; -import { StatusDropdown } from './ui/components/status-dropdown'; -import { StatusPaneView } from './ui/components/status-pane-view'; -import { ExplorerIntegration } from './ui/integrations/explorer-integration'; -import { StatusContextMenu } from './ui/menus/status-context-menu'; +// Importar vistas +import { StatusPaneViewController } from './views/status-pane-view'; -// Settings -import { NoteStatusSettingTab } from './settings/settings-tab'; +// Importar componentes UI +import { StatusBar } from 'components/status-bar'; +import { StatusDropdown } from 'components/status-dropdown'; +import { StatusContextMenu } from 'integrations/context-menu/status-context-menu'; -/** - * Main plugin class for Note Status functionality - */ export default class NoteStatus extends Plugin { - private boundSaveSettings: () => Promise; - private boundCheckNoteStatus: () => void; - private boundRefreshDropdown: () => void; - private boundRefreshUI: () => void; + settings: NoteStatusSettings; + + // Servicios + statusService: StatusService; + styleService: StyleService; + + // Componentes UI + statusBar: StatusBar; + statusDropdown: StatusDropdown + + // Integraciones + explorerIntegration: ExplorerIntegration; + fileContextMenuIntegration: FileContextMenuIntegration; + editorIntegration: EditorIntegration; + toolbarIntegration: ToolbarIntegration; + metadataIntegration: MetadataIntegration; + workspaceIntegration: WorkspaceIntegration; + commandIntegration: CommandIntegration; - settings: NoteStatusSettings; - statusService: StatusService; - styleService: StyleService; - statusBar: StatusBar; - statusDropdown: StatusDropdown; - explorerIntegration: ExplorerIntegration; - statusContextMenu: StatusContextMenu; - private statusPaneLeaf: WorkspaceLeaf | null = null; - private lastActiveFile: TFile | null = null; + statusPane: StatusPaneViewController; - // Debounced methods for better performance - private debouncedCheckNoteStatus = debounce( - () => this.checkNoteStatus(), - 100, - true - ); + private boundHandleStatusChanged: (event: CustomEvent) => void; - private debouncedUpdateExplorer = debounce( - () => this.explorerIntegration?.updateAllFileExplorerIcons(), - 150 - ); + async onload() { + try { + await this.loadSettings(); + this.initializeServices(); + this.registerViews(); + this.initializeUI(); + this.initializeIntegrations(); + this.setupCustomEvents(); + } catch (error) { + console.error('Error loading Note Status plugin:', error); + new Notice('Error loading Note Status plugin. Check console for details.'); + } + } - private debouncedUpdateStatusPane = debounce( - () => this.updateStatusPane(), - 200 - ); + private async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } - private hasShownErrorNotification = false; + private initializeServices() { + this.statusService = new StatusService(this.app, this.settings); + this.styleService = new StyleService(this.settings); + } - async onload() { - try { - // Load settings first before initializing other components - await this.loadSettings(); - - this.boundSaveSettings = this.saveSettings.bind(this); - this.boundCheckNoteStatus = this.checkNoteStatus.bind(this); - this.boundRefreshDropdown = () => this.statusDropdown?.render(); - this.boundRefreshUI = () => this.checkNoteStatus(); - - // Register icons and essential services first - this.registerIcons(); - this.statusService = new StatusService(this.app, this.settings); - this.styleService = new StyleService(this.settings); - - // Register views and commands right away - this.registerViews(); - this.registerCommands(); - - // Add settings tab - this.addSettingTab(new NoteStatusSettingTab(this.app, this)); - - // Set up custom events early - this.setupCustomEvents(); - - // Delay UI-heavy initialization until the layout is ready - this.app.workspace.onLayoutReady(async () => { - // Split initialization into phases for better performance - await this.initializeUIComponents(); - }); - } catch (error) { - console.error('Error loading Note Status plugin:', error); - new Notice('Error loading Note Status plugin. Check console for details.'); - } - } - - private async initializeUIComponents(): Promise { - // Initialize UI components - this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService); - this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService); - - // Initialize explorer integration with a slight delay - setTimeout(() => { - this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService); - this.statusContextMenu = new StatusContextMenu( - this.app, - this.settings, - this.statusService, - this.statusDropdown, - this.explorerIntegration - ); - - // Register events after explorer integration is ready - this.registerMenuHandlers(); - this.registerEvents(); - - // Check status for active file only initially - this.checkNoteStatus(); - - // Update only active file icon initially for better performance - const activeFile = this.app.workspace.getActiveFile(); - if (activeFile && this.settings.showStatusIconsInExplorer) { - this.explorerIntegration.updateFileExplorerIcons(activeFile); - } - - // Delay full explorer icon update to avoid startup lag - if (this.settings.showStatusIconsInExplorer) { - setTimeout(() => { - this.explorerIntegration.updateAllFileExplorerIcons(); - }, 2000); - } - }, 300); - } - - /** - * Initialize the plugin components - */ - private async initialize(): Promise { - // Register custom icons - this.registerIcons(); - - // Initialize services - this.initializeServices(); - - // Initialize UI components - this.initializeUI(); - - // Register views, commands, and events - this.registerViews(); - this.registerCommands(); - this.registerMenuHandlers(); - this.registerEvents(); - - // Set up custom events - this.setupCustomEvents(); - - // Add settings tab - this.addSettingTab(new NoteStatusSettingTab(this.app, this)); - - // Initialize UI on layout ready - this.app.workspace.onLayoutReady(async () => { - await this.onLayoutReady(); - }); - } - - /** - * Handle layout ready event for final initialization steps - */ - private async onLayoutReady(): Promise { - // Small delay to ensure everything is loaded - await new Promise(resolve => setTimeout(resolve, 200)); - this.checkNoteStatus(); - this.statusDropdown.update(this.getCurrentStatuses()); - - this.explorerIntegration.updateAllFileExplorerIcons(); - - // Add additional retry with delay to ensure icons are updated - setTimeout(() => { - this.explorerIntegration.updateAllFileExplorerIcons(); - }, 2000); - } - - /** - * Register custom icons - */ - private registerIcons(): void { - Object.entries(ICONS).forEach(([name, svg]) => { - // Create a parser and get a DOM element for the SVG - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(svg, 'image/svg+xml'); - const svgElement = svgDoc.documentElement; - - // Get the SVG as a string using XMLSerializer - const serializer = new XMLSerializer(); - const sanitizedSvg = serializer.serializeToString(svgElement); - - if (name === 'statusPane') { - addIcon('status-pane', sanitizedSvg); - } else { - addIcon(`note-status-${name}`, sanitizedSvg); - } - }); - } - - /** - * Register views for status pane - */ - private registerViews(): void { - this.registerView('status-pane', (leaf) => { - this.statusPaneLeaf = leaf; - return new StatusPaneView(leaf, this); - }); - - // Add ribbon icon - this.addRibbonIcon('status-pane', 'Open status pane', () => { - this.openStatusPane(); - }); - } - - /** - * Initialize plugin services - */ - private initializeServices(): void { - // These services depend on settings being loaded first - this.statusService = new StatusService(this.app, this.settings); - this.styleService = new StyleService(this.settings); - } - - /** - * Initialize UI components - */ - private initializeUI(): void { - this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService); - this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService); - this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService); - this.statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService, this.statusDropdown, this.explorerIntegration); - } - - /** - * Set up custom events for status changes and UI updates - */ - private setupCustomEvents(): void { - // Listen for settings changes - window.addEventListener('note-status:settings-changed', this.boundSaveSettings); - - - // Listen for force refresh - window.addEventListener('note-status:force-refresh', () => { - try { - this.forceRefreshUI(); - } catch (error) { - console.error('Error handling force refresh event:', error); - } - }); - - // Listen for status changes - window.addEventListener('note-status:status-changed', (e: any) => { - try { - const statuses = e.detail?.statuses || ['unknown']; - this.statusBar.update(statuses); - this.statusDropdown.update(statuses); - - // Update explorer icons for the active file - const activeFile = this.app.workspace.getActiveFile(); - if (activeFile) { - this.explorerIntegration.updateFileExplorerIcons(activeFile); - } - - setTimeout(() => this.statusDropdown.render(), 50); - } catch (error) { - console.error('Error handling status change event:', error); - } - }); - - // Listen for refresh dropdown - window.addEventListener('note-status:refresh-dropdown', this.boundRefreshDropdown); - - // Listen for UI refresh - window.addEventListener('note-status:refresh-ui', this.boundRefreshUI); - } - - /** - * Register plugin commands - */ - private registerCommands(): void { - // Refresh status command - this.addCommand({ - id: 'refresh-status', - name: 'Refresh status', - callback: () => { - this.checkNoteStatus(); - new Notice('Note status refreshed!'); - } - }); - - this.addCommand({ - id: 'force-refresh-ui', - name: 'Force refresh user interface', - callback: () => this.forceRefreshUI() - }); - - - // Insert status metadata command - this.addCommand({ - id: 'insert-status-metadata', - name: 'Insert status metadata', - editorCallback: (editor: Editor, view: MarkdownView) => { - this.statusService.insertStatusMetadataInEditor(editor); - new Notice('Status metadata inserted'); - } - }); - - // Open status pane command - this.addCommand({ - id: 'open-status-pane', - name: 'Open status pane', - callback: () => this.openStatusPane() - }); - - // Add status to note command - this.addCommand({ - id: 'add-status-to-note', - name: 'Add status to current note', - callback: () => this.showAddStatusToNoteMenu() - }); - } - - /** - * Show menu for adding a status to the current note - */ - private showAddStatusToNoteMenu(): void { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile || activeFile.extension !== 'md') { - new Notice('No markdown file is active'); - return; - } - - this.statusContextMenu.showForFile(activeFile, new MouseEvent('click')); - } - - /** - * Register menu handlers - */ - private registerMenuHandlers(): void { - // File explorer context menu - this.registerEvent( - this.app.workspace.on('file-menu', (menu, file, source) => { - if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') { - menu.addItem((item) => - item - .setTitle('Change status') - .setIcon('tag') - .onClick(() => { - const selectedFiles = this.explorerIntegration.getSelectedFiles(); - if (selectedFiles.length > 1) { - this.statusContextMenu.showForFiles(selectedFiles); - } else { - this.statusContextMenu.showForFiles([file]); - } - }) - ); - } - }) - ); - - // Multiple files selection menu - this.registerEvent( - this.app.workspace.on('files-menu', (menu, files) => { - const mdFiles: TFile[] = []; - for (const file of files) { - if (file instanceof TFile && file.extension === 'md') { - mdFiles.push(file); - } - } - if (mdFiles.length > 0) { - menu.addItem((item) => - item - .setTitle('Change status') - .setIcon('tag') - .onClick(() => { - this.statusContextMenu.showForFiles(mdFiles); - }) - ); - } - }) - ); - - // Editor context menu - this.registerEvent( - this.app.workspace.on('editor-menu', (menu, editor, view) => { - if (view instanceof MarkdownView) { - menu.addItem((item) => - item - .setTitle('Change note status') - .setIcon('tag') - .onClick(() => this.statusDropdown.showInContextMenu(editor, view)) - ); - } - }) - ); - } - - /** - * Register workspace and vault events - */ - private registerEvents(): void { - // File open event with optimization to avoid redundant updates - this.registerEvent(this.app.workspace.on('file-open', (file) => { - if (file instanceof TFile) { - // First make sure the button exists - this.statusDropdown.addToolbarButtonToActiveLeaf(); - // Then check status and update button - this.checkNoteStatus(); - } - })); - - // Editor change event - debounced to avoid performance issues - this.registerEvent(this.app.workspace.on('editor-change', () => { - this.debouncedCheckNoteStatus(); - })); - - // Add this for toolbar button persistence - this.registerEvent( - this.app.workspace.on('active-leaf-change', () => { - this.statusDropdown.addToolbarButtonToActiveLeaf(); - // Update the status after adding the button to ensure icon is correct - this.checkNoteStatus(); - }) - ); - - // Active leaf change event - optimized to avoid redundant updates - this.registerEvent(this.app.workspace.on('active-leaf-change', () => { - const activeFile = this.app.workspace.getActiveFile(); - - // Only update if the file actually changed - if (this.lastActiveFile?.path !== activeFile?.path) { - this.lastActiveFile = activeFile; - this.statusDropdown.update(this.getCurrentStatuses()); - this.debouncedUpdateStatusPane(); - } - })); - - this.registerFileEvents(); - this.registerMetadataEvents(); - - // Layout change event - ensure status pane is properly rendered - this.registerEvent(this.app.workspace.on('layout-change', () => { - this.debouncedUpdateStatusPane(); - })); - } - - /** - * Register file modification events - */ - private registerFileEvents(): void { - // File modification events with optimization - this.registerEvent(this.app.vault.on('modify', (file) => { - if (file instanceof TFile && file.extension === 'md') { - // Only update UI for the modified file - this.explorerIntegration.updateFileExplorerIcons(file); - - // If this is the active file, also update other UI elements - if (this.app.workspace.getActiveFile()?.path === file.path) { - this.checkNoteStatus(); - this.statusDropdown.update(this.getCurrentStatuses()); - } - - // Update the status pane but debounced - this.debouncedUpdateStatusPane(); - } - })); - - // File creation, deletion, and rename events - this.registerEvent(this.app.vault.on('create', (file) => { - if (file instanceof TFile && file.extension === 'md') { - this.explorerIntegration.updateFileExplorerIcons(file); - this.debouncedUpdateStatusPane(); - } - })); - - this.registerEvent(this.app.vault.on('delete', () => { - this.debouncedUpdateExplorer(); - this.debouncedUpdateStatusPane(); - })); - - this.registerEvent(this.app.vault.on('rename', (file) => { - if (file instanceof TFile && file.extension === 'md') { - this.explorerIntegration.updateAllFileExplorerIcons(); - this.debouncedUpdateStatusPane(); - } - })); - } - - /** - * Register metadata cache events - */ - private registerMetadataEvents(): void { - // Metadata change events - this.registerEvent(this.app.metadataCache.on('changed', (file) => { - if (file instanceof TFile && file.extension === 'md') { - this.explorerIntegration.updateFileExplorerIcons(file); - - // Update other UI elements if this is the active file - if (this.app.workspace.getActiveFile()?.path === file.path) { - this.checkNoteStatus(); - this.statusDropdown.update(this.getCurrentStatuses()); - } - - this.debouncedUpdateStatusPane(); - } - })); - - // Metadata resolved event - when all files are indexed - this.registerEvent( - this.app.metadataCache.on('resolved', () => { - // When metadata cache is fully resolved, update all icons - setTimeout(() => this.explorerIntegration.updateAllFileExplorerIcons(), 500); - }) - ); - } - - /** - * Check and update the status display for the active file - */ - checkNoteStatus(): void { - try { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') { - this.statusBar.update(['unknown']); - this.statusDropdown.update(['unknown']); - return; - } - - const statuses = this.statusService.getFileStatuses(activeFile); - this.statusBar.update(statuses); - this.statusDropdown.update(statuses); - } catch (error) { - console.error('Error checking note status:', error); - if (!this.hasShownErrorNotification) { - new Notice('Error checking note status. Check console for details.'); - this.hasShownErrorNotification = true; - setTimeout(() => { this.hasShownErrorNotification = false; }, 10000); - } - } - } - - /** - * Get the current statuses for the active file - * Always returns an array of status names - */ - getCurrentStatuses(): string[] { - try { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') { - return ['unknown']; - } - return this.statusService.getFileStatuses(activeFile); - } catch (error) { - console.error('Error getting current statuses:', error); - return ['unknown']; - } - } - - /** - * Open the status pane - */ - public async openStatusPane(): Promise { - try { - // Check if already open - const existing = this.app.workspace.getLeavesOfType('status-pane')[0]; - if (existing) { - this.app.workspace.setActiveLeaf(existing); - return; - } - - // Create a new leaf and show loading state - const leaf = this.app.workspace.getLeftLeaf(false); - if (leaf) { - await leaf.setViewState({ type: 'status-pane', active: true }); - this.statusPaneLeaf = leaf; - } - } catch (error) { - console.error('Error opening status pane:', error); - new Notice('Error opening status pane. Check console for details.'); - } - } - - /** - * Show status context menu - */ - showStatusContextMenu(files: TFile[]): void { - try { - this.statusContextMenu.showForFiles(files); - } catch (error) { - console.error('Error showing status context menu:', error); - new Notice('Error showing status context menu. Check console for details.'); - } - } - - /** - * Update the status pane - */ - async updateStatusPane(): Promise { - try { - if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) { - const searchQuery = (this.statusPaneLeaf.view as StatusPaneView).searchInput?.value.toLowerCase() || ''; - await (this.statusPaneLeaf.view as StatusPaneView).renderGroups(searchQuery); - } - } catch (error) { - console.error('Error updating status pane:', error); - } - } - - /** - * Load settings with error handling - */ - async loadSettings(): Promise { - try { - const loadedData = await this.loadData(); - this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData); - this.initializeDefaultColors(); - } catch (error) { - console.error('Error loading settings:', error); - new Notice('Error loading settings. Using defaults. Check console for details.'); - this.settings = Object.assign({}, DEFAULT_SETTINGS); - this.initializeDefaultColors(); - } - } - - /** - * Initialize default colors - */ - private initializeDefaultColors(): void { - // Ensure default colors are set for all statuses - for (const [status, color] of Object.entries(DEFAULT_COLORS)) { - if (!this.settings.statusColors[status]) { - this.settings.statusColors[status] = color; - } - } - - // Also initialize colors from enabled templates if not present - this.initializeTemplateColors(); - } - - /** - * Initialize colors from templates - */ - private initializeTemplateColors(): void { - // Make sure settings are properly initialized - if (!this.settings) { - console.error('Settings not initialized properly'); - return; - } - - if (!this.settings.useCustomStatusesOnly && this.statusService) { - const templateStatuses = this.statusService.getTemplateStatuses(); - for (const status of templateStatuses) { - if (status.color && !this.settings.statusColors[status.name]) { - this.settings.statusColors[status.name] = status.color; - } - } - } - } - - /** - * Save settings with error handling - */ - async saveSettings(): Promise { - try { - await this.saveData(this.settings); - this.updateComponentSettings(); - } catch (error) { - console.error('Error saving settings:', error); - new Notice('Error saving settings. Check console for details.'); - } - } - - /** - * Update all components with new settings - */ - private updateComponentSettings(): void { - // Update services with new settings - this.statusService.updateSettings(this.settings); - this.styleService.updateSettings(this.settings); - - // Update UI components with new settings - this.statusBar.updateSettings(this.settings); - this.statusDropdown.updateSettings(this.settings); - this.explorerIntegration.updateSettings(this.settings); - this.statusContextMenu.updateSettings(this.settings); - - // Update status pane if open - if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) { - (this.statusPaneLeaf.view as StatusPaneView).updateSettings(this.settings); - } - } - - /** - * Force refresh all UI components - */ - public forceRefreshUI(): void { - try { - // Cancel any pending updates - this.debouncedCheckNoteStatus.cancel(); - this.debouncedUpdateExplorer.cancel(); - this.debouncedUpdateStatusPane.cancel(); - - // Immediate updates - this.checkNoteStatus(); - this.explorerIntegration.updateAllFileExplorerIcons(); - this.updateStatusPane(); - - new Notice('UI forcefully refreshed'); - } catch (error) { - console.error('Error force refreshing UI:', error); - new Notice('Error refreshing UI. Check console for details.'); - } - } - - /** - * Clean up when the plugin is unloaded - */ - onunload(): void { - // Cancel debounced functions - this.debouncedCheckNoteStatus.cancel(); - this.debouncedUpdateExplorer.cancel(); - this.debouncedUpdateStatusPane.cancel(); - - // Clean up UI elements - this.statusBar.unload?.(); - this.statusDropdown.unload?.(); - this.explorerIntegration.unload?.(); - this.styleService.unload?.(); - - // Close status pane if open - const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0]; - if (statusPane) statusPane.detach(); - - // Remove event listeners for custom events - this.removeCustomEventListeners(); - } - - /** - * Remove custom event listeners - */ - private removeCustomEventListeners(): void { - window.removeEventListener('note-status:settings-changed', this.boundSaveSettings); - window.removeEventListener('note-status:status-changed', this.boundCheckNoteStatus); - window.removeEventListener('note-status:refresh-dropdown', this.boundRefreshDropdown); - window.removeEventListener('note-status:refresh-ui', this.boundRefreshUI); + private registerViews() { + // Register status pane view + this.registerView('status-pane', (leaf) => { + this.statusPane = new StatusPaneViewController(leaf, this); + return this.statusPane; + }); + + // Add ribbon icon + this.addRibbonIcon('tag', 'Open status pane', () => { + this.openStatusPane(); + }); + + // Añadir pestaña de configuración + this.addSettingTab(new NoteStatusSettingTab(this.app, this, this.statusService)); + } + + private initializeIntegrations() { + this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService); + this.toolbarIntegration = new ToolbarIntegration(this.app, this.settings, this.statusService, this.statusDropdown); + const statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService, this.statusDropdown, this.explorerIntegration); + this.fileContextMenuIntegration = new FileContextMenuIntegration(this.app, this.settings, this.statusService, this.explorerIntegration, statusContextMenu); + + this.editorIntegration = new EditorIntegration(this.app, this.settings, this.statusService, this.statusDropdown); + this.commandIntegration = new CommandIntegration( + this.app, + this, + this.settings, + this.statusService, + this.statusDropdown + ); + this.metadataIntegration = new MetadataIntegration( + this.app, + this.settings, + this.statusService, + this.explorerIntegration + ); + + this.workspaceIntegration = new WorkspaceIntegration( + this.app, + this.settings, + this.statusService, + this.toolbarIntegration + ); + + // Registrar eventos en cada integración + this.fileContextMenuIntegration.registerFileContextMenuEvents(); + this.editorIntegration.registerEditorMenus(); + this.commandIntegration.registerCommands(); + this.metadataIntegration.registerMetadataEvents(); + this.workspaceIntegration.registerWorkspaceEvents(); + } + + private setupCustomEvents() { + this.boundHandleStatusChanged = this.handleStatusChanged.bind(this); + window.addEventListener('note-status:status-changed', this.boundHandleStatusChanged); + } + + private initializeUI() { + this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService); + + // Inicializar barra de estado + this.statusBar = new StatusBar( + this.addStatusBarItem(), + this.settings, + this.statusService + ); + + // Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio) + if (this.settings.showStatusIconsInExplorer) { + setTimeout(() => { + this.explorerIntegration.updateAllFileExplorerIcons(); + }, 2000); + } + } + + private handleStatusChanged(event: CustomEvent) { + const { statuses, file } = event.detail; + + // Actualizar barra de estado + this.statusBar.update(statuses); + // Actualizar toolbar + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile?.path === file) { + this.toolbarIntegration.updateStatusDisplay(statuses); + } + // Actualizar dropdown + this.statusDropdown.update(statuses); + // Actualizar status pane si está abierto + const statusPaneLeaf = this.app.workspace.getLeavesOfType('status-pane')[0]; + if (statusPaneLeaf?.view && statusPaneLeaf.view instanceof StatusPaneViewController) { + (statusPaneLeaf.view as StatusPaneViewController).update(); } + // Actualizar explorador si es necesario + if (this.settings.showStatusIconsInExplorer && file) { + const fileObj = this.app.vault.getFileByPath(file); + if (fileObj) { + this.explorerIntegration.updateFileExplorerIcons(fileObj); + } + } + } + + private async openStatusPane() { + await StatusPaneViewController.open(this.app); + } + + async saveSettings() { + await this.saveData(this.settings); + + // Actualizar servicios + this.statusService.updateSettings(this.settings); + this.styleService.updateSettings(this.settings); + + // Actualizar integraciones + this.explorerIntegration.updateSettings(this.settings); + this.fileContextMenuIntegration.updateSettings(this.settings); + this.editorIntegration.updateSettings(this.settings); + this.metadataIntegration?.updateSettings(this.settings); + this.commandIntegration?.updateSettings(this.settings); + this.toolbarIntegration.updateSettings(this.settings); + this.workspaceIntegration.updateSettings(this.settings); + this.statusPane?.updateSettings(this.settings); + // Actualizar componentes UI + this.statusBar.updateSettings(this.settings); + this.statusDropdown.updateSettings(this.settings) + } + +onunload() { + // Clean up event listeners + if (this.boundHandleStatusChanged) { + window.removeEventListener('note-status:status-changed', this.boundHandleStatusChanged); + } + + // Clean up integrations + this.explorerIntegration?.unload(); + this.toolbarIntegration?.unload(); + this.fileContextMenuIntegration?.unload(); + this.workspaceIntegration?.unload(); + this.metadataIntegration?.unload(); + this.editorIntegration?.unload(); + + // Clean up services + this.styleService?.unload(); + + // Clean up UI components + this.statusBar?.unload(); + this.statusDropdown?.unload(); +} } diff --git a/manifest.json b/manifest.json index d74effb..f6553ac 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ }, "isDesktopOnly": false, "license": "MIT", - "icon": "status-pane", + "icon": "tag", "repository": "https://github.com/devonthesofa/obsidian-note-status", "issues": "https://github.com/devonthesofa/obsidian-note-status/issues", "releaseNotes": "Initial release with status management, customizable UI, and file explorer integration.", diff --git a/models/types.ts b/models/types.ts index 820bfc3..d1ddb48 100644 --- a/models/types.ts +++ b/models/types.ts @@ -26,6 +26,7 @@ export interface NoteStatusSettings { useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates useMultipleStatuses: boolean; // Whether to allow multiple statuses per note tagPrefix: string; // Prefix for the status tag (default: 'status') + strictStatuses: boolean; // Whether to only show known statuses excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane } @@ -39,4 +40,4 @@ export interface FileExplorerView extends View { titleEl?: HTMLElement; selfEl?: HTMLElement; }>; -} \ No newline at end of file +} diff --git a/services/status-service.ts b/services/status-service.ts index befbc97..c5a6e77 100644 --- a/services/status-service.ts +++ b/services/status-service.ts @@ -28,56 +28,38 @@ export class StatusService { * Updates the combined list of all statuses (from templates and custom) */ private updateAllStatuses(): void { - // Start with custom statuses if not using templates exclusively + // Start with custom statuses this.allStatuses = [...this.settings.customStatuses]; - // Add statuses from enabled templates + // Add statuses from enabled templates if not using custom only if (!this.settings.useCustomStatusesOnly) { const templateStatuses = this.getTemplateStatuses(); - // Add template statuses that don't have the same name as existing statuses for (const status of templateStatuses) { - if (!this.allStatuses.some(s => s.name.toLowerCase() === status.name.toLowerCase())) { + const existingIndex = this.allStatuses.findIndex(s => + s.name.toLowerCase() === status.name.toLowerCase()); + + if (existingIndex === -1) { + // Add new status this.allStatuses.push(status); - } else { - this.updateExistingStatusColor(status); + } else if (status.color) { + // Update color in settings if it doesn't exist + if (!this.settings.statusColors[status.name]) { + this.settings.statusColors[status.name] = status.color; + } } } } } - /** - * Update color for an existing status if it comes from a template - */ - private updateExistingStatusColor(status: Status): void { - // Update status colors if they come from a template and have colors - const existingStatusIndex = this.allStatuses.findIndex( - s => s.name.toLowerCase() === status.name.toLowerCase() - ); - - if (existingStatusIndex !== -1 && status.color) { - // Update color in settings if it doesn't exist - if (!this.settings.statusColors[status.name]) { - this.settings.statusColors[status.name] = status.color; - } - } - } - /** * Gets all statuses from enabled templates */ public getTemplateStatuses(): Status[] { - const statuses: Status[] = []; - - // Find templates that are enabled - for (const templateId of this.settings.enabledTemplates) { - const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId); - if (template) { - statuses.push(...template.statuses); - } - } - - return statuses; + return this.settings.enabledTemplates + .map(id => PREDEFINED_TEMPLATES.find(t => t.id === id)) + .filter(Boolean) + .flatMap(template => template ? template.statuses : []); } /** @@ -89,63 +71,48 @@ export class StatusService { /** * Get the statuses of a file from its metadata - * Returns an array of status names */ public getFileStatuses(file: TFile): string[] { const cachedMetadata = this.app.metadataCache.getFileCache(file); - const statuses: string[] = []; + if (!cachedMetadata?.frontmatter) return ['unknown']; - if (cachedMetadata?.frontmatter) { - // Check for status using the configured tag prefix - const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix]; - - if (frontmatterStatus) { - if (Array.isArray(frontmatterStatus)) { - // Handle array format - this.processStatusArray(frontmatterStatus, statuses); + const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix]; + if (!frontmatterStatus) return ['unknown']; + + const statuses: string[] = []; + + if (Array.isArray(frontmatterStatus)) { + for (const statusName of frontmatterStatus) { + if (this.settings.strictStatuses) { + this.addValidStatus(statusName.toString(), statuses); } else { - // Handle single value format (string) - convert to array format internally - this.processSingleStatus(frontmatterStatus.toString(), statuses); + const cleanStatus = statusName.toString().trim(); + if (cleanStatus) statuses.push(cleanStatus); } } + } else { + if (this.settings.strictStatuses) { + this.addValidStatus(frontmatterStatus.toString(), statuses); + } else { + const cleanStatus = frontmatterStatus.toString().trim(); + if (cleanStatus) statuses.push(cleanStatus); + } } - - // Return 'unknown' if no statuses found + return statuses.length > 0 ? statuses : ['unknown']; } /** - * Process an array of statuses from frontmatter + * Add a status to the list if it's valid */ - private processStatusArray(statusArray: any[], targetStatuses: string[]): void { - for (const statusName of statusArray) { - const normalizedStatus = statusName.toString().toLowerCase(); - const matchingStatus = this.allStatuses.find(s => - s.name.toLowerCase() === normalizedStatus); - - if (matchingStatus) { - targetStatuses.push(matchingStatus.name); - } - } - } - - /** - * Process a single status string from frontmatter - */ - private processSingleStatus(statusString: string, targetStatuses: string[]): void { - const normalizedStatus = statusString.toLowerCase(); - const matchingStatus = this.allStatuses.find(s => + private addValidStatus(statusName: string, targetStatuses: string[]): void { + const normalizedStatus = statusName.toLowerCase(); + const matchingStatus = this.allStatuses.find(s => s.name.toLowerCase() === normalizedStatus); - - if (matchingStatus) targetStatuses.push(matchingStatus.name); - } - - /** - * Get the primary status of a file (first one, or 'unknown') - */ - public getFilePrimaryStatus(file: TFile): string { - const statuses = this.getFileStatuses(file); - return statuses[0] || 'unknown'; + + if (matchingStatus) { + targetStatuses.push(matchingStatus.name); + } } /** @@ -158,167 +125,37 @@ export class StatusService { return customStatus ? customStatus.icon : '❓'; } - /** - * Update the statuses of a note - * @param newStatuses Array of status names to set - * @param file Optional file to update, otherwise uses active file - */ - public async updateNoteStatuses(newStatuses: string[], file?: TFile): Promise { - const targetFile = file || this.app.workspace.getActiveFile(); - if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return; - - // Use processFrontMatter instead of manual read/modify - await this.app.fileManager.processFrontMatter(targetFile, (frontmatter) => { - frontmatter[this.settings.tagPrefix] = newStatuses; - }); - - // Dispatch events for UI update - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses: newStatuses, file: targetFile.path } - })); - } - - /** - * Legacy method to update a single status for backward compatibility - */ - public async updateNoteStatus(newStatus: string, file?: TFile): Promise { - // Always store as an array even in single status mode - await this.updateNoteStatuses([newStatus], file); - } - - /** - * Add a status to a note's existing statuses - */ - public async addNoteStatus(statusToAdd: string, file?: TFile): Promise { - const targetFile = file || this.app.workspace.getActiveFile(); - if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return; - - const currentStatuses = this.getFileStatuses(targetFile); - - // Don't add if already exists - if (currentStatuses.includes(statusToAdd)) return; - - // Filter out 'unknown' status when adding valid statuses - const filteredStatuses = currentStatuses.filter(s => s !== 'unknown'); - const newStatuses = [...filteredStatuses, statusToAdd]; - - await this.updateNoteStatuses(newStatuses, targetFile); - - // Trigger UI updates - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses: newStatuses } - })); - } - - /** - * Remove a status from a note's existing statuses - */ - public async removeNoteStatus(statusToRemove: string, file?: TFile): Promise { - const targetFile = file || this.app.workspace.getActiveFile(); - if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return; - - const currentStatuses = this.getFileStatuses(targetFile); - const newStatuses = currentStatuses.filter(status => status !== statusToRemove); - - await this.updateNoteStatuses(newStatuses, targetFile); - - // Trigger UI updates - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses: newStatuses } - })); - } - - /** - * Toggle a status on/off for a note - */ - public async toggleNoteStatus(statusToToggle: string, file?: TFile): Promise { - const targetFile = file || this.app.workspace.getActiveFile(); - if (!targetFile || targetFile.extension !== 'md') return; - - const currentStatuses = this.getFileStatuses(targetFile); - let newStatuses: string[]; - - if (currentStatuses.includes(statusToToggle)) { - newStatuses = currentStatuses.filter(status => status !== statusToToggle); - } else { - // Filter out 'unknown' status when adding valid statuses - const filteredStatuses = currentStatuses.filter(s => s !== 'unknown'); - newStatuses = [...filteredStatuses, statusToToggle]; - } - - await this.updateNoteStatuses(newStatuses, targetFile); - - // Ensure UI updates - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses: newStatuses } - })); - } - - /** - * Batch update multiple files' statuses - * @param files Array of files to update - * @param statusesToSet Array of statuses to set - * @param mode 'replace' to replace all statuses, 'add' to add to existing - * @param showNotice Whether to show a notification (default: true) - */ - public async batchUpdateStatuses( - files: TFile[], - statusesToSet: string[], - mode: 'replace' | 'add' = 'replace', - showNotice = true - ): Promise { - if (files.length === 0) { - if (showNotice) { - new Notice('No files selected'); - } - return; - } - - for (const file of files) { - if (mode === 'replace') { - await this.updateNoteStatuses(statusesToSet, file); - } else { - // Add each status - for (const status of statusesToSet) { - await this.addNoteStatus(status, file); - } - } - } - - // Only show notice if explicitly requested and we're dealing with multiple files - if (showNotice && files.length > 1) { - const statusText = this.formatStatusText(statusesToSet); - new Notice(`Updated ${files.length} files with ${statusText}`); - } - } - - /** - * Format status text for notifications - */ - private formatStatusText(statusesToSet: string[]): string { - return statusesToSet.length === 1 - ? statusesToSet[0] - : `${statusesToSet.length} statuses`; - } - - /** * Insert status metadata in the editor */ - public insertStatusMetadataInEditor(editor: Editor): void { - const content = editor.getValue(); +public insertStatusMetadataInEditor(editor: Editor): void { + const content = editor.getValue(); + const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/); + + // Check if status metadata already exists + const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm'); + + if (frontMatterMatch) { + const frontMatter = frontMatterMatch[1]; + if (frontMatter.match(statusTagRegex)) { + // Status already exists, do nothing + return; + } + // Add to existing frontmatter const defaultStatuses = ['unknown']; const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`; - - // Check if frontmatter exists - const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/); - - if (frontMatterMatch) { - this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata); - } else { - this.createFrontmatterWithStatus(editor, content, statusMetadata); + this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata); + } else { + // No frontmatter, check if status exists in content somehow + if (content.match(statusTagRegex)) { + return; } + // Create new frontmatter + const defaultStatuses = ['unknown']; + const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`; + this.createFrontmatterWithStatus(editor, content, statusMetadata); } +} /** * Insert status metadata into existing frontmatter @@ -330,16 +167,11 @@ export class StatusService { statusMetadata: string ): void { const frontMatter = frontMatterMatch[1]; - let updatedFrontMatter = frontMatter; - - // Check if status tag already exists in frontmatter const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm'); - if (frontMatter.match(statusTagRegex)) { - updatedFrontMatter = frontMatter.replace(statusTagRegex, statusMetadata); - } else { - updatedFrontMatter = `${frontMatter}\n${statusMetadata}`; - } + const updatedFrontMatter = frontMatter.match(statusTagRegex) + ? frontMatter.replace(statusTagRegex, statusMetadata) + : `${frontMatter}\n${statusMetadata}`; const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`); editor.setValue(updatedContent); @@ -358,14 +190,11 @@ export class StatusService { */ public getMarkdownFiles(searchQuery = ''): TFile[] { const files = this.app.vault.getMarkdownFiles(); + if (!searchQuery) return files; - if (!searchQuery) { - return files; - } - - return files.filter(file => - file.basename.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const lowerQuery = searchQuery.toLowerCase(); + return files.filter(file => + file.basename.toLowerCase().includes(lowerQuery)); } /** @@ -375,22 +204,18 @@ export class StatusService { const statusGroups: Record = {}; // Initialize groups for all statuses - this.allStatuses.forEach(status => { + for (const status of this.allStatuses) { statusGroups[status.name] = []; - }); - - // Ensure 'unknown' status is included - if (!statusGroups['unknown']) { - statusGroups['unknown'] = []; } + + // Ensure 'unknown' status exists + statusGroups['unknown'] = statusGroups['unknown'] || []; - // Get all markdown files and filter by search query + // Get and process all files matching the search query const files = this.getMarkdownFiles(searchQuery); - for (const file of files) { const statuses = this.getFileStatuses(file); - // Add file to each of its status groups for (const status of statuses) { if (statusGroups[status]) { statusGroups[status].push(file); @@ -400,4 +225,164 @@ export class StatusService { return statusGroups; } -} \ No newline at end of file + + /** + * Centralizes all status modification operations + */ + private async modifyNoteStatus(options: { + files: TFile | TFile[]; + statuses: string | string[]; + operation: 'set' | 'add' | 'remove' | 'toggle'; + showNotice?: boolean; + }): Promise { + const { operation, showNotice = true } = options; + const targetFiles = Array.isArray(options.files) ? options.files : [options.files]; + const targetStatuses = Array.isArray(options.statuses) ? options.statuses : [options.statuses]; + + if (targetFiles.length === 0) { + if (showNotice) new Notice('No files selected'); + return; + } + + // Process each file + const updatePromises = targetFiles.map(async (file) => { + if (!file || file.extension !== 'md') return; + + // Get current statuses for the file + const currentStatuses = this.getFileStatuses(file); + let newStatuses: string[] = []; + + switch (operation) { + case 'set': + // Replace all statuses with the new ones + newStatuses = [...targetStatuses]; + break; + + case 'add': + // Add new statuses without duplicates + newStatuses = [...new Set([ + ...currentStatuses.filter(s => s !== 'unknown'), + ...targetStatuses + ])]; + break; + + case 'remove': + // Remove specified statuses + newStatuses = currentStatuses.filter( + status => !targetStatuses.includes(status) + ); + break; + + case 'toggle': + // Toggle each status (add if not present, remove if present) + newStatuses = [...currentStatuses]; + for (const status of targetStatuses) { + if (currentStatuses.includes(status)) { + newStatuses = newStatuses.filter(s => s !== status); + } else { + newStatuses = [...newStatuses.filter(s => s !== 'unknown'), status]; + } + } + break; + } + + // Handle empty result (should revert to unknown) + if (newStatuses.length === 0) { + newStatuses = ['unknown']; + } + + // Apply updates to frontmatter + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + frontmatter[this.settings.tagPrefix] = newStatuses; + }); + + + this.notifyStatusChanged(newStatuses, file) + }); + + await Promise.all(updatePromises); + + // Show notification for batch operations + if (showNotice && targetFiles.length > 1) { + const statusText = targetStatuses.length === 1 + ? targetStatuses[0] + : `${targetStatuses.length} statuses`; + const operationText = operation === 'set' ? 'updated' : + operation === 'add' ? 'added to' : + operation === 'remove' ? 'removed from' : 'toggled on'; + + new Notice(`${statusText} ${operationText} ${targetFiles.length} files`); + } + } + + /** + * Handles UI-triggered status changes with appropriate logic based on context + * This is the central function that all UI components should call for status changes + */ + public async handleStatusChange(options: { + files: TFile | TFile[]; + statuses: string | string[]; + isMultipleSelection?: boolean; + allowMultipleStatuses?: boolean; + operation?: 'set' | 'add' | 'remove' | 'toggle'; + showNotice?: boolean; + afterChange?: (updatedStatuses: string[]) => void; + }): Promise { + const { + files, + statuses, + isMultipleSelection = false, + allowMultipleStatuses = this.settings.useMultipleStatuses, + operation: explicitOperation, + showNotice = isMultipleSelection, + afterChange + } = options; + + const targetFiles = Array.isArray(files) ? files : [files]; + const targetStatuses = Array.isArray(statuses) ? statuses : [statuses]; + + // Determine operation based on context if not explicitly specified + let operation: 'set' | 'add' | 'remove' | 'toggle'; + + if (explicitOperation) { + operation = explicitOperation; + } else if (isMultipleSelection) { + // For multiple files, we need to check if we should add or remove + const firstStatus = targetStatuses[0]; // Use first status for multi-file operations + const filesWithStatus = targetFiles.filter(file => + this.getFileStatuses(file).includes(firstStatus) + ); + + operation = filesWithStatus.length > targetFiles.length / 2 ? 'remove' : 'add'; + } else { + // For single file operations + if (allowMultipleStatuses) { + operation = 'toggle'; + } else { + operation = 'set'; + } + } + + // Apply the changes + await this.modifyNoteStatus({ + files: targetFiles, + statuses: targetStatuses, + operation, + showNotice + }); + + } + + /** + * Dispatch status changed event + */ + private notifyStatusChanged(statuses: string[], file?: TFile): void { + // Dispatch the specific status change event + window.dispatchEvent(new CustomEvent('note-status:status-changed', { + detail: { + statuses, + file: file?.path + } + })); + } +} diff --git a/services/style-service.ts b/services/style-service.ts index 61991d8..a34634b 100644 --- a/services/style-service.ts +++ b/services/style-service.ts @@ -5,93 +5,98 @@ import { PREDEFINED_TEMPLATES } from '../constants/status-templates'; * Handles dynamic styling for the plugin */ export class StyleService { - private dynamicStyleEl?: HTMLStyleElement; - private settings: NoteStatusSettings; + private dynamicStyleEl?: HTMLStyleElement; + private settings: NoteStatusSettings; - constructor(settings: NoteStatusSettings) { - this.settings = settings; - this.initializeDynamicStyles(); - } + constructor(settings: NoteStatusSettings) { + this.settings = settings; + this.initializeDynamicStyles(); + } - /** - * Updates the settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.updateDynamicStyles(); - } + /** + * Updates the settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.updateDynamicStyles(); + } - /** - * Creates the style element if it doesn't exist - */ - private initializeDynamicStyles(): void { - if (!this.dynamicStyleEl) { - this.dynamicStyleEl = document.createElement('style'); - document.head.appendChild(this.dynamicStyleEl); - } - this.updateDynamicStyles(); - } + /** + * Creates the style element if it doesn't exist + */ + private initializeDynamicStyles(): void { + if (!this.dynamicStyleEl) { + this.dynamicStyleEl = document.createElement('style'); + document.head.appendChild(this.dynamicStyleEl); + } + this.updateDynamicStyles(); + } - /** - * Get all status colors including those from enabled templates - */ - private getAllStatusColors(): Record { - const colors = { ...this.settings.statusColors }; + /** + * Updates the dynamic styles based on current settings + */ + private updateDynamicStyles(): void { + if (!this.dynamicStyleEl) { + this.initializeDynamicStyles(); + return; + } - // Add colors from template statuses if not using custom statuses only - if (!this.settings.useCustomStatusesOnly) { - for (const templateId of this.settings.enabledTemplates) { - const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId); - if (template) { - // Add template colors if they don't already exist in colors - for (const status of template.statuses) { - if (status.color && !colors[status.name]) { - colors[status.name] = status.color; - } - } - } - } - } + const allColors = this.getAllStatusColors(); + const cssRules = this.generateColorCssRules(allColors); + this.dynamicStyleEl.textContent = cssRules; + } + + /** + * Get all status colors including those from enabled templates + */ + private getAllStatusColors(): Record { + const colors = { ...this.settings.statusColors }; - return colors; - } + if (this.settings.useCustomStatusesOnly) return colors; + + // Add colors from template statuses + for (const templateId of this.settings.enabledTemplates) { + const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId); + if (!template) continue; + + for (const status of template.statuses) { + if (status.color && !colors[status.name]) { + colors[status.name] = status.color; + } + } + } - /** - * Updates the dynamic styles based on current settings - */ - public updateDynamicStyles(): void { - if (!this.dynamicStyleEl) { - this.initializeDynamicStyles(); - return; - } + return colors; + } - // Get all status colors - const allColors = this.getAllStatusColors(); + /** + * Generate CSS rules for status colors + */ + private generateColorCssRules(colors: Record): string { + let css = ''; + + for (const [status, color] of Object.entries(colors)) { + css += ` + .status-${status} { + color: ${color} !important; + } + .note-status-bar .note-status-${status}, + .nav-file-title .note-status-${status} { + color: ${color} !important; + } + `; + } + + return css; + } - // Generate CSS for status colors - let css = ''; - for (const [status, color] of Object.entries(allColors)) { - css += ` - .status-${status} { - color: ${color} !important; - } - .note-status-bar .note-status-${status}, - .nav-file-title .note-status-${status} { - color: ${color} !important; - } - `; - } - - this.dynamicStyleEl.textContent = css; - } - - /** - * Cleans up styles when plugin is unloaded - */ - public unload(): void { - if (this.dynamicStyleEl) { - this.dynamicStyleEl.remove(); - this.dynamicStyleEl = undefined; - } - } + /** + * Cleans up styles when plugin is unloaded + */ + public unload(): void { + if (this.dynamicStyleEl) { + this.dynamicStyleEl.remove(); + this.dynamicStyleEl = undefined; + } + } } \ No newline at end of file diff --git a/settings/settings-tab.ts b/settings/settings-tab.ts deleted file mode 100644 index 928abe3..0000000 --- a/settings/settings-tab.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { App, PluginSettingTab, Setting, Notice } from 'obsidian'; -import { Status } from '../models/types'; -import { PREDEFINED_TEMPLATES } from '../constants/status-templates'; -import NoteStatus from 'main'; - -/** - * Settings tab for the Note Status plugin - */ -export class NoteStatusSettingTab extends PluginSettingTab { - plugin: NoteStatus; - - constructor(app: App, plugin: any) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - - // Status Template section - this.displayTemplateSettings(containerEl.createDiv()); - - // UI section - new Setting(containerEl).setName('User interface').setHeading(); - - - // Status bar settings - new Setting(containerEl) - .setName('Show status bar') - .setDesc('Display the status bar') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showStatusBar) - .onChange(async (value) => { - this.plugin.settings.showStatusBar = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Auto-hide status bar') - .setDesc('Hide the status bar when status is unknown') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.autoHideStatusBar) - .onChange(async (value) => { - this.plugin.settings.autoHideStatusBar = value; - await this.plugin.saveSettings(); - })); - - // File explorer settings - new Setting(containerEl) - .setName('Show status icons in file explorer') - .setDesc('Display status icons in the file explorer') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showStatusIconsInExplorer) - .onChange(async (value) => { - this.plugin.settings.showStatusIconsInExplorer = value; - await this.plugin.saveSettings(); - })); - - // NEW SETTING: Hide unknown status in explorer - new Setting(containerEl) - .setName('Hide unknown status in file explorer') - .setDesc('Hide status icons for files with unknown status in the file explorer') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.hideUnknownStatusInExplorer || false) - .onChange(async (value) => { - this.plugin.settings.hideUnknownStatusInExplorer = value; - await this.plugin.saveSettings(); - - // Refresh explorer icons when this setting changes - this.plugin.explorerIntegration.updateAllFileExplorerIcons(); - })); - - // Compact view setting - new Setting(containerEl) - .setName('Default to compact view') - .setDesc('Start the status pane in compact view by default') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.compactView || false) - .onChange(async (value) => { - this.plugin.settings.compactView = value; - await this.plugin.saveSettings(); - })); - - // Add option to exclude unknown status in pane view for performance - new Setting(containerEl) - .setName('Exclude unassigned notes from status pane') - .setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.excludeUnknownStatus || false) - .onChange(async (value) => { - this.plugin.settings.excludeUnknownStatus = value; - await this.plugin.saveSettings(); - - // Refresh the status pane if open - const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0]; - if (statusPane && statusPane.view) { - // Trigger refresh - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - } - })); - - new Setting(containerEl).setName('Status tag').setHeading(); - - // Option to use multiple statuses - new Setting(containerEl) - .setName('Enable multiple statuses') - .setDesc('Allow notes to have multiple statuses at the same time') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.useMultipleStatuses) - .onChange(async (value) => { - this.plugin.settings.useMultipleStatuses = value; - await this.plugin.saveSettings(); - - // Refresh UI to show multi-select or single-select options - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - })); - - // Status tag prefix - new Setting(containerEl) - .setName('Status tag prefix') - .setDesc('The YAML frontmatter tag name used for status (default: obsidian-note-status)') - .addText(text => text - .setValue(this.plugin.settings.tagPrefix) - .onChange(async (value) => { - // Don't allow empty tag prefix - if (!value.trim()) { - return; - } - - this.plugin.settings.tagPrefix = value.trim(); - await this.plugin.saveSettings(); - - // Add this line to trigger a full UI refresh - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - })); - - - // Status management section - new Setting(containerEl).setName('Custom statuses').setHeading(); - - // Option to use only custom statuses - new Setting(containerEl) - .setName('Use only custom statuses') - .setDesc('Ignore template statuses and use only the custom statuses defined below') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.useCustomStatusesOnly || false) - .onChange(async (value) => { - this.plugin.settings.useCustomStatusesOnly = value; - await this.plugin.saveSettings(); - - // Refresh the UI to show/hide template statuses - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - this.display(); - })); - - // Custom statuses list - const statusList = containerEl.createDiv({ cls: 'custom-status-list' }); - - const renderStatuses = () => { - statusList.empty(); - - this.plugin.settings.customStatuses.forEach((status: Status, index: number) => { - const setting = new Setting(statusList) - .setName(status.name) - .setClass('status-item'); - - // Name field - now properly implemented - setting.addText(text => text - .setPlaceholder('Name') - .setValue(status.name) - .onChange(async (value) => { - const oldName = status.name; - status.name = value || 'unnamed'; - - // Update color mapping when name changes - if (oldName !== status.name) { - this.plugin.settings.statusColors[status.name] = - this.plugin.settings.statusColors[oldName]; - delete this.plugin.settings.statusColors[oldName]; - } - - await this.plugin.saveSettings(); - })); - - // Icon field - setting.addText(text => text - .setPlaceholder('Icon') - .setValue(status.icon) - .onChange(async (value) => { - status.icon = value || '❓'; - await this.plugin.saveSettings(); - })); - - // Color picker - setting.addColorPicker(colorPicker => colorPicker - .setValue(this.plugin.settings.statusColors[status.name] || '#ffffff') - .onChange(async (value) => { - this.plugin.settings.statusColors[status.name] = value; - await this.plugin.saveSettings(); - })); - - // Description field - setting.addText(text => text - .setPlaceholder('Description') - .setValue(status.description || '') - .onChange(async (value) => { - status.description = value; - await this.plugin.saveSettings(); - })); - - // Remove button - setting.addButton(button => button - .setButtonText('Remove') - .setClass('status-remove-button') - .setWarning() - .onClick(async () => { - this.plugin.settings.customStatuses.splice(index, 1); - delete this.plugin.settings.statusColors[status.name]; - await this.plugin.saveSettings(); - renderStatuses(); - })); - }); - }; - - renderStatuses(); - - // Add new status - new Setting(containerEl) - .setName('Add new status') - .setDesc('Add a custom status with a name, icon, and color') - .addButton(button => button - .setButtonText('Add Status') - .setCta() - .onClick(async () => { - const newStatus = { - name: `status${this.plugin.settings.customStatuses.length + 1}`, - icon: '⭐' - }; - - this.plugin.settings.customStatuses.push(newStatus); - this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; // Initial white color - - await this.plugin.saveSettings(); - renderStatuses(); - })); - } - - /** - * Display template settings section - */ - private displayTemplateSettings(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Status templates').setHeading(); - containerEl.createEl('p', { - text: 'Enable predefined templates to quickly add common status workflows', - cls: 'setting-item-description' - }); - - // Create templates container - const templatesContainer = containerEl.createDiv({ cls: 'templates-container' }); - - // List each template with checkbox and preview - PREDEFINED_TEMPLATES.forEach(template => { - const templateEl = templatesContainer.createDiv({ cls: 'template-item' }); - - // Template header with checkbox and name - const headerEl = templateEl.createDiv({ cls: 'template-header' }); - - // Checkbox for enabling/disabling template - const isEnabled = this.plugin.settings.enabledTemplates.includes(template.id); - const checkbox = headerEl.createEl('input', { - type: 'checkbox', - cls: 'template-checkbox' - }); - checkbox.checked = isEnabled; - - checkbox.addEventListener('change', async () => { - if (checkbox.checked) { - // Enable template - if (!this.plugin.settings.enabledTemplates.includes(template.id)) { - this.plugin.settings.enabledTemplates.push(template.id); - } - } else { - // Disable template - this.plugin.settings.enabledTemplates = this.plugin.settings.enabledTemplates.filter( - (id: string) => id !== template.id - ); - } - - await this.plugin.saveSettings(); - - // Refresh UI - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - }); - - // Template name - headerEl.createEl('span', { - text: template.name, - cls: 'template-name' - }); - - // Template description - templateEl.createEl('div', { - text: template.description, - cls: 'template-description' - }); - - // Preview statuses - const statusesEl = templateEl.createDiv({ cls: 'template-statuses' }); - - template.statuses.forEach(status => { - const statusEl = statusesEl.createEl('div', { cls: 'template-status-chip' }); - - const colorDot = statusEl.createEl('span', { cls: 'status-color-dot' }); - colorDot.style.setProperty('--dot-color', status.color || '#ffffff') - - // Status icon and name - statusEl.createSpan({ text: `${status.icon} ${status.name}` }); - }); - }); - - // Import/Export buttons for templates - const buttonsContainer = containerEl.createDiv({ cls: 'template-buttons' }); - - // Import button - const importButton = buttonsContainer.createEl('button', { - text: 'Import template', - cls: 'mod-cta' - }); - - importButton.addEventListener('click', () => { - // Show notification about upcoming feature - new Notice('Template import functionality coming soon!'); - }); - - // Export button - const exportButton = buttonsContainer.createEl('button', { - text: 'Export templates', - cls: '' - }); - - exportButton.addEventListener('click', () => { - // Show notification about upcoming feature - new Notice('Template export functionality coming soon!'); - }); - } -} diff --git a/styles.css b/styles.css index e526f25..4179f0b 100644 --- a/styles.css +++ b/styles.css @@ -1,1348 +1 @@ -/* - * Note Status Plugin for Obsidian - * Main CSS file importing all components - * - * Author: Aleix Soler -*/ - -/* Base styles and variables */ -:root { - --status-transition-time: 0.22s; - --status-border-radius: var(--radius-s, 4px); - --status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15)); - --status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2)); - --status-icon-size: 16px; - - /* Animation curves */ - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --ease-in: cubic-bezier(0.4, 0, 1, 1); -} - -/* Animations */ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.9); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes slideInFade { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeInSlideDown { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes statusDropdownFadeIn { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes statusDropdownFadeOut { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.95); - } -} - -@keyframes pulse { - 0% { transform: scale(1); } - 50% { transform: scale(0.98); } - 100% { transform: scale(1); } -} - -/* Common utility classes */ -.note-status-empty-indicator { - color: var(--text-muted); - font-style: italic; - padding: var(--size-4-1, 4px); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-accent); - font-size: 1.2em; -} - -.note-status-modal-title { - margin: 0; - font-size: 1.3em; - line-height: 1.3; -} - -.note-status-modal-section { - display: flex; - flex-direction: column; - gap: 8px; -} - -.note-status-section-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.note-status-section-title { - margin: 0; - font-size: 1em; - font-weight: var(--font-semibold); - color: var(--text-normal); -} - -.note-status-status-count { - font-size: var(--font-smaller); - color: var(--text-muted); - transition: all 0.2s var(--ease-out); -} - -.note-status-status-count.has-selection { - color: var(--text-accent); - font-weight: var(--font-semibold); -} - -.note-status-status-select-container { - position: relative; -} - -.note-status-status-select { - width: 100%; - padding: 8px; - border-radius: var(--radius-s); - background: var(--background-primary); - border: var(--input-border-width) solid var(--background-modifier-border); - color: var(--text-normal); - font-size: var(--font-smaller); - outline: none; - transition: all 0.2s var(--ease-out); - max-height: 200px; -} - -.note-status-status-select option { - padding: 6px 8px; -} - -.note-status-status-select:focus { - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--interactive-accent-hover); -} - -.note-status-empty-option { - font-style: italic; - color: var(--text-muted); - text-align: center; - padding: 12px !important; -} - -/* Action buttons in modal */ -.note-status-modal-buttons { - display: flex; - justify-content: space-between; - margin-top: 8px; -} - -.note-status-select-all, -.note-status-apply-button { - padding: 8px 16px; - border-radius: var(--radius-s); - background: var(--background-secondary); - border: var(--input-border-width) solid var(--background-modifier-border); - color: var(--text-normal); - cursor: pointer; - transition: all 0.15s var(--ease-out); - font-size: var(--font-smaller); -} - -.note-status-select-all:hover, -.note-status-apply-button:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-select-all:active, -.note-status-apply-button:active { - transform: translateY(1px); - box-shadow: none; -} - -.note-status-apply-button { - background: var(--interactive-accent); - color: var(--text-on-accent); - border-color: var(--interactive-accent); -} - -.note-status-apply-button:disabled { - opacity: 0.6; - cursor: not-allowed; - pointer-events: none; -} - -/* Toolbar button styling */ -.note-status-toolbar-badge-container { - display: flex; - align-items: center; - justify-content: center; - position: relative; -} - -.note-status-toolbar-icon { - color: var(--text-normal) !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; -} - -/* Target all possible toolbar locations */ -.note-status-count-badge { - position: absolute; - top: -5px; - right: -8px; - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border-radius: 10px; - font-size: 10px; - min-width: 14px; - height: 14px; - padding: 0 3px; - display: flex; - align-items: center; - justify-content: center; - font-weight: var(--font-bold); -} - -/* Toolbar popover styling */ -.note-status-toolbar-popover { - width: 280px; - max-width: 90vw; - border-radius: var(--radius-m); - overflow: hidden; - box-shadow: var(--shadow-m); - border: 1px solid var(--background-modifier-border); - background: var(--background-primary); -} - -/* Print styles - hide interactive elements when printing */ -@media print { - .note-status-dropdown, - .note-status-bar, - .note-status-pane { - display: none !important; - } -} - -/* Dark mode adjustments */ -.theme-dark .note-status-popover { - box-shadow: var(--shadow-l); -} - -.theme-dark .note-status-option-selecting { - box-shadow: 0 0 0 1px var(--interactive-accent); -} - -/* High contrast improvements */ -@media (forced-colors: active) { - .note-status-chip, - .note-status-option.is-selected { - border: 1px solid currentColor; - } - - .note-status-action-button:focus-visible, - .note-status-chip:focus-visible, - .note-status-option:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - } -} - -/* Component styles */ -/* - * Status Pane Styles - */ - -/* Main container */ -.note-status-pane { - padding: var(--size-4-2, 8px); - background: var(--background-secondary); - color: var(--text-normal); - font-family: var(--font-interface); - overflow-y: auto; - height: 100%; - display: flex; - flex-direction: column; -} - -/* Header elements */ -.note-status-header { - position: sticky; - top: 0; - z-index: 10; - background: var(--background-secondary); - padding-bottom: var(--size-4-2, 8px); - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: var(--size-4-2, 8px); - display: flex; - flex-direction: column; - gap: var(--size-4-2, 8px); -} - -/* Search input styles */ -.note-status-search { - width: 100%; - position: relative; -} - -.search-input-wrapper, -.note-status-search-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.search-input-icon, -.note-status-search-icon { - position: absolute; - left: var(--size-4-1, 4px); - color: var(--text-muted); - display: flex; - align-items: center; - pointer-events: none; - padding: 4px; -} - -.note-status-search-input, -.note-status-modal-search-input, -.note-status-popover-search-input { - width: 100%; - padding: var(--input-padding); - padding-left: calc(var(--size-4-1, 4px) * 3 + 18px); - border: var(--input-border-width) solid var(--background-modifier-border); - border-radius: var(--input-radius); - background: var(--background-primary); - color: var(--text-normal); - outline: none; - transition: all var(--status-transition-time) var(--ease-out); -} - -.note-status-search-input:focus, -.note-status-modal-search-input:focus, -.note-status-popover-search-input:focus { - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--interactive-accent-hover); -} - -.search-input-clear-button, -.note-status-search-clear-button { - opacity: 0; - position: absolute; - right: var(--size-4-1, 4px); - cursor: pointer; - color: var(--text-muted); - display: flex; - align-items: center; - transition: opacity var(--status-transition-time) var(--ease-out); - padding: 4px; - border-radius: 50%; -} - -.search-input-clear-button.is-visible, -.note-status-search-clear-button.is-visible { - opacity: 1; -} - -.search-input-clear-button:hover, -.note-status-search-clear-button:hover { - color: var(--text-normal); - background-color: var(--background-modifier-hover); -} - -/* Actions toolbar */ -.status-pane-actions-container { - display: flex; - gap: var(--size-4-2, 8px); - justify-content: flex-end; - margin-top: var(--size-4-1, 4px); -} - -.note-status-view-toggle, -.note-status-actions-refresh { - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - border: var(--input-border-width) solid var(--background-modifier-border); - border-radius: var(--button-radius); - background: var(--background-primary); - color: var(--text-normal); - cursor: pointer; - transition: all var(--status-transition-time) var(--ease-out); - display: flex; - align-items: center; - justify-content: center; -} - -.note-status-view-toggle:hover, -.note-status-actions-refresh:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-view-toggle:active, -.note-status-actions-refresh:active { - transform: translateY(1px); - box-shadow: none; -} - -/* Status Groups Container */ -.note-status-groups-container { - flex: 1; - overflow-y: auto; - padding-right: 2px; /* Prevents scrollbar from hugging the edge */ -} - -/* Status Group Styling */ -.status-group { - margin-bottom: var(--size-4-3, 12px); - border-radius: var(--radius-s); - background: var(--background-primary-alt); - box-shadow: var(--shadow-xs); - overflow: hidden; - animation: fadeInSlideDown 0.3s var(--ease-out); -} - -.status-group .nav-folder-title { - cursor: pointer; - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - background: var(--background-secondary-alt); - transition: background var(--status-transition-time) var(--ease-out); -} - -.status-group .nav-folder-title:hover { - background: var(--background-modifier-hover); -} - -.status-group .nav-folder-title-content { - font-weight: var(--font-semibold); - display: flex; - align-items: center; - gap: var(--size-4-1, 4px); -} - -/* Collapse indicators */ -.collapse-indicator { - margin-right: var(--size-4-2, 8px); - display: flex; - align-items: center; - color: var(--text-muted); - transition: transform var(--status-transition-time) var(--ease-in-out); - opacity: 1; -} - -.is-collapsed .collapse-indicator { - transform: rotate(-90deg); -} - -/* File items */ -.status-group .nav-folder-children { - padding: var(--size-4-1, 4px); - background: var(--background-primary); - transition: height var(--status-transition-time) var(--ease-out); -} - -.status-group.nav-folder.is-collapsed .nav-folder-children { - display: none; -} - -.nav-file { - border-radius: var(--radius-s); - transition: background var(--status-transition-time) var(--ease-out); - margin-bottom: 2px; - position: relative; -} - -.nav-file:hover { - background: var(--background-modifier-hover); -} - -.nav-file-title { - display: flex; - align-items: center; - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - flex-wrap: nowrap; -} - -.nav-file-icon { - color: var(--text-muted); - margin-right: var(--size-4-2, 8px); - display: flex; - align-items: center; - flex-shrink: 0; -} - -.nav-file-title-content { - flex: 1; - min-width: 0; /* Allows text to shrink properly */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Compact view */ -.compact-view .nav-file-title { - padding: 2px var(--size-4-2, 8px); - font-size: var(--font-smaller); -} - -.compact-view .nav-folder-children { - padding: 0; -} - -.compact-view .nav-file { - margin-bottom: 0; - border-radius: 0; - border-bottom: 1px solid var(--background-modifier-border); -} - -.compact-view .nav-file:last-child { - border-bottom: none; -} - -/* - * Status Dropdown and Popover Styles - */ - -/* Status Dropdown */ -.note-status-dropdown { - padding: var(--size-4-2, 8px); - background: var(--background-primary); - position: sticky; - z-index: var(--layer-popover); - display: flex; - flex-direction: column; - gap: var(--size-4-2, 8px); - box-shadow: var(--shadow-s); - border-radius: var(--radius-m); - margin: 8px auto; - max-width: 720px; - border: 1px solid var(--background-modifier-border); - transition: opacity var(--status-transition-time) var(--ease-out), - transform var(--status-transition-time) var(--ease-out); -} - -.note-status-dropdown.note-status-animate-in { - animation: slideInFade 0.25s var(--ease-out); -} - -.note-status-dropdown.note-status-animate-out { - opacity: 0; - transform: translateY(-10px); - pointer-events: none; -} - -/* Header styling */ -.note-status-dropdown-header { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: var(--size-4-1, 4px); - border-bottom: 1px solid var(--background-modifier-border); -} - -.note-status-dropdown-title { - display: flex; - align-items: center; - gap: var(--size-4-1, 4px); - font-weight: var(--font-semibold); - color: var(--text-normal); -} - -.note-status-dropdown-icon { - display: flex; - align-items: center; - justify-content: center; - color: var(--text-accent); -} - -.note-status-dropdown-actions { - display: flex; - align-items: center; - gap: var(--size-4-1, 4px); -} - -.note-status-action-button { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-s); - background: transparent; - color: var(--text-muted); - cursor: pointer; - transition: all 0.15s ease; - border: none; - padding: 0; -} - -.note-status-action-button:hover { - background-color: var(--background-modifier-hover); - color: var(--text-normal); -} - -.note-status-action-button.note-status-button-active { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - transform: scale(0.95); -} - -/* Status chips container */ -.note-status-chips-container { - display: flex; - flex-wrap: wrap; - gap: var(--size-4-1, 4px); - padding: var(--size-4-1, 4px); - min-height: 36px; - background: var(--background-primary-alt); - border-radius: var(--radius-s); -} - -/* Status chips styling */ -.note-status-chip { - display: inline-flex; - align-items: center; - padding: 4px 10px; - background: var(--background-secondary); - border-radius: 16px; - box-shadow: var(--shadow-xs); - font-size: var(--font-smaller); - transition: all 0.15s var(--ease-out); - border: 1px solid var(--background-modifier-border); - max-width: 180px; - animation: scaleIn 0.2s var(--ease-out); -} - -.note-status-chip.clickable { - cursor: pointer; -} - -.note-status-chip.clickable:hover { - background: var(--background-modifier-hover); - transform: translateY(-1px); - box-shadow: var(--status-hover-shadow); -} - -.note-status-chip-removing { - transform: scale(0.8); - opacity: 0; - pointer-events: none; - transition: all 0.15s ease-out; -} - -.note-status-chip-icon { - margin-right: 6px; -} - -.note-status-chip-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.note-status-chip-remove { - margin-left: 6px; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - background: var(--background-modifier-border); - color: var(--text-muted); - cursor: pointer; - transition: all 0.15s var(--ease-out); -} - -.note-status-chip-remove:hover { - background: var(--text-accent); - color: var(--text-on-accent); - transform: scale(1.1); -} - -/* Add status button */ -.note-status-add-button { - display: flex; - align-items: center; - padding: 8px 12px; - background: var(--background-secondary-alt); - border-radius: var(--radius-m); - margin-top: 4px; - cursor: pointer; - transition: all 0.15s var(--ease-out); - border: 1px dashed var(--background-modifier-border); -} - -.note-status-add-button:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-xs); - transform: translateY(-1px); -} - -.note-status-add-button:active { - transform: translateY(0px); - box-shadow: none; -} - -.note-status-add-icon { - display: flex; - align-items: center; - justify-content: center; - margin-right: 8px; - color: var(--text-accent); -} - -.note-status-add-text { - color: var(--text-muted); - font-size: var(--font-smaller); -} - -/* Status popover */ -.note-status-popover { - position: absolute; - background: var(--background-primary); - border-radius: var(--radius-m); - box-shadow: var(--status-box-shadow); - z-index: calc(var(--layer-popover) + 10); - max-height: 300px; - display: flex; - flex-direction: column; - border: 1px solid var(--background-modifier-border); - min-width: 200px; - overflow: hidden; - width: auto !important; - opacity: 0; - transform: scale(0.95); - transition: opacity var(--status-transition-time) var(--ease-out), - transform var(--status-transition-time) var(--ease-out); - transform-origin: top left; - pointer-events: all !important; -} - -.note-status-popover.note-status-popover-animate-in { - opacity: 1; - transform: scale(1); -} - -.note-status-popover.note-status-popover-animate-out { - opacity: 0; - transform: scale(0.95); - pointer-events: none; -} - -/* Search container */ -.note-status-popover-search { - padding: 8px; - position: sticky; - top: 0; - background: var(--background-primary); - z-index: 3; - border-bottom: 1px solid var(--background-modifier-border); -} - -/* Status options container */ -.note-status-options-container { - overflow-y: auto; - overflow-x: hidden; - max-height: 250px; - padding: var(--size-4-1, 4px); - scrollbar-width: thin; - scrollbar-color: var(--background-modifier-border) transparent; -} - -.note-status-options-container::-webkit-scrollbar { - width: 6px; -} - -.note-status-options-container::-webkit-scrollbar-thumb { - background-color: var(--background-modifier-border); - border-radius: 3px; -} - -.note-status-options-container::-webkit-scrollbar-track { - background-color: transparent; -} - -/* Empty status options */ -.note-status-empty-options { - padding: 16px 12px; - text-align: center; - color: var(--text-muted); - font-style: italic; - font-size: var(--font-smaller); -} - -/* Status option items */ -.note-status-option { - display: flex; - align-items: center; - padding: 8px 12px; - margin: 2px 4px; - border-radius: var(--radius-s); - background: var(--background-primary); - color: var(--text-normal); - cursor: pointer !important; - transition: background-color 0.15s ease; -} - -.note-status-option:hover { - background: var(--background-modifier-hover); -} - -.note-status-option.is-selected { - background: var(--background-secondary-alt); - font-weight: var(--font-medium); -} - -.note-status-option-selecting { - background-color: var(--interactive-accent) !important; - color: var(--text-on-accent) !important; - animation: pulse 0.3s var(--ease-out); -} - -.note-status-option-icon { - margin-right: 8px; - font-size: 1.1em; -} - -.note-status-option-text { - flex: 1; - font-size: var(--font-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.note-status-option-check { - margin-left: auto; - color: var(--text-accent); -} - -/* Unified dropdown */ -.note-status-unified-dropdown { - width: 280px; - max-width: 90vw; - border-radius: var(--radius-m); - overflow: hidden; - box-shadow: var(--shadow-m); - border: 1px solid var(--background-modifier-border); - background: var(--background-primary); - z-index: 9999 !important; /* Ensure it's above everything */ - pointer-events: all !important; /* Ensure dropdown receives clicks */ -} - -/* Animation for unified dropdown */ -.note-status-popover-animate-in { - animation: statusDropdownFadeIn 0.22s var(--ease-out) forwards; -} - -.note-status-popover-animate-out { - animation: statusDropdownFadeOut 0.22s var(--ease-out) forwards; -} - -/* Popover header */ -.note-status-popover-header { - padding: 12px; - border-bottom: 1px solid var(--background-modifier-border); - background: var(--background-secondary-alt); - display: flex; - align-items: center; - position: sticky; - top: 0; - z-index: 2; -} - -.note-status-popover-title { - display: flex; - align-items: center; - gap: 8px; - font-weight: var(--font-semibold); - color: var(--text-normal); -} - -.note-status-popover-icon { - display: flex; - align-items: center; - justify-content: center; - color: var(--text-accent); -} - -.note-status-popover-chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 12px; - background: var(--background-primary-alt); - max-height: 120px; - overflow-y: auto; - border-bottom: 1px solid var(--background-modifier-border); -} - -/* - * Explorer integration styles - */ - -/* Explorer icons container */ -.note-status-icon-container { - display: inline-flex; - margin-left: var(--size-4-1, 4px); - gap: 2px; - align-items: center; - flex-shrink: 0; -} - -/* Individual status icons in explorer */ -.note-status-icon { - font-size: var(--font-ui-smaller); - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--status-icon-size); - height: var(--status-icon-size); - position: relative; - z-index: 500; /* Higher than most elements */ -} - -.nav-file-title .note-status-icon::after { - bottom: 150%; /* Position above in file explorer */ -} - -/* - * Status bar styles - */ - -/* Status bar refinements */ -.note-status-bar { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: var(--radius-s); - transition: all 0.2s var(--ease-out); - cursor: pointer; -} - -.note-status-bar:hover { - background-color: var(--background-modifier-hover); -} - -.note-status-badges { - display: flex; - align-items: center; - gap: 4px; -} - -.note-status-badge { - display: flex; - align-items: center; - padding: 2px 6px; - border-radius: 10px; - background: var(--background-secondary-alt); - font-size: var(--font-ui-smaller); - max-width: 100px; - margin-right: 2px; -} - -.note-status-badge-icon { - margin-right: 2px; -} - -.note-status-badge-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Status bar positioning and visibility */ -.status-bar .note-status-icon::after { - bottom: auto; - top: -30px; -} - -/* Auto-hide behavior */ -.note-status-bar.hidden { - display: none; -} - -.note-status-bar.auto-hide { - opacity: 0.5; - transition: opacity 0.2s var(--ease-out); -} - -.note-status-bar.visible { - opacity: 1; -} - -/* Additional styling for multiple status badges */ -.note-status-additional-badges { - display: flex; - align-items: center; - gap: 2px; - margin-left: 4px; -} - -.note-status-mini-badge { - font-size: var(--font-ui-smallest); - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--background-secondary); - box-shadow: var(--shadow-xs); -} - -/* - * Context menu and toolbar styles - */ - -/* Context menu enhancements */ -.note-status-context-menu .menu-item { - display: flex; - align-items: center; -} - -.note-status-menu-header { - font-weight: var(--font-semibold); - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: 2px; - pointer-events: none !important; -} - -.note-status-menu-section { - font-size: var(--font-ui-smaller); - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - pointer-events: none !important; -} - -/* Batch modal styling */ -.note-status-batch-modal { - max-width: 550px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.note-status-modal-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 8px; -} - -/* Status batch modal specific styles */ -.note-status-batch-menu { - z-index: 1000; -} - - - - - - - - - - -/* Positioning classes for dropdowns to avoid inline styles */ -.note-status-dummy-target { - position: fixed; - z-index: 1000; - width: 0; - height: 0; - left: var(--pos-x-px, 0); - top: var(--pos-y-px, 0); - pointer-events: none; -} - -.note-status-popover-fixed { - position: fixed; - z-index: 999; -} - -/* Use CSS custom properties with attr() for dynamic positioning */ -.note-status-popover-fixed { - --pos-x: 0; - --pos-y: 0; - --max-height: 300px; - left: calc(1px * attr(data-pos-x number, 0)); - top: calc(1px * attr(data-pos-y number, 0)); - max-height: var(--max-height); -} - -/* Since attr() with units doesn't have widespread support yet, - we'll add a fallback using CSS variables that can be updated via JS */ -.note-status-popover-fixed { - left: var(--pos-x-px, 0); - top: var(--pos-y-px, 0); - max-height: var(--max-height-px, 300px); -} - -/* Status dropdown and popover animation classes */ -.note-status-popover-animate-in { - animation: statusDropdownFadeIn 0.22s var(--ease-out) forwards; -} - -.note-status-popover-animate-out { - animation: statusDropdownFadeOut 0.22s var(--ease-out) forwards; -} - -/* Status Option during selection animation */ -.note-status-option-selecting { - background-color: var(--interactive-accent) !important; - color: var(--text-on-accent) !important; - animation: pulse 0.3s var(--ease-out); -} - -/* Chip animation for removing */ -.note-status-chip-removing { - transform: scale(0.8); - opacity: 0; - pointer-events: none; - transition: all 0.15s ease-out; -} - -/* Fallback positioning for right/bottom adjustments */ -.note-status-popover-right { - left: auto !important; - right: var(--right-offset-px, 10px); -} - -.note-status-popover-bottom { - top: auto !important; - bottom: var(--bottom-offset-px, 10px); -} - -.status-color-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--dot-color, #ffffff); - margin-right: 4px; -} - -.template-buttons { - margin-top: 15px; - display: flex; - gap: 10px; -} - -/* Template selection styling */ -.template-item { - border: 1px solid var(--background-modifier-border); - border-radius: var(--status-border-radius); - margin-bottom: 10px; - padding: 10px; - background: var(--background-primary-alt); -} - -.template-header { - display: flex; - align-items: center; - margin-bottom: 5px; -} - -.template-checkbox { - margin-right: 10px; -} - -.template-name { - font-weight: bold; -} - -.template-description { - font-size: 0.9em; - color: var(--text-muted); - margin-bottom: 8px; -} - -.template-statuses { - display: flex; - flex-wrap: wrap; - gap: 5px; -} - -.template-status-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; - background: var(--background-secondary); - font-size: 0.85em; -} - -/* Pagination styles */ -.note-status-pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-top: 1px solid var(--background-modifier-border); - margin-top: 8px; -} - -.note-status-pagination-button { - padding: 4px 8px; - border-radius: var(--radius-s); - background: var(--background-secondary); - border: var(--input-border-width) solid var(--background-modifier-border); - color: var(--text-normal); - cursor: pointer; - transition: all 0.15s var(--ease-out); - font-size: var(--font-smaller); -} - -.note-status-pagination-button:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-pagination-info { - font-size: var(--font-smaller); - color: var(--text-muted); -} - -/* Loading indicator */ -.note-status-loading { - display: flex; - justify-content: center; - align-items: center; - padding: 20px; - color: var(--text-muted); - font-style: italic; -} - -.note-status-loading span { - position: relative; - padding-left: 24px; -} - -.note-status-loading span:before { - content: ''; - position: absolute; - left: 0; - top: 50%; - width: 16px; - height: 16px; - margin-top: -8px; - border: 2px solid var(--background-modifier-border); - border-top-color: var(--text-accent); - border-radius: 50%; - animation: note-status-loading-spinner 0.8s linear infinite; -} - -@keyframes note-status-loading-spinner { - to { - transform: rotate(360deg); - } -} - -/* Improve status bar styles to fix any issues */ -.note-status-bar { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: var(--radius-s); - transition: all 0.2s var(--ease-out); - cursor: pointer; - height: 22px; /* Consistent height */ -} - -/* Make sure icon container doesn't disrupt layout */ -.note-status-icon-container { - display: inline-flex; - margin-left: 4px; - gap: 2px; - align-items: center; - flex-shrink: 0; - position: relative; /* Use relative positioning */ - z-index: 1; /* Keep on top */ -} - -/* Fix title content to ensure it's always visible */ -.nav-file-title-content { - flex: 1; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-right: 4px; /* Add margin to prevent overlap */ -} - -.note-status-empty-message { - margin-bottom: 12px; - text-align: center; - color: var(--text-muted); - font-style: italic; -} - -.note-status-button-container { - display: flex; - justify-content: center; - margin-top: 16px; -} - -.note-status-show-unassigned-button { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - padding: 8px 16px; - border-radius: var(--radius-s, 4px); - border: none; - cursor: pointer; - font-size: var(--font-ui-small); - transition: all 0.2s var(--ease-out); - box-shadow: var(--shadow-s); -} - -.note-status-show-unassigned-button:hover { - background-color: var(--interactive-accent-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-m); -} - -.note-status-show-unassigned-button:active { - transform: translateY(0); - box-shadow: var(--shadow-xs); -} \ No newline at end of file +:root{--status-transition-time: .22s;--status-border-radius: var(--radius-s, 4px);--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, .15));--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, .2));--status-icon-size: 16px;--ease-in-out: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(0, 0, .2, 1);--ease-in: cubic-bezier(.4, 0, 1, 1)}@keyframes note-status-scale-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes note-status-slide-in-fade{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}@keyframes note-status-fade-in-slide-down{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}@keyframes note-status-pulse{0%{transform:scale(1)}50%{transform:scale(.98)}to{transform:scale(1)}}.theme-dark .note-status-popover{box-shadow:var(--shadow-l)}.theme-dark .note-status-option-selecting{box-shadow:0 0 0 1px var(--interactive-accent)}@media (forced-colors: active){.note-status-chip,.note-status-option.is-selected{border:1px solid currentColor}.note-status-action-button:focus-visible,.note-status-chip:focus-visible,.note-status-option:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@media print{.note-status-unified-dropdown,.note-status-bar,.note-status-pane{display:none!important}}.note-status-empty-indicator{color:var(--text-muted);font-style:italic;padding:var(--size-4-1, 4px);display:flex;align-items:center;justify-content:center;color:var(--text-accent);font-size:1.2em}.note-status-action-button{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:var(--radius-s);background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s ease;border:none;padding:0}.note-status-action-button:hover{background-color:var(--background-modifier-hover);color:var(--text-normal)}.note-status-action-button.note-status-button-active{background-color:var(--interactive-accent);color:var(--text-on-accent);transform:scale(.95)}.note-status-loading{display:flex;justify-content:center;align-items:center;padding:20px;color:var(--text-muted);font-style:italic}.note-status-loading span{position:relative;padding-left:24px}.note-status-loading span:before{content:"";position:absolute;left:0;top:50%;width:16px;height:16px;margin-top:-8px;border:2px solid var(--background-modifier-border);border-top-color:var(--text-accent);border-radius:50%;animation:note-status-loading-spinner .8s linear infinite}@keyframes note-status-loading-spinner{to{transform:rotate(360deg)}}.note-status-dummy-target{position:fixed;z-index:1000;width:0;height:0;left:var(--pos-x-px, 0);top:var(--pos-y-px, 0);pointer-events:none}.note-status-popover-fixed{position:fixed;z-index:999;--pos-x: 0;--pos-y: 0;--max-height: 300px;left:var(--pos-x-px, 0);top:var(--pos-y-px, 0);max-height:var(--max-height-px, 300px)}.note-status-popover-right{left:auto!important;right:var(--right-offset-px, 10px)}.note-status-popover-bottom{top:auto!important;bottom:var(--bottom-offset-px, 10px)}.note-status-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--radius-s);transition:all .2s var(--ease-out);cursor:pointer;height:22px}.note-status-bar:hover{background-color:var(--background-modifier-hover)}.note-status-badges{display:flex;align-items:center;gap:4px}.note-status-badge{display:flex;align-items:center;padding:2px 6px;border-radius:10px;background:var(--background-secondary-alt);font-size:var(--font-ui-smaller);max-width:100px;margin-right:2px}.note-status-badge-icon{margin-right:2px}.note-status-badge-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status-bar .note-status-icon:after{bottom:auto;top:-30px}.note-status-bar.hidden{display:none}.note-status-bar.auto-hide{opacity:.5;transition:opacity .2s var(--ease-out)}.note-status-bar.visible{opacity:1}.note-status-pane{padding:var(--size-4-2, 8px);background:var(--background-secondary);color:var(--text-normal);font-family:var(--font-interface);overflow-y:auto;height:100%;display:flex;flex-direction:column}.note-status-header{position:sticky;top:0;z-index:10;background:var(--background-secondary);padding-bottom:var(--size-4-2, 8px);border-bottom:1px solid var(--background-modifier-border);margin-bottom:var(--size-4-2, 8px);display:flex;flex-direction:column;gap:var(--size-4-2, 8px)}.note-status-search{width:100%;position:relative}.search-input-wrapper,.note-status-search-wrapper{position:relative;display:flex;align-items:center}.search-input-icon,.note-status-search-icon{position:absolute;left:var(--size-4-1, 4px);color:var(--text-muted);display:flex;align-items:center;pointer-events:none;padding:4px}.note-status-search-input,.note-status-popover-search-input{width:100%;padding:var(--input-padding);padding-left:calc(var(--size-4-1, 4px) * 3 + 18px);border:var(--input-border-width) solid var(--background-modifier-border);border-radius:var(--input-radius);background:var(--background-primary);color:var(--text-normal);outline:none;transition:all var(--status-transition-time) var(--ease-out)}.note-status-search-input:focus,.note-status-popover-search-input:focus{border-color:var(--interactive-accent);box-shadow:0 0 0 2px var(--interactive-accent-hover)}.search-input-clear-button,.note-status-search-clear-button{opacity:0;position:absolute;right:var(--size-4-1, 4px);cursor:pointer;color:var(--text-muted);display:flex;align-items:center;transition:opacity var(--status-transition-time) var(--ease-out);padding:4px;border-radius:50%}.search-input-clear-button.is-visible,.note-status-search-clear-button.is-visible{opacity:1}.search-input-clear-button:hover,.note-status-search-clear-button:hover{color:var(--text-normal);background-color:var(--background-modifier-hover)}.status-pane-actions-container{display:flex;gap:var(--size-4-2, 8px);justify-content:flex-end;margin-top:var(--size-4-1, 4px)}.note-status-view-toggle,.note-status-actions-refresh{padding:var(--size-4-1, 4px) var(--size-4-2, 8px);border:var(--input-border-width) solid var(--background-modifier-border);border-radius:var(--button-radius);background:var(--background-primary);color:var(--text-normal);cursor:pointer;transition:all var(--status-transition-time) var(--ease-out);display:flex;align-items:center;justify-content:center}.note-status-view-toggle:hover,.note-status-actions-refresh:hover{background:var(--background-modifier-hover);box-shadow:var(--shadow-s)}.note-status-view-toggle:active,.note-status-actions-refresh:active{transform:translateY(1px);box-shadow:none}.note-status-groups-container{flex:1;overflow-y:auto;padding-right:2px}.note-status-group{margin-bottom:var(--size-4-3, 12px);border-radius:var(--radius-s);background:var(--background-primary-alt);box-shadow:var(--shadow-xs);overflow:hidden;animation:note-status-fade-in-slide-down .3s var(--ease-out)}.note-status-group .nav-folder-title{cursor:pointer;padding:var(--size-4-1, 4px) var(--size-4-2, 8px);background:var(--background-secondary-alt);transition:background var(--status-transition-time) var(--ease-out)}.note-status-group .nav-folder-title:hover{background:var(--background-modifier-hover)}.note-status-group .nav-folder-title-content{font-weight:var(--font-semibold);display:flex;align-items:center;gap:var(--size-4-1, 4px)}.note-status-collapse-indicator{margin-right:var(--size-4-2, 8px);display:flex;align-items:center;color:var(--text-muted);transition:transform var(--status-transition-time) var(--ease-in-out);opacity:1}.note-status-is-collapsed .note-status-collapse-indicator{transform:rotate(-90deg)}.note-status-group .nav-folder-children{padding:var(--size-4-1, 4px);background:var(--background-primary);transition:height var(--status-transition-time) var(--ease-out)}.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children{display:none}.nav-file{border-radius:var(--radius-s);transition:background var(--status-transition-time) var(--ease-out);margin-bottom:2px;position:relative}.nav-file:hover{background:var(--background-modifier-hover)}.nav-file-title{display:flex;align-items:center;padding:var(--size-4-1, 4px) var(--size-4-2, 8px);flex-wrap:nowrap}.nav-file-icon{color:var(--text-muted);margin-right:var(--size-4-2, 8px);display:flex;align-items:center;flex-shrink:0}.nav-file-title-content{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-compact-view .nav-file-title{padding:2px var(--size-4-2, 8px);font-size:var(--font-smaller)}.note-status-compact-view .nav-folder-children{padding:0}.note-status-compact-view .nav-file{margin-bottom:0;border-radius:0;border-bottom:1px solid var(--background-modifier-border)}.note-status-compact-view .nav-file:last-child{border-bottom:none}.note-status-empty-message{margin-bottom:12px;text-align:center;color:var(--text-muted);font-style:italic}.note-status-button-container{display:flex;justify-content:center;margin-top:16px}.note-status-show-unassigned-button{background-color:var(--interactive-accent);color:var(--text-on-accent);padding:8px 16px;border-radius:var(--radius-s, 4px);border:none;cursor:pointer;font-size:var(--font-ui-small);transition:all .2s var(--ease-out);box-shadow:var(--shadow-s)}.note-status-show-unassigned-button:hover{background-color:var(--interactive-accent-hover);transform:translateY(-1px);box-shadow:var(--shadow-m)}.note-status-show-unassigned-button:active{transform:translateY(0);box-shadow:var(--shadow-xs)}.note-status-pagination{display:flex;justify-content:space-between;align-items:center;padding:8px;border-top:1px solid var(--background-modifier-border);margin-top:8px}.note-status-pagination-button{padding:4px 8px;border-radius:var(--radius-s);background:var(--background-secondary);border:var(--input-border-width) solid var(--background-modifier-border);color:var(--text-normal);cursor:pointer;transition:all .15s var(--ease-out);font-size:var(--font-smaller)}.note-status-pagination-button:hover{background:var(--background-modifier-hover);box-shadow:var(--shadow-s)}.note-status-pagination-info{font-size:var(--font-smaller);color:var(--text-muted)}.note-status-popover{position:absolute;background:var(--background-primary);border-radius:var(--radius-m);box-shadow:var(--status-box-shadow);z-index:calc(var(--layer-popover) + 10);max-height:300px;display:flex;flex-direction:column;border:1px solid var(--background-modifier-border);min-width:200px;overflow:hidden;width:auto!important;opacity:0;transform:scale(.95);transition:opacity var(--status-transition-time) var(--ease-out),transform var(--status-transition-time) var(--ease-out);transform-origin:top left;pointer-events:all!important}.note-status-popover.note-status-popover-animate-in{opacity:1;transform:scale(1)}.note-status-popover.note-status-popover-animate-out{opacity:0;transform:scale(.95);pointer-events:none}.note-status-popover-search{padding:8px;position:sticky;top:0;background:var(--background-primary);z-index:3;border-bottom:1px solid var(--background-modifier-border)}.note-status-options-container{overflow-y:auto;overflow-x:hidden;max-height:250px;padding:var(--size-4-1, 4px);scrollbar-width:thin;scrollbar-color:var(--background-modifier-border) transparent}.note-status-options-container::-webkit-scrollbar{width:6px}.note-status-options-container::-webkit-scrollbar-thumb{background-color:var(--background-modifier-border);border-radius:3px}.note-status-options-container::-webkit-scrollbar-track{background-color:transparent}.note-status-empty-options{padding:16px 12px;text-align:center;color:var(--text-muted);font-style:italic;font-size:var(--font-smaller)}.note-status-option{display:flex;align-items:center;padding:8px 12px;margin:2px 4px;border-radius:var(--radius-s);background:var(--background-primary);color:var(--text-normal);cursor:pointer!important;transition:background-color .15s ease}.note-status-option:hover{background:var(--background-modifier-hover)}.note-status-option.is-selected{background:var(--background-secondary-alt);font-weight:var(--font-medium)}.note-status-option-selecting{background-color:var(--interactive-accent)!important;color:var(--text-on-accent)!important;animation:note-status-pulse .3s var(--ease-out)}.note-status-option-icon{margin-right:8px;font-size:1.1em}.note-status-option-text{flex:1;font-size:var(--font-smaller);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-option-check{margin-left:auto;color:var(--text-accent)}.note-status-unified-dropdown{width:280px;max-width:90vw;border-radius:var(--radius-m);overflow:hidden;box-shadow:var(--shadow-m);border:1px solid var(--background-modifier-border);background:var(--background-primary);z-index:9999!important;pointer-events:all!important}.note-status-popover-animate-in{animation:note-status-dropdown-fade-in .22s var(--ease-out) forwards}@keyframes note-status-dropdown-fade-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes note-status-dropdown-fade-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.note-status-popover-header{padding:12px;border-bottom:1px solid var(--background-modifier-border);background:var(--background-secondary-alt);display:flex;align-items:center;position:sticky;top:0;z-index:2}.note-status-popover-title{display:flex;align-items:center;gap:8px;font-weight:var(--font-semibold);color:var(--text-normal)}.note-status-popover-icon{display:flex;align-items:center;justify-content:center;color:var(--text-accent)}.note-status-popover-chips{display:flex;flex-wrap:wrap;gap:6px;padding:12px;background:var(--background-primary-alt);max-height:120px;overflow-y:auto;border-bottom:1px solid var(--background-modifier-border)}.note-status-chip{display:inline-flex;align-items:center;padding:4px 10px;background:var(--background-secondary);border-radius:16px;box-shadow:var(--shadow-xs);font-size:var(--font-smaller);transition:all .15s var(--ease-out);border:1px solid var(--background-modifier-border);max-width:180px;animation:note-status-scale-in .2s var(--ease-out)}.note-status-chip.clickable{cursor:pointer}.note-status-chip.clickable:hover{background:var(--background-modifier-hover);transform:translateY(-1px);box-shadow:var(--status-hover-shadow)}.note-status-chip-removing{transform:scale(.8);opacity:0;pointer-events:none;transition:all .15s ease-out}.note-status-chip-icon{margin-right:6px}.note-status-chip-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-chip-remove{margin-left:6px;width:16px;height:16px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--background-modifier-border);color:var(--text-muted);cursor:pointer;transition:all .15s var(--ease-out)}.note-status-chip-remove:hover{background:var(--text-accent);color:var(--text-on-accent);transform:scale(1.1)}.note-status-toolbar-badge-container{display:flex;align-items:center;justify-content:center;position:relative}.note-status-toolbar-icon{color:var(--text-normal)!important;display:flex!important;align-items:center!important;justify-content:center!important}.note-status-count-badge{position:absolute;top:-5px;right:-8px;background-color:var(--interactive-accent);color:var(--text-on-accent);border-radius:10px;font-size:10px;min-width:14px;height:14px;padding:0 3px;display:flex;align-items:center;justify-content:center;font-weight:var(--font-bold)}.note-status-icon-container{display:inline-flex;margin-left:var(--size-4-1, 4px);gap:2px;align-items:center;flex-shrink:0}.note-status-icon{font-size:var(--font-ui-smaller);display:inline-flex;align-items:center;justify-content:center;width:var(--status-icon-size);height:var(--status-icon-size);position:relative;z-index:500}.nav-file-title .note-status-icon:after{bottom:150%}.status-color-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background-color:var(--dot-color, #ffffff);margin-right:4px}.template-buttons{margin-top:15px;display:flex;gap:10px}.template-item{border:1px solid var(--background-modifier-border);border-radius:var(--status-border-radius);margin-bottom:10px;padding:10px;background:var(--background-primary-alt)}.template-header{display:flex;align-items:center;margin-bottom:5px}.template-checkbox{margin-right:10px}.template-name{font-weight:700}.template-description{font-size:.9em;color:var(--text-muted);margin-bottom:8px}.template-statuses{display:flex;flex-wrap:wrap;gap:5px}.template-status-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;background:var(--background-secondary);font-size:.85em} diff --git a/styles/base.css b/styles/base.css new file mode 100644 index 0000000..1be0960 --- /dev/null +++ b/styles/base.css @@ -0,0 +1,70 @@ +/* + * Base styles and variables for Note Status Plugin + */ +:root { + --status-transition-time: 0.22s; + --status-border-radius: var(--radius-s, 4px); + --status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15)); + --status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2)); + --status-icon-size: 16px; + + /* Animation curves */ + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); +} + +/* Animations */ +@keyframes note-status-scale-in { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes note-status-slide-in-fade { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes note-status-fade-in-slide-down { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes note-status-pulse { + 0% { transform: scale(1); } + 50% { transform: scale(0.98); } + 100% { transform: scale(1); } +} + +/* Dark mode adjustments */ +.theme-dark .note-status-popover { + box-shadow: var(--shadow-l); +} + +.theme-dark .note-status-option-selecting { + box-shadow: 0 0 0 1px var(--interactive-accent); +} + +/* High contrast improvements */ +@media (forced-colors: active) { + .note-status-chip, + .note-status-option.is-selected { + border: 1px solid currentColor; + } + + .note-status-action-button:focus-visible, + .note-status-chip:focus-visible, + .note-status-option:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } +} + +/* Print styles */ +@media print { + .note-status-unified-dropdown, + .note-status-bar, + .note-status-pane { + display: none !important; + } +} \ No newline at end of file diff --git a/styles/components/dropdown.css b/styles/components/dropdown.css new file mode 100644 index 0000000..e81413c --- /dev/null +++ b/styles/components/dropdown.css @@ -0,0 +1,250 @@ +/* + * Dropdown component styles + */ +.note-status-popover { + position: absolute; + background: var(--background-primary); + border-radius: var(--radius-m); + box-shadow: var(--status-box-shadow); + z-index: calc(var(--layer-popover) + 10); + max-height: 300px; + display: flex; + flex-direction: column; + border: 1px solid var(--background-modifier-border); + min-width: 200px; + overflow: hidden; + width: auto !important; + opacity: 0; + transform: scale(0.95); + transition: opacity var(--status-transition-time) var(--ease-out), + transform var(--status-transition-time) var(--ease-out); + transform-origin: top left; + pointer-events: all !important; +} + +.note-status-popover.note-status-popover-animate-in { + opacity: 1; + transform: scale(1); +} + +.note-status-popover.note-status-popover-animate-out { + opacity: 0; + transform: scale(0.95); + pointer-events: none; +} + +/* Search container */ +.note-status-popover-search { + padding: 8px; + position: sticky; + top: 0; + background: var(--background-primary); + z-index: 3; + border-bottom: 1px solid var(--background-modifier-border); +} + +/* Status options container */ +.note-status-options-container { + overflow-y: auto; + overflow-x: hidden; + max-height: 250px; + padding: var(--size-4-1, 4px); + scrollbar-width: thin; + scrollbar-color: var(--background-modifier-border) transparent; +} + +.note-status-options-container::-webkit-scrollbar { + width: 6px; +} + +.note-status-options-container::-webkit-scrollbar-thumb { + background-color: var(--background-modifier-border); + border-radius: 3px; +} + +.note-status-options-container::-webkit-scrollbar-track { + background-color: transparent; +} + +/* Empty status options */ +.note-status-empty-options { + padding: 16px 12px; + text-align: center; + color: var(--text-muted); + font-style: italic; + font-size: var(--font-smaller); +} + +/* Status option items */ +.note-status-option { + display: flex; + align-items: center; + padding: 8px 12px; + margin: 2px 4px; + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + cursor: pointer !important; + transition: background-color 0.15s ease; +} + +.note-status-option:hover { + background: var(--background-modifier-hover); +} + +.note-status-option.is-selected { + background: var(--background-secondary-alt); + font-weight: var(--font-medium); +} + +.note-status-option-selecting { + background-color: var(--interactive-accent) !important; + color: var(--text-on-accent) !important; + animation: note-status-pulse 0.3s var(--ease-out); +} + +.note-status-option-icon { + margin-right: 8px; + font-size: 1.1em; +} + +.note-status-option-text { + flex: 1; + font-size: var(--font-smaller); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.note-status-option-check { + margin-left: auto; + color: var(--text-accent); +} + +/* Unified dropdown */ +.note-status-unified-dropdown { + width: 280px; + max-width: 90vw; + border-radius: var(--radius-m); + overflow: hidden; + box-shadow: var(--shadow-m); + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + z-index: 9999 !important; + pointer-events: all !important; +} + +/* Animation for unified dropdown */ +.note-status-popover-animate-in { + animation: note-status-dropdown-fade-in 0.22s var(--ease-out) forwards; +} + +@keyframes note-status-dropdown-fade-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes note-status-dropdown-fade-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.95); } +} + +/* Popover header */ +.note-status-popover-header { + padding: 12px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary-alt); + display: flex; + align-items: center; + position: sticky; + top: 0; + z-index: 2; +} + +.note-status-popover-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: var(--font-semibold); + color: var(--text-normal); +} + +.note-status-popover-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-accent); +} + +.note-status-popover-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 12px; + background: var(--background-primary-alt); + max-height: 120px; + overflow-y: auto; + border-bottom: 1px solid var(--background-modifier-border); +} + +/* Status chips styling */ +.note-status-chip { + display: inline-flex; + align-items: center; + padding: 4px 10px; + background: var(--background-secondary); + border-radius: 16px; + box-shadow: var(--shadow-xs); + font-size: var(--font-smaller); + transition: all 0.15s var(--ease-out); + border: 1px solid var(--background-modifier-border); + max-width: 180px; + animation: note-status-scale-in 0.2s var(--ease-out); +} + +.note-status-chip.clickable { + cursor: pointer; +} + +.note-status-chip.clickable:hover { + background: var(--background-modifier-hover); + transform: translateY(-1px); + box-shadow: var(--status-hover-shadow); +} + +.note-status-chip-removing { + transform: scale(0.8); + opacity: 0; + pointer-events: none; + transition: all 0.15s ease-out; +} + +.note-status-chip-icon { + margin-right: 6px; +} + +.note-status-chip-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.note-status-chip-remove { + margin-left: 6px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--background-modifier-border); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s var(--ease-out); +} + +.note-status-chip-remove:hover { + background: var(--text-accent); + color: var(--text-on-accent); + transform: scale(1.1); +} \ No newline at end of file diff --git a/styles/components/explorer.css b/styles/components/explorer.css new file mode 100644 index 0000000..6b008ad --- /dev/null +++ b/styles/components/explorer.css @@ -0,0 +1,58 @@ +/* + * Explorer integration styles + */ +.note-status-toolbar-badge-container { + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.note-status-toolbar-icon { + color: var(--text-normal) !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.note-status-count-badge { + position: absolute; + top: -5px; + right: -8px; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-radius: 10px; + font-size: 10px; + min-width: 14px; + height: 14px; + padding: 0 3px; + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--font-bold); +} + +/* Explorer icons container */ +.note-status-icon-container { + display: inline-flex; + margin-left: var(--size-4-1, 4px); + gap: 2px; + align-items: center; + flex-shrink: 0; +} + +/* Individual status icons in explorer */ +.note-status-icon { + font-size: var(--font-ui-smaller); + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--status-icon-size); + height: var(--status-icon-size); + position: relative; + z-index: 500; /* Higher than most elements */ +} + +.nav-file-title .note-status-icon::after { + bottom: 150%; /* Position above in file explorer */ +} \ No newline at end of file diff --git a/styles/components/settings.css b/styles/components/settings.css new file mode 100644 index 0000000..705f93a --- /dev/null +++ b/styles/components/settings.css @@ -0,0 +1,62 @@ +/* + * Settings styles + */ +/* Template styling */ +.status-color-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--dot-color, #ffffff); + margin-right: 4px; +} + +.template-buttons { + margin-top: 15px; + display: flex; + gap: 10px; +} + +/* Template selection styling */ +.template-item { + border: 1px solid var(--background-modifier-border); + border-radius: var(--status-border-radius); + margin-bottom: 10px; + padding: 10px; + background: var(--background-primary-alt); +} + +.template-header { + display: flex; + align-items: center; + margin-bottom: 5px; +} + +.template-checkbox { + margin-right: 10px; +} + +.template-name { + font-weight: bold; +} + +.template-description { + font-size: 0.9em; + color: var(--text-muted); + margin-bottom: 8px; +} + +.template-statuses { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.template-status-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + background: var(--background-secondary); + font-size: 0.85em; +} \ No newline at end of file diff --git a/styles/components/status-bar.css b/styles/components/status-bar.css new file mode 100644 index 0000000..faa63f1 --- /dev/null +++ b/styles/components/status-bar.css @@ -0,0 +1,64 @@ +/* + * Status bar styles + */ +.note-status-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-s); + transition: all 0.2s var(--ease-out); + cursor: pointer; + height: 22px; +} + +.note-status-bar:hover { + background-color: var(--background-modifier-hover); +} + +.note-status-badges { + display: flex; + align-items: center; + gap: 4px; +} + +.note-status-badge { + display: flex; + align-items: center; + padding: 2px 6px; + border-radius: 10px; + background: var(--background-secondary-alt); + font-size: var(--font-ui-smaller); + max-width: 100px; + margin-right: 2px; +} + +.note-status-badge-icon { + margin-right: 2px; +} + +.note-status-badge-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Status bar positioning and visibility */ +.status-bar .note-status-icon::after { + bottom: auto; + top: -30px; +} + +/* Auto-hide behavior */ +.note-status-bar.hidden { + display: none; +} + +.note-status-bar.auto-hide { + opacity: 0.5; + transition: opacity 0.2s var(--ease-out); +} + +.note-status-bar.visible { + opacity: 1; +} \ No newline at end of file diff --git a/styles/components/status-pane.css b/styles/components/status-pane.css new file mode 100644 index 0000000..e4db2cc --- /dev/null +++ b/styles/components/status-pane.css @@ -0,0 +1,313 @@ +/* + * Status Pane Styles + */ +.note-status-pane { + padding: var(--size-4-2, 8px); + background: var(--background-secondary); + color: var(--text-normal); + font-family: var(--font-interface); + overflow-y: auto; + height: 100%; + display: flex; + flex-direction: column; +} + +/* Header elements */ +.note-status-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--background-secondary); + padding-bottom: var(--size-4-2, 8px); + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: var(--size-4-2, 8px); + display: flex; + flex-direction: column; + gap: var(--size-4-2, 8px); +} + +/* Search input styles */ +.note-status-search { + width: 100%; + position: relative; +} + +.search-input-wrapper, +.note-status-search-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.search-input-icon, +.note-status-search-icon { + position: absolute; + left: var(--size-4-1, 4px); + color: var(--text-muted); + display: flex; + align-items: center; + pointer-events: none; + padding: 4px; +} + +.note-status-search-input, +.note-status-popover-search-input { + width: 100%; + padding: var(--input-padding); + padding-left: calc(var(--size-4-1, 4px) * 3 + 18px); + border: var(--input-border-width) solid var(--background-modifier-border); + border-radius: var(--input-radius); + background: var(--background-primary); + color: var(--text-normal); + outline: none; + transition: all var(--status-transition-time) var(--ease-out); +} + +.note-status-search-input:focus, +.note-status-popover-search-input:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} + +.search-input-clear-button, +.note-status-search-clear-button { + opacity: 0; + position: absolute; + right: var(--size-4-1, 4px); + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + transition: opacity var(--status-transition-time) var(--ease-out); + padding: 4px; + border-radius: 50%; +} + +.search-input-clear-button.is-visible, +.note-status-search-clear-button.is-visible { + opacity: 1; +} + +.search-input-clear-button:hover, +.note-status-search-clear-button:hover { + color: var(--text-normal); + background-color: var(--background-modifier-hover); +} + +/* Actions toolbar */ +.status-pane-actions-container { + display: flex; + gap: var(--size-4-2, 8px); + justify-content: flex-end; + margin-top: var(--size-4-1, 4px); +} + +.note-status-view-toggle, +.note-status-actions-refresh { + padding: var(--size-4-1, 4px) var(--size-4-2, 8px); + border: var(--input-border-width) solid var(--background-modifier-border); + border-radius: var(--button-radius); + background: var(--background-primary); + color: var(--text-normal); + cursor: pointer; + transition: all var(--status-transition-time) var(--ease-out); + display: flex; + align-items: center; + justify-content: center; +} + +.note-status-view-toggle:hover, +.note-status-actions-refresh:hover { + background: var(--background-modifier-hover); + box-shadow: var(--shadow-s); +} + +.note-status-view-toggle:active, +.note-status-actions-refresh:active { + transform: translateY(1px); + box-shadow: none; +} + +/* Status Groups Container */ +.note-status-groups-container { + flex: 1; + overflow-y: auto; + padding-right: 2px; /* Prevents scrollbar from hugging the edge */ +} + +/* Status Group Styling */ +.note-status-group { + margin-bottom: var(--size-4-3, 12px); + border-radius: var(--radius-s); + background: var(--background-primary-alt); + box-shadow: var(--shadow-xs); + overflow: hidden; + animation: note-status-fade-in-slide-down 0.3s var(--ease-out); +} + +.note-status-group .nav-folder-title { + cursor: pointer; + padding: var(--size-4-1, 4px) var(--size-4-2, 8px); + background: var(--background-secondary-alt); + transition: background var(--status-transition-time) var(--ease-out); +} + +.note-status-group .nav-folder-title:hover { + background: var(--background-modifier-hover); +} + +.note-status-group .nav-folder-title-content { + font-weight: var(--font-semibold); + display: flex; + align-items: center; + gap: var(--size-4-1, 4px); +} + +/* Collapse indicators */ +.note-status-collapse-indicator { + margin-right: var(--size-4-2, 8px); + display: flex; + align-items: center; + color: var(--text-muted); + transition: transform var(--status-transition-time) var(--ease-in-out); + opacity: 1; +} + +.note-status-is-collapsed .note-status-collapse-indicator { + transform: rotate(-90deg); +} + +/* File items */ +.note-status-group .nav-folder-children { + padding: var(--size-4-1, 4px); + background: var(--background-primary); + transition: height var(--status-transition-time) var(--ease-out); +} + +.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children { + display: none; +} + +.nav-file { + border-radius: var(--radius-s); + transition: background var(--status-transition-time) var(--ease-out); + margin-bottom: 2px; + position: relative; +} + +.nav-file:hover { + background: var(--background-modifier-hover); +} + +.nav-file-title { + display: flex; + align-items: center; + padding: var(--size-4-1, 4px) var(--size-4-2, 8px); + flex-wrap: nowrap; +} + +.nav-file-icon { + color: var(--text-muted); + margin-right: var(--size-4-2, 8px); + display: flex; + align-items: center; + flex-shrink: 0; +} + +.nav-file-title-content { + flex: 1; + min-width: 0; /* Allows text to shrink properly */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Compact view */ +.note-status-compact-view .nav-file-title { + padding: 2px var(--size-4-2, 8px); + font-size: var(--font-smaller); +} + +.note-status-compact-view .nav-folder-children { + padding: 0; +} + +.note-status-compact-view .nav-file { + margin-bottom: 0; + border-radius: 0; + border-bottom: 1px solid var(--background-modifier-border); +} + +.note-status-compact-view .nav-file:last-child { + border-bottom: none; +} + +/* Empty message and show unassigned button */ +.note-status-empty-message { + margin-bottom: 12px; + text-align: center; + color: var(--text-muted); + font-style: italic; +} + +.note-status-button-container { + display: flex; + justify-content: center; + margin-top: 16px; +} + +.note-status-show-unassigned-button { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + padding: 8px 16px; + border-radius: var(--radius-s, 4px); + border: none; + cursor: pointer; + font-size: var(--font-ui-small); + transition: all 0.2s var(--ease-out); + box-shadow: var(--shadow-s); +} + +.note-status-show-unassigned-button:hover { + background-color: var(--interactive-accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-m); +} + +.note-status-show-unassigned-button:active { + transform: translateY(0); + box-shadow: var(--shadow-xs); +} + +/* + * Pagination styles + */ +.note-status-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + border-top: 1px solid var(--background-modifier-border); + margin-top: 8px; +} + +.note-status-pagination-button { + padding: 4px 8px; + border-radius: var(--radius-s); + background: var(--background-secondary); + border: var(--input-border-width) solid var(--background-modifier-border); + color: var(--text-normal); + cursor: pointer; + transition: all 0.15s var(--ease-out); + font-size: var(--font-smaller); +} + +.note-status-pagination-button:hover { + background: var(--background-modifier-hover); + box-shadow: var(--shadow-s); +} + +.note-status-pagination-info { + font-size: var(--font-smaller); + color: var(--text-muted); +} \ No newline at end of file diff --git a/styles/index.css b/styles/index.css new file mode 100644 index 0000000..48f5275 --- /dev/null +++ b/styles/index.css @@ -0,0 +1,14 @@ +/* + * Note Status Plugin for Obsidian + * Main CSS file importing all components + * + * Author: Aleix Soler + */ + +@import 'base.css'; +@import 'utils.css'; +@import 'components/status-bar.css'; +@import 'components/status-pane.css'; +@import 'components/dropdown.css'; +@import 'components/explorer.css'; +@import 'components/settings.css'; \ No newline at end of file diff --git a/styles/utils.css b/styles/utils.css new file mode 100644 index 0000000..9d3496b --- /dev/null +++ b/styles/utils.css @@ -0,0 +1,106 @@ +/* + * Utility classes for Note Status Plugin + */ +.note-status-empty-indicator { + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-1, 4px); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-accent); + font-size: 1.2em; +} + +/* Action buttons in modal */ +.note-status-action-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-s); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; + border: none; + padding: 0; +} + +.note-status-action-button:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +.note-status-action-button.note-status-button-active { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + transform: scale(0.95); +} + +/* Loading indicator */ +.note-status-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + color: var(--text-muted); + font-style: italic; +} + +.note-status-loading span { + position: relative; + padding-left: 24px; +} + +.note-status-loading span:before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 16px; + height: 16px; + margin-top: -8px; + border: 2px solid var(--background-modifier-border); + border-top-color: var(--text-accent); + border-radius: 50%; + animation: note-status-loading-spinner 0.8s linear infinite; +} + +@keyframes note-status-loading-spinner { + to { transform: rotate(360deg); } +} + +/* Positioning classes for dropdowns */ +.note-status-dummy-target { + position: fixed; + z-index: 1000; + width: 0; + height: 0; + left: var(--pos-x-px, 0); + top: var(--pos-y-px, 0); + pointer-events: none; +} + +.note-status-popover-fixed { + position: fixed; + z-index: 999; + --pos-x: 0; + --pos-y: 0; + --max-height: 300px; + left: var(--pos-x-px, 0); + top: var(--pos-y-px, 0); + max-height: var(--max-height-px, 300px); +} + +/* Position adjustment classes */ +.note-status-popover-right { + left: auto !important; + right: var(--right-offset-px, 10px); +} + +.note-status-popover-bottom { + top: auto !important; + bottom: var(--bottom-offset-px, 10px); +} \ No newline at end of file diff --git a/ui/components/status-bar.ts b/ui/components/status-bar.ts deleted file mode 100644 index 44c38a6..0000000 --- a/ui/components/status-bar.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { setTooltip } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { StatusService } from '../../services/status-service'; - -/** - * Handles the status bar functionality - */ -export class StatusBar { - private statusBarEl: HTMLElement; - private settings: NoteStatusSettings; - private statusService: StatusService; - private currentStatuses: string[] = ['unknown']; - - constructor(statusBarEl: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) { - this.statusBarEl = statusBarEl; - this.settings = settings; - this.statusService = statusService; - - // Add initial class - this.statusBarEl.addClass('note-status-bar'); - - // Add right-click handler for force refresh - this.statusBarEl.addEventListener('contextmenu', (e) => { - e.preventDefault(); - window.dispatchEvent(new CustomEvent('note-status:force-refresh')); - }); - - // Initial render - this.update(['unknown']); - } - - /** - * Update the status bar with new statuses - */ - public update(statuses: string[]): void { - this.currentStatuses = statuses; - this.render(); - } - - /** - * Render the status bar based on current settings and statuses - */ - public render(): void { - this.statusBarEl.empty(); - this.statusBarEl.removeClass('left', 'hidden', 'auto-hide', 'visible'); - this.statusBarEl.addClass('note-status-bar'); - - if (!this.settings.showStatusBar) { - this.statusBarEl.addClass('hidden'); - return; - } - - // Handle single vs. multiple status display - if (this.currentStatuses.length === 1 || !this.settings.useMultipleStatuses) { - // Display single status - const primaryStatus = this.currentStatuses[0]; - - // Get status object to get description - const statusObj = this.statusService.getAllStatuses().find(s => s.name === primaryStatus); - const tooltipValue = statusObj?.description ? `${primaryStatus} - ${statusObj.description}` : primaryStatus; - - // Create status text - const statusText = this.statusBarEl.createEl('span', { - text: `Status: ${primaryStatus}`, - cls: `note-status-${primaryStatus}` - }); - - // Add tooltip - setTooltip(statusText, tooltipValue); - - // Create status icon - const statusIcon = this.statusBarEl.createEl('span', { - text: this.statusService.getStatusIcon(primaryStatus), - cls: `note-status-icon status-${primaryStatus}` - }); - - // Add tooltip to icon too - setTooltip(statusIcon, tooltipValue); - } else { - // Display multiple statuses - // Create status text - this.statusBarEl.createEl('span', { - text: `Statuses: `, - cls: 'note-status-label' - }); - - // Create container for status badges - const badgesContainer = this.statusBarEl.createEl('span', { - cls: 'note-status-badges' - }); - - // Add status badges - this.currentStatuses.forEach(status => { - const statusObj = this.statusService.getAllStatuses().find(s => s.name === status); - const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}` : status; - - const badge = badgesContainer.createEl('span', { - cls: `note-status-badge status-${status}` - }); - - // Add tooltip - setTooltip(badge, tooltipValue); - - badge.createEl('span', { - text: this.statusService.getStatusIcon(status), - cls: 'note-status-badge-icon' - }); - - badge.createEl('span', { - text: status, - cls: 'note-status-badge-text' - }); - }); - } - - // Handle auto-hide behavior - const onlyUnknown = this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown'; - if (this.settings.autoHideStatusBar && onlyUnknown) { - this.statusBarEl.addClass('auto-hide'); - setTimeout(() => { - if (onlyUnknown && this.settings.showStatusBar) { - this.statusBarEl.addClass('hidden'); - } - }, 500); - } else { - this.statusBarEl.addClass('visible'); - } - } - - /** - * Update settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.render(); - } - - /** - * Clean up when plugin is unloaded - */ - public unload(): void { - // Clean up any event listeners or DOM elements - this.statusBarEl.empty(); - } -} diff --git a/ui/components/status-dropdown-component.ts b/ui/components/status-dropdown-component.ts deleted file mode 100644 index 8d8ffc5..0000000 --- a/ui/components/status-dropdown-component.ts +++ /dev/null @@ -1,642 +0,0 @@ -import { App, setIcon, TFile, setTooltip } from 'obsidian'; -import { NoteStatusSettings, Status } from '../../models/types'; -import { StatusService } from '../../services/status-service'; - -/** - * Unified dropdown component for status selection - * This component handles the UI for selecting statuses across multiple contexts - */ -export class StatusDropdownComponent { - private app: App; - private statusService: StatusService; - private settings: NoteStatusSettings; - private dropdownElement: HTMLElement | null = null; - private currentStatuses: string[] = ['unknown']; - private onStatusChange: (statuses: string[]) => void; - private animationDuration = 220; - private clickOutsideHandler: (e: MouseEvent) => void; - private targetFile: TFile | null = null; - public isOpen = false; - - - constructor(app: App, statusService: StatusService, settings: NoteStatusSettings) { - this.app = app; - this.statusService = statusService; - this.settings = settings; - this.onStatusChange = () => {}; - - // Bind methods - this.handleClickOutside = this.handleClickOutside.bind(this); - this.handleEscapeKey = this.handleEscapeKey.bind(this); - this.clickOutsideHandler = this.handleClickOutside; - } - - /** - * Updates the current statuses - */ - public updateStatuses(statuses: string[] | string): void { - // Normalize input to always be an array - if (typeof statuses === 'string') { - this.currentStatuses = [statuses]; - } else { - this.currentStatuses = [...statuses]; // Create a copy to ensure it's updated - } - - // Update dropdown if it's open - if (this.isOpen && this.dropdownElement) { - this.refreshDropdownContent(); - } - } - - /** - * Set the target file for status updates - */ - public setTargetFile(file: TFile | null): void { - this.targetFile = file; - } - - /** - * Updates settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - - // Update dropdown if it's open - if (this.isOpen && this.dropdownElement) { - this.refreshDropdownContent(); - } - } - - /** - * Set callback for status changes - */ - public setOnStatusChange(callback: (statuses: string[]) => void): void { - this.onStatusChange = callback; - } - - /** - * Get the current status change callback - */ - public getOnStatusChange(): (statuses: string[]) => void { - return this.onStatusChange; - } - - /** - * Toggle the dropdown visibility - */ - public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void { - if (this.isOpen) { - this.close(); - // Add small delay to ensure dropdown is fully closed before potentially reopening - // This prevents state conflicts when clicking in rapid succession - setTimeout(() => { - // Check if we're still in the closing state to prevent reopening if user clicked elsewhere - if (!this.isOpen && !this.dropdownElement) { - this.open(targetEl, position); - } - }, 50); - } else { - this.open(targetEl, position); - } - } - - /** - * Open the dropdown - */ - public open(targetEl: HTMLElement, position?: { x: number, y: number }): void { - // Make sure any existing dropdown is fully closed - if (this.isOpen || this.dropdownElement) { - this.close(); - // Add small delay before opening new dropdown to ensure proper cleanup - setTimeout(() => { - this.actuallyOpen(targetEl, position); - }, 10); - return; - } - - this.actuallyOpen(targetEl, position); - } - - private actuallyOpen(targetEl: HTMLElement, position?: { x: number, y: number }): void { - this.isOpen = true; - - // Create dropdown element - this.createDropdownElement(); - - // Fill dropdown content - this.refreshDropdownContent(); - - // Position the dropdown - if (position) { - this.positionAt(position.x, position.y); - } else { - this.positionRelativeTo(targetEl); - } - - // Add animation class - this.dropdownElement?.addClass('note-status-popover-animate-in'); - - // Add event listeners - setTimeout(() => { - document.addEventListener('click', this.clickOutsideHandler); - document.addEventListener('keydown', this.handleEscapeKey); - }, 10); - } - - /** - * Create the dropdown element - */ - private createDropdownElement(): void { - this.dropdownElement = document.createElement('div'); - this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown'); - document.body.appendChild(this.dropdownElement); - } - - /** - * Close the dropdown - */ - public close(): void { - if (!this.dropdownElement || !this.isOpen) return; - - // Add exit animation - this.dropdownElement.addClass('note-status-popover-animate-out'); - - // Clean up event listeners - document.removeEventListener('click', this.clickOutsideHandler); - document.removeEventListener('keydown', this.handleEscapeKey); - - // Set isOpen to false immediately to prevent multiple open/close conflicts - this.isOpen = false; - - // Remove after animation completes - setTimeout(() => { - if (this.dropdownElement) { - this.dropdownElement.remove(); - this.dropdownElement = null; - } - }, this.animationDuration); - } - - /** - * Refresh the dropdown content - */ - private refreshDropdownContent(): void { - if (!this.dropdownElement) return; - - // Clear content - this.dropdownElement.empty(); - - // Create header - this.createHeader(); - - // Create status chips section - this.createStatusChips(); - - // Create search filter - const searchInput = this.createSearchFilter(); - - // Create status options container - const statusOptionsContainer = this.dropdownElement.createDiv({ - cls: 'note-status-options-container' - }); - - // Get all available statuses - const allStatuses = this.statusService.getAllStatuses() - .filter(status => status.name !== 'unknown'); - - // Create a function to populate options with filtering - const populateOptions = (filter = '') => { - this.populateStatusOptions(statusOptionsContainer, allStatuses, filter); - }; - - // Initial population - populateOptions(); - - // Add search functionality - searchInput.addEventListener('input', () => { - populateOptions(searchInput.value); - }); - - // Focus search input after a short delay - setTimeout(() => { - searchInput.focus(); - }, 50); - } - - /** - * Create the dropdown header - */ - private createHeader(): void { - if (!this.dropdownElement) return; - - const headerEl = this.dropdownElement.createDiv({ cls: 'note-status-popover-header' }); - - // Title with icon - const titleEl = headerEl.createDiv({ cls: 'note-status-popover-title' }); - const iconContainer = titleEl.createDiv({ cls: 'note-status-popover-icon' }); - setIcon(iconContainer, 'tag'); - titleEl.createSpan({ text: 'Note status', cls: 'note-status-popover-label' }); - } - - /** - * Create the status chips section - */ - private createStatusChips(): void { - if (!this.dropdownElement) return; - - const chipsContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-chips' }); - - // Show 'No status' indicator if no statuses or only unknown status - if (this.hasNoValidStatus()) { - this.createEmptyStatusIndicator(chipsContainer); - } else { - // Add chip for each status - this.createStatusChipElements(chipsContainer); - } - } - - /** - * Check if there are no valid statuses - */ - private hasNoValidStatus(): boolean { - return this.currentStatuses.length === 0 || - (this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown'); - } - - /** - * Create empty status indicator - */ - private createEmptyStatusIndicator(container: HTMLElement): void { - container.createDiv({ - cls: 'note-status-empty-indicator', - text: 'No status assigned' - }); - } - - /** - * Create chips for all current statuses - */ - private createStatusChipElements(container: HTMLElement): void { - this.currentStatuses.forEach(status => { - if (status === 'unknown') return; // Skip unknown status - - const statusObj = this.statusService.getAllStatuses().find(s => s.name === status); - if (!statusObj) return; - - this.createSingleStatusChip(container, status, statusObj); - }); - } - - /** - * Create a single status chip - */ - private createSingleStatusChip(container: HTMLElement, status: string, statusObj: Status): void { - const chipEl = container.createDiv({ - cls: `note-status-chip status-${status}` - }); - - // Status icon - chipEl.createSpan({ - text: statusObj.icon, - cls: 'note-status-chip-icon' - }); - - // Status name - chipEl.createSpan({ - text: statusObj.name, - cls: 'note-status-chip-text' - }); - - // Add remove button regardless of the number of statuses - // This allows removal even when there's only one status - this.addRemoveButton(chipEl, status); - } - - /** - * Add a remove button to a status chip - */ - private addRemoveButton(chipEl: HTMLElement, status: string): void { - const statusObj = this.statusService.getAllStatuses().find(s => s.name === status); - const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status; - - // Add tooltip to the chip element - setTooltip(chipEl, tooltipValue); - - const removeBtn = chipEl.createDiv({ - cls: 'note-status-chip-remove', - attr: { - 'aria-label': `Remove ${status} status`, - 'title': `Remove ${status} status` - } - }); - setIcon(removeBtn, 'x'); - - removeBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - - // Add remove animation - chipEl.addClass('note-status-chip-removing'); - - // Wait for animation to complete before actually removing - setTimeout(async () => { - if (this.targetFile) { - // Remove status from single file - await this.removeStatus(status); - } else { - // This is a batch operation - remove from all files - // Call the onStatusChange callback with the status to be removed - this.onStatusChange([status]); - } - }, 150); - }); - } - - /** - * Remove a status from the target file - */ - private async removeStatus(status: string): Promise { - if (!this.targetFile) return; - - // Remove this status - await this.statusService.removeNoteStatus(status, this.targetFile); - - // Get updated statuses - const updatedStatuses = this.statusService.getFileStatuses(this.targetFile); - - // Update current statuses - this.currentStatuses = updatedStatuses; - - // Refresh chips - this.refreshDropdownContent(); - - // Call status change callback - this.onStatusChange(updatedStatuses); - } - - /** - * Create the search filter input - */ - private createSearchFilter(): HTMLInputElement { - if (!this.dropdownElement) { - throw new Error("Dropdown element not initialized"); - } - - const searchContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-search' }); - const searchInput = searchContainer.createEl('input', { - type: 'text', - placeholder: 'Filter statuses...', - cls: 'note-status-popover-search-input' - }); - - return searchInput; - } - - /** - * Populate status options with optional filtering - */ - private populateStatusOptions( - container: HTMLElement, - statuses: Status[], - filter = '' - ): void { - container.empty(); - - const filteredStatuses = this.filterStatuses(statuses, filter); - - if (filteredStatuses.length === 0) { - this.createNoMatchingStatusesMessage(container, filter); - return; - } - - filteredStatuses.forEach(status => { - this.createStatusOption(container, status); - }); - } - - /** - * Filter statuses based on search term - */ - private filterStatuses(statuses: Status[], filter: string): Status[] { - if (!filter) return statuses; - - return statuses.filter(status => - status.name.toLowerCase().includes(filter.toLowerCase()) || - status.icon.includes(filter) - ); - } - - /** - * Create a message for no matching statuses - */ - private createNoMatchingStatusesMessage(container: HTMLElement, filter: string): void { - container.createDiv({ - cls: 'note-status-empty-options', - text: filter ? `No statuses match "${filter}"` : 'No statuses found' - }); - } - - /** - * Create a single status option element - */ - private createStatusOption(container: HTMLElement, status: Status): void { - const isSelected = this.currentStatuses.includes(status.name); - - const optionEl = container.createDiv({ - cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}` - }); - - // Status icon - optionEl.createSpan({ - text: status.icon, - cls: 'note-status-option-icon' - }); - - // Status name - optionEl.createSpan({ - text: status.name, - cls: 'note-status-option-text' - }); - - // Add tooltip with description if available - if (status.description) { - setTooltip(optionEl, `${status.name} - ${status.description}`); - } - - // Check icon for selected status - if (isSelected) { - const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' }); - setIcon(checkIcon, 'check'); - } - - // Add click handler - optionEl.addEventListener('click', () => this.handleStatusOptionClick(optionEl, status)); - } - - /** - * Handle click on a status option - */ - private handleStatusOptionClick(optionEl: HTMLElement, status: Status): void { - try { - // Add selection animation - optionEl.addClass('note-status-option-selecting'); - - // Apply status changes after brief delay for animation - setTimeout(async () => { - if (this.targetFile) { - await this.handleStatusChangeForTargetFile(status); - } else { - // This is for batch operations or when no specific file is targeted - this.onStatusChange([status.name]); - - // Usually we want to close after selection in this case - if (!this.settings.useMultipleStatuses) { - this.close(); - } - } - }, 150); - } catch (error) { - console.error('Error updating status:', error); - } - } - - /** - * Handle status change for a specific target file - */ - private async handleStatusChangeForTargetFile(status: Status): Promise { - if (!this.targetFile) return; - - if (this.settings.useMultipleStatuses) { - await this.statusService.toggleNoteStatus(status.name, this.targetFile); - } else { - await this.statusService.updateNoteStatuses([status.name], this.targetFile); - // Close dropdown in single status mode - this.close(); - } - - // Get fresh status from file - const freshStatuses = this.statusService.getFileStatuses(this.targetFile); - - // Update current statuses - this.currentStatuses = [...freshStatuses]; - - // Call status change callback - this.onStatusChange(freshStatuses); - } - - /** - * Position the dropdown at specific coordinates - */ - private positionAt(x: number, y: number): void { - if (!this.dropdownElement) return; - - this.dropdownElement.addClass('note-status-popover-fixed'); - this.dropdownElement.style.setProperty('--pos-x-px', `${x}px`); - this.dropdownElement.style.setProperty('--pos-y-px', `${y}px`); - - // Ensure dropdown doesn't go off-screen - setTimeout(() => this.adjustPositionToViewport(), 0); - } - - /** - * Adjust the dropdown position to ensure it's visible in the viewport - */ - private adjustPositionToViewport(): void { - if (!this.dropdownElement) return; - - const rect = this.dropdownElement.getBoundingClientRect(); - - if (rect.right > window.innerWidth) { - this.dropdownElement.addClass('note-status-popover-right'); - this.dropdownElement.style.setProperty('--right-offset-px', '10px'); - } else { - this.dropdownElement.removeClass('note-status-popover-right'); - } - - if (rect.bottom > window.innerHeight) { - this.dropdownElement.addClass('note-status-popover-bottom'); - this.dropdownElement.style.setProperty('--bottom-offset-px', '10px'); - } else { - this.dropdownElement.removeClass('note-status-popover-bottom'); - } - - // Set max height based on viewport - const maxHeight = window.innerHeight - rect.top - 20; - this.dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`); - } - - /** - * Position the dropdown relative to a target element - */ - private positionRelativeTo(targetEl: HTMLElement): void { - if (!this.dropdownElement) return; - - // Apply positioning class - this.dropdownElement.addClass('note-status-popover-fixed'); - - // Get target element's position - const targetRect = targetEl.getBoundingClientRect(); - - // Position below the element - using CSS custom properties - this.dropdownElement.style.setProperty('--pos-y-px', `${targetRect.bottom + 5}px`); - - // Align to left edge of target by default - this.dropdownElement.style.setProperty('--pos-x-px', `${targetRect.left}px`); - - // Check if dropdown would go off-screen - setTimeout(() => this.adjustRelativePosition(targetRect), 0); - } - - /** - * Adjust position when positioned relative to an element - */ - private adjustRelativePosition(targetRect: DOMRect): void { - if (!this.dropdownElement) return; - - const rect = this.dropdownElement.getBoundingClientRect(); - - if (rect.right > window.innerWidth) { - // Align to right edge instead - this.dropdownElement.addClass('note-status-popover-right'); - this.dropdownElement.style.setProperty('--right-offset-px', `${window.innerWidth - targetRect.right}px`); - } else { - this.dropdownElement.removeClass('note-status-popover-right'); - } - - if (rect.bottom > window.innerHeight) { - // Position above the element instead - this.dropdownElement.addClass('note-status-popover-bottom'); - this.dropdownElement.style.setProperty('--bottom-offset-px', `${window.innerHeight - targetRect.top + 5}px`); - } else { - this.dropdownElement.removeClass('note-status-popover-bottom'); - } - - // Set max height based on viewport - const maxHeight = window.innerHeight - rect.top - 20; - this.dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`); - } - - /** - * Handle click outside the dropdown - */ - private handleClickOutside(e: MouseEvent): void { - if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) { - this.close(); - } - } - - /** - * Handle escape key to close dropdown - */ - private handleEscapeKey(e: KeyboardEvent): void { - if (e.key === 'Escape') { - this.close(); - } - } - - /** - * Dispose of resources - */ - public dispose(): void { - this.close(); - } -} diff --git a/ui/components/status-dropdown.ts b/ui/components/status-dropdown.ts deleted file mode 100644 index 88a972e..0000000 --- a/ui/components/status-dropdown.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { MarkdownView, Editor, Notice, TFile } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { StatusService } from '../../services/status-service'; -import { StatusDropdownComponent } from './status-dropdown-component'; - -/** - * Enhanced status dropdown with toolbar integration - */ -export class StatusDropdown { - private app: any; - private settings: NoteStatusSettings; - private statusService: StatusService; - private currentStatuses: string[] = ['unknown']; - private toolbarButton?: HTMLElement; - private dropdownComponent: StatusDropdownComponent; - - constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - - // Initialize the dropdown component - this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings); - - // Configure dropdown component callbacks - this.setupDropdownCallbacks(); - - } - - /** - * Set up the dropdown callbacks - */ - private setupDropdownCallbacks(): void { - this.dropdownComponent.setOnStatusChange((statuses) => { - // Update current statuses and toolbar button - this.currentStatuses = [...statuses]; // Make sure to copy the array - this.updateToolbarButton(); - - // Dispatch events for UI update - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses } - })); - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - }); - } - - /** - * Initialize the toolbar button in the Obsidian ribbon - */ - private initToolbarButton(): void { - // Clear any existing button - if (this.toolbarButton) { - this.toolbarButton.remove(); - this.toolbarButton = undefined; - } - - // Wait for next tick to ensure the UI is fully rendered - setTimeout(() => { - const toolbarContainer = this.findToolbarContainer(); - - if (!toolbarContainer) { - console.error('Note Status: Could not find toolbar container'); - return; - } - this.createToolbarButton(toolbarContainer); - }, 500); // Waiting 500ms to ensure the UI is ready - } - - /** - * Find the toolbar container element - */ - private findToolbarContainer(): HTMLElement | null { - return document.querySelector('.workspace-leaf .view-header .view-actions') - } - - /** - * Create the toolbar button element - */ - private createToolbarButton(toolbarContainer: HTMLElement): void { - // Create the button element - this.toolbarButton = document.createElement('button'); - this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action'); - this.toolbarButton.setAttribute('aria-label', 'Note status'); - - // Update initial button state - this.updateToolbarButton(); - - // Add click handler - this.toolbarButton.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - this.toggleStatusPopover(); - }); - - // Insert at a more reliable position - just prepend to the container - try { - toolbarContainer.prepend(this.toolbarButton); - } catch (error) { - console.error('Note Status: Error inserting toolbar button', error); - // Fallback - just append it - toolbarContainer.appendChild(this.toolbarButton); - } - } - - /** - * Updates the toolbar button appearance based on current statuses - */ - private updateToolbarButton(): void { - if (!this.toolbarButton) return; - - // Clear existing content - this.toolbarButton.empty(); - - // Check if we have a valid status - const hasValidStatus = this.currentStatuses.length > 0 && - !this.currentStatuses.every(status => status === 'unknown'); - - // Create badge container - const badgeContainer = document.createElement('div'); - badgeContainer.addClass('note-status-toolbar-badge-container'); - - if (hasValidStatus) { - // Add status badge with proper icon - this.addStatusBadge(badgeContainer); - } else { - // Add default status icon when no valid status - this.addDefaultStatusIcon(badgeContainer); - } - - this.toolbarButton.appendChild(badgeContainer); - } - - /** - * Add a status badge to the toolbar button - */ - private addStatusBadge(container: HTMLElement): void { - // Show primary status icon and indicator for multiple statuses - const primaryStatus = this.currentStatuses[0]; - const statusInfo = this.statusService.getAllStatuses().find(s => s.name === primaryStatus); - - if (statusInfo) { - // Primary status icon - const iconSpan = document.createElement('span'); - iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`); - iconSpan.textContent = statusInfo.icon; - container.appendChild(iconSpan); - - // Add count indicator if multiple statuses - if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) { - const countBadge = document.createElement('span'); - countBadge.addClass('note-status-count-badge'); - countBadge.textContent = `+${this.currentStatuses.length - 1}`; - container.appendChild(countBadge); - } - } - } - - /** - * Add default status icon when no valid status - */ - private addDefaultStatusIcon(container: HTMLElement): void { - const iconSpan = document.createElement('span'); - iconSpan.addClass('note-status-toolbar-icon', 'status-unknown'); - - // Use the statusService to get the proper icon for 'unknown' status - iconSpan.textContent = this.statusService.getStatusIcon('unknown'); - container.appendChild(iconSpan); - } - - /** - * Updates the dropdown UI based on current statuses - */ - public update(currentStatuses: string[] | string): void { - // Normalize input to always be an array - if (typeof currentStatuses === 'string') { - this.currentStatuses = [currentStatuses]; - } else { - this.currentStatuses = [...currentStatuses]; // Create a copy to ensure it's updated - } - - // Update toolbar button with new status - this.updateToolbarButton(); - - // Update dropdown component - this.dropdownComponent.updateStatuses(this.currentStatuses); - } - - /** - * Updates settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.updateToolbarButton(); - this.dropdownComponent.updateSettings(settings); - } - - /** - * Toggle the status popover - */ - private toggleStatusPopover(): void { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) return; - - this.openStatusDropdown({ - target: this.toolbarButton, - files: [activeFile] - }); - } - - /** - * Show status dropdown in context menu - */ - public showInContextMenu(editor: Editor, view: MarkdownView): void { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) return; - - // Pass context to indicate this is from editor context menu - this.openStatusDropdown({ - editor: editor, - view: view, - files: [activeFile], - onStatusChange: async (statuses) => { - if (statuses.length > 0) { - // Use a direct file operation, not batch update - if (this.settings.useMultipleStatuses) { - await this.statusService.toggleNoteStatus(statuses[0], activeFile); - } else { - await this.statusService.updateNoteStatuses(statuses, activeFile); - } - - // Trigger UI updates - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses, file: activeFile.path } - })); - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - } - } - }); - } - - /** - * Get position from cursor or fallback positions - */ - private getCursorPosition(editor: Editor, view: MarkdownView): { x: number, y: number } { - try { - // Get cursor position in the document - const cursor = editor.getCursor('head'); - - // Try to find the DOM representation of the cursor position - const lineElement = view.contentEl.querySelector(`.cm-line:nth-child(${cursor.line + 1})`); - - if (lineElement) { - const rect = lineElement.getBoundingClientRect(); - // Position near the current line with some offset - return { - x: rect.left + 20, - y: rect.bottom + 5 - }; - } - - // Fallback to editor element position - const editorEl = view.contentEl.querySelector('.cm-editor'); - if (editorEl) { - const rect = editorEl.getBoundingClientRect(); - return { - x: rect.left + 100, // Offset from left - y: rect.top + 100 // Offset from top - }; - } - } catch (error) { - console.error('Error getting position for dropdown:', error); - } - - // Last resort - use middle of viewport - return { - x: window.innerWidth / 2, - y: window.innerHeight / 3 - }; - } - - /** - * Create a dummy target element for positioning - */ - private createDummyTarget(position: { x: number, y: number }): HTMLElement { - const dummyTarget = document.createElement('div'); - dummyTarget.addClass('note-status-dummy-target'); - dummyTarget.style.setProperty('--pos-x-px', `${position.x}px`); - dummyTarget.style.setProperty('--pos-y-px', `${position.y}px`); - document.body.appendChild(dummyTarget); - return dummyTarget; - } - - /** - * Render method (kept for compatibility) - */ - public render(): void { - // This is now a no-op, as the dropdown component handles everything internally - } - - /** - * Remove dropdown when plugin is unloaded - */ - public unload(): void { - // Clean up dropdown component - this.dropdownComponent.dispose(); - - // Remove toolbar button - if (this.toolbarButton) { - this.toolbarButton.remove(); - this.toolbarButton = undefined; - } - } - - /** - * Universal function to open the status dropdown in any context - * @param options Configuration options for opening the dropdown - */ - public openStatusDropdown(options: { - target?: HTMLElement; - position?: { x: number, y: number }; - files?: TFile[]; - editor?: Editor; - view?: MarkdownView; - mode?: 'replace' | 'add'; - onStatusChange?: (statuses: string[]) => void; - }): void { - // IMPORTANT: Force reset the dropdown component's state - // Add this at the beginning of the method - if (this.dropdownComponent.isOpen) { - this.dropdownComponent.close(); - // Give it a moment to clean up before proceeding - setTimeout(() => { - this._openStatusDropdown(options); - }, 50); - return; - } - - this._openStatusDropdown(options); - } - - /** - * Find common statuses across multiple files - */ - private findCommonStatuses(files: TFile[]): string[] { - if (files.length === 0) return ['unknown']; - - // Get statuses for first file - const firstFileStatuses = this.statusService.getFileStatuses(files[0]); - - // Filter to only include statuses that exist on all files - return firstFileStatuses.filter(status => { - // Skip unknown status - if (status === 'unknown') return false; - - // Check if status exists on all files - return files.every(file => - this.statusService.getFileStatuses(file).includes(status) - ); - }); - } - - /** - * Toggle a status across multiple files - */ - private async toggleStatusForFiles(files: TFile[], status: string): Promise { - // Count how many files have this status - const filesWithStatus = files.filter(file => - this.statusService.getFileStatuses(file).includes(status) - ); - - // If more than half have the status, remove it; otherwise, add it - const shouldRemove = filesWithStatus.length > files.length / 2; - - for (const file of files) { - if (shouldRemove) { - await this.statusService.removeNoteStatus(status, file); - } else { - await this.statusService.addNoteStatus(status, file); - } - } - } - - private _openStatusDropdown(options: { - target?: HTMLElement; - position?: { x: number, y: number }; - files?: TFile[]; - editor?: Editor; - view?: MarkdownView; - mode?: 'replace' | 'add'; - onStatusChange?: (statuses: string[]) => void; - }): void { - // If no files provided, use active file - const files = options.files || [this.app.workspace.getActiveFile()].filter(Boolean); - if (!files.length) { - new Notice('No files selected'); - return; - } - - // IMPORTANT: Always reset state at the beginning of each operation - // This ensures no lingering callbacks or files from previous operations - this.dropdownComponent.setTargetFile(null); - this.dropdownComponent.setOnStatusChange(() => {}); - - // Determine if we're handling single or multiple files - const isSingleFile = files.length === 1; - const targetFile = isSingleFile ? files[0] : null; - - // Set target file (if single) or null (if multiple) - this.dropdownComponent.setTargetFile(targetFile); - - // Get current statuses appropriately - let currentStatuses: string[]; - - if (targetFile) { - // For single file, get its current statuses - currentStatuses = this.statusService.getFileStatuses(targetFile); - } else if (files.length > 1) { - // For multiple files, find common statuses to display - currentStatuses = this.findCommonStatuses(files); - } else { - currentStatuses = ['unknown']; - } - - // Update dropdown with current statuses - this.dropdownComponent.updateStatuses(currentStatuses); - - // Set custom callback for status changes if provided - if (options.onStatusChange) { - // Use the provided callback directly - this.dropdownComponent.setOnStatusChange(options.onStatusChange); - } else if (!isSingleFile) { - // Create a local copy of files to avoid closure issues - const filesForBatch = [...files]; - - // Set batch operation callback for multiple files - // Use toggle behavior for batch operations like single files - this.dropdownComponent.setOnStatusChange(async (statuses) => { - if (statuses.length > 0) { - // Get the last selected status for toggling - const toggledStatus = statuses[statuses.length - 1]; - - // Toggle this status across all files - await this.toggleStatusForFiles(filesForBatch, toggledStatus); - - // Dispatch events for UI update - window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', { - detail: { - status: toggledStatus, - fileCount: filesForBatch.length - } - })); - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - } - }); - } else { - // Default callback for standard operations - this.dropdownComponent.setOnStatusChange((statuses) => { - // Just emit events for UI updates - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses } - })); - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - }); - } - - // For dropdown from editor - if (options.editor && options.view) { - const position = this.getCursorPosition(options.editor, options.view); - const dummyTarget = this.createDummyTarget(position); - this.dropdownComponent.open(dummyTarget, position); - - // Clean up dummy target - setTimeout(() => { - if (dummyTarget.parentNode) { - dummyTarget.parentNode.removeChild(dummyTarget); - } - }, 100); - return; - } - - // For dropdown from toolbar button - if (options.target) { - if (options.position) { - this.dropdownComponent.open(options.target, options.position); - } else { - const rect = options.target.getBoundingClientRect(); - const position = { - x: rect.left, - y: rect.bottom + 5 - }; - this.dropdownComponent.open(options.target, position); - } - return; - } - - // For direct position (context menus) - if (options.position) { - const dummyTarget = document.createElement('div'); - dummyTarget.addClass('note-status-dummy-target'); - dummyTarget.style.setProperty('--pos-x-px', `${options.position.x}px`); - dummyTarget.style.setProperty('--pos-y-px', `${options.position.y}px`); - document.body.appendChild(dummyTarget); - - this.dropdownComponent.open(dummyTarget, options.position); - - // Clean up dummy target - setTimeout(() => { - if (dummyTarget.parentNode) { - dummyTarget.parentNode.removeChild(dummyTarget); - } - }, 100); - return; - } - - // Fallback to center position - const center = { - x: window.innerWidth / 2, - y: window.innerHeight / 3 - }; - const fallbackTarget = document.createElement('div'); - fallbackTarget.addClass('note-status-dummy-target'); - fallbackTarget.style.setProperty('--pos-x-px', `${center.x}px`); - fallbackTarget.style.setProperty('--pos-y-px', `${center.y}px`); - document.body.appendChild(fallbackTarget); - - this.dropdownComponent.open(fallbackTarget, center); - - // Clean up fallback target - setTimeout(() => { - if (fallbackTarget.parentNode) { - fallbackTarget.parentNode.removeChild(fallbackTarget); - } - }, 100); - } - - /** - * Adds the toolbar button to the active leaf - */ - public addToolbarButtonToActiveLeaf(): void { - const activeLeaf = this.app.workspace.activeLeaf; - if (!activeLeaf || !activeLeaf.view || !(activeLeaf.view instanceof MarkdownView)) { - return; - } - - // Get the toolbar container - const containerEl = activeLeaf.view.containerEl; - const toolbarContainer = containerEl.querySelector('.view-header .view-actions'); - if (!toolbarContainer) { - return; - } - - // Check if button already exists in this toolbar - const existingButton = toolbarContainer.querySelector('.note-status-toolbar-button'); - if (existingButton) { - this.toolbarButton = existingButton as HTMLElement; - this.updateToolbarButton(); // Update existing button - return; - } - - // Create new button - this.toolbarButton = document.createElement('button'); - this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action'); - this.toolbarButton.setAttribute('aria-label', 'Note status'); - - // Update the button state - this.updateToolbarButton(); - - // Add click handler - this.toolbarButton.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - this.toggleStatusPopover(); - }); - - // Add to toolbar at the beginning (left side) instead of the end - if (toolbarContainer.firstChild) { - toolbarContainer.insertBefore(this.toolbarButton, toolbarContainer.firstChild); - } else { - toolbarContainer.appendChild(this.toolbarButton); - } - } -} \ No newline at end of file diff --git a/ui/components/status-pane-view.ts b/ui/components/status-pane-view.ts deleted file mode 100644 index 2c98460..0000000 --- a/ui/components/status-pane-view.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { TFile, WorkspaceLeaf, View, Menu, Notice, setIcon } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { StatusService } from '../../services/status-service'; -import NoteStatus from 'main'; - -/** - * Status Pane View for managing note statuses - */ -export class StatusPaneView extends View { - plugin: NoteStatus; - searchInput: HTMLInputElement | null = null; - private settings: NoteStatusSettings; - private statusService: StatusService; - private paginationState: { - itemsPerPage: number; - currentPage: Record; - }; - - constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { - super(leaf); - this.plugin = plugin; - this.settings = plugin.settings; - this.statusService = plugin.statusService; - - // Initialize pagination - this.paginationState = { - itemsPerPage: 100, // Show 100 items per page by default - currentPage: {} // Track current page for each status - }; - } - - getViewType(): string { - return 'status-pane'; - } - - getDisplayText(): string { - return 'Status pane'; - } - - getIcon(): string { - return 'status-pane'; - } - - async onOpen(): Promise { - await this.setupPane(); - } - - async setupPane(): Promise { - const { containerEl } = this; - containerEl.empty(); - containerEl.addClass('note-status-pane', 'nav-files-container'); - - // Add a header container for better layout - const headerContainer = containerEl.createDiv({ cls: 'note-status-header' }); - - // Search container with search input - this.createSearchInput(headerContainer); - - // Actions toolbar (view toggle, refresh) - this.createActionToolbar(headerContainer); - - // Set initial compact view state - containerEl.toggleClass('compact-view', this.settings.compactView); - - // Add a container for the groups - const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' }); - - // Add loading indicator - const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' }); - loadingIndicator.innerHTML = 'Loading notes...'; - - // Use non-blocking render - setTimeout(async () => { - await this.renderGroups(''); - loadingIndicator.remove(); - }, 10); - } - - private createSearchInput(container: HTMLElement): void { - const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' }); - const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' }); - - // Search icon - const searchIcon = searchWrapper.createEl('span', { cls: 'search-input-icon' }); - setIcon(searchIcon, 'search'); - - // Create the search input - this.searchInput = searchWrapper.createEl('input', { - type: 'text', - placeholder: 'Search notes...', - cls: 'note-status-search-input search-input' - }); - - // Add search event listener - this.searchInput.addEventListener('input', () => { - this.renderGroups(this.searchInput!.value.toLowerCase()); - this.toggleClearButton(); - }); - - // Clear search button (hidden by default) - const clearSearchBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' }); - setIcon(clearSearchBtn, 'x'); - - clearSearchBtn.addEventListener('click', () => { - if (this.searchInput) { - this.searchInput.value = ''; - this.renderGroups(''); - this.toggleClearButton(); - } - }); - } - - private toggleClearButton(): void { - const clearBtn = this.containerEl.querySelector('.search-input-clear-button'); - if (clearBtn && this.searchInput) { - clearBtn.toggleClass('is-visible', !!this.searchInput.value); - } - } - - private createActionToolbar(container: HTMLElement): void { - const actionsContainer = container.createDiv({ cls: 'status-pane-actions-container' }); - - // Toggle compact view button - const viewToggleButton = actionsContainer.createEl('button', { - type: 'button', - title: this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View', - cls: 'note-status-view-toggle clickable-icon' - }); - - setIcon(viewToggleButton, this.settings.compactView ? 'layout' : 'table'); - - viewToggleButton.addEventListener('click', async () => { - this.settings.compactView = !this.settings.compactView; - viewToggleButton.title = this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View'; - viewToggleButton.empty(); - setIcon(viewToggleButton, this.settings.compactView ? 'layout' : 'table'); - - // Trigger settings update - window.dispatchEvent(new CustomEvent('note-status:settings-changed')); - - this.containerEl.toggleClass('compact-view', this.settings.compactView); - await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - }); - - // Refresh button - const refreshButton = actionsContainer.createEl('button', { - type: 'button', - title: 'Refresh statuses', - cls: 'note-status-actions-refresh clickable-icon' - }); - - setIcon(refreshButton, 'refresh-cw'); - - refreshButton.addEventListener('click', async () => { - await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - new Notice('Status pane refreshed'); - }); - } - - async renderGroups(searchQuery = ''): Promise { - const { containerEl } = this; - const groupsContainer = containerEl.querySelector('.note-status-groups-container'); - if (!groupsContainer) return; - - // Show loading indicator for non-empty search queries - if (searchQuery) { - groupsContainer.empty(); - const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' }); - loadingIndicator.innerHTML = `Searching for "${searchQuery}"...`; - - // Let the UI update before continuing - await new Promise(resolve => setTimeout(resolve, 0)); - } else { - groupsContainer.empty(); - } - - // Group files by status (with optimizations) - const statusGroups = this.getFilteredStatusGroups(searchQuery); - - // Remove the loading indicator if it exists - const loadingIndicator = groupsContainer.querySelector('.note-status-loading'); - if (loadingIndicator) { - loadingIndicator.remove(); - } - - // Render each status group - Object.entries(statusGroups).forEach(([status, files]) => { - if (files.length > 0) { - // Skip unknown status if setting enabled - if (status === 'unknown' && this.settings.excludeUnknownStatus) { - return; - } - this.renderStatusGroup(groupsContainer as HTMLElement, status, files); - } - }); - - // If no groups were rendered, show a message - if (groupsContainer.childElementCount === 0) { - const emptyMessage = groupsContainer.createDiv({ cls: 'note-status-empty-indicator' }); - if (searchQuery) { - emptyMessage.textContent = `No notes found matching "${searchQuery}"`; - } else if (this.settings.excludeUnknownStatus) { - // Clear any existing content and use a structured layout - emptyMessage.empty(); - - // Add message text in its own container - emptyMessage.createDiv({ - text: 'No notes with status found. Unassigned notes are currently hidden.', - cls: 'note-status-empty-message' - }); - - // Create separate container for the button - const btnContainer = emptyMessage.createDiv({ - cls: 'note-status-button-container' - }); - - // Add a styled button in its own container - const showUnknownBtn = btnContainer.createEl('button', { - text: 'Show unassigned notes', - cls: 'note-status-show-unassigned-button' - }); - - showUnknownBtn.addEventListener('click', async () => { - this.settings.excludeUnknownStatus = false; - await this.plugin.saveSettings(); - this.renderGroups(searchQuery); - }); - } - } - } - - /** - * Optimized method to get status groups with filtering - */ - private getFilteredStatusGroups(searchQuery = ''): Record { - // Use the statusService but apply our own filtering for better performance - const rawGroups = this.statusService.groupFilesByStatus(searchQuery); - const filteredGroups: Record = {}; - - // Filter out empty groups and respect the excludeUnknownStatus setting - Object.entries(rawGroups).forEach(([status, files]) => { - if (files.length > 0 && !(status === 'unknown' && this.settings.excludeUnknownStatus)) { - filteredGroups[status] = files; - } - }); - - return filteredGroups; - } - - private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void { - const groupEl = container.createDiv({ cls: 'status-group nav-folder' }); - const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' }); - - // Create a container for the collapse button and title - const collapseContainer = titleEl.createDiv({ cls: 'collapse-indicator' }); - setIcon(collapseContainer, 'chevron-down'); - - // Create a container for the title content - const titleContentContainer = titleEl.createDiv({ cls: 'nav-folder-title-content' }); - - const statusIcon = this.statusService.getStatusIcon(status); - titleContentContainer.createSpan({ - text: `${status} ${statusIcon} (${files.length})`, - cls: `status-${status}` - }); - - // Handle collapsing/expanding behavior - const isCollapsed = this.settings.collapsedStatuses[status] ?? false; - - if (isCollapsed) { - groupEl.addClass('is-collapsed'); - collapseContainer.empty(); - setIcon(collapseContainer, 'chevron-right'); - } - - titleEl.addEventListener('click', (e) => { - e.preventDefault(); - const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed'); - - // Toggle the collapsed state - if (isCurrentlyCollapsed) { - groupEl.removeClass('is-collapsed'); - collapseContainer.empty(); - setIcon(collapseContainer, 'chevron-down'); - } else { - groupEl.addClass('is-collapsed'); - collapseContainer.empty(); - setIcon(collapseContainer, 'chevron-right'); - } - - // Update the settings - this.settings.collapsedStatuses[status] = !isCurrentlyCollapsed; - - // Trigger settings save - window.dispatchEvent(new CustomEvent('note-status:settings-changed')); - }); - - // Create and populate child elements - const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' }); - - // Sort files by name - files.sort((a, b) => a.basename.localeCompare(b.basename)); - - // Initialize pagination for this status if not already done - if (!this.paginationState.currentPage[status]) { - this.paginationState.currentPage[status] = 0; - } - - // Calculate pagination - const currentPage = this.paginationState.currentPage[status]; - const itemsPerPage = this.paginationState.itemsPerPage; - const totalPages = Math.ceil(files.length / itemsPerPage); - const startIndex = currentPage * itemsPerPage; - const endIndex = Math.min(startIndex + itemsPerPage, files.length); - - // Create file list items for current page only - const filesToRender = files.slice(startIndex, endIndex); - filesToRender.forEach(file => { - this.createFileListItem(childrenEl, file, status); - }); - - // Add pagination controls if needed - if (files.length > itemsPerPage) { - this.addPaginationControls(childrenEl, status, currentPage, totalPages, files.length); - } - } - - /** - * Add pagination controls to a group - */ - private addPaginationControls( - container: HTMLElement, - status: string, - currentPage: number, - totalPages: number, - totalItems: number - ): void { - const paginationEl = container.createDiv({ cls: 'note-status-pagination' }); - - // Add previous page button if not on first page - if (currentPage > 0) { - const prevButton = paginationEl.createEl('button', { - text: 'Previous', - cls: 'note-status-pagination-button' - }); - - prevButton.addEventListener('click', (e) => { - e.stopPropagation(); - this.paginationState.currentPage[status] = currentPage - 1; - this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - }); - } - - // Add page indicator - paginationEl.createSpan({ - text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`, - cls: 'note-status-pagination-info' - }); - - // Add next page button if not on last page - if (currentPage < totalPages - 1) { - const nextButton = paginationEl.createEl('button', { - text: 'Next', - cls: 'note-status-pagination-button' - }); - - nextButton.addEventListener('click', (e) => { - e.stopPropagation(); - this.paginationState.currentPage[status] = currentPage + 1; - this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - }); - } - } - - private createFileListItem(container: HTMLElement, file: TFile, status: string): void { - if (!file || !(file instanceof TFile)) return; // Skip if file is invalid - - const fileEl = container.createDiv({ cls: 'nav-file' }); - const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); - - // Add file icon if in standard view - if (!this.settings.compactView) { - const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' }); - setIcon(fileIcon, 'file'); - } - - // Add file name with proper class for styling - fileTitleEl.createSpan({ - text: file.basename, - cls: 'nav-file-title-content' - }); - - // Add status indicator - fileTitleEl.createSpan({ - cls: `note-status-icon nav-file-tag status-${status}`, - text: this.statusService.getStatusIcon(status) - }); - - // Add click handler to open the file - fileEl.addEventListener('click', (e) => { - e.preventDefault(); - this.app.workspace.openLinkText(file.path, file.path, true); - }); - - // Add context menu - fileEl.addEventListener('contextmenu', (e) => { - e.preventDefault(); - this.showFileContextMenu(e, file); - }); - } - - private showFileContextMenu(e: MouseEvent, file: TFile): void { - const menu = new Menu(); - - // Add status change options - menu.addItem((item) => - item.setTitle('Change status') - .setIcon('tag') - .onClick(() => { - // Use the position from the event - this.plugin.statusContextMenu.showForFile(file, e); - }) - ); - - // Add open options - menu.addItem((item) => - item.setTitle('Open in new tab') - .setIcon('lucide-external-link') - .onClick(() => { - this.app.workspace.openLinkText(file.path, file.path, 'tab'); - }) - ); - - menu.showAtMouseEvent(e); - } - - onClose(): Promise { - this.containerEl.empty(); - return Promise.resolve(); - } - - /** - * Update view when settings change - */ - updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.containerEl.toggleClass('compact-view', settings.compactView); - - // Refresh the view with the current search query - this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - } -} \ No newline at end of file diff --git a/ui/integrations/explorer-integration.ts b/ui/integrations/explorer-integration.ts deleted file mode 100644 index 58f0f8a..0000000 --- a/ui/integrations/explorer-integration.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { App, TFile, debounce, setTooltip } from 'obsidian'; -import { FileExplorerView, NoteStatusSettings } from '../../models/types'; -import { StatusService } from '../../services/status-service'; - -/** - * Enhanced file explorer integration for displaying status icons - * Includes performance optimizations and error handling - */ -export class ExplorerIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private iconUpdateQueue: Set = new Set(); - private isProcessingQueue = false; - - // Debounced update function for better performance - private debouncedUpdateAll = debounce( - () => this.processUpdateQueue(), - 100, - true - ); - - constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - } - - /** - * Update settings reference and refresh icons - */ - public updateSettings(settings: NoteStatusSettings): void { - const previousShowIcons = this.settings.showStatusIconsInExplorer; - this.settings = settings; - - this.handleSettingsChange(previousShowIcons); - } - - /** - * Handle settings change and update UI accordingly - */ - private handleSettingsChange(previousShowIcons: boolean): void { - // Update icons based on new settings - if (previousShowIcons !== this.settings.showStatusIconsInExplorer) { - if (this.settings.showStatusIconsInExplorer) { - this.updateAllFileExplorerIcons(); - } else { - this.removeAllFileExplorerIcons(); - } - } else if (this.settings.showStatusIconsInExplorer) { - // If setting hasn't changed but is enabled, refresh the icons - // This handles cases where custom statuses or colors changed - this.updateAllFileExplorerIcons(); - } - } - - /** - * Find the file explorer view - */ - private findFileExplorerView(): FileExplorerView | null { - // Try the standard method first - const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0]; - if (leaf && leaf.view) { - return leaf.view as FileExplorerView; - } - - // If that fails, try to find it by searching all leaves - for (const leaf of this.app.workspace.getLeavesOfType('')) { - if (leaf.view && 'fileItems' in leaf.view) { - return leaf.view as FileExplorerView; - } - } - - return null; - } - - /** - * Add a file to the update queue - */ - public queueFileUpdate(file: TFile): void { - if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; - - this.iconUpdateQueue.add(file.path); - this.debouncedUpdateAll(); - } - - /** - * Process the queue of files that need icon updates - */ - private async processUpdateQueue(): Promise { - if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return; - - this.isProcessingQueue = true; - - try { - const fileExplorerView = this.findFileExplorerView(); - if (!fileExplorerView || !fileExplorerView.fileItems) { - // Schedule retry if view not found - setTimeout(() => this.debouncedUpdateAll(), 200); - return; - } - - // Process files in the queue in batches to avoid blocking UI - const batchSize = 50; - const allPaths = Array.from(this.iconUpdateQueue); - - for (let i = 0; i < allPaths.length; i += batchSize) { - const batch = allPaths.slice(i, i + batchSize); - - // Process this batch - for (const filePath of batch) { - const file = this.app.vault.getFileByPath(filePath); - if (file && file instanceof TFile) { - this.updateSingleFileIcon(file, fileExplorerView); - } - this.iconUpdateQueue.delete(filePath); - } - - // Let the UI breathe between batches - if (i + batchSize < allPaths.length) { - await new Promise(resolve => setTimeout(resolve, 0)); - } - } - } catch (error) { - console.error('Note Status: Error processing file update queue', error); - } finally { - this.isProcessingQueue = false; - - // If more files were added while processing, process them too - if (this.iconUpdateQueue.size > 0) { - this.debouncedUpdateAll(); - } - } - } - - /** - * Update a single file's icon in the file explorer - */ - private updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView): void { - if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; - - try { - const fileItem = fileExplorerView.fileItems[file.path]; - if (!fileItem) { - return; // File not in explorer view - } - - const titleEl = fileItem.titleEl || fileItem.selfEl; - if (!titleEl) { - return; // No title element found - } - - // Get statuses for this file - use fresh metadata cache - const statuses = this.statusService.getFileStatuses(file); - - // Remove existing status icons if present (careful not to remove other elements) - this.removeExistingIcons(titleEl); - - // Hide unknown status if setting is enabled - if (this.shouldHideUnknownStatus(statuses)) { - return; - } - - // Add status icons WITHOUT disrupting the existing title - this.addStatusIcons(titleEl, statuses); - - } catch (error) { - console.error(`Note Status: Error updating icon for ${file.path}`, error); - } - } - - /** - * Check if unknown status should be hidden - */ - private shouldHideUnknownStatus(statuses: string[]): boolean { - return this.settings.hideUnknownStatusInExplorer && - statuses.length === 1 && - statuses[0] === 'unknown'; - } - - /** - * Remove existing status icons from an element - */ - private removeExistingIcons(element: HTMLElement): void { - // Only select our specific status icon elements - const existingIcons = element.querySelectorAll('.note-status-icon, .note-status-icon-container'); - - existingIcons.forEach(icon => { - // Only remove elements with our classes to avoid removing title or other elements - if (icon.classList.contains('note-status-icon') || - icon.classList.contains('note-status-icon-container')) { - icon.remove(); - } - }); - } - - /** - * Add status icons to a title element - */ - private addStatusIcons(titleEl: HTMLElement, statuses: string[]): void { - // Create container for multiple icons - using createElement to avoid side effects - const iconContainer = document.createElement('span'); - iconContainer.className = 'note-status-icon-container'; - - // Add all status icons - if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') { - // Add all icons if using multiple statuses - this.addMultipleStatusIcons(iconContainer, statuses); - } else { - // Just show primary status - this.addPrimaryStatusIcon(iconContainer, statuses); - } - - // Only append if we added icons - if (iconContainer.childElementCount > 0) { - // Use appendChild to add to the end instead of replacing content - titleEl.appendChild(iconContainer); - } - } - - /** - * Add multiple status icons to a container - */ - private addMultipleStatusIcons(container: HTMLElement, statuses: string[]): void { - statuses.forEach(status => { - this.addSingleStatusIcon(container, status); - }); - } - - /** - * Add primary status icon to a container - */ - private addPrimaryStatusIcon(container: HTMLElement, statuses: string[]): void { - const primaryStatus = statuses[0] || 'unknown'; - if (primaryStatus !== 'unknown' || !this.settings.autoHideStatusBar) { - this.addSingleStatusIcon(container, primaryStatus); - } - } - - /** - * Add a single status icon - */ - private addSingleStatusIcon(container: HTMLElement, status: string): void { - // Create icon element instead of modifying container directly - const iconEl = document.createElement('span'); - iconEl.className = `note-status-icon nav-file-tag status-${status}`; - iconEl.textContent = this.statusService.getStatusIcon(status); - - // Add tooltip with status name - const statusObj = this.statusService.getAllStatuses().find(s => s.name === status); - const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status; - setTooltip(iconEl, tooltipValue); - - // Append to container - container.appendChild(iconEl); - } - - /** - * Update a single file's icon in the file explorer (public method) - */ - public updateFileExplorerIcons(file: TFile): void { - if (!file || !this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; - - // Add direct immediate update for critical files (like active file) - const activeFile = this.app.workspace.getActiveFile(); - const isActiveFile = activeFile && activeFile.path === file.path; - - // For active file, update immediately and also queue - if (isActiveFile) { - this.updateSingleFileIconDirectly(file); - } - - // Also queue update for normal processing - this.queueFileUpdate(file); - } - - /** - * Update a single file icon directly (for immediate updates) - */ - private updateSingleFileIconDirectly(file: TFile): void { - try { - const fileExplorer = this.findFileExplorerView(); - if (!fileExplorer) return; - - this.updateSingleFileIcon(file, fileExplorer); - } catch (error) { - console.error('Note Status: Error updating file icon directly', error); - } - } - - /** - * Update all file icons in the explorer - with performance optimizations - */ - public updateAllFileExplorerIcons(): void { - // Remove all icons if setting is turned off - if (!this.settings.showStatusIconsInExplorer) { - this.removeAllFileExplorerIcons(); - return; - } - - // Process files in batches - const processFilesInBatches = async () => { - const files = this.app.vault.getMarkdownFiles(); - const batchSize = 100; // Process 100 files at a time - - for (let i = 0; i < files.length; i += batchSize) { - const batch = files.slice(i, i + batchSize); - batch.forEach(file => this.queueFileUpdate(file)); - - // Let the UI breathe between batches - if (i + batchSize < files.length) { - await new Promise(resolve => setTimeout(resolve, 0)); - } - } - }; - - // Start processing - processFilesInBatches(); - } - - /** - * Remove all status icons from the file explorer - */ - public removeAllFileExplorerIcons(): void { - const fileExplorer = this.findFileExplorerView(); - if (!fileExplorer || !fileExplorer.fileItems) return; - - // Use Object.values to avoid issues with concurrent modification - Object.values(fileExplorer.fileItems).forEach((fileItem) => { - const titleEl = fileItem.titleEl || fileItem.selfEl; - if (!titleEl) return; - - this.removeExistingIcons(titleEl); - }); - - // Clear the update queue - this.iconUpdateQueue.clear(); - } - - /** - * Get selected files from the file explorer - */ - public getSelectedFiles(): TFile[] { - const fileExplorer = this.findFileExplorerView(); - if (!fileExplorer || !fileExplorer.fileItems) return []; - - const selectedFiles: TFile[] = []; - - Object.entries(fileExplorer.fileItems).forEach(([_, item]) => { - if (item.el?.classList.contains('is-selected') && - item.file && item.file instanceof TFile && - item.file.extension === 'md') { - selectedFiles.push(item.file); - } - }); - - return selectedFiles; - } - - /** - * Clean up when plugin is unloaded - */ - public unload(): void { - this.removeAllFileExplorerIcons(); - this.debouncedUpdateAll.cancel(); - } -} \ No newline at end of file diff --git a/ui/menus/status-context-menu.ts b/ui/menus/status-context-menu.ts deleted file mode 100644 index ea3b7c2..0000000 --- a/ui/menus/status-context-menu.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { App, Menu, TFile } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { StatusService } from '../../services/status-service'; -import { StatusDropdown } from 'ui/components/status-dropdown'; -import { ExplorerIntegration } from 'ui/integrations/explorer-integration'; - -/** - * Handles context menu interactions for status changes - */ -export class StatusContextMenu { - private statusDropdown: StatusDropdown; - private settings: NoteStatusSettings; - private statusService: StatusService; - private explorerIntegration: ExplorerIntegration; - public app: App; - - constructor(app: App, settings: NoteStatusSettings, statusService: StatusService, statusDropdown: StatusDropdown, explorerIntegration:ExplorerIntegration) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.statusDropdown = statusDropdown; - this.explorerIntegration = explorerIntegration; - } - - /** - * Updates settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - /** - * Shows the context menu for changing a status of one or more files - */ - public showForFiles(files: TFile[], position?: { x: number; y: number }): void { - if (files.length === 0) return; - - // For a single file, show dropdown directly - if (files.length === 1) { - this.showSingleFileDropdown(files[0], position); - return; - } - - // For multiple files, show menu first - this.showMultipleFilesMenu(files, position); - } - - /** - * Show dropdown for a single file - */ - private showSingleFileDropdown(file: TFile, position?: { x: number; y: number }): void { - this.statusDropdown.openStatusDropdown({ - position: position, - files: [file], - onStatusChange: async (statuses) => { - if (statuses.length > 0) { - await this.handleStatusUpdateForFile(file, statuses); - } - } - }); - } - - /** - * Show menu for multiple files - */ - private showMultipleFilesMenu(files: TFile[], position?: { x: number; y: number }): void { - const menu = new Menu(); - - // Add title section - menu.addItem((item) => { - item.setTitle(`Update ${files.length} files`) - .setDisabled(true); - return item; - }); - - // Simplified toggle-based approach for multiple files - menu.addItem((item) => - item - .setTitle('Manage statuses...') - .setIcon('tag') - .onClick(() => { - this.statusDropdown.openStatusDropdown({ - position: position, - files: files - }); - }) - ); - - // Show the menu - if (position) { - menu.showAtPosition(position); - } else { - menu.showAtMouseEvent(new MouseEvent('contextmenu')); - } - } - - /** - * Shows a context menu for a single file - */ - public showForFile(file: TFile, event: MouseEvent): void { - // Ensure file is a valid TFile instance before proceeding - if (!(file instanceof TFile) || file.extension !== 'md') { - return; - } - - const position = { x: event.clientX, y: event.clientY }; - - this.statusDropdown.openStatusDropdown({ - position: position, - files: [file], - onStatusChange: async (statuses) => { - if (statuses.length > 0) { - await this.handleStatusUpdateForFile(file, statuses); - } - } - }); - } - - /** - * Handle status update for a specific file - */ - private async handleStatusUpdateForFile(file: TFile, statuses: string[]): Promise { - // Add instanceof check for safety - if (!(file instanceof TFile) || file.extension !== 'md' || statuses.length === 0) return; - - // Update the file with the selected status - if (this.settings.useMultipleStatuses) { - // If the status is already applied, toggle it off - const currentStatuses = this.statusService.getFileStatuses(file); - if (currentStatuses.includes(statuses[0])) { - await this.statusService.removeNoteStatus(statuses[0], file); - } else { - // Add the status - await this.statusService.addNoteStatus(statuses[0], file); - } - } else { - // Replace with the single status - use the direct method, not batch - await this.statusService.updateNoteStatuses([statuses[0]], file); - } - - // Force explorer icon update for this specific file - this.app.metadataCache.trigger('changed', file); - - // Directly update the explorer icon - this.updateExplorerIcon(file); - - // Dispatch events to update other UI components - this.triggerUIUpdates(statuses, file); - } - - /** - * Update explorer icon for a file - */ - private updateExplorerIcon(file: TFile): void { - setTimeout(() => { - if (this.explorerIntegration) { - this.explorerIntegration.updateFileExplorerIcons(file); - } - }, 50); - } - - /** - * Trigger UI updates after status changes - */ - private triggerUIUpdates(statuses: string[], file: TFile): void { - window.dispatchEvent(new CustomEvent('note-status:status-changed', { - detail: { statuses, file: file.path } - })); - - window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); - - // Force a full UI refresh to ensure all elements are updated - setTimeout(() => { - window.dispatchEvent(new CustomEvent('note-status:force-refresh')); - }, 100); - } -} \ No newline at end of file diff --git a/views/status-pane-view/index.ts b/views/status-pane-view/index.ts new file mode 100644 index 0000000..93b29c9 --- /dev/null +++ b/views/status-pane-view/index.ts @@ -0,0 +1,2 @@ +export { StatusPaneView } from './status-pane-view'; +export { StatusPaneViewController } from './status-pane-view-controller'; diff --git a/views/status-pane-view/status-pane-view-controller.ts b/views/status-pane-view/status-pane-view-controller.ts new file mode 100644 index 0000000..19be0b0 --- /dev/null +++ b/views/status-pane-view/status-pane-view-controller.ts @@ -0,0 +1,170 @@ +import { TFile, WorkspaceLeaf, View, Notice, App } from 'obsidian'; +import { NoteStatusSettings } from '../../models/types'; +import { StatusService } from '../../services/status-service'; +import NoteStatus from 'main'; +import { StatusPaneView } from './status-pane-view'; + +export class StatusPaneViewController extends View { + private renderer: StatusPaneView; + private settings: NoteStatusSettings; + private statusService: StatusService; + private plugin: NoteStatus; + private searchQuery = ''; + private paginationState = { + itemsPerPage: 100, + currentPage: {} as Record + }; + + static async open(app: App): Promise { + const existing = app.workspace.getLeavesOfType('status-pane')[0]; + if (existing) { + app.workspace.setActiveLeaf(existing); + return; + } + + const leaf = app.workspace.getLeftLeaf(false); + if (leaf) { + await leaf.setViewState({ type: 'status-pane', active: true }); + } + } + + constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { + super(leaf); + this.plugin = plugin; + this.settings = plugin.settings; + this.statusService = plugin.statusService; + this.renderer = new StatusPaneView(this.statusService); + } + + getViewType(): string { return 'status-pane'; } + + getDisplayText(): string { return 'Status pane'; } + + getIcon(): string { return 'tag'; } + + async onOpen(): Promise { + await this.setupPane(); + } + + onClose(): Promise { + this.containerEl.empty(); + return Promise.resolve(); + } + + + private async setupPane(): Promise { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass('note-status-pane', 'nav-files-container'); + containerEl.toggleClass('note-status-compact-view', this.settings.compactView); + + this.renderer.createHeader(containerEl, this.settings.compactView, { + onSearch: (query) => { + this.paginationState = { + itemsPerPage: 100, + currentPage: {} as Record + } + this.searchQuery = query; + this.renderGroups(query); + }, + onToggleView: () => { + this.settings.compactView = !this.settings.compactView; + containerEl.toggleClass('note-status-compact-view', this.settings.compactView); + window.dispatchEvent(new CustomEvent('note-status:settings-changed')); + this.renderGroups(this.searchQuery); + }, + onRefresh: async () => { + await this.renderGroups(this.searchQuery); + new Notice('Status pane refreshed'); + } + }); + + const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' }); + const loadingIndicator = this.renderer.createLoadingIndicator(groupsContainer); + + setTimeout(async () => { + await this.renderGroups(''); + loadingIndicator.remove(); + }, 10); + } + + private async renderGroups(searchQuery = ''): Promise { + const groupsContainerEl = this.containerEl.querySelector('.note-status-groups-container') as HTMLElement; + if (!groupsContainerEl) return; + + if (searchQuery) { + groupsContainerEl.empty(); + this.renderer.createLoadingIndicator(groupsContainerEl, `Searching for "${searchQuery}"...`); + await new Promise(resolve => setTimeout(resolve, 0)); + } else { + groupsContainerEl.empty(); + } + + const statusGroups = this.getFilteredStatusGroups(searchQuery); + groupsContainerEl.empty(); + + const hasGroups = this.renderer.renderStatusGroups( + groupsContainerEl, + statusGroups, + { + excludeUnknown: this.settings.excludeUnknownStatus, + isCompactView: this.settings.compactView, + collapsedStatuses: this.settings.collapsedStatuses, + pagination: this.paginationState, + callbacks: { + onFileClick: (file) => { + this.app.workspace.openLinkText(file.path, file.path, true); + }, + onStatusToggle: (status, collapsed) => { + this.settings.collapsedStatuses[status] = collapsed; + }, + onContextMenu: (e, file) => { + }, + onPageChange: (status, page) => { + this.paginationState.currentPage[status] = page; + this.renderGroups(this.searchQuery); + } + } + } + ); + + if (!hasGroups) { + this.renderer.renderEmptyState( + groupsContainerEl, + searchQuery, + this.settings.excludeUnknownStatus, + async () => { + this.settings.excludeUnknownStatus = false; + await this.plugin.saveSettings(); + this.renderGroups(searchQuery); + } + ); + } + } + + private getFilteredStatusGroups(searchQuery = ''): Record { + const rawGroups = this.statusService.groupFilesByStatus(searchQuery); + const filteredGroups: Record = {}; + + Object.entries(rawGroups).forEach(([status, files]) => { + if (files.length > 0) { + filteredGroups[status] = files; + } + }); + + return filteredGroups; + } + + updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.containerEl.toggleClass('note-status-compact-view', settings.compactView); + this.renderGroups(this.searchQuery); + } + + public update(): void { + // TODO: needs to be improved/fixed, every change + // this.renderGroups(this.searchQuery).then(() => { + // + // }); + } +} diff --git a/views/status-pane-view/status-pane-view.ts b/views/status-pane-view/status-pane-view.ts new file mode 100644 index 0000000..a11486b --- /dev/null +++ b/views/status-pane-view/status-pane-view.ts @@ -0,0 +1,245 @@ +import { TFile, setIcon } from 'obsidian'; +import { StatusService } from '../../services/status-service'; + +export class StatusPaneView{ + constructor(private statusService: StatusService) {} + + createHeader(container: HTMLElement, isCompactView: boolean, callbacks: { + onSearch: (query: string) => void, + onToggleView: () => void, + onRefresh: () => void + }): HTMLElement { + const header = container.createDiv({ cls: 'note-status-header' }); + + // Search input + const searchContainer = header.createDiv({ cls: 'note-status-search search-input-container' }); + const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' }); + + const searchIcon = searchWrapper.createEl('span', { cls: 'search-input-icon' }); + setIcon(searchIcon, 'search'); + + const searchInput = searchWrapper.createEl('input', { + type: 'text', + placeholder: 'Search notes...', + cls: 'note-status-search-input search-input' + }); + + const clearBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' }); + setIcon(clearBtn, 'x'); + + searchInput.addEventListener('input', () => { + callbacks.onSearch(searchInput.value.toLowerCase()); + clearBtn.toggleClass('is-visible', !!searchInput.value); + }); + + clearBtn.addEventListener('click', () => { + searchInput.value = ''; + callbacks.onSearch(''); + clearBtn.toggleClass('is-visible', false); + }); + + // Action buttons + const actions = header.createDiv({ cls: 'status-pane-actions-container' }); + + const viewBtn = actions.createEl('button', { + type: 'button', + title: isCompactView ? 'Switch to Standard View' : 'Switch to Compact View', + cls: 'note-status-view-toggle clickable-icon' + }); + setIcon(viewBtn, isCompactView ? 'layout' : 'table'); + viewBtn.addEventListener('click', callbacks.onToggleView); + + const refreshBtn = actions.createEl('button', { + type: 'button', + title: 'Refresh statuses', + cls: 'note-status-actions-refresh clickable-icon' + }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.addEventListener('click', callbacks.onRefresh); + + return header; + } + + renderStatusGroups(container: HTMLElement, statusGroups: Record, options: { + excludeUnknown: boolean, + isCompactView: boolean, + collapsedStatuses: Record, + pagination: { + itemsPerPage: number, + currentPage: Record + }, + callbacks: { + onFileClick: (file: TFile) => void, + onStatusToggle: (status: string, collapsed: boolean) => void, + onContextMenu: (e: MouseEvent, file: TFile) => void, + onPageChange: (status: string, page: number) => void + } + }): boolean { + // Clear container first + container.empty(); + + let hasGroups = false; + + Object.entries(statusGroups).forEach(([status, files]) => { + if (files.length > 0 && !(status === 'unknown' && options.excludeUnknown)) { + this.renderGroup(container, status, files, options); + hasGroups = true; + } + }); + + if (!hasGroups) { + return false; + } + + return true; + } + + private renderGroup(container: HTMLElement, status: string, files: TFile[], options: any): void { + const groupEl = container.createDiv({ cls: 'note-status-group nav-folder' }); + const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' }); + const isCollapsed = options.collapsedStatuses[status] ?? false; + + // Collapse indicator + const collapseIcon = titleEl.createDiv({ cls: 'note-status-collapse-indicator' }); + setIcon(collapseIcon, isCollapsed ? 'chevron-right' : 'chevron-down'); + + // Title content + const titleContent = titleEl.createDiv({ cls: 'nav-folder-title-content' }); + const statusIcon = this.statusService.getStatusIcon(status); + titleContent.createSpan({ + text: `${status} ${statusIcon} (${files.length})`, + cls: `status-${status}` + }); + + // Set collapse state + if (isCollapsed) { + groupEl.addClass('note-status-is-collapsed'); + } + + // Toggle collapse on click + titleEl.addEventListener('click', (e) => { + e.preventDefault(); + const newCollapsed = !groupEl.hasClass('note-status-is-collapsed'); + groupEl.toggleClass('note-status-is-collapsed', newCollapsed); + collapseIcon.empty(); + setIcon(collapseIcon, newCollapsed ? 'chevron-right' : 'chevron-down'); + options.callbacks.onStatusToggle(status, newCollapsed); + }); + + // Render content + const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' }); + + // Pagination + const currentPage = options.pagination.currentPage[status] || 0; + const itemsPerPage = options.pagination.itemsPerPage; + const totalPages = Math.ceil(files.length / itemsPerPage); + const startIndex = currentPage * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, files.length); + + // File items + files.slice(startIndex, endIndex).forEach(file => { + this.renderFileItem(childrenEl, file, status, options.isCompactView, options.callbacks); + }); + + // Add pagination if needed + if (files.length > itemsPerPage) { + this.addPagination(childrenEl, status, currentPage, totalPages, files.length, options.callbacks); + } + } + + private renderFileItem(container: HTMLElement, file: TFile, status: string, isCompactView: boolean, callbacks: any): void { + const fileEl = container.createDiv({ cls: 'nav-file' }); + const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); + + if (!isCompactView) { + const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' }); + setIcon(fileIcon, 'file'); + } + + fileTitleEl.createSpan({ + text: file.basename, + cls: 'nav-file-title-content' + }); + + fileTitleEl.createSpan({ + cls: `note-status-icon nav-file-tag status-${status}`, + text: this.statusService.getStatusIcon(status) + }); + + fileEl.addEventListener('click', (e) => { + e.preventDefault(); + callbacks.onFileClick(file); + }); + + fileEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + callbacks.onContextMenu(e, file); + }); + } + + private addPagination(container: HTMLElement, status: string, currentPage: number, totalPages: number, totalItems: number, callbacks: any): void { + const paginationEl = container.createDiv({ cls: 'note-status-pagination' }); + + if (currentPage > 0) { + const prevButton = paginationEl.createEl('button', { + text: 'Previous', + cls: 'note-status-pagination-button' + }); + + prevButton.addEventListener('click', (e) => { + e.stopPropagation(); + callbacks.onPageChange(status, currentPage - 1); + }); + } + + paginationEl.createSpan({ + text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`, + cls: 'note-status-pagination-info' + }); + + if (currentPage < totalPages - 1) { + const nextButton = paginationEl.createEl('button', { + text: 'Next', + cls: 'note-status-pagination-button' + }); + + nextButton.addEventListener('click', (e) => { + e.stopPropagation(); + callbacks.onPageChange(status, currentPage + 1); + }); + } + } + + renderEmptyState(container: HTMLElement, searchQuery: string, excludeUnknown: boolean, onShowUnassigned: () => void): void { + const emptyMessage = container.createDiv({ cls: 'note-status-empty-indicator' }); + + if (searchQuery) { + emptyMessage.textContent = `No notes found matching "${searchQuery}"`; + return; + } + + if (excludeUnknown) { + emptyMessage.createDiv({ + text: 'No notes with status found. Unassigned notes are currently hidden.', + cls: 'note-status-empty-message' + }); + + const btnContainer = emptyMessage.createDiv({ + cls: 'note-status-button-container' + }); + + const showUnknownBtn = btnContainer.createEl('button', { + text: 'Show unassigned notes', + cls: 'note-status-show-unassigned-button' + }); + + showUnknownBtn.addEventListener('click', onShowUnassigned); + } + } + + createLoadingIndicator(container: HTMLElement, text?: string): HTMLElement { + const loadingIndicator = container.createDiv({ cls: 'note-status-loading' }); + loadingIndicator.innerHTML = `${text || 'Loading notes...'}`; + return loadingIndicator; + } +}