feat: reusable dropdown component

This commit is contained in:
Aleix Soler 2025-04-17 12:36:34 +02:00
parent 1ef574e157
commit 26f6f3e896
6 changed files with 1102 additions and 587 deletions

View file

@ -15,6 +15,7 @@ import { StyleService } from './services/style-service';
// Import UI components
import { StatusBar } from './ui/status-bar';
import { StatusDropdown } from './ui/status-dropdown';
import { StatusDropdownComponent } from './ui/status-dropdown-component';
import { StatusPaneView } from './ui/status-pane-view';
import { ExplorerIntegration } from './ui/explorer';
import { BatchStatusModal } from './ui/modals';

View file

@ -1192,4 +1192,244 @@
/* Debug outline to help identify the button (you can remove this after debugging) */
.note-status-toolbar-button-container {
outline: 1px solid rgba(255, 0, 0, 0.3) !important;
}
/* Make the unified dropdown look consistent in all contexts */
.note-status-unified-dropdown {
width: 280px;
max-width: 90vw;
border-radius: var(--radius-m, 8px);
overflow: hidden;
box-shadow: var(--shadow-m), 0 0 15px rgba(0, 0, 0, 0.1);
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 */
}
/* Better 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;
}
@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);
}
}
/* Fix for positioning when transitioning */
.note-status-popover {
transform-origin: top left;
pointer-events: all !important;
}
/* Make sure clickable elements in dropdown work properly */
.note-status-option,
.note-status-option-icon,
.note-status-option-text,
.note-status-chip,
.note-status-chip-remove,
.note-status-popover-search-input {
pointer-events: all !important;
}
/* Fix option clicking */
.note-status-option {
cursor: pointer !important;
transition: background-color 0.15s ease;
}
.note-status-option:hover {
background-color: var(--background-modifier-hover) !important;
}
.note-status-option.is-selected {
background-color: var(--interactive-accent-hover) !important;
color: var(--text-on-accent) !important;
}
/* Make options more visible when selecting */
.note-status-option-selecting {
background-color: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
animation: pulse 0.3s var(--ease-out);
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(0.98); }
100% { transform: scale(1); }
}
/* Ensure dropdown shows above modal if needed */
.modal-container .note-status-unified-dropdown {
z-index: 10001 !important;
}
/* Make sure scrollable containers in dropdown work properly */
.note-status-options-container {
max-height: 250px;
overflow-y: auto;
scrollbar-width: thin;
}
/* Fix dropdown header styling */
.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;
}
/* Improve status badges styling */
.note-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
margin: 2px;
border-radius: 12px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
color: var(--text-normal);
font-size: 0.9em;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-status-chip-icon {
margin-right: 4px;
}
/* Remove button on badges */
.note-status-chip-remove {
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--background-modifier-border);
color: var(--text-muted);
cursor: pointer !important;
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);
}
/* Improve search input */
.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-popover-search-input {
width: 100%;
padding: 8px;
border-radius: var(--input-radius, 4px);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
}
.note-status-popover-search-input:focus {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2);
outline: none;
}
/* Better options styling */
.note-status-option {
display: flex;
align-items: center;
padding: 8px 12px;
margin: 2px 4px;
border-radius: var(--radius-s, 4px);
background: var(--background-primary);
color: var(--text-normal);
}
.note-status-option.is-selected {
font-weight: 500;
}
.note-status-option-icon {
margin-right: 8px;
font-size: 1.1em;
}
.note-status-option-check {
margin-left: auto;
color: var(--text-accent);
}
/* Fix toolbar button */
.note-status-toolbar-button-container {
display: flex !important;
align-items: center !important;
margin-right: 8px !important;
}
.note-status-toolbar-button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
border-radius: var(--radius-s, 4px) !important;
background: transparent !important;
color: var(--text-normal) !important;
cursor: pointer !important;
transition: background 0.2s ease !important;
}
.note-status-toolbar-button:hover {
background: var(--background-modifier-hover) !important;
}
/* Prevent event propagation issues */
.note-status-unified-dropdown * {
pointer-events: all !important;
}
/* Animation for badge removal */
.note-status-chip-removing {
transform: scale(0.8);
opacity: 0;
transition: all 0.15s ease-out;
}

