mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
feat: reusable dropdown component
This commit is contained in:
parent
1ef574e157
commit
26f6f3e896
6 changed files with 1102 additions and 587 deletions
1
main.ts
1
main.ts
|
|
@ -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';
|
||||
|
|
|
|||
240
styles.css
240
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
463
ui/status-dropdown-component.ts
Normal file
463
ui/status-dropdown-component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue