mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
599 lines
No EOL
17 KiB
TypeScript
599 lines
No EOL
17 KiB
TypeScript
import { setIcon, TFile } 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: any;
|
|
private statusService: StatusService;
|
|
private settings: NoteStatusSettings;
|
|
private dropdownElement: HTMLElement | null = null;
|
|
private currentStatuses: string[] = ['unknown'];
|
|
private onStatusChange: (statuses: string[]) => void;
|
|
private isOpen = false;
|
|
private animationDuration = 220;
|
|
private clickOutsideHandler: (e: MouseEvent) => void;
|
|
private targetFile: TFile | null = null;
|
|
|
|
constructor(app: any, 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();
|
|
} else {
|
|
this.open(targetEl, position);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open the dropdown
|
|
*/
|
|
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
|
if (this.isOpen) {
|
|
this.close();
|
|
}
|
|
|
|
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);
|
|
|
|
// Remove after animation completes
|
|
setTimeout(() => {
|
|
if (this.dropdownElement) {
|
|
this.dropdownElement.remove();
|
|
this.dropdownElement = null;
|
|
this.isOpen = false;
|
|
}
|
|
}, 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 if multiple statuses allowed
|
|
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
|
|
this.addRemoveButton(chipEl, status);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a remove button to a status chip
|
|
*/
|
|
private addRemoveButton(chipEl: HTMLElement, status: string): void {
|
|
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');
|
|
|
|
// Only proceed with removal if we have a target file
|
|
if (this.targetFile) {
|
|
// Wait for animation to complete before actually removing
|
|
setTimeout(async () => {
|
|
await this.removeStatus(status);
|
|
}, 150);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove a status from the target file
|
|
*/
|
|
private async removeStatus(status: string): Promise<void> {
|
|
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'
|
|
});
|
|
|
|
// 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<void> {
|
|
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.style.position = 'fixed';
|
|
this.dropdownElement.style.zIndex = '999';
|
|
this.dropdownElement.style.left = `${x}px`;
|
|
this.dropdownElement.style.top = `${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.style.left = `${window.innerWidth - rect.width - 10}px`;
|
|
}
|
|
|
|
if (rect.bottom > window.innerHeight) {
|
|
this.dropdownElement.style.top = `${window.innerHeight - rect.height - 10}px`;
|
|
}
|
|
|
|
// Set max height based on viewport
|
|
const maxHeight = window.innerHeight - rect.top - 20;
|
|
this.dropdownElement.style.maxHeight = `${maxHeight}px`;
|
|
}
|
|
|
|
/**
|
|
* Position the dropdown relative to a target element
|
|
*/
|
|
private positionRelativeTo(targetEl: HTMLElement): void {
|
|
if (!this.dropdownElement) return;
|
|
|
|
// Reset positioning
|
|
this.dropdownElement.style.position = 'fixed';
|
|
this.dropdownElement.style.zIndex = '999';
|
|
|
|
// Get target element's position
|
|
const targetRect = targetEl.getBoundingClientRect();
|
|
|
|
// Position below the element
|
|
this.dropdownElement.style.top = `${targetRect.bottom + 5}px`;
|
|
|
|
// Align to left edge of target by default
|
|
this.dropdownElement.style.left = `${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.style.left = 'auto';
|
|
this.dropdownElement.style.right = `${window.innerWidth - targetRect.right}px`;
|
|
}
|
|
|
|
if (rect.bottom > window.innerHeight) {
|
|
// Position above the element instead
|
|
this.dropdownElement.style.top = 'auto';
|
|
this.dropdownElement.style.bottom = `${window.innerHeight - targetRect.top + 5}px`;
|
|
}
|
|
|
|
// Set max height based on viewport
|
|
const maxHeight = window.innerHeight - rect.top - 20;
|
|
this.dropdownElement.style.maxHeight = `${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();
|
|
}
|
|
} |