View file

@ -1,6 +1,7 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
import { StatusDropdownComponent } from './status-dropdown-component';
/**
* Handles context menu interactions for status changes
@ -9,11 +10,29 @@ export class StatusContextMenu {
private settings: NoteStatusSettings;
private statusService: StatusService;
public app: App;
private dropdownComponent: StatusDropdownComponent;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
// Set up the callback to handle status changes
this.dropdownComponent.setOnStatusChange((statuses) => {
// Notify UI of status changes
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses }
}));
// Force a full UI refresh to ensure all elements are updated
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
// Also trigger explorer update specifically
setTimeout(() => {
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}, 50);
});
}
/**
@ -21,95 +40,209 @@ export class StatusContextMenu {
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.dropdownComponent.updateSettings(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 single file, we can show the dropdown directly without menu
if (files.length === 1) {
const targetFile = files[0];
// Get the current statuses for this file
const currentStatuses = this.statusService.getFileStatuses(targetFile);
// Create a dummy target element for dropdown positioning
const dummyTarget = document.createElement('div');
if (position) {
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
dummyTarget.style.width = '0';
dummyTarget.style.height = '0';
document.body.appendChild(dummyTarget);
}
// Set the target file for the dropdown to update
this.dropdownComponent.setTargetFile(targetFile);
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
// If no position, use screen center
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target after dropdown is shown
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
return;
}
// For multiple files, show a menu first with options for batch actions
const menu = new Menu();
// Get all available statuses
const allStatuses = this.statusService.getAllStatuses();
if (this.settings.useMultipleStatuses) {
// Add a replace section
menu.addItem((item) =>
item
.setTitle('Replace with...')
.setIcon('note')
.onClick(() => {
const replaceMenu = new Menu();
allStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
replaceMenu.addItem((subItem) =>
subItem
.setTitle(`${status.name} ${status.icon}`)
.setIcon('tag')
.onClick(async () => {
await this.statusService.batchUpdateStatuses(files, [status.name], 'replace');
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
})
);
});
if (position) {
replaceMenu.showAtPosition(position);
} else {
replaceMenu.showAtMouseEvent(new MouseEvent('contextmenu'));
menu.addClass('note-status-batch-menu');
// Add title section
menu.addItem((item) => {
item.setTitle(`Update ${files.length} files`)
.setDisabled(true);
return item;
});
// Add "Replace with status" option
menu.addItem((item) =>
item
.setTitle('Replace with status...')
.setIcon('tag')
.onClick(() => {
// Create dummy element for positioning
const dummyTarget = document.createElement('div');
if (position) {
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
document.body.appendChild(dummyTarget);
}
// Clear target file since we're handling multiple
this.dropdownComponent.setTargetFile(null);
// Set up callback for multiple files
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// Batch update all files with the selected statuses
await this.statusService.batchUpdateStatuses(files, statuses, 'replace');
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
statuses,
fileCount: files.length,
mode: 'replace'
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
})
);
// Add "Add status" option
});
// Reset to unknown status for selection
this.dropdownComponent.updateStatuses(['unknown']);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
})
);
// Add "Add status" option (only for multiple status mode)
if (this.settings.useMultipleStatuses) {
menu.addItem((item) =>
item
.setTitle('Add status...')
.setIcon('plus')
.onClick(() => {
const addMenu = new Menu();
allStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
addMenu.addItem((subItem) =>
subItem
.setTitle(`${status.name} ${status.icon}`)
.setIcon('plus')
.onClick(async () => {
await this.statusService.batchUpdateStatuses(files, [status.name], 'add');
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
})
);
});
// Create dummy element for positioning
const dummyTarget = document.createElement('div');
if (position) {
addMenu.showAtPosition(position);
} else {
addMenu.showAtMouseEvent(new MouseEvent('contextmenu'));
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
document.body.appendChild(dummyTarget);
}
// Clear target file since we're handling multiple
this.dropdownComponent.setTargetFile(null);
// Set up callback for batch add
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// We'll use the explicitly selected status for the add operation
// Get only the newly selected status
const statusToAdd = statuses[0]; // Use the first selected status
if (statusToAdd && statusToAdd !== 'unknown') {
// Add this status to all files
for (const file of files) {
await this.statusService.addNoteStatus(statusToAdd, file);
}
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
statuses: [statusToAdd],
fileCount: files.length,
mode: 'add'
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
}
});
// Reset to unknown status for selection
this.dropdownComponent.updateStatuses(['unknown']);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
})
);
} else {
// Single status mode - but still using arrays internally
allStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
menu.addItem((item) =>
item
.setTitle(`${status.name} ${status.icon}`)
.setIcon('tag')
.onClick(async () => {
// Always use arrays, even in single status mode
await this.statusService.batchUpdateStatuses(files, [status.name], 'replace');
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
})
);
});
}
// Add option to open the batch modal for more advanced options
menu.addItem((item) =>
item
.setTitle('Open batch update modal...')
.setIcon('lucide-edit')
.onClick(() => {
window.dispatchEvent(new CustomEvent('note-status:open-batch-modal'));
})
);
// Show the menu
if (position) {
menu.showAtPosition(position);
@ -122,27 +255,68 @@ export class StatusContextMenu {
* Shows a context menu for a single file
*/
public showForFile(file: TFile, event: MouseEvent): void {
const menu = new Menu();
// Add status change options
menu.addItem((item) =>
item.setTitle('Change Status')
.setIcon('tag')
.onClick(() => {
this.showForFiles([file]);
})
);
// Add other file options
// Example: Open in new tab
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(event);
// Create position from event
const position = { x: event.clientX, y: event.clientY };
// Modify the onStatusChange callback specifically for this file
const originalCallback = this.dropdownComponent.getOnStatusChange();
// Set a custom callback for this specific file operation
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// 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
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
setTimeout(() => {
const explorerIntegration = (this.app as any).plugins.plugins['note-status']?.explorerIntegration;
if (explorerIntegration) {
explorerIntegration.updateFileExplorerIcons(file);
}
}, 50);
// Dispatch events to update other UI components
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);
}
});
// Show the dropdown
this.showForFiles([file], position);
// Restore the original callback after a delay
setTimeout(() => {
this.dropdownComponent.setOnStatusChange(originalCallback);
}, 300);
}
/**
* Clean up resources
*/
public dispose(): void {
this.dropdownComponent.dispose();
}
}

View file

@ -0,0 +1,463 @@
import { setIcon, TFile } from 'obsidian';
import { NoteStatusSettings, Status } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Unified dropdown component for status selection
*/
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);
}
/**
* 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.dropdownElement = document.createElement('div');
this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown');
document.body.appendChild(this.dropdownElement);
// 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(() => {
this.clickOutsideHandler = this.handleClickOutside;
document.addEventListener('click', this.clickOutsideHandler);
document.addEventListener('keydown', this.handleEscapeKey);
}, 10);
}
/**
* 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
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' });
// Current status chips
const chipsContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-chips' });
// Show 'No status' indicator if no statuses or only unknown status
if (this.currentStatuses.length === 0 ||
(this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown')) {
chipsContainer.createDiv({
cls: 'note-status-empty-indicator',
text: 'No status assigned'
});
} else {
// Add chip for each status
this.currentStatuses.forEach(status => {
if (status === 'unknown') return; // Skip unknown status
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
if (!statusObj) return;
const chipEl = chipsContainer.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'
});
// Remove button (only show if multiple statuses allowed)
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
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 () => {
// 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);
}, 150);
}
});
}
});
}
// Create search filter
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'
});
// 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');
// Populate status options
const populateOptions = (filter = '') => {
statusOptionsContainer.empty();
const filteredStatuses = allStatuses.filter(status => !filter ||
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter));
if (filteredStatuses.length === 0) {
// Show empty state
statusOptionsContainer.createDiv({
cls: 'note-status-empty-options',
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
});
return;
}
filteredStatuses.forEach(status => {
const isSelected = this.currentStatuses.includes(status.name);
const optionEl = statusOptionsContainer.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', async () => {
try {
// Add selection animation
optionEl.addClass('note-status-option-selecting');
// Apply status changes after brief delay for animation
setTimeout(async () => {
// Handle the case where we have a specific target file
if (this.targetFile) {
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];
// Refresh status options if dropdown still open
if (this.isOpen && this.settings.useMultipleStatuses) {
populateOptions(searchInput.value);
}
// Call status change callback
this.onStatusChange(freshStatuses);
} else {
// This is for batch operations or when no specific file is targeted
// We'll just trigger the callback with the selected status
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);
}
});
});
};
// Initial population
populateOptions();
// Add search functionality
searchInput.addEventListener('input', () => {
populateOptions(searchInput.value);
});
// Focus search input after a short delay
setTimeout(() => {
searchInput.focus();
}, 50);
}
/**
* 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(() => {
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`;
}, 0);
}
/**
* 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 to the right
setTimeout(() => {
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`;
}, 0);
}
/**
* 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();
}
}

View file

@ -1,6 +1,7 @@
import { MarkdownView, Notice, Editor, Menu, setIcon } from 'obsidian';
import { NoteStatusSettings, Status } from '../models/types';
import { MarkdownView, Editor, setIcon } 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
@ -9,25 +10,31 @@ export class StatusDropdown {
private app: any;
private settings: NoteStatusSettings;
private statusService: StatusService;
private dropdownContainer?: HTMLElement;
private statusChipsContainer?: HTMLElement;
private currentStatuses: string[] = ['unknown'];
private statusPopover?: HTMLElement;
private isPopoverOpen = false;
private clickOutsideHandler: any;
private toolbarButtonContainer?: HTMLElement;
private toolbarButton?: HTMLElement;
private dropdownComponent: StatusDropdownComponent;
// Animation timings
private readonly ANIMATION_DURATION = 220;
constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
// Bind methods to preserve this context
this.handleClickOutside = this.handleClickOutside.bind(this);
// Initialize the dropdown component
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
// Configure dropdown component callbacks
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 toolbar button after layout is ready
this.app.workspace.onLayoutReady(() => {
@ -39,7 +46,7 @@ export class StatusDropdown {
* Initialize the toolbar button in the Obsidian ribbon
*/
private initToolbarButton(): void {
// Clear timeout to prevent multiple initializations
// Clear any existing button
if (this.toolbarButton) {
this.toolbarButton.remove();
this.toolbarButton = undefined;
@ -76,43 +83,21 @@ export class StatusDropdown {
// Add click handler
this.toolbarButton.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleStatusPopover(this.toolbarButton);
e.preventDefault();
this.toggleStatusPopover();
});
// Add the button to the container
this.toolbarButtonContainer.appendChild(this.toolbarButton);
// Add some debug styling to check visibility
this.toolbarButtonContainer.style.border = '2px solid red';
this.toolbarButton.style.border = '1px solid blue';
// Force display properties
this.toolbarButtonContainer.style.display = 'flex';
this.toolbarButtonContainer.style.visibility = 'visible';
this.toolbarButtonContainer.style.opacity = '1';
// Insert at a more reliable position - just prepend to the container
try {
toolbarContainer.prepend(this.toolbarButtonContainer);
console.log('Note Status: Toolbar button added successfully at position:',
Array.from(toolbarContainer.children).indexOf(this.toolbarButtonContainer));
} catch (error) {
console.error('Note Status: Error inserting toolbar button', error);
// Fallback - just append it
toolbarContainer.appendChild(this.toolbarButtonContainer);
}
// Force a redraw
setTimeout(() => {
if (this.toolbarButtonContainer) {
this.toolbarButtonContainer.style.display = 'none';
setTimeout(() => {
if (this.toolbarButtonContainer) {
this.toolbarButtonContainer.style.display = 'flex';
}
}, 10);
}
}, 100);
}, 500); // Waiting 500ms to ensure the UI is ready
}
@ -178,10 +163,8 @@ export class StatusDropdown {
// Update toolbar button
this.updateToolbarButton();
// Clear and render fresh dropdown if it exists
if (this.dropdownContainer) {
this.render();
}
// Update dropdown component
this.dropdownComponent.updateStatuses(this.currentStatuses);
}
/**
@ -190,472 +173,124 @@ export class StatusDropdown {
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateToolbarButton();
if (this.dropdownContainer) {
this.render();
}
}
/**
* Render or remove the dropdown based on settings
*/
public render(): void {
// Remove existing popover if open
if (this.isPopoverOpen) {
this.closeStatusPopover();
}
this.dropdownComponent.updateSettings(settings);
}
/**
* Toggle the status popover
*/
private toggleStatusPopover(targetEl?: HTMLElement): void {
if (this.isPopoverOpen) {
this.closeStatusPopover();
} else {
this.openStatusPopover(targetEl);
private toggleStatusPopover(): void {
// Get the active file first
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// Set the target file for the dropdown component
this.dropdownComponent.setTargetFile(activeFile);
// Get current statuses
const currentStatuses = this.statusService.getFileStatuses(activeFile);
this.dropdownComponent.updateStatuses(currentStatuses);
// Create a position below the toolbar button
if (this.toolbarButton) {
const rect = this.toolbarButton.getBoundingClientRect();
const position = {
x: rect.left,
y: rect.bottom + 5
};
// Force open the dropdown with explicit position
this.dropdownComponent.open(this.toolbarButton, position);
}
}
/**
* Open the status selection popover
* @param targetEl - The element that triggered the popover (for positioning)
*/
private openStatusPopover(targetEl?: HTMLElement): void {
if (this.isPopoverOpen) {
this.closeStatusPopover();
return;
}
this.isPopoverOpen = true;
// Create popover element
this.statusPopover = document.createElement('div');
this.statusPopover.addClass('note-status-popover', 'note-status-toolbar-popover');
document.body.appendChild(this.statusPopover);
// Create header
const headerEl = this.statusPopover.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' });
// Current status chips
const chipsContainer = this.statusPopover.createDiv({ cls: 'note-status-popover-chips' });
// Show 'No status' indicator if no statuses or only unknown status
if (this.currentStatuses.length === 0 ||
(this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown')) {
chipsContainer.createDiv({
cls: 'note-status-empty-indicator',
text: 'No status assigned'
});
} else {
// Add chip for each status
this.currentStatuses.forEach(status => {
if (status === 'unknown') return; // Skip unknown status
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
if (!statusObj) return;
const chipEl = chipsContainer.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'
});
// Remove button (only show if multiple statuses allowed)
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
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 () => {
// Remove this status
await this.statusService.removeNoteStatus(status);
// Get updated statuses
const updatedStatuses = this.statusService.getFileStatuses(this.app.workspace.getActiveFile());
// Update current statuses
this.currentStatuses = updatedStatuses;
// Update toolbar button
this.updateToolbarButton();
// Refresh chips
this.openStatusPopover(targetEl);
// Dispatch event for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: updatedStatuses }
}));
}, 150);
});
}
});
}
// Create search filter
const searchContainer = this.statusPopover.createDiv({ cls: 'note-status-popover-search' });
const searchInput = searchContainer.createEl('input', {
type: 'text',
placeholder: 'Filter statuses...',
cls: 'note-status-popover-search-input'
});
// Create status options container
const statusOptionsContainer = this.statusPopover.createDiv({ cls: 'note-status-options-container' });
// Get all available statuses
const allStatuses = this.statusService.getAllStatuses()
.filter(status => status.name !== 'unknown');
// Populate status options
const populateOptions = (filter = '') => {
statusOptionsContainer.empty();
const filteredStatuses = allStatuses.filter(status => !filter ||
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter));
if (filteredStatuses.length === 0) {
// Show empty state
statusOptionsContainer.createDiv({
cls: 'note-status-empty-options',
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
});
return;
}
filteredStatuses.forEach(status => {
const isSelected = this.currentStatuses.includes(status.name);
const optionEl = statusOptionsContainer.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', async () => {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
try {
// Add selection animation
optionEl.addClass('note-status-option-selecting');
// Apply status changes after brief delay for animation
setTimeout(async () => {
if (this.settings.useMultipleStatuses) {
await this.statusService.toggleNoteStatus(status.name);
} else {
await this.statusService.updateNoteStatuses([status.name]);
// Close popover in single status mode
this.closeStatusPopover();
}
// Get fresh status from file
const freshStatuses = this.statusService.getFileStatuses(activeFile);
// Update current statuses and toolbar button
this.currentStatuses = [...freshStatuses];
this.updateToolbarButton();
// Refresh status options if popover still open
if (this.isPopoverOpen && this.settings.useMultipleStatuses) {
populateOptions(searchInput.value);
}
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: freshStatuses }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}, 150);
} catch (error) {
console.error('Error updating status:', error);
new Notice('Failed to update status');
}
});
});
};
// Initial population
populateOptions();
// Add search functionality
searchInput.addEventListener('input', () => {
populateOptions(searchInput.value);
});
// Position the popover relative to the toolbar button
this.positionToolbarPopover(targetEl);
// Add animation class
this.statusPopover.addClass('note-status-popover-animate-in');
// Focus search input
setTimeout(() => {
searchInput.focus();
}, 50);
// Add click outside listener to close popover
setTimeout(() => {
this.clickOutsideHandler = this.handleClickOutside;
document.addEventListener('click', this.clickOutsideHandler);
// Also add escape key listener
document.addEventListener('keydown', this.handleEscapeKey);
}, 10);
}
/**
* Position the popover relative to the toolbar button
*/
private positionToolbarPopover(targetEl?: HTMLElement): void {
if (!this.statusPopover || !targetEl) return;
// Reset positioning
this.statusPopover.style.position = 'fixed';
this.statusPopover.style.zIndex = '999';
// Get target element's position
const targetRect = targetEl.getBoundingClientRect();
// Position below the button
this.statusPopover.style.top = `${targetRect.bottom + 5}px`;
this.statusPopover.style.right = `${window.innerWidth - targetRect.right}px`;
// Set max height based on viewport
const maxHeight = window.innerHeight - targetRect.bottom - 40;
this.statusPopover.style.maxHeight = `${maxHeight}px`;
}
/**
* Handle escape key to close popover
*/
private handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.isPopoverOpen) {
this.closeStatusPopover();
}
};
/**
* Handle click outside the popover
*/
private handleClickOutside(e: MouseEvent) {
if (this.statusPopover && !this.statusPopover.contains(e.target as Node) &&
this.toolbarButton && !this.toolbarButton.contains(e.target as Node)) {
this.closeStatusPopover();
}
}
/**
* Close the status selection popover
*/
private closeStatusPopover(): void {
if (!this.statusPopover) return;
// Add exit animation
this.statusPopover.addClass('note-status-popover-animate-out');
// Clean up event listeners immediately
document.removeEventListener('click', this.clickOutsideHandler);
document.removeEventListener('keydown', this.handleEscapeKey);
// Remove after animation completes
setTimeout(() => {
if (this.statusPopover) {
this.statusPopover.remove();
this.statusPopover = undefined;
this.isPopoverOpen = false;
}
}, this.ANIMATION_DURATION);
}
/**
* Show status dropdown in context menu
*/
public showInContextMenu(editor: Editor, view: MarkdownView): void {
const menu = new Menu();
const customClass = 'note-status-context-menu';
// Apply class manually since Menu doesn't have addClass method
(menu as any).dom?.addClass?.(customClass);
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// For editor context menu, use cursor position if possible
let position: {x: number, y: number};
try {
// Try to get cursor position
const cursor = editor.getCursor('head');
const editorPosition = editor.posToCoords(cursor);
if (editorPosition) {
position = {
x: editorPosition.left,
y: editorPosition.top + 20 // Add small offset below cursor
};
} else {
// If can't get cursor position, use editor element position
const editorEl = view.contentEl.querySelector('.cm-editor');
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
position = {
x: rect.left + 100, // Offset from left
y: rect.top + 100 // Offset from top
};
} else {
// Last resort - use middle of viewport
position = {
x: window.innerWidth / 2,
y: window.innerHeight / 3
};
}
}
} catch (error) {
console.error('Error getting position for dropdown:', error);
// Fallback to center of screen
position = {
x: window.innerWidth / 2,
y: window.innerHeight / 3
};
}
// Create a dummy target element for positioning
const dummyTarget = document.createElement('div');
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
dummyTarget.style.zIndex = '1000';
document.body.appendChild(dummyTarget);
// Set the active file as the target for the dropdown
this.dropdownComponent.setTargetFile(activeFile);
// Get current statuses for this file
const currentStatuses = this.statusService.getFileStatuses(activeFile);
// Add a header item
menu.addItem((item) => {
item.setTitle('Change Note Status')
.setDisabled(true);
// Apply class manually since MenuItem doesn't have setClass method
(item as any).dom?.addClass?.('note-status-menu-header');
return item;
});
// Get all available statuses grouped by template
const allStatuses = this.statusService.getAllStatuses();
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Group statuses by template name if they have one
const statusesByTemplate: Record<string, Status[]> = { 'Custom': [] };
// Show dropdown at the calculated position
this.dropdownComponent.open(dummyTarget, position);
allStatuses.forEach(status => {
if (status.name !== 'unknown') {
// For now, just put all in custom group
// In a future version, we could implement proper template grouping
statusesByTemplate['Custom'].push(status);
// Clean up dummy target after dropdown is shown
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
});
// Add status options by template
Object.entries(statusesByTemplate).forEach(([templateName, statuses]) => {
if (statuses.length === 0) return;
// Add template section
if (Object.keys(statusesByTemplate).length > 1) {
menu.addSeparator();
menu.addItem((item) => {
item.setTitle(templateName)
.setDisabled(true);
// Apply class manually since MenuItem doesn't have setClass method
(item as any).dom?.addClass?.('note-status-menu-section');
return item;
});
}
// Add statuses from this template
statuses.forEach(status => {
const isActive = currentStatuses.includes(status.name);
menu.addItem((item) => {
item
.setTitle(`${status.icon} ${status.name}`)
.setIcon(isActive ? 'check-circle' : 'circle');
// Apply class manually if active
if (isActive) {
(item as any).dom?.addClass?.('is-active');
}
if (this.settings.useMultipleStatuses) {
// Toggle mode - add checkmark for active statuses
item.onClick(async () => {
await this.statusService.toggleNoteStatus(status.name);
// Get updated statuses
const updatedStatuses = this.statusService.getFileStatuses(activeFile);
// Update current statuses and toolbar button
this.currentStatuses = updatedStatuses;
this.updateToolbarButton();
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: updatedStatuses }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
});
} else {
// Single select mode - but still using arrays internally
item.onClick(async () => {
await this.statusService.updateNoteStatuses([status.name]);
// Update current statuses and toolbar button
this.currentStatuses = [status.name];
this.updateToolbarButton();
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: [status.name] }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
});
}
return item;
});
});
});
// Add separator and additional actions
menu.addSeparator();
// Add "batch update" option
menu.addItem((item) => {
item.setTitle('Batch Update Status...')
.setIcon('folders')
.onClick(() => {
// Dispatch event to open batch modal
window.dispatchEvent(new CustomEvent('note-status:open-batch-modal'));
});
return item;
});
// Position menu near cursor
const cursor = editor.getCursor('to');
editor.posToOffset(cursor);
const editorEl = view.contentEl.querySelector('.cm-content');
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
menu.showAtPosition({ x: rect.left + 20, y: rect.top + 40 });
} else {
menu.showAtPosition({ x: 0, y: 0 });
}
}, 100);
}
/**
* 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 {
// Close any open popover
this.closeStatusPopover();
// Clean up dropdown component
this.dropdownComponent.dispose();
// Remove toolbar button
if (this.toolbarButtonContainer) {

View file

@ -249,25 +249,27 @@ export class StatusPaneView extends View {
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(() => {
this.plugin.showStatusContextMenu([file]);
})
item.setTitle('Change Status')
.setIcon('tag')
.onClick(() => {
// Use the position from the event
const position = { x: e.clientX, y: e.clientY };
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');
})
item.setTitle('Open in New Tab')
.setIcon('lucide-external-link')
.onClick(() => {
this.app.workspace.openLinkText(file.path, file.path, 'tab');
})
);
menu.showAtMouseEvent(e);
}