mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
Merge pull request #25 from devonthesofa/refactor/clean-up-css-and-js
Major Refactor: Modular Codebase & Stability Fixes
This commit is contained in:
commit
2c811673c3
54 changed files with 4761 additions and 5141 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,3 +20,4 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
styles.css
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -223,13 +223,35 @@ note-status/
|
|||
│ │ └── file-utils.ts # File-related helpers
|
||||
│ └── settings/ # Settings UI
|
||||
│ └── settings-tab.ts # Settings tab definition
|
||||
└── styles.css # CSS styles
|
||||
├── styles/ # Modular CSS files
|
||||
│ ├── base.css # Variables and base styles
|
||||
│ ├── utils.css # Utility classes
|
||||
│ ├── components/ # Component-specific styles
|
||||
│ │ ├── status-bar.css # Status bar styles
|
||||
│ │ ├── status-pane.css # Status pane styles
|
||||
│ │ ├── dropdown.css # Dropdown component styles
|
||||
│ │ ├── explorer.css # File explorer styles
|
||||
│ │ └── settings.css # Settings UI styles
|
||||
│ └── index.css # Main CSS import file
|
||||
└── styles.css # Auto-generated final CSS
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
- Node.js and npm installed.
|
||||
- Obsidian API knowledge (TypeScript-based).
|
||||
|
||||
### CSS Modularization
|
||||
The plugin's CSS has been modularized to improve maintainability:
|
||||
|
||||
- **Component-based**: Each UI component has its own CSS file
|
||||
- **Auto-bundling**: The build process automatically bundles all CSS files into `styles.css`
|
||||
- **Development workflow**: CSS changes are hot-reloaded during development
|
||||
|
||||
When making CSS changes:
|
||||
1. Modify the appropriate file in the `styles/` directory
|
||||
2. For new components, create a dedicated CSS file and import it in `styles/index.css`
|
||||
3. The build process will automatically bundle everything into `styles.css`
|
||||
|
||||
### Building the Plugin
|
||||
1. Clone this repository:
|
||||
```bash
|
||||
|
|
|
|||
4
components/status-bar/index.ts
Normal file
4
components/status-bar/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { StatusBarController } from './status-bar-controller';
|
||||
|
||||
export { StatusBarController as StatusBar};
|
||||
export default StatusBarController;
|
||||
94
components/status-bar/status-bar-controller.ts
Normal file
94
components/status-bar/status-bar-controller.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
import { StatusBarView } from './status-bar-view';
|
||||
|
||||
/**
|
||||
* Controller for the status bar
|
||||
*/
|
||||
export class StatusBarController {
|
||||
private view: StatusBarView;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ['unknown'];
|
||||
|
||||
constructor(statusBarContainer: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.view = new StatusBarView(statusBarContainer);
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
this.update(['unknown']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar with new statuses
|
||||
*/
|
||||
public update(statuses: string[]): void {
|
||||
this.currentStatuses = statuses;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the status bar
|
||||
*/
|
||||
private render(): void {
|
||||
this.view.reset();
|
||||
|
||||
if (!this.settings.showStatusBar) {
|
||||
this.view.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.settings.useMultipleStatuses) {
|
||||
this.renderStatuses([this.currentStatuses[0]]);
|
||||
} else {
|
||||
this.renderStatuses(this.currentStatuses);
|
||||
}
|
||||
|
||||
this.handleAutoHide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render statuses - handles both single and multiple status cases
|
||||
*/
|
||||
private renderStatuses(statuses: string[]): void {
|
||||
const statusDetails = statuses.map(status => {
|
||||
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
|
||||
return {
|
||||
name: status,
|
||||
icon: this.statusService.getStatusIcon(status),
|
||||
tooltipText: statusObj?.description ? `${status} - ${statusObj.description}` : status
|
||||
};
|
||||
});
|
||||
|
||||
this.view.renderStatuses(statusDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-hide behavior
|
||||
*/
|
||||
private handleAutoHide(): void {
|
||||
const onlyUnknown = this.currentStatuses.length === 1 &&
|
||||
this.currentStatuses[0] === 'unknown';
|
||||
|
||||
if (this.settings.autoHideStatusBar && onlyUnknown) {
|
||||
this.view.hide();
|
||||
} else {
|
||||
this.view.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.view.destroy();
|
||||
}
|
||||
}
|
||||
107
components/status-bar/status-bar-view.ts
Normal file
107
components/status-bar/status-bar-view.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { setTooltip } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Renders the status bar UI
|
||||
*/
|
||||
export class StatusBarView {
|
||||
private element: HTMLElement;
|
||||
|
||||
constructor(element: HTMLElement) {
|
||||
this.element = element;
|
||||
this.element.addClass('note-status-bar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the element and resets CSS classes
|
||||
*/
|
||||
reset(): void {
|
||||
this.element.empty();
|
||||
this.element.removeClass('left', 'hidden', 'auto-hide', 'visible');
|
||||
this.element.addClass('note-status-bar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the status bar
|
||||
*/
|
||||
hide(): void {
|
||||
this.element.addClass('hidden');
|
||||
this.element.removeClass('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the status bar
|
||||
*/
|
||||
show(): void {
|
||||
this.element.removeClass('hidden');
|
||||
this.element.addClass('visible');
|
||||
}
|
||||
|
||||
|
||||
renderStatuses(statuses: Array<{name: string, icon: string, tooltipText: string}>): void {
|
||||
if (statuses.length === 1) {
|
||||
this.renderSingleStatus(statuses[0].name, statuses[0].icon, statuses[0].tooltipText);
|
||||
} else {
|
||||
this.renderMultipleStatuses(statuses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single status
|
||||
*/
|
||||
private renderSingleStatus(status: string, icon: string, tooltipText: string): void {
|
||||
const statusText = this.element.createEl('span', {
|
||||
text: `Status: ${status}`,
|
||||
cls: `note-status-${status}`
|
||||
});
|
||||
setTooltip(statusText, tooltipText);
|
||||
|
||||
const statusIcon = this.element.createEl('span', {
|
||||
text: icon,
|
||||
cls: `note-status-icon status-${status}`
|
||||
});
|
||||
setTooltip(statusIcon, tooltipText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render multiple statuses
|
||||
*/
|
||||
private renderMultipleStatuses(statuses: Array<{name: string, icon: string, tooltipText: string}>): void {
|
||||
this.element.createEl('span', {
|
||||
text: 'Statuses: ',
|
||||
cls: 'note-status-label'
|
||||
});
|
||||
|
||||
const badgesContainer = this.element.createEl('span', {
|
||||
cls: 'note-status-badges'
|
||||
});
|
||||
|
||||
statuses.forEach(status => this.createStatusBadge(badgesContainer, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status badge for multiple status display
|
||||
*/
|
||||
private createStatusBadge(container: HTMLElement, status: {name: string, icon: string, tooltipText: string}): void {
|
||||
const badge = container.createEl('span', {
|
||||
cls: `note-status-badge status-${status.name}`
|
||||
});
|
||||
setTooltip(badge, status.tooltipText);
|
||||
|
||||
badge.createEl('span', {
|
||||
text: status.icon,
|
||||
cls: 'note-status-badge-icon'
|
||||
});
|
||||
|
||||
badge.createEl('span', {
|
||||
text: status.name,
|
||||
cls: 'note-status-badge-text'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the element
|
||||
*/
|
||||
destroy(): void {
|
||||
this.element.empty();
|
||||
}
|
||||
}
|
||||
29
components/status-dropdown/dropdown-events.ts
Normal file
29
components/status-dropdown/dropdown-events.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Event handlers setup and cleanup for the dropdown
|
||||
*/
|
||||
|
||||
/**
|
||||
* Setup event handlers for the dropdown
|
||||
*/
|
||||
export function setupDropdownEvents(options: {
|
||||
clickOutsideHandler: (e: MouseEvent) => void,
|
||||
escapeKeyHandler: (e: KeyboardEvent) => void
|
||||
}): void {
|
||||
const { clickOutsideHandler, escapeKeyHandler } = options;
|
||||
|
||||
document.addEventListener('click', clickOutsideHandler);
|
||||
document.addEventListener('keydown', escapeKeyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown event handlers
|
||||
*/
|
||||
export function removeDropdownEvents(options: {
|
||||
clickOutsideHandler: (e: MouseEvent) => void,
|
||||
escapeKeyHandler: (e: KeyboardEvent) => void
|
||||
}): void {
|
||||
const { clickOutsideHandler, escapeKeyHandler } = options;
|
||||
|
||||
document.removeEventListener('click', clickOutsideHandler);
|
||||
document.removeEventListener('keydown', escapeKeyHandler);
|
||||
}
|
||||
255
components/status-dropdown/dropdown-manager.ts
Normal file
255
components/status-dropdown/dropdown-manager.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { MarkdownView, Editor, Notice, TFile, App } from 'obsidian';
|
||||
import { DropdownUI } from './dropdown-ui';
|
||||
import { DropdownOptions, DropdownDependencies } from './types';
|
||||
import { createDummyTarget } from './dropdown-position';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { NoteStatusSettings } from 'models/types';
|
||||
|
||||
/**
|
||||
* High-level manager for status dropdown interactions
|
||||
*/
|
||||
export class DropdownManager {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ['unknown'];
|
||||
private toolbarButton?: HTMLElement;
|
||||
private dropdownUI: DropdownUI;
|
||||
|
||||
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
const deps: DropdownDependencies = { app, settings, statusService };
|
||||
this.dropdownUI = new DropdownUI(deps);
|
||||
this.setupDropdownCallbacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dropdown callbacks
|
||||
*/
|
||||
private setupDropdownCallbacks(): void {
|
||||
|
||||
this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => {
|
||||
if (!targetFile) return;
|
||||
|
||||
const isMultiple = Array.isArray(targetFile);
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
operation: 'remove',
|
||||
showNotice: isMultiple
|
||||
});
|
||||
});
|
||||
|
||||
this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => {
|
||||
// Check if handling multiple files
|
||||
const isMultipleFiles = Array.isArray(targetFile) && targetFile.length > 1;
|
||||
|
||||
if (isMultipleFiles) {
|
||||
const files = targetFile as TFile[];
|
||||
// Count how many files already have this status
|
||||
const filesWithStatus = files.filter(file =>
|
||||
this.statusService.getFileStatuses(file).includes(status)
|
||||
);
|
||||
|
||||
// If ALL have the status, remove it. Otherwise, add it
|
||||
const operation = filesWithStatus.length === files.length ? 'remove' : (!this.settings.useMultipleStatuses) ? 'set':'add';
|
||||
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status,
|
||||
isMultipleSelection: true,
|
||||
operation: operation
|
||||
});
|
||||
} else {
|
||||
// For individual files, maintain default behavior
|
||||
await this.statusService.handleStatusChange({
|
||||
files: targetFile,
|
||||
statuses: status
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dropdown UI based on current statuses
|
||||
*/
|
||||
public update(currentStatuses: string[] | string, _file?: TFile): void {
|
||||
this.currentStatuses = Array.isArray(currentStatuses) ?
|
||||
[...currentStatuses] : [currentStatuses];
|
||||
|
||||
this.dropdownUI.updateStatuses(this.currentStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.dropdownUI.updateSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position from cursor or fallback
|
||||
*/
|
||||
private getCursorPosition(editor: Editor, view: MarkdownView): { x: number, y: number } {
|
||||
try {
|
||||
const cursor = editor.getCursor('head');
|
||||
const lineElement = view.contentEl.querySelector(`.cm-line:nth-child(${cursor.line + 1})`);
|
||||
|
||||
if (lineElement) {
|
||||
const rect = lineElement.getBoundingClientRect();
|
||||
return { x: rect.left + 20, y: rect.bottom + 5 };
|
||||
}
|
||||
|
||||
const editorEl = view.contentEl.querySelector('.cm-editor');
|
||||
if (editorEl) {
|
||||
const rect = editorEl.getBoundingClientRect();
|
||||
return { x: rect.left + 100, y: rect.top + 100 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting position for dropdown:', error);
|
||||
}
|
||||
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Universal function to open the status dropdown
|
||||
*/
|
||||
public openStatusDropdown(options: DropdownOptions): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
const files = options.files || (activeFile ? [activeFile] : []);
|
||||
if (!files.length) {
|
||||
new Notice('No files selected');
|
||||
return;
|
||||
}
|
||||
if (!files.length || !files[0]) {
|
||||
new Notice('No files selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dropdownUI.isOpen) {
|
||||
this.resetDropdown();
|
||||
return
|
||||
}
|
||||
|
||||
const isSingleFile = files.length === 1;
|
||||
|
||||
// Set up target files appropriately
|
||||
if (isSingleFile) {
|
||||
const targetFile = files[0];
|
||||
this.dropdownUI.setTargetFile(targetFile);
|
||||
const currentStatuses = this.statusService.getFileStatuses(targetFile);
|
||||
this.dropdownUI.updateStatuses(currentStatuses);
|
||||
} else {
|
||||
// For multiple files, set the whole collection
|
||||
this.dropdownUI.setTargetFiles(files);
|
||||
const commonStatuses = this.findCommonStatuses(files);
|
||||
this.dropdownUI.updateStatuses(commonStatuses);
|
||||
}
|
||||
|
||||
this.positionAndOpenDropdown(options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset dropdown state before opening
|
||||
*/
|
||||
public resetDropdown(): void {
|
||||
this.dropdownUI.close();
|
||||
this.dropdownUI.setTargetFile(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position and open the dropdown
|
||||
*/
|
||||
private positionAndOpenDropdown(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number, y: number };
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
}): void {
|
||||
if (options.editor && options.view) {
|
||||
const position = this.getCursorPosition(options.editor, options.view);
|
||||
this.openWithPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.target) {
|
||||
if (options.position) {
|
||||
this.dropdownUI.open(options.target, options.position);
|
||||
} else {
|
||||
const rect = options.target.getBoundingClientRect();
|
||||
this.dropdownUI.open(options.target, {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 5
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.position) {
|
||||
this.openWithPosition(options.position);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openWithPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown at a specific position using dummy target
|
||||
*/
|
||||
private openWithPosition(position: { x: number, y: number }): void {
|
||||
const dummyTarget = createDummyTarget(position);
|
||||
this.dropdownUI.open(dummyTarget, position);
|
||||
|
||||
setTimeout(() => {
|
||||
if (dummyTarget.parentNode) {
|
||||
dummyTarget.parentNode.removeChild(dummyTarget);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common statuses across multiple files
|
||||
*/
|
||||
private findCommonStatuses(files: TFile[]): string[] {
|
||||
if (files.length === 0) return ['unknown'];
|
||||
|
||||
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
|
||||
|
||||
return firstFileStatuses.filter(status =>
|
||||
status !== 'unknown' &&
|
||||
files.every(file => this.statusService.getFileStatuses(file).includes(status))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render method (kept for compatibility)
|
||||
*/
|
||||
public render(): void {
|
||||
// No-op - dropdown component handles rendering internally
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.dropdownUI.dispose();
|
||||
|
||||
if (this.toolbarButton) {
|
||||
this.toolbarButton.remove();
|
||||
this.toolbarButton = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
105
components/status-dropdown/dropdown-position.ts
Normal file
105
components/status-dropdown/dropdown-position.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Dropdown positioning utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Position the dropdown
|
||||
*/
|
||||
export function positionDropdown(options: {
|
||||
dropdownElement: HTMLElement,
|
||||
targetEl: HTMLElement,
|
||||
position?: { x: number, y: number }
|
||||
}): void {
|
||||
const { dropdownElement, targetEl, position } = options;
|
||||
|
||||
if (position) {
|
||||
positionAt(dropdownElement, position.x, position.y);
|
||||
} else {
|
||||
positionRelativeTo(dropdownElement, targetEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown at specific coordinates
|
||||
*/
|
||||
function positionAt(dropdownElement: HTMLElement, x: number, y: number): void {
|
||||
dropdownElement.addClass('note-status-popover-fixed');
|
||||
dropdownElement.style.setProperty('--pos-x-px', `${x}px`);
|
||||
dropdownElement.style.setProperty('--pos-y-px', `${y}px`);
|
||||
|
||||
setTimeout(() => adjustPositionToViewport(dropdownElement), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the dropdown position to ensure it's visible in the viewport
|
||||
*/
|
||||
function adjustPositionToViewport(dropdownElement: HTMLElement): void {
|
||||
const rect = dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
dropdownElement.addClass('note-status-popover-right');
|
||||
dropdownElement.style.setProperty('--right-offset-px', '10px');
|
||||
} else {
|
||||
dropdownElement.removeClass('note-status-popover-right');
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
dropdownElement.addClass('note-status-popover-bottom');
|
||||
dropdownElement.style.setProperty('--bottom-offset-px', '10px');
|
||||
} else {
|
||||
dropdownElement.removeClass('note-status-popover-bottom');
|
||||
}
|
||||
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown relative to a target element
|
||||
*/
|
||||
function positionRelativeTo(dropdownElement: HTMLElement, targetEl: HTMLElement): void {
|
||||
dropdownElement.addClass('note-status-popover-fixed');
|
||||
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
|
||||
dropdownElement.style.setProperty('--pos-y-px', `${targetRect.bottom + 5}px`);
|
||||
dropdownElement.style.setProperty('--pos-x-px', `${targetRect.left}px`);
|
||||
|
||||
setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust position when positioned relative to an element
|
||||
*/
|
||||
function adjustRelativePosition(dropdownElement: HTMLElement, targetRect: DOMRect): void {
|
||||
const rect = dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
dropdownElement.addClass('note-status-popover-right');
|
||||
dropdownElement.style.setProperty('--right-offset-px', `${window.innerWidth - targetRect.right}px`);
|
||||
} else {
|
||||
dropdownElement.removeClass('note-status-popover-right');
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
dropdownElement.addClass('note-status-popover-bottom');
|
||||
dropdownElement.style.setProperty('--bottom-offset-px', `${window.innerHeight - targetRect.top + 5}px`);
|
||||
} else {
|
||||
dropdownElement.removeClass('note-status-popover-bottom');
|
||||
}
|
||||
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dummy target element for positioning
|
||||
*/
|
||||
export function createDummyTarget(position: { x: number, y: number }): HTMLElement {
|
||||
const dummyTarget = document.createElement('div');
|
||||
dummyTarget.addClass('note-status-dummy-target');
|
||||
dummyTarget.style.setProperty('--pos-x-px', `${position.x}px`);
|
||||
dummyTarget.style.setProperty('--pos-y-px', `${position.y}px`);
|
||||
document.body.appendChild(dummyTarget);
|
||||
return dummyTarget;
|
||||
}
|
||||
314
components/status-dropdown/dropdown-render.ts
Normal file
314
components/status-dropdown/dropdown-render.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { setIcon, TFile, setTooltip } from 'obsidian';
|
||||
import { StatusRemoveHandler, StatusSelectHandler } from './types';
|
||||
import { NoteStatusSettings, Status } from 'models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
|
||||
/**
|
||||
* Render the dropdown content
|
||||
*/
|
||||
export function renderDropdownContent(options: {
|
||||
dropdownElement: HTMLElement,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
currentStatuses: string[],
|
||||
targetFile: TFile | null,
|
||||
targetFiles: TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler,
|
||||
onSelectStatus: StatusSelectHandler
|
||||
}): void {
|
||||
const {
|
||||
dropdownElement,
|
||||
settings,
|
||||
statusService,
|
||||
currentStatuses,
|
||||
targetFile,
|
||||
targetFiles,
|
||||
onRemoveStatus,
|
||||
onSelectStatus
|
||||
} = options;
|
||||
|
||||
dropdownElement.empty();
|
||||
|
||||
// Create UI sections
|
||||
createHeader(dropdownElement, targetFiles);
|
||||
|
||||
const target = targetFiles.length > 1 ? targetFiles : targetFile;
|
||||
createStatusChips(dropdownElement, currentStatuses, statusService, target ?? [], onRemoveStatus);
|
||||
const searchInput = createSearchFilter(dropdownElement);
|
||||
|
||||
// Create status options container
|
||||
const statusOptionsContainer = dropdownElement.createDiv({
|
||||
cls: 'note-status-options-container'
|
||||
});
|
||||
|
||||
// Get all available statuses (excluding 'unknown')
|
||||
const allStatuses = statusService.getAllStatuses()
|
||||
.filter(status => status.name !== 'unknown');
|
||||
|
||||
// Function to populate options with filtering
|
||||
const populateOptions = (filter = '') => {
|
||||
populateStatusOptions({
|
||||
container: statusOptionsContainer,
|
||||
statuses: allStatuses,
|
||||
currentStatuses,
|
||||
settings,
|
||||
targetFiles,
|
||||
onSelectStatus,
|
||||
filter
|
||||
});
|
||||
};
|
||||
|
||||
// Initial population
|
||||
populateOptions();
|
||||
|
||||
// Add search functionality
|
||||
searchInput.addEventListener('input', () => {
|
||||
populateOptions(searchInput.value);
|
||||
});
|
||||
|
||||
// Focus search input after a short delay
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dropdown header
|
||||
*/
|
||||
function createHeader(dropdownElement: HTMLElement, targetFiles: TFile[]): void {
|
||||
const headerEl = dropdownElement.createDiv({ cls: 'note-status-popover-header' });
|
||||
const titleEl = headerEl.createDiv({ cls: 'note-status-popover-title' });
|
||||
|
||||
const iconContainer = titleEl.createDiv({ cls: 'note-status-popover-icon' });
|
||||
setIcon(iconContainer, 'tag');
|
||||
|
||||
titleEl.createSpan({ text: 'Note status', cls: 'note-status-popover-label' });
|
||||
|
||||
// If multiple files are selected, show count
|
||||
if (targetFiles.length > 1) {
|
||||
titleEl.createSpan({
|
||||
text: ` (${targetFiles.length} files)`,
|
||||
cls: 'note-status-popover-count'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the status chips section
|
||||
*/
|
||||
function createStatusChips(
|
||||
dropdownElement: HTMLElement,
|
||||
currentStatuses: string[],
|
||||
statusService: StatusService,
|
||||
targetFile: TFile | TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler
|
||||
): void {
|
||||
const chipsContainer = dropdownElement.createDiv({ cls: 'note-status-popover-chips' });
|
||||
|
||||
const hasNoValidStatus = currentStatuses.length === 0 ||
|
||||
(currentStatuses.length === 1 && currentStatuses[0] === 'unknown');
|
||||
|
||||
if (hasNoValidStatus) {
|
||||
chipsContainer.createDiv({
|
||||
cls: 'note-status-empty-indicator',
|
||||
text: 'No status assigned'
|
||||
});
|
||||
} else {
|
||||
createStatusChipElements(chipsContainer, currentStatuses, statusService, targetFile, onRemoveStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chips for all current statuses
|
||||
*/
|
||||
function createStatusChipElements(
|
||||
container: HTMLElement,
|
||||
currentStatuses: string[],
|
||||
statusService: StatusService,
|
||||
target: TFile | TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler
|
||||
): void {
|
||||
currentStatuses.forEach(status => {
|
||||
if (status === 'unknown') return;
|
||||
|
||||
const statusObj = statusService.getAllStatuses().find(s => s.name === status);
|
||||
if (!statusObj) return;
|
||||
|
||||
createSingleStatusChip(container, status, statusObj, target, onRemoveStatus);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single status chip
|
||||
*/
|
||||
function createSingleStatusChip(
|
||||
container: HTMLElement,
|
||||
status: string,
|
||||
statusObj: Status,
|
||||
target: TFile | TFile[],
|
||||
onRemoveStatus: StatusRemoveHandler
|
||||
): void {
|
||||
const chipEl = container.createDiv({
|
||||
cls: `note-status-chip status-${status}`
|
||||
});
|
||||
|
||||
// Status icon and name
|
||||
chipEl.createSpan({
|
||||
text: statusObj.icon,
|
||||
cls: 'note-status-chip-icon'
|
||||
});
|
||||
|
||||
chipEl.createSpan({
|
||||
text: statusObj.name,
|
||||
cls: 'note-status-chip-text'
|
||||
});
|
||||
|
||||
addRemoveButton(chipEl, status, statusObj, target, onRemoveStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a remove button to a status chip
|
||||
*/
|
||||
function addRemoveButton(
|
||||
chipEl: HTMLElement,
|
||||
status: string,
|
||||
statusObj: Status,
|
||||
targetFile: TFile | TFile[] | null,
|
||||
onRemoveStatus: StatusRemoveHandler
|
||||
): void {
|
||||
const tooltipValue = statusObj.description ? `${status} - ${statusObj.description}`: status;
|
||||
|
||||
setTooltip(chipEl, tooltipValue);
|
||||
|
||||
const removeBtn = chipEl.createDiv({
|
||||
cls: 'note-status-chip-remove',
|
||||
attr: {
|
||||
'aria-label': `Remove ${status} status`,
|
||||
'title': `Remove ${status} status`
|
||||
}
|
||||
});
|
||||
setIcon(removeBtn, 'x');
|
||||
|
||||
removeBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
chipEl.addClass('note-status-chip-removing');
|
||||
|
||||
setTimeout(async () => {
|
||||
if (targetFile) {
|
||||
await onRemoveStatus(status, targetFile);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the search filter input
|
||||
*/
|
||||
function createSearchFilter(dropdownElement: HTMLElement): HTMLInputElement {
|
||||
const searchContainer = dropdownElement.createDiv({ cls: 'note-status-popover-search' });
|
||||
|
||||
return searchContainer.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter statuses...',
|
||||
cls: 'note-status-popover-search-input'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate status options with optional filtering
|
||||
*/
|
||||
function populateStatusOptions(options: {
|
||||
container: HTMLElement,
|
||||
statuses: Status[],
|
||||
currentStatuses: string[],
|
||||
settings: NoteStatusSettings,
|
||||
targetFiles: TFile[],
|
||||
onSelectStatus: StatusSelectHandler,
|
||||
filter?: string
|
||||
}): void {
|
||||
const {
|
||||
container,
|
||||
statuses,
|
||||
currentStatuses,
|
||||
settings,
|
||||
targetFiles,
|
||||
onSelectStatus,
|
||||
filter = ''
|
||||
} = options;
|
||||
|
||||
container.empty();
|
||||
|
||||
const filteredStatuses = filter ?
|
||||
statuses.filter(status =>
|
||||
status.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
status.icon.includes(filter)
|
||||
) :
|
||||
statuses;
|
||||
|
||||
if (filteredStatuses.length === 0) {
|
||||
container.createDiv({
|
||||
cls: 'note-status-empty-options',
|
||||
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
filteredStatuses.forEach(status => {
|
||||
createStatusOption({
|
||||
container,
|
||||
status,
|
||||
isSelected: currentStatuses.includes(status.name),
|
||||
settings,
|
||||
targetFiles,
|
||||
onSelectStatus
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single status option element
|
||||
*/
|
||||
function createStatusOption(options: {
|
||||
container: HTMLElement,
|
||||
status: Status,
|
||||
isSelected: boolean,
|
||||
settings: NoteStatusSettings,
|
||||
targetFiles: TFile[],
|
||||
onSelectStatus: StatusSelectHandler
|
||||
}): void {
|
||||
const { container, status, isSelected, settings, targetFiles, onSelectStatus } = options;
|
||||
|
||||
const optionEl = container.createDiv({
|
||||
cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}`
|
||||
});
|
||||
|
||||
// Status icon and name
|
||||
optionEl.createSpan({
|
||||
text: status.icon,
|
||||
cls: 'note-status-option-icon'
|
||||
});
|
||||
|
||||
optionEl.createSpan({
|
||||
text: status.name,
|
||||
cls: 'note-status-option-text'
|
||||
});
|
||||
|
||||
// Add tooltip if description available
|
||||
if (status.description) {
|
||||
setTooltip(optionEl, `${status.name} - ${status.description}`);
|
||||
}
|
||||
|
||||
// Check icon for selected status
|
||||
if (isSelected) {
|
||||
const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' });
|
||||
setIcon(checkIcon, 'check');
|
||||
}
|
||||
|
||||
optionEl.addEventListener('click', () => {
|
||||
optionEl.addClass('note-status-option-selecting');
|
||||
setTimeout(async () => {
|
||||
if (targetFiles.length > 0) {
|
||||
await onSelectStatus(status.name, targetFiles);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
220
components/status-dropdown/dropdown-ui.ts
Normal file
220
components/status-dropdown/dropdown-ui.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { DropdownDependencies, StatusRemoveHandler, StatusSelectHandler } from './types';
|
||||
import { positionDropdown } from './dropdown-position';
|
||||
import { renderDropdownContent } from './dropdown-render';
|
||||
import { setupDropdownEvents } from './dropdown-events';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { NoteStatusSettings } from 'models/types';
|
||||
|
||||
/**
|
||||
* Core UI component for the status dropdown
|
||||
*/
|
||||
export class DropdownUI {
|
||||
private app: App;
|
||||
private statusService: StatusService;
|
||||
private settings: NoteStatusSettings;
|
||||
|
||||
private dropdownElement: HTMLElement | null = null;
|
||||
private currentStatuses: string[] = ['unknown'];
|
||||
private targetFile: TFile | null = null;
|
||||
private targetFiles: TFile[] = [];
|
||||
private animationDuration = 220;
|
||||
|
||||
public isOpen = false;
|
||||
private onRemoveStatus: StatusRemoveHandler = async () => {};
|
||||
private onSelectStatus: StatusSelectHandler = async () => {};
|
||||
|
||||
// Event handlers
|
||||
private clickOutsideHandler: (e: MouseEvent) => void;
|
||||
private escapeKeyHandler: (e: KeyboardEvent) => void;
|
||||
|
||||
constructor({ app, settings, statusService }: DropdownDependencies) {
|
||||
this.app = app;
|
||||
this.statusService = statusService;
|
||||
this.settings = settings;
|
||||
|
||||
// Bind methods
|
||||
this.clickOutsideHandler = this.handleClickOutside.bind(this);
|
||||
this.escapeKeyHandler = this.handleEscapeKey.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the target file for status updates
|
||||
*/
|
||||
public setTargetFile(file: TFile | null): void {
|
||||
this.targetFile = file;
|
||||
this.targetFiles = file ? [file] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple target files for status updates
|
||||
*/
|
||||
public setTargetFiles(files: TFile[]): void {
|
||||
this.targetFiles = [...files];
|
||||
this.targetFile = files.length === 1 ? files[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register handler for removing a status
|
||||
*/
|
||||
public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void {
|
||||
this.onRemoveStatus = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register handler for selecting a status
|
||||
*/
|
||||
public setOnSelectStatusHandler(handler: StatusSelectHandler): void {
|
||||
this.onSelectStatus = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current statuses
|
||||
*/
|
||||
public updateStatuses(statuses: string[] | string): void {
|
||||
this.currentStatuses = Array.isArray(statuses) ? [...statuses] : [statuses];
|
||||
|
||||
if (this.isOpen && this.dropdownElement) {
|
||||
this.refreshDropdownContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
|
||||
if (this.isOpen && this.dropdownElement) {
|
||||
this.refreshDropdownContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the dropdown visibility
|
||||
*/
|
||||
public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
setTimeout(() => {
|
||||
if (!this.isOpen && !this.dropdownElement) {
|
||||
this.open(targetEl, position);
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
this.open(targetEl, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dropdown
|
||||
*/
|
||||
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
||||
if (this.isOpen || this.dropdownElement) {
|
||||
this.close();
|
||||
setTimeout(() => this.actuallyOpen(targetEl, position), 10);
|
||||
return;
|
||||
}
|
||||
|
||||
this.actuallyOpen(targetEl, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually open the dropdown (internal method)
|
||||
*/
|
||||
private actuallyOpen(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
||||
this.isOpen = true;
|
||||
|
||||
// Create dropdown element
|
||||
this.createDropdownElement();
|
||||
this.refreshDropdownContent();
|
||||
|
||||
// Position the dropdown
|
||||
positionDropdown({
|
||||
dropdownElement: this.dropdownElement!,
|
||||
targetEl,
|
||||
position
|
||||
});
|
||||
|
||||
this.dropdownElement?.addClass('note-status-popover-animate-in');
|
||||
|
||||
// Add event listeners with slight delay to prevent immediate triggering
|
||||
setTimeout(() => {
|
||||
setupDropdownEvents({
|
||||
clickOutsideHandler: this.clickOutsideHandler,
|
||||
escapeKeyHandler: this.escapeKeyHandler
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dropdown element
|
||||
*/
|
||||
private createDropdownElement(): void {
|
||||
this.dropdownElement = document.createElement('div');
|
||||
this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown');
|
||||
document.body.appendChild(this.dropdownElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dropdown
|
||||
*/
|
||||
public close(): void {
|
||||
if (!this.dropdownElement || !this.isOpen) return;
|
||||
|
||||
this.dropdownElement.addClass('note-status-popover-animate-out');
|
||||
|
||||
document.removeEventListener('click', this.clickOutsideHandler);
|
||||
document.removeEventListener('keydown', this.escapeKeyHandler);
|
||||
|
||||
this.isOpen = false;
|
||||
|
||||
if (this.dropdownElement) {
|
||||
this.dropdownElement.remove();
|
||||
this.dropdownElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the dropdown content
|
||||
*/
|
||||
private refreshDropdownContent(): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
renderDropdownContent({
|
||||
dropdownElement: this.dropdownElement,
|
||||
settings: this.settings,
|
||||
statusService: this.statusService,
|
||||
currentStatuses: this.currentStatuses,
|
||||
targetFile: this.targetFile,
|
||||
targetFiles: this.targetFiles,
|
||||
onRemoveStatus: this.onRemoveStatus,
|
||||
onSelectStatus: this.onSelectStatus
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside the dropdown
|
||||
*/
|
||||
private handleClickOutside(e: MouseEvent): void {
|
||||
if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key to close dropdown
|
||||
*/
|
||||
private handleEscapeKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
6
components/status-dropdown/index.ts
Normal file
6
components/status-dropdown/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { DropdownManager } from './dropdown-manager';
|
||||
import { DropdownOptions } from './types';
|
||||
|
||||
export type { DropdownOptions };
|
||||
export { DropdownManager as StatusDropdown };
|
||||
export default DropdownManager;
|
||||
36
components/status-dropdown/types.ts
Normal file
36
components/status-dropdown/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// # Tipos comunes
|
||||
import { NoteStatusSettings } from 'models/types';
|
||||
import { App, TFile, Editor, MarkdownView } from 'obsidian';
|
||||
import { StatusService } from 'services/status-service';
|
||||
|
||||
/**
|
||||
* Options for opening status dropdown
|
||||
*/
|
||||
export interface DropdownOptions {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number, y: number };
|
||||
files?: TFile[];
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
mode?: 'replace' | 'add' | 'remove' | 'toggle';
|
||||
onStatusChange?: (statuses: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status removal handler function type
|
||||
*/
|
||||
export type StatusRemoveHandler = (status: string, targetFile?: TFile | TFile[]) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Status selection handler function type
|
||||
*/
|
||||
export type StatusSelectHandler = (status: string, targetFile: TFile | TFile[]) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Common dependencies for dropdown components
|
||||
*/
|
||||
export interface DropdownDependencies {
|
||||
app: App;
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
}
|
||||
75
components/toolbar-button.ts
Normal file
75
components/toolbar-button.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { NoteStatusSettings } from "models/types";
|
||||
import { StatusService } from "services/status-service";
|
||||
|
||||
export class ToolbarButton {
|
||||
private element: HTMLElement | null = null;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
|
||||
constructor(settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
}
|
||||
|
||||
public createElement(): HTMLElement {
|
||||
const button = document.createElement('button');
|
||||
button.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action');
|
||||
button.setAttribute('aria-label', 'Note status');
|
||||
|
||||
this.element = button;
|
||||
return button;
|
||||
}
|
||||
|
||||
public updateDisplay(statuses: string[]): void {
|
||||
if (!this.element) return;
|
||||
|
||||
this.element.empty();
|
||||
|
||||
const hasValidStatus = statuses.length > 0 && statuses[0] !== 'unknown';
|
||||
const badgeContainer = document.createElement('div');
|
||||
badgeContainer.addClass('note-status-toolbar-badge-container');
|
||||
|
||||
if (hasValidStatus) {
|
||||
this.renderStatusBadge(badgeContainer, statuses);
|
||||
} else {
|
||||
this.renderUnknownBadge(badgeContainer);
|
||||
}
|
||||
|
||||
this.element.appendChild(badgeContainer);
|
||||
}
|
||||
|
||||
private renderStatusBadge(container: HTMLElement, statuses: string[]): void {
|
||||
const primaryStatus = statuses[0];
|
||||
const icon = this.statusService.getStatusIcon(primaryStatus);
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`);
|
||||
iconSpan.textContent = icon;
|
||||
container.appendChild(iconSpan);
|
||||
|
||||
if (this.settings.useMultipleStatuses && statuses.length > 1) {
|
||||
const countBadge = document.createElement('span');
|
||||
countBadge.addClass('note-status-count-badge');
|
||||
countBadge.textContent = `${statuses.length}`;
|
||||
container.appendChild(countBadge);
|
||||
}
|
||||
}
|
||||
|
||||
private renderUnknownBadge(container: HTMLElement): void {
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.addClass('note-status-toolbar-icon', 'status-unknown');
|
||||
iconSpan.textContent = this.statusService.getStatusIcon('unknown');
|
||||
container.appendChild(iconSpan);
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
|
|||
useCustomStatusesOnly: false,
|
||||
useMultipleStatuses: true,
|
||||
tagPrefix: 'obsidian-note-status',
|
||||
strictStatuses: false, // Default to show all statuses from frontmatter
|
||||
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
|
||||
};
|
||||
|
||||
|
|
@ -35,4 +36,4 @@ export const DEFAULT_COLORS: Record<string, string> = {
|
|||
completed: '#00aaff', // Blue for accent
|
||||
dropped: '#ff0000', // Red for error
|
||||
unknown: '#888888' // Gray for muted
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ if you want to view the source, please visit the github repository of this plugi
|
|||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
// CSS bundling function
|
||||
const buildStyles = async () => {
|
||||
await esbuild.build({
|
||||
entryPoints: ["styles/index.css"],
|
||||
bundle: true,
|
||||
outfile: "styles.css",
|
||||
minify: prod,
|
||||
logLevel: "info",
|
||||
});
|
||||
};
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
|
|
@ -31,7 +42,8 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
...builtins
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
|
|
@ -41,9 +53,20 @@ const context = await esbuild.context({
|
|||
minify: prod,
|
||||
});
|
||||
|
||||
// CSS context for watch mode
|
||||
const cssContext = await esbuild.context({
|
||||
entryPoints: ["styles/index.css"],
|
||||
bundle: true,
|
||||
outfile: "styles.css",
|
||||
minify: prod,
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
await buildStyles(); // Build CSS in production mode
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
await cssContext.watch(); // Watch CSS files in development mode
|
||||
}
|
||||
227
integrations/commands/command-integration.ts
Normal file
227
integrations/commands/command-integration.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// integrations/commands/command-integration.ts
|
||||
import { App, Editor, MarkdownView, Notice, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { StatusDropdown } from 'components/status-dropdown';
|
||||
import { StatusPaneViewController } from 'views/status-pane-view';
|
||||
import NoteStatus from 'main';
|
||||
|
||||
export class CommandIntegration {
|
||||
private app: App;
|
||||
private plugin: NoteStatus;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: NoteStatus,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown
|
||||
) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerCommands(): void {
|
||||
// Open status pane
|
||||
this.plugin.addCommand({
|
||||
id: 'open-status-pane',
|
||||
name: 'Open status pane',
|
||||
callback: () => StatusPaneViewController.open(this.app)
|
||||
});
|
||||
|
||||
// Change status of current note
|
||||
this.plugin.addCommand({
|
||||
id: 'change-status',
|
||||
name: 'Change status of current note',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusDropdown.openStatusDropdown({ files: [file] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Insert status metadata
|
||||
this.plugin.addCommand({
|
||||
id: 'insert-status-metadata',
|
||||
name: 'Insert status metadata',
|
||||
editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView) => {
|
||||
if (!view.file) return false;
|
||||
|
||||
const statuses = this.statusService.getFileStatuses(view.file);
|
||||
const hasNoStatus = statuses.length === 1 && statuses[0] === 'unknown';
|
||||
|
||||
if (!checking && hasNoStatus) {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
new Notice('Status metadata inserted');
|
||||
}
|
||||
return hasNoStatus;
|
||||
}
|
||||
});
|
||||
|
||||
// Cycle through statuses
|
||||
this.plugin.addCommand({
|
||||
id: 'cycle-status',
|
||||
name: 'Cycle to next status',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.cycleStatus(file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Quick status commands for common statuses
|
||||
const quickStatuses = ['active', 'completed', 'onHold', 'dropped'];
|
||||
quickStatuses.forEach(status => {
|
||||
this.plugin.addCommand({
|
||||
id: `set-status-${status}`,
|
||||
name: `Set status to ${status}`,
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: status,
|
||||
operation: 'set'
|
||||
});
|
||||
new Notice(`Status set to ${status}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clear status
|
||||
this.plugin.addCommand({
|
||||
id: 'clear-status',
|
||||
name: 'Clear status (set to unknown)',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: 'unknown',
|
||||
operation: 'set'
|
||||
});
|
||||
new Notice('Status cleared');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Copy status from current note
|
||||
this.plugin.addCommand({
|
||||
id: 'copy-status',
|
||||
name: 'Copy status from current note',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
(this.app as any).clipboard = statuses;
|
||||
new Notice(`Copied status: ${statuses.join(', ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Paste status to current note
|
||||
this.plugin.addCommand({
|
||||
id: 'paste-status',
|
||||
name: 'Paste status to current note',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
const clipboard = (this.app as any).clipboard;
|
||||
if (!file || !clipboard || !Array.isArray(clipboard)) return false;
|
||||
|
||||
if (!checking) {
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: clipboard,
|
||||
operation: 'set'
|
||||
});
|
||||
new Notice(`Pasted status: ${clipboard.join(', ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle multiple statuses mode
|
||||
this.plugin.addCommand({
|
||||
id: 'toggle-multiple-statuses',
|
||||
name: 'Toggle multiple statuses mode',
|
||||
callback: () => {
|
||||
this.settings.useMultipleStatuses = !this.settings.useMultipleStatuses;
|
||||
this.plugin.saveSettings();
|
||||
new Notice(`Multiple statuses mode ${this.settings.useMultipleStatuses ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Search notes by status
|
||||
this.plugin.addCommand({
|
||||
id: 'search-by-status',
|
||||
name: 'Search notes by current status',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file) return false;
|
||||
|
||||
if (!checking) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`;
|
||||
(this.app as any).internalPlugins.getPluginById('global-search').instance.openGlobalSearch(query);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private cycleStatus(file: TFile): void {
|
||||
const allStatuses = this.statusService.getAllStatuses()
|
||||
.filter(s => s.name !== 'unknown')
|
||||
.map(s => s.name);
|
||||
|
||||
if (allStatuses.length === 0) {
|
||||
new Notice('No statuses available');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatuses = this.statusService.getFileStatuses(file);
|
||||
const currentStatus = currentStatuses[0];
|
||||
|
||||
let nextIndex = 0;
|
||||
if (currentStatus !== 'unknown') {
|
||||
const currentIndex = allStatuses.indexOf(currentStatus);
|
||||
nextIndex = (currentIndex + 1) % allStatuses.length;
|
||||
}
|
||||
|
||||
this.statusService.handleStatusChange({
|
||||
files: file,
|
||||
statuses: allStatuses[nextIndex],
|
||||
operation: 'set'
|
||||
});
|
||||
|
||||
new Notice(`Status changed to ${allStatuses[nextIndex]}`);
|
||||
}
|
||||
}
|
||||
95
integrations/context-menu/file-context-menu-integration.ts
Normal file
95
integrations/context-menu/file-context-menu-integration.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { App, Menu, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from 'models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { ExplorerIntegration } from 'integrations/explorer/explorer-integration';
|
||||
import { StatusContextMenu } from 'integrations/context-menu/status-context-menu';
|
||||
|
||||
/**
|
||||
* Gestiona la integración de menús contextuales con el explorador de archivos
|
||||
*/
|
||||
export class FileContextMenuIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
private statusContextMenu: StatusContextMenu;
|
||||
private fileMenuEventRef: any;
|
||||
private filesMenuEventRef: any;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
explorerIntegration: ExplorerIntegration,
|
||||
statusContextMenu: StatusContextMenu
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
this.statusContextMenu = statusContextMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra los eventos del menú contextual de archivos
|
||||
*/
|
||||
public registerFileContextMenuEvents(): void {
|
||||
this.fileMenuEventRef = (menu: Menu, file: any, source: string) => {
|
||||
if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') {
|
||||
this.addStatusChangeMenu(menu, file);
|
||||
}
|
||||
};
|
||||
|
||||
this.filesMenuEventRef = (menu: Menu, files: any[]) => {
|
||||
const mdFiles = files.filter(file =>
|
||||
file instanceof TFile && file.extension === 'md'
|
||||
) as TFile[];
|
||||
|
||||
if (mdFiles.length > 0) {
|
||||
this.addBatchStatusChangeMenu(menu, mdFiles);
|
||||
}
|
||||
};
|
||||
|
||||
this.app.workspace.on('file-menu', this.fileMenuEventRef);
|
||||
this.app.workspace.on('files-menu', this.filesMenuEventRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade opción de cambio de estado al menú contextual de un archivo
|
||||
*/
|
||||
private addStatusChangeMenu(menu: Menu, file: TFile): void {
|
||||
this.statusContextMenu.addStatusMenuItemToSingleFile(menu, file, (file) => {
|
||||
const selectedFiles = this.explorerIntegration.getSelectedFiles();
|
||||
if (selectedFiles.length > 1) {
|
||||
this.statusContextMenu.showForFiles(selectedFiles);
|
||||
} else {
|
||||
this.statusContextMenu.showForFiles([file]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade opción de cambio de estado para múltiples archivos
|
||||
*/
|
||||
private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void {
|
||||
this.statusContextMenu.addStatusMenuItemToBatch(menu, files, (files) => {
|
||||
this.statusContextMenu.showForFiles(files);
|
||||
});
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.fileMenuEventRef) {
|
||||
this.app.workspace.off('file-menu', this.fileMenuEventRef);
|
||||
}
|
||||
if (this.filesMenuEventRef) {
|
||||
this.app.workspace.off('files-menu', this.filesMenuEventRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
integrations/context-menu/status-context-menu.ts
Normal file
130
integrations/context-menu/status-context-menu.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { App, Menu, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from 'models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { StatusDropdown } from 'components/status-dropdown';
|
||||
import { ExplorerIntegration } from 'integrations/explorer/explorer-integration';
|
||||
|
||||
/**
|
||||
* Gestiona los menús contextuales para cambios de estado
|
||||
*/
|
||||
export class StatusContextMenu {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown,
|
||||
explorerIntegration: ExplorerIntegration
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade ítem de menú para cambiar estado de un archivo
|
||||
*/
|
||||
public addStatusMenuItemToSingleFile(menu: Menu, file: TFile, onClick: (file: TFile) => void): void {
|
||||
menu.addItem(item =>
|
||||
item
|
||||
.setTitle('Change status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => onClick(file))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade ítem de menú para cambiar estado de múltiples archivos
|
||||
*/
|
||||
public addStatusMenuItemToBatch(menu: Menu, files: TFile[], onClick: (files: TFile[]) => void): void {
|
||||
menu.addItem(item =>
|
||||
item
|
||||
.setTitle('Change status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => onClick(files))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para cambiar estado de uno o más archivos
|
||||
* @param files Archivos a los que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
public showForFiles(files: TFile[], position?: { x: number; y: number }): void {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length === 1) {
|
||||
this.showForSingleFile(files[0], position);
|
||||
} else {
|
||||
this.showForMultipleFiles(files, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para un solo archivo
|
||||
* @param file Archivo al que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
private showForSingleFile(file: TFile, position?: { x: number; y: number }): void {
|
||||
if (!(file instanceof TFile) || file.extension !== 'md') return;
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el menú contextual para múltiples archivos
|
||||
* @param files Archivos a los que cambiar el estado
|
||||
* @param position Posición opcional para mostrar el menú
|
||||
*/
|
||||
private showForMultipleFiles(files: TFile[], position?: { x: number; y: number }): void {
|
||||
const menu = new Menu();
|
||||
|
||||
// Elemento de información (deshabilitado)
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Update ${files.length} files`)
|
||||
.setDisabled(true);
|
||||
return item;
|
||||
});
|
||||
|
||||
// Opción para gestionar estados
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Manage statuses...')
|
||||
.setIcon('tag')
|
||||
.onClick(() => {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position,
|
||||
files
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Mostrar el menú en la posición adecuada
|
||||
if (position) {
|
||||
menu.showAtPosition(position);
|
||||
} else {
|
||||
// Use a centered position
|
||||
menu.showAtPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
80
integrations/editor/editor-integration.ts
Normal file
80
integrations/editor/editor-integration.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { App, Editor, MarkdownView, Menu, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { StatusDropdown } from 'components/status-dropdown';
|
||||
|
||||
export class EditorIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private editorMenuRef: any;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerEditorMenus(): void {
|
||||
this.editorMenuRef = (menu: Menu, editor: Editor, view: MarkdownView) => {
|
||||
if (view.file) {
|
||||
this.addStatusMenuItems(menu, editor, view);
|
||||
}
|
||||
};
|
||||
|
||||
this.app.workspace.on('editor-menu', this.editorMenuRef);
|
||||
}
|
||||
|
||||
private addStatusMenuItems(menu: Menu, editor: Editor, view: MarkdownView): void {
|
||||
menu.addItem(item =>
|
||||
item
|
||||
.setTitle('Change note status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => {
|
||||
if (view.file) {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
files: [view.file],
|
||||
editor,
|
||||
view
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Only show insert metadata if it doesn't exist
|
||||
if (view.file) {
|
||||
const statuses = this.statusService.getFileStatuses(view.file);
|
||||
if (statuses.length === 1 && statuses[0] === 'unknown') {
|
||||
menu.addItem(item =>
|
||||
item
|
||||
.setTitle('Insert status metadata')
|
||||
.setIcon('plus-circle')
|
||||
.onClick(() => {
|
||||
this.insertStatusMetadata(editor);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public insertStatusMetadata(editor: Editor): void {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.editorMenuRef) {
|
||||
this.app.workspace.off('editor-menu', this.editorMenuRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
integrations/editor/index.ts
Normal file
2
integrations/editor/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { EditorIntegration } from './editor-integration';
|
||||
export { ToolbarIntegration } from './toolbar-integration';
|
||||
118
integrations/editor/toolbar-integration.ts
Normal file
118
integrations/editor/toolbar-integration.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { App, MarkdownView } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
import { StatusDropdown } from '../../components/status-dropdown';
|
||||
import { ToolbarButton } from 'components/toolbar-button';
|
||||
|
||||
/**
|
||||
* Gestiona la integración con la barra de herramientas del editor
|
||||
*/
|
||||
export class ToolbarIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private statusDropdown: StatusDropdown;
|
||||
private buttonView: ToolbarButton;
|
||||
private buttonElement: HTMLElement | null = null;
|
||||
private currentLeafId: string | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
statusDropdown: StatusDropdown
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.buttonView = new ToolbarButton(settings, statusService);
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.buttonView.updateSettings(settings);
|
||||
this.updateStatusDisplay([]);
|
||||
}
|
||||
|
||||
public addToolbarButtonToActiveLeaf(statuses?: string[]): void {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView)) return;
|
||||
|
||||
const leafId = (activeLeaf as any).id || activeLeaf.view.containerEl.id;
|
||||
|
||||
// Only recreate button if we're on a different leaf or button doesn't exist
|
||||
if (this.currentLeafId !== leafId || !this.buttonElement || !this.isButtonInDOM()) {
|
||||
this.recreateButton(activeLeaf.view, leafId);
|
||||
}
|
||||
|
||||
this.updateButtonDisplay(statuses);
|
||||
}
|
||||
|
||||
private recreateButton(view: MarkdownView, leafId: string): void {
|
||||
const toolbarContainer = view.containerEl.querySelector('.view-header .view-actions');
|
||||
if (!toolbarContainer) return;
|
||||
|
||||
// Remove old button if it exists
|
||||
this.removeToolbarButton();
|
||||
|
||||
// Create new button
|
||||
this.buttonElement = this.buttonView.createElement();
|
||||
this.buttonElement.addEventListener('click', this.handleButtonClick.bind(this));
|
||||
|
||||
if (toolbarContainer.firstChild) {
|
||||
toolbarContainer.insertBefore(this.buttonElement, toolbarContainer.firstChild);
|
||||
} else {
|
||||
toolbarContainer.appendChild(this.buttonElement);
|
||||
}
|
||||
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
private isButtonInDOM(): boolean {
|
||||
return this.buttonElement?.isConnected === true;
|
||||
}
|
||||
|
||||
private removeToolbarButton(): void {
|
||||
this.app.workspace.iterateAllLeaves(leaf => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
const buttons = leaf.view.containerEl.querySelectorAll('.note-status-toolbar-button');
|
||||
buttons.forEach(button => button.remove());
|
||||
}
|
||||
});
|
||||
|
||||
this.buttonElement = null;
|
||||
this.currentLeafId = null;
|
||||
}
|
||||
|
||||
private updateButtonDisplay(overrideStatuses?: string[]): void {
|
||||
if (!this.buttonElement) return;
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
const statuses = overrideStatuses?.length ? overrideStatuses : this.statusService.getFileStatuses(activeFile);
|
||||
this.buttonView.updateDisplay(statuses);
|
||||
}
|
||||
|
||||
private handleButtonClick(e: MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
target: this.buttonElement || undefined,
|
||||
files: [activeFile]
|
||||
});
|
||||
}
|
||||
|
||||
public updateStatusDisplay(statuses: string[]): void {
|
||||
this.updateButtonDisplay(statuses);
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
// this.buttonView.destroy();
|
||||
this.removeToolbarButton();
|
||||
}
|
||||
}
|
||||
350
integrations/explorer/explorer-integration.ts
Normal file
350
integrations/explorer/explorer-integration.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { App, TFile, setTooltip, debounce } from 'obsidian';
|
||||
import { NoteStatusSettings, FileExplorerView } from '../../models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
|
||||
|
||||
/**
|
||||
* Manages the logic for file explorer status integration
|
||||
*/
|
||||
export class ExplorerIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private ui: ExplorerIntegrationUI;
|
||||
private iconUpdateQueue = new Set<string>();
|
||||
private isProcessingQueue = false;
|
||||
private debouncedUpdateAll: ReturnType<typeof debounce>;
|
||||
private fileItemsWasUndefined = false;
|
||||
|
||||
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.ui = new ExplorerIntegrationUI(app, settings, statusService, () => {
|
||||
this.fileItemsWasUndefined = true;
|
||||
});
|
||||
this.debouncedUpdateAll = debounce(this.processUpdateQueue.bind(this), 100, true);
|
||||
|
||||
// Listen for layout changes to detect when file explorer becomes available
|
||||
this.app.workspace.on('layout-change', () => {
|
||||
this.checkFileExplorerAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file explorer became available after being unavailable
|
||||
*/
|
||||
private checkFileExplorerAvailability(): void {
|
||||
if (!this.settings.showStatusIconsInExplorer) return;
|
||||
|
||||
const fileExplorer = this.ui.findFileExplorerView();
|
||||
if (fileExplorer?.fileItems && this.fileItemsWasUndefined) {
|
||||
this.fileItemsWasUndefined = false;
|
||||
setTimeout(() => this.updateAllFileExplorerIcons(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings and refreshes UI if necessary
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
const shouldRefreshIcons =
|
||||
this.settings.showStatusIconsInExplorer !== settings.showStatusIconsInExplorer ||
|
||||
this.settings.hideUnknownStatusInExplorer !== settings.hideUnknownStatusInExplorer;
|
||||
|
||||
this.settings = {...settings};
|
||||
this.ui.updateSettings(settings);
|
||||
|
||||
if (shouldRefreshIcons) {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
|
||||
if (settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
|
||||
}
|
||||
} else if (settings.showStatusIconsInExplorer) {
|
||||
this.updateAllFileExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates icons for a specific file
|
||||
*/
|
||||
public updateFileExplorerIcons(file: TFile): void {
|
||||
if (!file || !this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file.path) {
|
||||
this.ui.updateSingleFileIconDirectly(file, this.statusService);
|
||||
}
|
||||
|
||||
this.queueFileUpdate(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all icons in the explorer
|
||||
*/
|
||||
public updateAllFileExplorerIcons(): void {
|
||||
if (!this.settings.showStatusIconsInExplorer) {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
this.processFilesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets selected files from the explorer
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
return this.ui.getSelectedFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a file for icon update
|
||||
*/
|
||||
private queueFileUpdate(file: TFile): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
|
||||
|
||||
this.iconUpdateQueue.add(file.path);
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the update queue
|
||||
*/
|
||||
private async processUpdateQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
try {
|
||||
const fileExplorerView = this.ui.findFileExplorerView();
|
||||
if (!fileExplorerView || !fileExplorerView.fileItems) {
|
||||
this.fileItemsWasUndefined = true;
|
||||
setTimeout(() => this.debouncedUpdateAll(), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const allPaths = Array.from(this.iconUpdateQueue);
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < allPaths.length; i += batchSize) {
|
||||
await this.processBatch(allPaths.slice(i, i + batchSize), fileExplorerView);
|
||||
|
||||
if (i + batchSize < allPaths.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Note Status: Error processing file update queue', error);
|
||||
} finally {
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
if (this.iconUpdateQueue.size > 0) {
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of files
|
||||
*/
|
||||
private async processBatch(paths: string[], fileExplorerView: any): Promise<void> {
|
||||
for (const path of paths) {
|
||||
const file = this.app.vault.getFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
this.ui.updateSingleFileIcon(file, fileExplorerView, this.statusService);
|
||||
}
|
||||
this.iconUpdateQueue.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process files in batches
|
||||
*/
|
||||
private async processFilesInBatches(): Promise<void> {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
files.slice(i, i + batchSize).forEach(file => this.queueFileUpdate(file));
|
||||
|
||||
if (i + batchSize < files.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when unloading the plugin
|
||||
*/
|
||||
public unload(): void {
|
||||
this.ui.removeAllFileExplorerIcons();
|
||||
this.debouncedUpdateAll.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages UI operations for file explorer icons
|
||||
*/
|
||||
export class ExplorerIntegrationUI {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private onFileItemsUndefined: () => void;
|
||||
|
||||
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService, onFileItemsUndefined: () => void) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.onFileItemsUndefined = onFileItemsUndefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the settings
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the file explorer view
|
||||
*/
|
||||
public findFileExplorerView(): FileExplorerView | null {
|
||||
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
|
||||
if (leaf?.view) return leaf.view as FileExplorerView;
|
||||
|
||||
for (const leaf of this.app.workspace.getLeavesOfType('')) {
|
||||
if (leaf.view && 'fileItems' in leaf.view) {
|
||||
return leaf.view as FileExplorerView;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single file icon
|
||||
*/
|
||||
public updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView, statusService: StatusService): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
|
||||
|
||||
try {
|
||||
// Check if fileItems is initialized
|
||||
if (!fileExplorerView.fileItems) {
|
||||
this.onFileItemsUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileItem = fileExplorerView.fileItems[file.path];
|
||||
if (!fileItem) return;
|
||||
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (!titleEl) return;
|
||||
|
||||
const statuses = statusService.getFileStatuses(file);
|
||||
|
||||
this.removeExistingIcons(titleEl);
|
||||
|
||||
if (this.shouldSkipIcon(statuses)) return;
|
||||
|
||||
this.addStatusIcons(titleEl, statuses, statusService);
|
||||
} catch (error) {
|
||||
console.error(`Note Status: Error updating icon for ${file.path}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the icon for the active file directly
|
||||
*/
|
||||
public updateSingleFileIconDirectly(file: TFile, statusService: StatusService): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (fileExplorer) {
|
||||
this.updateSingleFileIcon(file, fileExplorer, statusService);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all file explorer icons
|
||||
*/
|
||||
public removeAllFileExplorerIcons(): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer?.fileItems) return;
|
||||
|
||||
Object.values(fileExplorer.fileItems).forEach(fileItem => {
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (titleEl) this.removeExistingIcons(titleEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected files
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer?.fileItems) return [];
|
||||
|
||||
return Object.values(fileExplorer.fileItems)
|
||||
.filter(item =>
|
||||
item.el?.classList.contains('is-selected') &&
|
||||
item.file instanceof TFile &&
|
||||
item.file.extension === 'md')
|
||||
.map(item => item.file as TFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the icon should be skipped based on status
|
||||
*/
|
||||
private shouldSkipIcon(statuses: string[]): boolean {
|
||||
return this.settings.hideUnknownStatusInExplorer &&
|
||||
statuses.length === 1 &&
|
||||
statuses[0] === 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing status icons
|
||||
*/
|
||||
private removeExistingIcons(element: HTMLElement): void {
|
||||
const iconSelectors = '.note-status-icon, .note-status-icon-container';
|
||||
element.querySelectorAll(iconSelectors).forEach(icon => {
|
||||
if (icon.classList.contains('note-status-icon') ||
|
||||
icon.classList.contains('note-status-icon-container')) {
|
||||
icon.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds status icons to an element
|
||||
*/
|
||||
private addStatusIcons(titleEl: HTMLElement, statuses: string[], statusService: StatusService): void {
|
||||
const iconContainer = document.createElement('span');
|
||||
iconContainer.className = 'note-status-icon-container';
|
||||
|
||||
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
|
||||
statuses.forEach(status => this.addSingleStatusIcon(iconContainer, status, statusService));
|
||||
} else {
|
||||
const primaryStatus = statuses[0] || 'unknown';
|
||||
if (primaryStatus !== 'unknown' || !this.settings.hideUnknownStatusInExplorer) {
|
||||
this.addSingleStatusIcon(iconContainer, primaryStatus, statusService);
|
||||
}
|
||||
}
|
||||
|
||||
if (iconContainer.childElementCount > 0) {
|
||||
titleEl.appendChild(iconContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single status icon
|
||||
*/
|
||||
private addSingleStatusIcon(container: HTMLElement, status: string, statusService: StatusService): void {
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
|
||||
iconEl.textContent = statusService.getStatusIcon(status);
|
||||
|
||||
const statusObj = statusService.getAllStatuses().find(s => s.name === status);
|
||||
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
|
||||
setTooltip(iconEl, tooltipValue);
|
||||
|
||||
container.appendChild(iconEl);
|
||||
}
|
||||
}
|
||||
1
integrations/explorer/index.ts
Normal file
1
integrations/explorer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ExplorerIntegration } from './explorer-integration';
|
||||
1
integrations/metadata-cache/index.ts
Normal file
1
integrations/metadata-cache/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { MetadataIntegration } from './metadata-integration';
|
||||
71
integrations/metadata-cache/metadata-integration.ts
Normal file
71
integrations/metadata-cache/metadata-integration.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { ExplorerIntegration } from '../explorer/explorer-integration';
|
||||
import { StatusService } from 'services/status-service';
|
||||
|
||||
export class MetadataIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
private metadataChangedRef: any;
|
||||
private metadataResolvedRef: any;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
explorerIntegration: ExplorerIntegration
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public registerMetadataEvents(): void {
|
||||
this.metadataChangedRef = (file: TFile) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.handleMetadataChanged(file);
|
||||
}
|
||||
};
|
||||
|
||||
this.metadataResolvedRef = () => {
|
||||
setTimeout(() => {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
this.app.metadataCache.on('changed', this.metadataChangedRef);
|
||||
this.app.metadataCache.on('resolved', this.metadataResolvedRef);
|
||||
}
|
||||
|
||||
private handleMetadataChanged(file: TFile): void {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
}
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file.path) {
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses, file: file.path }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public unload(): void {
|
||||
if (this.metadataChangedRef) {
|
||||
this.app.metadataCache.off('changed', this.metadataChangedRef);
|
||||
}
|
||||
if (this.metadataResolvedRef) {
|
||||
this.app.metadataCache.off('resolved', this.metadataResolvedRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
integrations/settings/settings-controller.ts
Normal file
119
integrations/settings/settings-controller.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { App } from 'obsidian';
|
||||
import { Status } from '../../models/types';
|
||||
import NoteStatus from 'main';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { NoteStatusSettingsUI, SettingsUICallbacks } from './settings-ui';
|
||||
|
||||
/**
|
||||
* Controller for settings UI, handles business logic and plugin state updates
|
||||
*/
|
||||
export class NoteStatusSettingsController implements SettingsUICallbacks {
|
||||
private app: App;
|
||||
private plugin: NoteStatus;
|
||||
private statusService: StatusService;
|
||||
private ui: NoteStatusSettingsUI;
|
||||
|
||||
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.statusService = statusService;
|
||||
this.ui = new NoteStatusSettingsUI(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the settings interface
|
||||
*/
|
||||
display(containerEl: HTMLElement): void {
|
||||
this.ui.render(containerEl, this.plugin.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles template enable/disable toggle
|
||||
*/
|
||||
async onTemplateToggle(templateId: string, enabled: boolean): Promise<void> {
|
||||
if (enabled) {
|
||||
if (!this.plugin.settings.enabledTemplates.includes(templateId)) {
|
||||
this.plugin.settings.enabledTemplates.push(templateId);
|
||||
}
|
||||
} else {
|
||||
this.plugin.settings.enabledTemplates = this.plugin.settings.enabledTemplates.filter(
|
||||
(id: string) => id !== templateId
|
||||
);
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles general setting changes
|
||||
*/
|
||||
async onSettingChange(key: string, value: any): Promise<void> {
|
||||
(this.plugin.settings as any)[key] = value;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles custom status field changes
|
||||
*/
|
||||
async onCustomStatusChange(index: number, field: string, value: any): Promise<void> {
|
||||
const status = this.plugin.settings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
if (field === 'name') {
|
||||
const oldName = status.name;
|
||||
status.name = value;
|
||||
|
||||
if (oldName !== status.name) {
|
||||
this.plugin.settings.statusColors[status.name] =
|
||||
this.plugin.settings.statusColors[oldName];
|
||||
delete this.plugin.settings.statusColors[oldName];
|
||||
}
|
||||
} else if (field === 'color') {
|
||||
this.plugin.settings.statusColors[status.name] = value;
|
||||
} else {
|
||||
(status as any)[field] = value;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles custom status removal
|
||||
*/
|
||||
async onCustomStatusRemove(index: number): Promise<void> {
|
||||
const status = this.plugin.settings.customStatuses[index];
|
||||
if (!status) return;
|
||||
|
||||
this.plugin.settings.customStatuses.splice(index, 1);
|
||||
delete this.plugin.settings.statusColors[status.name];
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Re-render the custom statuses section
|
||||
const statusList = document.querySelector('.custom-status-list') as HTMLElement;
|
||||
if (statusList) {
|
||||
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles adding new custom status
|
||||
*/
|
||||
async onCustomStatusAdd(): Promise<void> {
|
||||
const newStatus: Status = {
|
||||
name: `status${this.plugin.settings.customStatuses.length + 1}`,
|
||||
icon: '⭐'
|
||||
};
|
||||
|
||||
this.plugin.settings.customStatuses.push(newStatus);
|
||||
this.plugin.settings.statusColors[newStatus.name] = '#ffffff';
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Re-render the custom statuses section
|
||||
const statusList = document.querySelector('.custom-status-list') as HTMLElement;
|
||||
if (statusList) {
|
||||
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
integrations/settings/settings-tab.ts
Normal file
23
integrations/settings/settings-tab.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { App, PluginSettingTab } from 'obsidian';
|
||||
import NoteStatus from 'main';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { NoteStatusSettingsController } from './settings-controller';
|
||||
|
||||
/**
|
||||
* Settings tab for the Note Status plugin - delegates to controller
|
||||
*/
|
||||
export class NoteStatusSettingTab extends PluginSettingTab {
|
||||
private controller: NoteStatusSettingsController;
|
||||
|
||||
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
|
||||
super(app, plugin);
|
||||
this.controller = new NoteStatusSettingsController(app, plugin, statusService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the settings interface
|
||||
*/
|
||||
display(): void {
|
||||
this.controller.display(this.containerEl);
|
||||
}
|
||||
}
|
||||
235
integrations/settings/settings-ui.ts
Normal file
235
integrations/settings/settings-ui.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { Setting } from 'obsidian';
|
||||
import { Status } from '../../models/types';
|
||||
import { PREDEFINED_TEMPLATES } from '../../constants/status-templates';
|
||||
|
||||
/**
|
||||
* Callbacks interface for settings UI interactions
|
||||
*/
|
||||
export interface SettingsUICallbacks {
|
||||
/** Handle template enable/disable toggle */
|
||||
onTemplateToggle: (templateId: string, enabled: boolean) => Promise<void>;
|
||||
/** Handle general setting changes */
|
||||
onSettingChange: (key: string, value: any) => Promise<void>;
|
||||
/** Handle custom status field changes */
|
||||
onCustomStatusChange: (index: number, field: string, value: any) => Promise<void>;
|
||||
/** Handle custom status removal */
|
||||
onCustomStatusRemove: (index: number) => Promise<void>;
|
||||
/** Handle adding new custom status */
|
||||
onCustomStatusAdd: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure UI component for rendering settings interface
|
||||
*/
|
||||
export class NoteStatusSettingsUI {
|
||||
private callbacks: SettingsUICallbacks;
|
||||
|
||||
constructor(callbacks: SettingsUICallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the complete settings interface
|
||||
*/
|
||||
render(containerEl: HTMLElement, settings: any): void {
|
||||
containerEl.empty();
|
||||
|
||||
this.renderTemplateSettings(containerEl, settings);
|
||||
this.renderUISettings(containerEl, settings);
|
||||
this.renderTagSettings(containerEl, settings);
|
||||
this.renderCustomStatusSettings(containerEl, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the status templates section
|
||||
*/
|
||||
private renderTemplateSettings(containerEl: HTMLElement, settings: any): void {
|
||||
new Setting(containerEl).setName('Status templates').setHeading();
|
||||
|
||||
containerEl.createEl('p', {
|
||||
text: 'Enable predefined templates to quickly add common status workflows',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
|
||||
const templatesContainer = containerEl.createDiv({ cls: 'templates-container' });
|
||||
|
||||
PREDEFINED_TEMPLATES.forEach(template => {
|
||||
const templateEl = templatesContainer.createDiv({ cls: 'template-item' });
|
||||
const headerEl = templateEl.createDiv({ cls: 'template-header' });
|
||||
|
||||
const isEnabled = settings.enabledTemplates.includes(template.id);
|
||||
const checkbox = headerEl.createEl('input', {
|
||||
type: 'checkbox',
|
||||
cls: 'template-checkbox'
|
||||
});
|
||||
checkbox.checked = isEnabled;
|
||||
|
||||
checkbox.addEventListener('change', () => {
|
||||
this.callbacks.onTemplateToggle(template.id, checkbox.checked);
|
||||
});
|
||||
|
||||
headerEl.createEl('span', {
|
||||
text: template.name,
|
||||
cls: 'template-name'
|
||||
});
|
||||
|
||||
templateEl.createEl('div', {
|
||||
text: template.description,
|
||||
cls: 'template-description'
|
||||
});
|
||||
|
||||
const statusesEl = templateEl.createDiv({ cls: 'template-statuses' });
|
||||
template.statuses.forEach(status => {
|
||||
const statusEl = statusesEl.createEl('div', { cls: 'template-status-chip' });
|
||||
const colorDot = statusEl.createEl('span', { cls: 'status-color-dot' });
|
||||
colorDot.style.setProperty('--dot-color', status.color || '#ffffff');
|
||||
statusEl.createSpan({ text: `${status.icon} ${status.name}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the UI display settings section
|
||||
*/
|
||||
private renderUISettings(containerEl: HTMLElement, settings: any): void {
|
||||
new Setting(containerEl).setName('User interface').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show status bar')
|
||||
.setDesc('Display the status bar')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.showStatusBar)
|
||||
.onChange(value => this.callbacks.onSettingChange('showStatusBar', value)));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Auto-hide status bar')
|
||||
.setDesc('Hide the status bar when status is unknown')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.autoHideStatusBar)
|
||||
.onChange(value => this.callbacks.onSettingChange('autoHideStatusBar', value)));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show status icons in file explorer')
|
||||
.setDesc('Display status icons in the file explorer')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.showStatusIconsInExplorer)
|
||||
.onChange(value => this.callbacks.onSettingChange('showStatusIconsInExplorer', value)));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Hide unknown status in file explorer')
|
||||
.setDesc('Hide status icons for files with unknown status in the file explorer')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.hideUnknownStatusInExplorer || false)
|
||||
.onChange(value => this.callbacks.onSettingChange('hideUnknownStatusInExplorer', value)));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Default to compact view')
|
||||
.setDesc('Start the status pane in compact view by default')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.compactView || false)
|
||||
.onChange(value => this.callbacks.onSettingChange('compactView', value)));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Exclude unassigned notes from status pane')
|
||||
.setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.excludeUnknownStatus || false)
|
||||
.onChange(value => this.callbacks.onSettingChange('excludeUnknownStatus', value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the status tag configuration section
|
||||
*/
|
||||
private renderTagSettings(containerEl: HTMLElement, settings: any): void {
|
||||
new Setting(containerEl).setName('Status tag').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Enable multiple statuses')
|
||||
.setDesc('Allow notes to have multiple statuses at the same time')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.useMultipleStatuses)
|
||||
.onChange(value => this.callbacks.onSettingChange('useMultipleStatuses', value)));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Status tag prefix')
|
||||
.setDesc('The YAML frontmatter tag name used for status (default: obsidian-note-status)')
|
||||
.addText(text => text
|
||||
.setValue(settings.tagPrefix)
|
||||
.onChange(value => {
|
||||
if (value.trim()) {
|
||||
this.callbacks.onSettingChange('tagPrefix', value.trim());
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Strict status validation')
|
||||
.setDesc('Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.strictStatuses || false)
|
||||
.onChange(value => this.callbacks.onSettingChange('strictStatuses', value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the custom status management section
|
||||
*/
|
||||
private renderCustomStatusSettings(containerEl: HTMLElement, settings: any): void {
|
||||
new Setting(containerEl).setName('Custom statuses').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Use only custom statuses')
|
||||
.setDesc('Ignore template statuses and use only the custom statuses defined below')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(settings.useCustomStatusesOnly || false)
|
||||
.onChange(value => this.callbacks.onSettingChange('useCustomStatusesOnly', value)));
|
||||
|
||||
const statusList = containerEl.createDiv({ cls: 'custom-status-list' });
|
||||
this.renderCustomStatuses(statusList, settings);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Add new status')
|
||||
.setDesc('Add a custom status with a name, icon, and color')
|
||||
.addButton(button => button
|
||||
.setButtonText('Add Status')
|
||||
.setCta()
|
||||
.onClick(() => this.callbacks.onCustomStatusAdd()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of custom statuses with edit controls
|
||||
*/
|
||||
renderCustomStatuses(statusList: HTMLElement, settings: any): void {
|
||||
statusList.empty();
|
||||
|
||||
settings.customStatuses.forEach((status: Status, index: number) => {
|
||||
const setting = new Setting(statusList)
|
||||
.setName(status.name)
|
||||
.setClass('status-item');
|
||||
|
||||
setting.addText(text => text
|
||||
.setPlaceholder('Name')
|
||||
.setValue(status.name)
|
||||
.onChange(value => this.callbacks.onCustomStatusChange(index, 'name', value || 'unnamed')));
|
||||
|
||||
setting.addText(text => text
|
||||
.setPlaceholder('Icon')
|
||||
.setValue(status.icon)
|
||||
.onChange(value => this.callbacks.onCustomStatusChange(index, 'icon', value || '❓')));
|
||||
|
||||
setting.addColorPicker(colorPicker => colorPicker
|
||||
.setValue(settings.statusColors[status.name] || '#ffffff')
|
||||
.onChange(value => this.callbacks.onCustomStatusChange(index, 'color', value)));
|
||||
|
||||
setting.addText(text => text
|
||||
.setPlaceholder('Description')
|
||||
.setValue(status.description || '')
|
||||
.onChange(value => this.callbacks.onCustomStatusChange(index, 'description', value)));
|
||||
|
||||
setting.addButton(button => button
|
||||
.setButtonText('Remove')
|
||||
.setClass('status-remove-button')
|
||||
.setWarning()
|
||||
.onClick(() => this.callbacks.onCustomStatusRemove(index)));
|
||||
});
|
||||
}
|
||||
}
|
||||
1
integrations/workspace/index.ts
Normal file
1
integrations/workspace/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { WorkspaceIntegration } from './workspace-integration';
|
||||
124
integrations/workspace/workspace-integration.ts
Normal file
124
integrations/workspace/workspace-integration.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { App, TFile, WorkspaceLeaf } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { ToolbarIntegration } from '../editor/toolbar-integration';
|
||||
import { StatusService } from 'services/status-service';
|
||||
|
||||
/**
|
||||
* Gestiona la integración con el workspace de Obsidian
|
||||
*/
|
||||
export class WorkspaceIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private toolbarIntegration: ToolbarIntegration;
|
||||
private lastActiveFile: TFile | null = null;
|
||||
private fileOpenEventRef: any;
|
||||
private activeLeafChangeEventRef: any;
|
||||
private layoutChangeEventRef: any;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: NoteStatusSettings,
|
||||
statusService: StatusService,
|
||||
toolbarIntegration: ToolbarIntegration
|
||||
) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.toolbarIntegration = toolbarIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la configuración
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra eventos del workspace
|
||||
*/
|
||||
public registerWorkspaceEvents(): void {
|
||||
this.fileOpenEventRef = (file: any) => {
|
||||
if (file instanceof TFile) {
|
||||
this.handleFileOpen(file);
|
||||
}
|
||||
};
|
||||
|
||||
this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => {
|
||||
this.handleActiveLeafChange(leaf);
|
||||
};
|
||||
|
||||
this.layoutChangeEventRef = () => {
|
||||
this.handleLayoutChange();
|
||||
};
|
||||
|
||||
this.app.workspace.on('file-open', this.fileOpenEventRef);
|
||||
this.app.workspace.on('active-leaf-change', this.activeLeafChangeEventRef);
|
||||
this.app.workspace.on('layout-change', this.layoutChangeEventRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja apertura de archivo
|
||||
*/
|
||||
private handleFileOpen(file: TFile): void {
|
||||
// Añade el botón de la barra de herramientas
|
||||
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
|
||||
|
||||
// Actualiza estado
|
||||
this.propagateNoteStatusChange(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cambio de hoja activa
|
||||
*/
|
||||
private handleActiveLeafChange(leaf: WorkspaceLeaf): void {
|
||||
// Añade el botón de la barra de herramientas
|
||||
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
|
||||
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
// Solo actualiza si el archivo realmente cambió
|
||||
// if (this.lastActiveFile?.path !== activeFile?.path) {
|
||||
// this.lastActiveFile = activeFile;
|
||||
// this.propagateNoteStatusChange();
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja y propaga el cambio de layout
|
||||
*/
|
||||
private handleLayoutChange(): void {
|
||||
window.dispatchEvent(new CustomEvent('note-status:update-pane'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica y propaga el estado de la nota activa
|
||||
*/
|
||||
private propagateNoteStatusChange(file: TFile): void {
|
||||
try {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
let fileStatuses: string[] = [];
|
||||
if (activeFile && activeFile.extension === 'md') {
|
||||
fileStatuses = this.statusService.getFileStatuses(activeFile)
|
||||
}
|
||||
// Dispara evento para que otros componentes se actualicen
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses: fileStatuses, file: file }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error checking note status:', error);
|
||||
}
|
||||
}
|
||||
public unload(): void {
|
||||
if (this.fileOpenEventRef) {
|
||||
this.app.workspace.off('file-open', this.fileOpenEventRef);
|
||||
}
|
||||
if (this.activeLeafChangeEventRef) {
|
||||
this.app.workspace.off('active-leaf-change', this.activeLeafChangeEventRef);
|
||||
}
|
||||
if (this.layoutChangeEventRef) {
|
||||
this.app.workspace.off('layout-change', this.layoutChangeEventRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
943
main.ts
943
main.ts
|
|
@ -1,753 +1,218 @@
|
|||
import { Editor, MarkdownView, Notice, Plugin, TFile, addIcon, WorkspaceLeaf, debounce } from 'obsidian';
|
||||
|
||||
// Constants
|
||||
import { ICONS } from './constants/icons';
|
||||
import { DEFAULT_SETTINGS, DEFAULT_COLORS } from './constants/defaults';
|
||||
|
||||
// Types
|
||||
import { Plugin, Notice } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS } from './constants/defaults';
|
||||
import { NoteStatusSettings } from './models/types';
|
||||
import { StatusService } from 'services/status-service';
|
||||
import { StyleService } from 'services/style-service';
|
||||
|
||||
// Services
|
||||
import { StatusService } from './services/status-service';
|
||||
import { StyleService } from './services/style-service';
|
||||
// Importar integraciones
|
||||
import { ExplorerIntegration } from './integrations/explorer';
|
||||
import { EditorIntegration, ToolbarIntegration } from './integrations/editor';
|
||||
import { MetadataIntegration } from './integrations/metadata-cache';
|
||||
import { WorkspaceIntegration } from './integrations/workspace';
|
||||
import { FileContextMenuIntegration } from 'integrations/context-menu/file-context-menu-integration';
|
||||
import { NoteStatusSettingTab } from 'integrations/settings/settings-tab';
|
||||
import { CommandIntegration } from 'integrations/commands/command-integration';
|
||||
|
||||
// UI Components
|
||||
import { StatusBar } from './ui/components/status-bar';
|
||||
import { StatusDropdown } from './ui/components/status-dropdown';
|
||||
import { StatusPaneView } from './ui/components/status-pane-view';
|
||||
import { ExplorerIntegration } from './ui/integrations/explorer-integration';
|
||||
import { StatusContextMenu } from './ui/menus/status-context-menu';
|
||||
// Importar vistas
|
||||
import { StatusPaneViewController } from './views/status-pane-view';
|
||||
|
||||
// Settings
|
||||
import { NoteStatusSettingTab } from './settings/settings-tab';
|
||||
// Importar componentes UI
|
||||
import { StatusBar } from 'components/status-bar';
|
||||
import { StatusDropdown } from 'components/status-dropdown';
|
||||
import { StatusContextMenu } from 'integrations/context-menu/status-context-menu';
|
||||
|
||||
/**
|
||||
* Main plugin class for Note Status functionality
|
||||
*/
|
||||
export default class NoteStatus extends Plugin {
|
||||
private boundSaveSettings: () => Promise<void>;
|
||||
private boundCheckNoteStatus: () => void;
|
||||
private boundRefreshDropdown: () => void;
|
||||
private boundRefreshUI: () => void;
|
||||
settings: NoteStatusSettings;
|
||||
|
||||
// Servicios
|
||||
statusService: StatusService;
|
||||
styleService: StyleService;
|
||||
|
||||
// Componentes UI
|
||||
statusBar: StatusBar;
|
||||
statusDropdown: StatusDropdown
|
||||
|
||||
// Integraciones
|
||||
explorerIntegration: ExplorerIntegration;
|
||||
fileContextMenuIntegration: FileContextMenuIntegration;
|
||||
editorIntegration: EditorIntegration;
|
||||
toolbarIntegration: ToolbarIntegration;
|
||||
metadataIntegration: MetadataIntegration;
|
||||
workspaceIntegration: WorkspaceIntegration;
|
||||
commandIntegration: CommandIntegration;
|
||||
|
||||
settings: NoteStatusSettings;
|
||||
statusService: StatusService;
|
||||
styleService: StyleService;
|
||||
statusBar: StatusBar;
|
||||
statusDropdown: StatusDropdown;
|
||||
explorerIntegration: ExplorerIntegration;
|
||||
statusContextMenu: StatusContextMenu;
|
||||
private statusPaneLeaf: WorkspaceLeaf | null = null;
|
||||
private lastActiveFile: TFile | null = null;
|
||||
statusPane: StatusPaneViewController;
|
||||
|
||||
// Debounced methods for better performance
|
||||
private debouncedCheckNoteStatus = debounce(
|
||||
() => this.checkNoteStatus(),
|
||||
100,
|
||||
true
|
||||
);
|
||||
private boundHandleStatusChanged: (event: CustomEvent) => void;
|
||||
|
||||
private debouncedUpdateExplorer = debounce(
|
||||
() => this.explorerIntegration?.updateAllFileExplorerIcons(),
|
||||
150
|
||||
);
|
||||
async onload() {
|
||||
try {
|
||||
await this.loadSettings();
|
||||
this.initializeServices();
|
||||
this.registerViews();
|
||||
this.initializeUI();
|
||||
this.initializeIntegrations();
|
||||
this.setupCustomEvents();
|
||||
} catch (error) {
|
||||
console.error('Error loading Note Status plugin:', error);
|
||||
new Notice('Error loading Note Status plugin. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
private debouncedUpdateStatusPane = debounce(
|
||||
() => this.updateStatusPane(),
|
||||
200
|
||||
);
|
||||
private async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
private hasShownErrorNotification = false;
|
||||
private initializeServices() {
|
||||
this.statusService = new StatusService(this.app, this.settings);
|
||||
this.styleService = new StyleService(this.settings);
|
||||
}
|
||||
|
||||
async onload() {
|
||||
try {
|
||||
// Load settings first before initializing other components
|
||||
await this.loadSettings();
|
||||
|
||||
this.boundSaveSettings = this.saveSettings.bind(this);
|
||||
this.boundCheckNoteStatus = this.checkNoteStatus.bind(this);
|
||||
this.boundRefreshDropdown = () => this.statusDropdown?.render();
|
||||
this.boundRefreshUI = () => this.checkNoteStatus();
|
||||
|
||||
// Register icons and essential services first
|
||||
this.registerIcons();
|
||||
this.statusService = new StatusService(this.app, this.settings);
|
||||
this.styleService = new StyleService(this.settings);
|
||||
|
||||
// Register views and commands right away
|
||||
this.registerViews();
|
||||
this.registerCommands();
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new NoteStatusSettingTab(this.app, this));
|
||||
|
||||
// Set up custom events early
|
||||
this.setupCustomEvents();
|
||||
|
||||
// Delay UI-heavy initialization until the layout is ready
|
||||
this.app.workspace.onLayoutReady(async () => {
|
||||
// Split initialization into phases for better performance
|
||||
await this.initializeUIComponents();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading Note Status plugin:', error);
|
||||
new Notice('Error loading Note Status plugin. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeUIComponents(): Promise<void> {
|
||||
// Initialize UI components
|
||||
this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService);
|
||||
this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
|
||||
|
||||
// Initialize explorer integration with a slight delay
|
||||
setTimeout(() => {
|
||||
this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
|
||||
this.statusContextMenu = new StatusContextMenu(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown,
|
||||
this.explorerIntegration
|
||||
);
|
||||
|
||||
// Register events after explorer integration is ready
|
||||
this.registerMenuHandlers();
|
||||
this.registerEvents();
|
||||
|
||||
// Check status for active file only initially
|
||||
this.checkNoteStatus();
|
||||
|
||||
// Update only active file icon initially for better performance
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile && this.settings.showStatusIconsInExplorer) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(activeFile);
|
||||
}
|
||||
|
||||
// Delay full explorer icon update to avoid startup lag
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}, 2000);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin components
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
// Register custom icons
|
||||
this.registerIcons();
|
||||
|
||||
// Initialize services
|
||||
this.initializeServices();
|
||||
|
||||
// Initialize UI components
|
||||
this.initializeUI();
|
||||
|
||||
// Register views, commands, and events
|
||||
this.registerViews();
|
||||
this.registerCommands();
|
||||
this.registerMenuHandlers();
|
||||
this.registerEvents();
|
||||
|
||||
// Set up custom events
|
||||
this.setupCustomEvents();
|
||||
|
||||
// Add settings tab
|
||||
this.addSettingTab(new NoteStatusSettingTab(this.app, this));
|
||||
|
||||
// Initialize UI on layout ready
|
||||
this.app.workspace.onLayoutReady(async () => {
|
||||
await this.onLayoutReady();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle layout ready event for final initialization steps
|
||||
*/
|
||||
private async onLayoutReady(): Promise<void> {
|
||||
// Small delay to ensure everything is loaded
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
this.checkNoteStatus();
|
||||
this.statusDropdown.update(this.getCurrentStatuses());
|
||||
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
|
||||
// Add additional retry with delay to ensure icons are updated
|
||||
setTimeout(() => {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom icons
|
||||
*/
|
||||
private registerIcons(): void {
|
||||
Object.entries(ICONS).forEach(([name, svg]) => {
|
||||
// Create a parser and get a DOM element for the SVG
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(svg, 'image/svg+xml');
|
||||
const svgElement = svgDoc.documentElement;
|
||||
|
||||
// Get the SVG as a string using XMLSerializer
|
||||
const serializer = new XMLSerializer();
|
||||
const sanitizedSvg = serializer.serializeToString(svgElement);
|
||||
|
||||
if (name === 'statusPane') {
|
||||
addIcon('status-pane', sanitizedSvg);
|
||||
} else {
|
||||
addIcon(`note-status-${name}`, sanitizedSvg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register views for status pane
|
||||
*/
|
||||
private registerViews(): void {
|
||||
this.registerView('status-pane', (leaf) => {
|
||||
this.statusPaneLeaf = leaf;
|
||||
return new StatusPaneView(leaf, this);
|
||||
});
|
||||
|
||||
// Add ribbon icon
|
||||
this.addRibbonIcon('status-pane', 'Open status pane', () => {
|
||||
this.openStatusPane();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plugin services
|
||||
*/
|
||||
private initializeServices(): void {
|
||||
// These services depend on settings being loaded first
|
||||
this.statusService = new StatusService(this.app, this.settings);
|
||||
this.styleService = new StyleService(this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize UI components
|
||||
*/
|
||||
private initializeUI(): void {
|
||||
this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService);
|
||||
this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
|
||||
this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
|
||||
this.statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService, this.statusDropdown, this.explorerIntegration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up custom events for status changes and UI updates
|
||||
*/
|
||||
private setupCustomEvents(): void {
|
||||
// Listen for settings changes
|
||||
window.addEventListener('note-status:settings-changed', this.boundSaveSettings);
|
||||
|
||||
|
||||
// Listen for force refresh
|
||||
window.addEventListener('note-status:force-refresh', () => {
|
||||
try {
|
||||
this.forceRefreshUI();
|
||||
} catch (error) {
|
||||
console.error('Error handling force refresh event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for status changes
|
||||
window.addEventListener('note-status:status-changed', (e: any) => {
|
||||
try {
|
||||
const statuses = e.detail?.statuses || ['unknown'];
|
||||
this.statusBar.update(statuses);
|
||||
this.statusDropdown.update(statuses);
|
||||
|
||||
// Update explorer icons for the active file
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(activeFile);
|
||||
}
|
||||
|
||||
setTimeout(() => this.statusDropdown.render(), 50);
|
||||
} catch (error) {
|
||||
console.error('Error handling status change event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for refresh dropdown
|
||||
window.addEventListener('note-status:refresh-dropdown', this.boundRefreshDropdown);
|
||||
|
||||
// Listen for UI refresh
|
||||
window.addEventListener('note-status:refresh-ui', this.boundRefreshUI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register plugin commands
|
||||
*/
|
||||
private registerCommands(): void {
|
||||
// Refresh status command
|
||||
this.addCommand({
|
||||
id: 'refresh-status',
|
||||
name: 'Refresh status',
|
||||
callback: () => {
|
||||
this.checkNoteStatus();
|
||||
new Notice('Note status refreshed!');
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'force-refresh-ui',
|
||||
name: 'Force refresh user interface',
|
||||
callback: () => this.forceRefreshUI()
|
||||
});
|
||||
|
||||
|
||||
// Insert status metadata command
|
||||
this.addCommand({
|
||||
id: 'insert-status-metadata',
|
||||
name: 'Insert status metadata',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
this.statusService.insertStatusMetadataInEditor(editor);
|
||||
new Notice('Status metadata inserted');
|
||||
}
|
||||
});
|
||||
|
||||
// Open status pane command
|
||||
this.addCommand({
|
||||
id: 'open-status-pane',
|
||||
name: 'Open status pane',
|
||||
callback: () => this.openStatusPane()
|
||||
});
|
||||
|
||||
// Add status to note command
|
||||
this.addCommand({
|
||||
id: 'add-status-to-note',
|
||||
name: 'Add status to current note',
|
||||
callback: () => this.showAddStatusToNoteMenu()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show menu for adding a status to the current note
|
||||
*/
|
||||
private showAddStatusToNoteMenu(): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile || activeFile.extension !== 'md') {
|
||||
new Notice('No markdown file is active');
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusContextMenu.showForFile(activeFile, new MouseEvent('click'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register menu handlers
|
||||
*/
|
||||
private registerMenuHandlers(): void {
|
||||
// File explorer context menu
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-menu', (menu, file, source) => {
|
||||
if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Change status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => {
|
||||
const selectedFiles = this.explorerIntegration.getSelectedFiles();
|
||||
if (selectedFiles.length > 1) {
|
||||
this.statusContextMenu.showForFiles(selectedFiles);
|
||||
} else {
|
||||
this.statusContextMenu.showForFiles([file]);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Multiple files selection menu
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('files-menu', (menu, files) => {
|
||||
const mdFiles: TFile[] = [];
|
||||
for (const file of files) {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
mdFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (mdFiles.length > 0) {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Change status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => {
|
||||
this.statusContextMenu.showForFiles(mdFiles);
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Editor context menu
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('editor-menu', (menu, editor, view) => {
|
||||
if (view instanceof MarkdownView) {
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Change note status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => this.statusDropdown.showInContextMenu(editor, view))
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register workspace and vault events
|
||||
*/
|
||||
private registerEvents(): void {
|
||||
// File open event with optimization to avoid redundant updates
|
||||
this.registerEvent(this.app.workspace.on('file-open', (file) => {
|
||||
if (file instanceof TFile) {
|
||||
// First make sure the button exists
|
||||
this.statusDropdown.addToolbarButtonToActiveLeaf();
|
||||
// Then check status and update button
|
||||
this.checkNoteStatus();
|
||||
}
|
||||
}));
|
||||
|
||||
// Editor change event - debounced to avoid performance issues
|
||||
this.registerEvent(this.app.workspace.on('editor-change', () => {
|
||||
this.debouncedCheckNoteStatus();
|
||||
}));
|
||||
|
||||
// Add this for toolbar button persistence
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('active-leaf-change', () => {
|
||||
this.statusDropdown.addToolbarButtonToActiveLeaf();
|
||||
// Update the status after adding the button to ensure icon is correct
|
||||
this.checkNoteStatus();
|
||||
})
|
||||
);
|
||||
|
||||
// Active leaf change event - optimized to avoid redundant updates
|
||||
this.registerEvent(this.app.workspace.on('active-leaf-change', () => {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
// Only update if the file actually changed
|
||||
if (this.lastActiveFile?.path !== activeFile?.path) {
|
||||
this.lastActiveFile = activeFile;
|
||||
this.statusDropdown.update(this.getCurrentStatuses());
|
||||
this.debouncedUpdateStatusPane();
|
||||
}
|
||||
}));
|
||||
|
||||
this.registerFileEvents();
|
||||
this.registerMetadataEvents();
|
||||
|
||||
// Layout change event - ensure status pane is properly rendered
|
||||
this.registerEvent(this.app.workspace.on('layout-change', () => {
|
||||
this.debouncedUpdateStatusPane();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register file modification events
|
||||
*/
|
||||
private registerFileEvents(): void {
|
||||
// File modification events with optimization
|
||||
this.registerEvent(this.app.vault.on('modify', (file) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
// Only update UI for the modified file
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
|
||||
// If this is the active file, also update other UI elements
|
||||
if (this.app.workspace.getActiveFile()?.path === file.path) {
|
||||
this.checkNoteStatus();
|
||||
this.statusDropdown.update(this.getCurrentStatuses());
|
||||
}
|
||||
|
||||
// Update the status pane but debounced
|
||||
this.debouncedUpdateStatusPane();
|
||||
}
|
||||
}));
|
||||
|
||||
// File creation, deletion, and rename events
|
||||
this.registerEvent(this.app.vault.on('create', (file) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
this.debouncedUpdateStatusPane();
|
||||
}
|
||||
}));
|
||||
|
||||
this.registerEvent(this.app.vault.on('delete', () => {
|
||||
this.debouncedUpdateExplorer();
|
||||
this.debouncedUpdateStatusPane();
|
||||
}));
|
||||
|
||||
this.registerEvent(this.app.vault.on('rename', (file) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
this.debouncedUpdateStatusPane();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register metadata cache events
|
||||
*/
|
||||
private registerMetadataEvents(): void {
|
||||
// Metadata change events
|
||||
this.registerEvent(this.app.metadataCache.on('changed', (file) => {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
|
||||
// Update other UI elements if this is the active file
|
||||
if (this.app.workspace.getActiveFile()?.path === file.path) {
|
||||
this.checkNoteStatus();
|
||||
this.statusDropdown.update(this.getCurrentStatuses());
|
||||
}
|
||||
|
||||
this.debouncedUpdateStatusPane();
|
||||
}
|
||||
}));
|
||||
|
||||
// Metadata resolved event - when all files are indexed
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on('resolved', () => {
|
||||
// When metadata cache is fully resolved, update all icons
|
||||
setTimeout(() => this.explorerIntegration.updateAllFileExplorerIcons(), 500);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and update the status display for the active file
|
||||
*/
|
||||
checkNoteStatus(): void {
|
||||
try {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') {
|
||||
this.statusBar.update(['unknown']);
|
||||
this.statusDropdown.update(['unknown']);
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = this.statusService.getFileStatuses(activeFile);
|
||||
this.statusBar.update(statuses);
|
||||
this.statusDropdown.update(statuses);
|
||||
} catch (error) {
|
||||
console.error('Error checking note status:', error);
|
||||
if (!this.hasShownErrorNotification) {
|
||||
new Notice('Error checking note status. Check console for details.');
|
||||
this.hasShownErrorNotification = true;
|
||||
setTimeout(() => { this.hasShownErrorNotification = false; }, 10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current statuses for the active file
|
||||
* Always returns an array of status names
|
||||
*/
|
||||
getCurrentStatuses(): string[] {
|
||||
try {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') {
|
||||
return ['unknown'];
|
||||
}
|
||||
return this.statusService.getFileStatuses(activeFile);
|
||||
} catch (error) {
|
||||
console.error('Error getting current statuses:', error);
|
||||
return ['unknown'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the status pane
|
||||
*/
|
||||
public async openStatusPane(): Promise<void> {
|
||||
try {
|
||||
// Check if already open
|
||||
const existing = this.app.workspace.getLeavesOfType('status-pane')[0];
|
||||
if (existing) {
|
||||
this.app.workspace.setActiveLeaf(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new leaf and show loading state
|
||||
const leaf = this.app.workspace.getLeftLeaf(false);
|
||||
if (leaf) {
|
||||
await leaf.setViewState({ type: 'status-pane', active: true });
|
||||
this.statusPaneLeaf = leaf;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error opening status pane:', error);
|
||||
new Notice('Error opening status pane. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status context menu
|
||||
*/
|
||||
showStatusContextMenu(files: TFile[]): void {
|
||||
try {
|
||||
this.statusContextMenu.showForFiles(files);
|
||||
} catch (error) {
|
||||
console.error('Error showing status context menu:', error);
|
||||
new Notice('Error showing status context menu. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status pane
|
||||
*/
|
||||
async updateStatusPane(): Promise<void> {
|
||||
try {
|
||||
if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) {
|
||||
const searchQuery = (this.statusPaneLeaf.view as StatusPaneView).searchInput?.value.toLowerCase() || '';
|
||||
await (this.statusPaneLeaf.view as StatusPaneView).renderGroups(searchQuery);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating status pane:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings with error handling
|
||||
*/
|
||||
async loadSettings(): Promise<void> {
|
||||
try {
|
||||
const loadedData = await this.loadData();
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
|
||||
this.initializeDefaultColors();
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
new Notice('Error loading settings. Using defaults. Check console for details.');
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS);
|
||||
this.initializeDefaultColors();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default colors
|
||||
*/
|
||||
private initializeDefaultColors(): void {
|
||||
// Ensure default colors are set for all statuses
|
||||
for (const [status, color] of Object.entries(DEFAULT_COLORS)) {
|
||||
if (!this.settings.statusColors[status]) {
|
||||
this.settings.statusColors[status] = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Also initialize colors from enabled templates if not present
|
||||
this.initializeTemplateColors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize colors from templates
|
||||
*/
|
||||
private initializeTemplateColors(): void {
|
||||
// Make sure settings are properly initialized
|
||||
if (!this.settings) {
|
||||
console.error('Settings not initialized properly');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.settings.useCustomStatusesOnly && this.statusService) {
|
||||
const templateStatuses = this.statusService.getTemplateStatuses();
|
||||
for (const status of templateStatuses) {
|
||||
if (status.color && !this.settings.statusColors[status.name]) {
|
||||
this.settings.statusColors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings with error handling
|
||||
*/
|
||||
async saveSettings(): Promise<void> {
|
||||
try {
|
||||
await this.saveData(this.settings);
|
||||
this.updateComponentSettings();
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
new Notice('Error saving settings. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all components with new settings
|
||||
*/
|
||||
private updateComponentSettings(): void {
|
||||
// Update services with new settings
|
||||
this.statusService.updateSettings(this.settings);
|
||||
this.styleService.updateSettings(this.settings);
|
||||
|
||||
// Update UI components with new settings
|
||||
this.statusBar.updateSettings(this.settings);
|
||||
this.statusDropdown.updateSettings(this.settings);
|
||||
this.explorerIntegration.updateSettings(this.settings);
|
||||
this.statusContextMenu.updateSettings(this.settings);
|
||||
|
||||
// Update status pane if open
|
||||
if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) {
|
||||
(this.statusPaneLeaf.view as StatusPaneView).updateSettings(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh all UI components
|
||||
*/
|
||||
public forceRefreshUI(): void {
|
||||
try {
|
||||
// Cancel any pending updates
|
||||
this.debouncedCheckNoteStatus.cancel();
|
||||
this.debouncedUpdateExplorer.cancel();
|
||||
this.debouncedUpdateStatusPane.cancel();
|
||||
|
||||
// Immediate updates
|
||||
this.checkNoteStatus();
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
this.updateStatusPane();
|
||||
|
||||
new Notice('UI forcefully refreshed');
|
||||
} catch (error) {
|
||||
console.error('Error force refreshing UI:', error);
|
||||
new Notice('Error refreshing UI. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when the plugin is unloaded
|
||||
*/
|
||||
onunload(): void {
|
||||
// Cancel debounced functions
|
||||
this.debouncedCheckNoteStatus.cancel();
|
||||
this.debouncedUpdateExplorer.cancel();
|
||||
this.debouncedUpdateStatusPane.cancel();
|
||||
|
||||
// Clean up UI elements
|
||||
this.statusBar.unload?.();
|
||||
this.statusDropdown.unload?.();
|
||||
this.explorerIntegration.unload?.();
|
||||
this.styleService.unload?.();
|
||||
|
||||
// Close status pane if open
|
||||
const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0];
|
||||
if (statusPane) statusPane.detach();
|
||||
|
||||
// Remove event listeners for custom events
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove custom event listeners
|
||||
*/
|
||||
private removeCustomEventListeners(): void {
|
||||
window.removeEventListener('note-status:settings-changed', this.boundSaveSettings);
|
||||
window.removeEventListener('note-status:status-changed', this.boundCheckNoteStatus);
|
||||
window.removeEventListener('note-status:refresh-dropdown', this.boundRefreshDropdown);
|
||||
window.removeEventListener('note-status:refresh-ui', this.boundRefreshUI);
|
||||
private registerViews() {
|
||||
// Register status pane view
|
||||
this.registerView('status-pane', (leaf) => {
|
||||
this.statusPane = new StatusPaneViewController(leaf, this);
|
||||
return this.statusPane;
|
||||
});
|
||||
|
||||
// Add ribbon icon
|
||||
this.addRibbonIcon('tag', 'Open status pane', () => {
|
||||
this.openStatusPane();
|
||||
});
|
||||
|
||||
// Añadir pestaña de configuración
|
||||
this.addSettingTab(new NoteStatusSettingTab(this.app, this, this.statusService));
|
||||
}
|
||||
|
||||
private initializeIntegrations() {
|
||||
this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
|
||||
this.toolbarIntegration = new ToolbarIntegration(this.app, this.settings, this.statusService, this.statusDropdown);
|
||||
const statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService, this.statusDropdown, this.explorerIntegration);
|
||||
this.fileContextMenuIntegration = new FileContextMenuIntegration(this.app, this.settings, this.statusService, this.explorerIntegration, statusContextMenu);
|
||||
|
||||
this.editorIntegration = new EditorIntegration(this.app, this.settings, this.statusService, this.statusDropdown);
|
||||
this.commandIntegration = new CommandIntegration(
|
||||
this.app,
|
||||
this,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.statusDropdown
|
||||
);
|
||||
this.metadataIntegration = new MetadataIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.explorerIntegration
|
||||
);
|
||||
|
||||
this.workspaceIntegration = new WorkspaceIntegration(
|
||||
this.app,
|
||||
this.settings,
|
||||
this.statusService,
|
||||
this.toolbarIntegration
|
||||
);
|
||||
|
||||
// Registrar eventos en cada integración
|
||||
this.fileContextMenuIntegration.registerFileContextMenuEvents();
|
||||
this.editorIntegration.registerEditorMenus();
|
||||
this.commandIntegration.registerCommands();
|
||||
this.metadataIntegration.registerMetadataEvents();
|
||||
this.workspaceIntegration.registerWorkspaceEvents();
|
||||
}
|
||||
|
||||
private setupCustomEvents() {
|
||||
this.boundHandleStatusChanged = this.handleStatusChanged.bind(this);
|
||||
window.addEventListener('note-status:status-changed', this.boundHandleStatusChanged);
|
||||
}
|
||||
|
||||
private initializeUI() {
|
||||
this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
|
||||
|
||||
// Inicializar barra de estado
|
||||
this.statusBar = new StatusBar(
|
||||
this.addStatusBarItem(),
|
||||
this.settings,
|
||||
this.statusService
|
||||
);
|
||||
|
||||
// Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio)
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
setTimeout(() => {
|
||||
this.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleStatusChanged(event: CustomEvent) {
|
||||
const { statuses, file } = event.detail;
|
||||
|
||||
// Actualizar barra de estado
|
||||
this.statusBar.update(statuses);
|
||||
// Actualizar toolbar
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (activeFile?.path === file) {
|
||||
this.toolbarIntegration.updateStatusDisplay(statuses);
|
||||
}
|
||||
// Actualizar dropdown
|
||||
this.statusDropdown.update(statuses);
|
||||
// Actualizar status pane si está abierto
|
||||
const statusPaneLeaf = this.app.workspace.getLeavesOfType('status-pane')[0];
|
||||
if (statusPaneLeaf?.view && statusPaneLeaf.view instanceof StatusPaneViewController) {
|
||||
(statusPaneLeaf.view as StatusPaneViewController).update();
|
||||
}
|
||||
// Actualizar explorador si es necesario
|
||||
if (this.settings.showStatusIconsInExplorer && file) {
|
||||
const fileObj = this.app.vault.getFileByPath(file);
|
||||
if (fileObj) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(fileObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async openStatusPane() {
|
||||
await StatusPaneViewController.open(this.app);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
|
||||
// Actualizar servicios
|
||||
this.statusService.updateSettings(this.settings);
|
||||
this.styleService.updateSettings(this.settings);
|
||||
|
||||
// Actualizar integraciones
|
||||
this.explorerIntegration.updateSettings(this.settings);
|
||||
this.fileContextMenuIntegration.updateSettings(this.settings);
|
||||
this.editorIntegration.updateSettings(this.settings);
|
||||
this.metadataIntegration?.updateSettings(this.settings);
|
||||
this.commandIntegration?.updateSettings(this.settings);
|
||||
this.toolbarIntegration.updateSettings(this.settings);
|
||||
this.workspaceIntegration.updateSettings(this.settings);
|
||||
this.statusPane?.updateSettings(this.settings);
|
||||
// Actualizar componentes UI
|
||||
this.statusBar.updateSettings(this.settings);
|
||||
this.statusDropdown.updateSettings(this.settings)
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Clean up event listeners
|
||||
if (this.boundHandleStatusChanged) {
|
||||
window.removeEventListener('note-status:status-changed', this.boundHandleStatusChanged);
|
||||
}
|
||||
|
||||
// Clean up integrations
|
||||
this.explorerIntegration?.unload();
|
||||
this.toolbarIntegration?.unload();
|
||||
this.fileContextMenuIntegration?.unload();
|
||||
this.workspaceIntegration?.unload();
|
||||
this.metadataIntegration?.unload();
|
||||
this.editorIntegration?.unload();
|
||||
|
||||
// Clean up services
|
||||
this.styleService?.unload();
|
||||
|
||||
// Clean up UI components
|
||||
this.statusBar?.unload();
|
||||
this.statusDropdown?.unload();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"isDesktopOnly": false,
|
||||
"license": "MIT",
|
||||
"icon": "status-pane",
|
||||
"icon": "tag",
|
||||
"repository": "https://github.com/devonthesofa/obsidian-note-status",
|
||||
"issues": "https://github.com/devonthesofa/obsidian-note-status/issues",
|
||||
"releaseNotes": "Initial release with status management, customizable UI, and file explorer integration.",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface NoteStatusSettings {
|
|||
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
|
||||
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note
|
||||
tagPrefix: string; // Prefix for the status tag (default: 'status')
|
||||
strictStatuses: boolean; // Whether to only show known statuses
|
||||
excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane
|
||||
}
|
||||
|
||||
|
|
@ -39,4 +40,4 @@ export interface FileExplorerView extends View {
|
|||
titleEl?: HTMLElement;
|
||||
selfEl?: HTMLElement;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,56 +28,38 @@ export class StatusService {
|
|||
* Updates the combined list of all statuses (from templates and custom)
|
||||
*/
|
||||
private updateAllStatuses(): void {
|
||||
// Start with custom statuses if not using templates exclusively
|
||||
// Start with custom statuses
|
||||
this.allStatuses = [...this.settings.customStatuses];
|
||||
|
||||
// Add statuses from enabled templates
|
||||
// Add statuses from enabled templates if not using custom only
|
||||
if (!this.settings.useCustomStatusesOnly) {
|
||||
const templateStatuses = this.getTemplateStatuses();
|
||||
|
||||
// Add template statuses that don't have the same name as existing statuses
|
||||
for (const status of templateStatuses) {
|
||||
if (!this.allStatuses.some(s => s.name.toLowerCase() === status.name.toLowerCase())) {
|
||||
const existingIndex = this.allStatuses.findIndex(s =>
|
||||
s.name.toLowerCase() === status.name.toLowerCase());
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// Add new status
|
||||
this.allStatuses.push(status);
|
||||
} else {
|
||||
this.updateExistingStatusColor(status);
|
||||
} else if (status.color) {
|
||||
// Update color in settings if it doesn't exist
|
||||
if (!this.settings.statusColors[status.name]) {
|
||||
this.settings.statusColors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update color for an existing status if it comes from a template
|
||||
*/
|
||||
private updateExistingStatusColor(status: Status): void {
|
||||
// Update status colors if they come from a template and have colors
|
||||
const existingStatusIndex = this.allStatuses.findIndex(
|
||||
s => s.name.toLowerCase() === status.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (existingStatusIndex !== -1 && status.color) {
|
||||
// Update color in settings if it doesn't exist
|
||||
if (!this.settings.statusColors[status.name]) {
|
||||
this.settings.statusColors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all statuses from enabled templates
|
||||
*/
|
||||
public getTemplateStatuses(): Status[] {
|
||||
const statuses: Status[] = [];
|
||||
|
||||
// Find templates that are enabled
|
||||
for (const templateId of this.settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
statuses.push(...template.statuses);
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
return this.settings.enabledTemplates
|
||||
.map(id => PREDEFINED_TEMPLATES.find(t => t.id === id))
|
||||
.filter(Boolean)
|
||||
.flatMap(template => template ? template.statuses : []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,63 +71,48 @@ export class StatusService {
|
|||
|
||||
/**
|
||||
* Get the statuses of a file from its metadata
|
||||
* Returns an array of status names
|
||||
*/
|
||||
public getFileStatuses(file: TFile): string[] {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
const statuses: string[] = [];
|
||||
if (!cachedMetadata?.frontmatter) return ['unknown'];
|
||||
|
||||
if (cachedMetadata?.frontmatter) {
|
||||
// Check for status using the configured tag prefix
|
||||
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
|
||||
|
||||
if (frontmatterStatus) {
|
||||
if (Array.isArray(frontmatterStatus)) {
|
||||
// Handle array format
|
||||
this.processStatusArray(frontmatterStatus, statuses);
|
||||
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
|
||||
if (!frontmatterStatus) return ['unknown'];
|
||||
|
||||
const statuses: string[] = [];
|
||||
|
||||
if (Array.isArray(frontmatterStatus)) {
|
||||
for (const statusName of frontmatterStatus) {
|
||||
if (this.settings.strictStatuses) {
|
||||
this.addValidStatus(statusName.toString(), statuses);
|
||||
} else {
|
||||
// Handle single value format (string) - convert to array format internally
|
||||
this.processSingleStatus(frontmatterStatus.toString(), statuses);
|
||||
const cleanStatus = statusName.toString().trim();
|
||||
if (cleanStatus) statuses.push(cleanStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.settings.strictStatuses) {
|
||||
this.addValidStatus(frontmatterStatus.toString(), statuses);
|
||||
} else {
|
||||
const cleanStatus = frontmatterStatus.toString().trim();
|
||||
if (cleanStatus) statuses.push(cleanStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// Return 'unknown' if no statuses found
|
||||
|
||||
return statuses.length > 0 ? statuses : ['unknown'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of statuses from frontmatter
|
||||
* Add a status to the list if it's valid
|
||||
*/
|
||||
private processStatusArray(statusArray: any[], targetStatuses: string[]): void {
|
||||
for (const statusName of statusArray) {
|
||||
const normalizedStatus = statusName.toString().toLowerCase();
|
||||
const matchingStatus = this.allStatuses.find(s =>
|
||||
s.name.toLowerCase() === normalizedStatus);
|
||||
|
||||
if (matchingStatus) {
|
||||
targetStatuses.push(matchingStatus.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single status string from frontmatter
|
||||
*/
|
||||
private processSingleStatus(statusString: string, targetStatuses: string[]): void {
|
||||
const normalizedStatus = statusString.toLowerCase();
|
||||
const matchingStatus = this.allStatuses.find(s =>
|
||||
private addValidStatus(statusName: string, targetStatuses: string[]): void {
|
||||
const normalizedStatus = statusName.toLowerCase();
|
||||
const matchingStatus = this.allStatuses.find(s =>
|
||||
s.name.toLowerCase() === normalizedStatus);
|
||||
|
||||
if (matchingStatus) targetStatuses.push(matchingStatus.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary status of a file (first one, or 'unknown')
|
||||
*/
|
||||
public getFilePrimaryStatus(file: TFile): string {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
return statuses[0] || 'unknown';
|
||||
|
||||
if (matchingStatus) {
|
||||
targetStatuses.push(matchingStatus.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -158,167 +125,37 @@ export class StatusService {
|
|||
return customStatus ? customStatus.icon : '❓';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the statuses of a note
|
||||
* @param newStatuses Array of status names to set
|
||||
* @param file Optional file to update, otherwise uses active file
|
||||
*/
|
||||
public async updateNoteStatuses(newStatuses: string[], file?: TFile): Promise<void> {
|
||||
const targetFile = file || this.app.workspace.getActiveFile();
|
||||
if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return;
|
||||
|
||||
// Use processFrontMatter instead of manual read/modify
|
||||
await this.app.fileManager.processFrontMatter(targetFile, (frontmatter) => {
|
||||
frontmatter[this.settings.tagPrefix] = newStatuses;
|
||||
});
|
||||
|
||||
// Dispatch events for UI update
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses: newStatuses, file: targetFile.path }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method to update a single status for backward compatibility
|
||||
*/
|
||||
public async updateNoteStatus(newStatus: string, file?: TFile): Promise<void> {
|
||||
// Always store as an array even in single status mode
|
||||
await this.updateNoteStatuses([newStatus], file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a status to a note's existing statuses
|
||||
*/
|
||||
public async addNoteStatus(statusToAdd: string, file?: TFile): Promise<void> {
|
||||
const targetFile = file || this.app.workspace.getActiveFile();
|
||||
if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return;
|
||||
|
||||
const currentStatuses = this.getFileStatuses(targetFile);
|
||||
|
||||
// Don't add if already exists
|
||||
if (currentStatuses.includes(statusToAdd)) return;
|
||||
|
||||
// Filter out 'unknown' status when adding valid statuses
|
||||
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
|
||||
const newStatuses = [...filteredStatuses, statusToAdd];
|
||||
|
||||
await this.updateNoteStatuses(newStatuses, targetFile);
|
||||
|
||||
// Trigger UI updates
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses: newStatuses }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a status from a note's existing statuses
|
||||
*/
|
||||
public async removeNoteStatus(statusToRemove: string, file?: TFile): Promise<void> {
|
||||
const targetFile = file || this.app.workspace.getActiveFile();
|
||||
if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return;
|
||||
|
||||
const currentStatuses = this.getFileStatuses(targetFile);
|
||||
const newStatuses = currentStatuses.filter(status => status !== statusToRemove);
|
||||
|
||||
await this.updateNoteStatuses(newStatuses, targetFile);
|
||||
|
||||
// Trigger UI updates
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses: newStatuses }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a status on/off for a note
|
||||
*/
|
||||
public async toggleNoteStatus(statusToToggle: string, file?: TFile): Promise<void> {
|
||||
const targetFile = file || this.app.workspace.getActiveFile();
|
||||
if (!targetFile || targetFile.extension !== 'md') return;
|
||||
|
||||
const currentStatuses = this.getFileStatuses(targetFile);
|
||||
let newStatuses: string[];
|
||||
|
||||
if (currentStatuses.includes(statusToToggle)) {
|
||||
newStatuses = currentStatuses.filter(status => status !== statusToToggle);
|
||||
} else {
|
||||
// Filter out 'unknown' status when adding valid statuses
|
||||
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
|
||||
newStatuses = [...filteredStatuses, statusToToggle];
|
||||
}
|
||||
|
||||
await this.updateNoteStatuses(newStatuses, targetFile);
|
||||
|
||||
// Ensure UI updates
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses: newStatuses }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple files' statuses
|
||||
* @param files Array of files to update
|
||||
* @param statusesToSet Array of statuses to set
|
||||
* @param mode 'replace' to replace all statuses, 'add' to add to existing
|
||||
* @param showNotice Whether to show a notification (default: true)
|
||||
*/
|
||||
public async batchUpdateStatuses(
|
||||
files: TFile[],
|
||||
statusesToSet: string[],
|
||||
mode: 'replace' | 'add' = 'replace',
|
||||
showNotice = true
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
if (showNotice) {
|
||||
new Notice('No files selected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (mode === 'replace') {
|
||||
await this.updateNoteStatuses(statusesToSet, file);
|
||||
} else {
|
||||
// Add each status
|
||||
for (const status of statusesToSet) {
|
||||
await this.addNoteStatus(status, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only show notice if explicitly requested and we're dealing with multiple files
|
||||
if (showNotice && files.length > 1) {
|
||||
const statusText = this.formatStatusText(statusesToSet);
|
||||
new Notice(`Updated ${files.length} files with ${statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format status text for notifications
|
||||
*/
|
||||
private formatStatusText(statusesToSet: string[]): string {
|
||||
return statusesToSet.length === 1
|
||||
? statusesToSet[0]
|
||||
: `${statusesToSet.length} statuses`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Insert status metadata in the editor
|
||||
*/
|
||||
public insertStatusMetadataInEditor(editor: Editor): void {
|
||||
const content = editor.getValue();
|
||||
public insertStatusMetadataInEditor(editor: Editor): void {
|
||||
const content = editor.getValue();
|
||||
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
||||
|
||||
// Check if status metadata already exists
|
||||
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
|
||||
|
||||
if (frontMatterMatch) {
|
||||
const frontMatter = frontMatterMatch[1];
|
||||
if (frontMatter.match(statusTagRegex)) {
|
||||
// Status already exists, do nothing
|
||||
return;
|
||||
}
|
||||
// Add to existing frontmatter
|
||||
const defaultStatuses = ['unknown'];
|
||||
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
|
||||
|
||||
// Check if frontmatter exists
|
||||
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
||||
|
||||
if (frontMatterMatch) {
|
||||
this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata);
|
||||
} else {
|
||||
this.createFrontmatterWithStatus(editor, content, statusMetadata);
|
||||
this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata);
|
||||
} else {
|
||||
// No frontmatter, check if status exists in content somehow
|
||||
if (content.match(statusTagRegex)) {
|
||||
return;
|
||||
}
|
||||
// Create new frontmatter
|
||||
const defaultStatuses = ['unknown'];
|
||||
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
|
||||
this.createFrontmatterWithStatus(editor, content, statusMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert status metadata into existing frontmatter
|
||||
|
|
@ -330,16 +167,11 @@ export class StatusService {
|
|||
statusMetadata: string
|
||||
): void {
|
||||
const frontMatter = frontMatterMatch[1];
|
||||
let updatedFrontMatter = frontMatter;
|
||||
|
||||
// Check if status tag already exists in frontmatter
|
||||
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
|
||||
|
||||
if (frontMatter.match(statusTagRegex)) {
|
||||
updatedFrontMatter = frontMatter.replace(statusTagRegex, statusMetadata);
|
||||
} else {
|
||||
updatedFrontMatter = `${frontMatter}\n${statusMetadata}`;
|
||||
}
|
||||
const updatedFrontMatter = frontMatter.match(statusTagRegex)
|
||||
? frontMatter.replace(statusTagRegex, statusMetadata)
|
||||
: `${frontMatter}\n${statusMetadata}`;
|
||||
|
||||
const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`);
|
||||
editor.setValue(updatedContent);
|
||||
|
|
@ -358,14 +190,11 @@ export class StatusService {
|
|||
*/
|
||||
public getMarkdownFiles(searchQuery = ''): TFile[] {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
if (!searchQuery) return files;
|
||||
|
||||
if (!searchQuery) {
|
||||
return files;
|
||||
}
|
||||
|
||||
return files.filter(file =>
|
||||
file.basename.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return files.filter(file =>
|
||||
file.basename.toLowerCase().includes(lowerQuery));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -375,22 +204,18 @@ export class StatusService {
|
|||
const statusGroups: Record<string, TFile[]> = {};
|
||||
|
||||
// Initialize groups for all statuses
|
||||
this.allStatuses.forEach(status => {
|
||||
for (const status of this.allStatuses) {
|
||||
statusGroups[status.name] = [];
|
||||
});
|
||||
|
||||
// Ensure 'unknown' status is included
|
||||
if (!statusGroups['unknown']) {
|
||||
statusGroups['unknown'] = [];
|
||||
}
|
||||
|
||||
// Ensure 'unknown' status exists
|
||||
statusGroups['unknown'] = statusGroups['unknown'] || [];
|
||||
|
||||
// Get all markdown files and filter by search query
|
||||
// Get and process all files matching the search query
|
||||
const files = this.getMarkdownFiles(searchQuery);
|
||||
|
||||
for (const file of files) {
|
||||
const statuses = this.getFileStatuses(file);
|
||||
|
||||
// Add file to each of its status groups
|
||||
for (const status of statuses) {
|
||||
if (statusGroups[status]) {
|
||||
statusGroups[status].push(file);
|
||||
|
|
@ -400,4 +225,164 @@ export class StatusService {
|
|||
|
||||
return statusGroups;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes all status modification operations
|
||||
*/
|
||||
private async modifyNoteStatus(options: {
|
||||
files: TFile | TFile[];
|
||||
statuses: string | string[];
|
||||
operation: 'set' | 'add' | 'remove' | 'toggle';
|
||||
showNotice?: boolean;
|
||||
}): Promise<void> {
|
||||
const { operation, showNotice = true } = options;
|
||||
const targetFiles = Array.isArray(options.files) ? options.files : [options.files];
|
||||
const targetStatuses = Array.isArray(options.statuses) ? options.statuses : [options.statuses];
|
||||
|
||||
if (targetFiles.length === 0) {
|
||||
if (showNotice) new Notice('No files selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each file
|
||||
const updatePromises = targetFiles.map(async (file) => {
|
||||
if (!file || file.extension !== 'md') return;
|
||||
|
||||
// Get current statuses for the file
|
||||
const currentStatuses = this.getFileStatuses(file);
|
||||
let newStatuses: string[] = [];
|
||||
|
||||
switch (operation) {
|
||||
case 'set':
|
||||
// Replace all statuses with the new ones
|
||||
newStatuses = [...targetStatuses];
|
||||
break;
|
||||
|
||||
case 'add':
|
||||
// Add new statuses without duplicates
|
||||
newStatuses = [...new Set([
|
||||
...currentStatuses.filter(s => s !== 'unknown'),
|
||||
...targetStatuses
|
||||
])];
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
// Remove specified statuses
|
||||
newStatuses = currentStatuses.filter(
|
||||
status => !targetStatuses.includes(status)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'toggle':
|
||||
// Toggle each status (add if not present, remove if present)
|
||||
newStatuses = [...currentStatuses];
|
||||
for (const status of targetStatuses) {
|
||||
if (currentStatuses.includes(status)) {
|
||||
newStatuses = newStatuses.filter(s => s !== status);
|
||||
} else {
|
||||
newStatuses = [...newStatuses.filter(s => s !== 'unknown'), status];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle empty result (should revert to unknown)
|
||||
if (newStatuses.length === 0) {
|
||||
newStatuses = ['unknown'];
|
||||
}
|
||||
|
||||
// Apply updates to frontmatter
|
||||
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
frontmatter[this.settings.tagPrefix] = newStatuses;
|
||||
});
|
||||
|
||||
|
||||
this.notifyStatusChanged(newStatuses, file)
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// Show notification for batch operations
|
||||
if (showNotice && targetFiles.length > 1) {
|
||||
const statusText = targetStatuses.length === 1
|
||||
? targetStatuses[0]
|
||||
: `${targetStatuses.length} statuses`;
|
||||
const operationText = operation === 'set' ? 'updated' :
|
||||
operation === 'add' ? 'added to' :
|
||||
operation === 'remove' ? 'removed from' : 'toggled on';
|
||||
|
||||
new Notice(`${statusText} ${operationText} ${targetFiles.length} files`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles UI-triggered status changes with appropriate logic based on context
|
||||
* This is the central function that all UI components should call for status changes
|
||||
*/
|
||||
public async handleStatusChange(options: {
|
||||
files: TFile | TFile[];
|
||||
statuses: string | string[];
|
||||
isMultipleSelection?: boolean;
|
||||
allowMultipleStatuses?: boolean;
|
||||
operation?: 'set' | 'add' | 'remove' | 'toggle';
|
||||
showNotice?: boolean;
|
||||
afterChange?: (updatedStatuses: string[]) => void;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
files,
|
||||
statuses,
|
||||
isMultipleSelection = false,
|
||||
allowMultipleStatuses = this.settings.useMultipleStatuses,
|
||||
operation: explicitOperation,
|
||||
showNotice = isMultipleSelection,
|
||||
afterChange
|
||||
} = options;
|
||||
|
||||
const targetFiles = Array.isArray(files) ? files : [files];
|
||||
const targetStatuses = Array.isArray(statuses) ? statuses : [statuses];
|
||||
|
||||
// Determine operation based on context if not explicitly specified
|
||||
let operation: 'set' | 'add' | 'remove' | 'toggle';
|
||||
|
||||
if (explicitOperation) {
|
||||
operation = explicitOperation;
|
||||
} else if (isMultipleSelection) {
|
||||
// For multiple files, we need to check if we should add or remove
|
||||
const firstStatus = targetStatuses[0]; // Use first status for multi-file operations
|
||||
const filesWithStatus = targetFiles.filter(file =>
|
||||
this.getFileStatuses(file).includes(firstStatus)
|
||||
);
|
||||
|
||||
operation = filesWithStatus.length > targetFiles.length / 2 ? 'remove' : 'add';
|
||||
} else {
|
||||
// For single file operations
|
||||
if (allowMultipleStatuses) {
|
||||
operation = 'toggle';
|
||||
} else {
|
||||
operation = 'set';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the changes
|
||||
await this.modifyNoteStatus({
|
||||
files: targetFiles,
|
||||
statuses: targetStatuses,
|
||||
operation,
|
||||
showNotice
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch status changed event
|
||||
*/
|
||||
private notifyStatusChanged(statuses: string[], file?: TFile): void {
|
||||
// Dispatch the specific status change event
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: {
|
||||
statuses,
|
||||
file: file?.path
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,93 +5,98 @@ import { PREDEFINED_TEMPLATES } from '../constants/status-templates';
|
|||
* Handles dynamic styling for the plugin
|
||||
*/
|
||||
export class StyleService {
|
||||
private dynamicStyleEl?: HTMLStyleElement;
|
||||
private settings: NoteStatusSettings;
|
||||
private dynamicStyleEl?: HTMLStyleElement;
|
||||
private settings: NoteStatusSettings;
|
||||
|
||||
constructor(settings: NoteStatusSettings) {
|
||||
this.settings = settings;
|
||||
this.initializeDynamicStyles();
|
||||
}
|
||||
constructor(settings: NoteStatusSettings) {
|
||||
this.settings = settings;
|
||||
this.initializeDynamicStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.updateDynamicStyles();
|
||||
}
|
||||
/**
|
||||
* Updates the settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.updateDynamicStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the style element if it doesn't exist
|
||||
*/
|
||||
private initializeDynamicStyles(): void {
|
||||
if (!this.dynamicStyleEl) {
|
||||
this.dynamicStyleEl = document.createElement('style');
|
||||
document.head.appendChild(this.dynamicStyleEl);
|
||||
}
|
||||
this.updateDynamicStyles();
|
||||
}
|
||||
/**
|
||||
* Creates the style element if it doesn't exist
|
||||
*/
|
||||
private initializeDynamicStyles(): void {
|
||||
if (!this.dynamicStyleEl) {
|
||||
this.dynamicStyleEl = document.createElement('style');
|
||||
document.head.appendChild(this.dynamicStyleEl);
|
||||
}
|
||||
this.updateDynamicStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all status colors including those from enabled templates
|
||||
*/
|
||||
private getAllStatusColors(): Record<string, string> {
|
||||
const colors = { ...this.settings.statusColors };
|
||||
/**
|
||||
* Updates the dynamic styles based on current settings
|
||||
*/
|
||||
private updateDynamicStyles(): void {
|
||||
if (!this.dynamicStyleEl) {
|
||||
this.initializeDynamicStyles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add colors from template statuses if not using custom statuses only
|
||||
if (!this.settings.useCustomStatusesOnly) {
|
||||
for (const templateId of this.settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
// Add template colors if they don't already exist in colors
|
||||
for (const status of template.statuses) {
|
||||
if (status.color && !colors[status.name]) {
|
||||
colors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const allColors = this.getAllStatusColors();
|
||||
const cssRules = this.generateColorCssRules(allColors);
|
||||
this.dynamicStyleEl.textContent = cssRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all status colors including those from enabled templates
|
||||
*/
|
||||
private getAllStatusColors(): Record<string, string> {
|
||||
const colors = { ...this.settings.statusColors };
|
||||
|
||||
return colors;
|
||||
}
|
||||
if (this.settings.useCustomStatusesOnly) return colors;
|
||||
|
||||
// Add colors from template statuses
|
||||
for (const templateId of this.settings.enabledTemplates) {
|
||||
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
|
||||
if (!template) continue;
|
||||
|
||||
for (const status of template.statuses) {
|
||||
if (status.color && !colors[status.name]) {
|
||||
colors[status.name] = status.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dynamic styles based on current settings
|
||||
*/
|
||||
public updateDynamicStyles(): void {
|
||||
if (!this.dynamicStyleEl) {
|
||||
this.initializeDynamicStyles();
|
||||
return;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
// Get all status colors
|
||||
const allColors = this.getAllStatusColors();
|
||||
/**
|
||||
* Generate CSS rules for status colors
|
||||
*/
|
||||
private generateColorCssRules(colors: Record<string, string>): string {
|
||||
let css = '';
|
||||
|
||||
for (const [status, color] of Object.entries(colors)) {
|
||||
css += `
|
||||
.status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
.note-status-bar .note-status-${status},
|
||||
.nav-file-title .note-status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
// Generate CSS for status colors
|
||||
let css = '';
|
||||
for (const [status, color] of Object.entries(allColors)) {
|
||||
css += `
|
||||
.status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
.note-status-bar .note-status-${status},
|
||||
.nav-file-title .note-status-${status} {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
this.dynamicStyleEl.textContent = css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up styles when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
if (this.dynamicStyleEl) {
|
||||
this.dynamicStyleEl.remove();
|
||||
this.dynamicStyleEl = undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Cleans up styles when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
if (this.dynamicStyleEl) {
|
||||
this.dynamicStyleEl.remove();
|
||||
this.dynamicStyleEl = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
|
||||
import { Status } from '../models/types';
|
||||
import { PREDEFINED_TEMPLATES } from '../constants/status-templates';
|
||||
import NoteStatus from 'main';
|
||||
|
||||
/**
|
||||
* Settings tab for the Note Status plugin
|
||||
*/
|
||||
export class NoteStatusSettingTab extends PluginSettingTab {
|
||||
plugin: NoteStatus;
|
||||
|
||||
constructor(app: App, plugin: any) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// Status Template section
|
||||
this.displayTemplateSettings(containerEl.createDiv());
|
||||
|
||||
// UI section
|
||||
new Setting(containerEl).setName('User interface').setHeading();
|
||||
|
||||
|
||||
// Status bar settings
|
||||
new Setting(containerEl)
|
||||
.setName('Show status bar')
|
||||
.setDesc('Display the status bar')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showStatusBar)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showStatusBar = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Auto-hide status bar')
|
||||
.setDesc('Hide the status bar when status is unknown')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.autoHideStatusBar)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoHideStatusBar = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// File explorer settings
|
||||
new Setting(containerEl)
|
||||
.setName('Show status icons in file explorer')
|
||||
.setDesc('Display status icons in the file explorer')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showStatusIconsInExplorer)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showStatusIconsInExplorer = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// NEW SETTING: Hide unknown status in explorer
|
||||
new Setting(containerEl)
|
||||
.setName('Hide unknown status in file explorer')
|
||||
.setDesc('Hide status icons for files with unknown status in the file explorer')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.hideUnknownStatusInExplorer || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.hideUnknownStatusInExplorer = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Refresh explorer icons when this setting changes
|
||||
this.plugin.explorerIntegration.updateAllFileExplorerIcons();
|
||||
}));
|
||||
|
||||
// Compact view setting
|
||||
new Setting(containerEl)
|
||||
.setName('Default to compact view')
|
||||
.setDesc('Start the status pane in compact view by default')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.compactView || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.compactView = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Add option to exclude unknown status in pane view for performance
|
||||
new Setting(containerEl)
|
||||
.setName('Exclude unassigned notes from status pane')
|
||||
.setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.excludeUnknownStatus || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.excludeUnknownStatus = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Refresh the status pane if open
|
||||
const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0];
|
||||
if (statusPane && statusPane.view) {
|
||||
// Trigger refresh
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
}
|
||||
}));
|
||||
|
||||
new Setting(containerEl).setName('Status tag').setHeading();
|
||||
|
||||
// Option to use multiple statuses
|
||||
new Setting(containerEl)
|
||||
.setName('Enable multiple statuses')
|
||||
.setDesc('Allow notes to have multiple statuses at the same time')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.useMultipleStatuses)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.useMultipleStatuses = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Refresh UI to show multi-select or single-select options
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
}));
|
||||
|
||||
// Status tag prefix
|
||||
new Setting(containerEl)
|
||||
.setName('Status tag prefix')
|
||||
.setDesc('The YAML frontmatter tag name used for status (default: obsidian-note-status)')
|
||||
.addText(text => text
|
||||
.setValue(this.plugin.settings.tagPrefix)
|
||||
.onChange(async (value) => {
|
||||
// Don't allow empty tag prefix
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugin.settings.tagPrefix = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Add this line to trigger a full UI refresh
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
}));
|
||||
|
||||
|
||||
// Status management section
|
||||
new Setting(containerEl).setName('Custom statuses').setHeading();
|
||||
|
||||
// Option to use only custom statuses
|
||||
new Setting(containerEl)
|
||||
.setName('Use only custom statuses')
|
||||
.setDesc('Ignore template statuses and use only the custom statuses defined below')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.useCustomStatusesOnly || false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.useCustomStatusesOnly = value;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Refresh the UI to show/hide template statuses
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// Custom statuses list
|
||||
const statusList = containerEl.createDiv({ cls: 'custom-status-list' });
|
||||
|
||||
const renderStatuses = () => {
|
||||
statusList.empty();
|
||||
|
||||
this.plugin.settings.customStatuses.forEach((status: Status, index: number) => {
|
||||
const setting = new Setting(statusList)
|
||||
.setName(status.name)
|
||||
.setClass('status-item');
|
||||
|
||||
// Name field - now properly implemented
|
||||
setting.addText(text => text
|
||||
.setPlaceholder('Name')
|
||||
.setValue(status.name)
|
||||
.onChange(async (value) => {
|
||||
const oldName = status.name;
|
||||
status.name = value || 'unnamed';
|
||||
|
||||
// Update color mapping when name changes
|
||||
if (oldName !== status.name) {
|
||||
this.plugin.settings.statusColors[status.name] =
|
||||
this.plugin.settings.statusColors[oldName];
|
||||
delete this.plugin.settings.statusColors[oldName];
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Icon field
|
||||
setting.addText(text => text
|
||||
.setPlaceholder('Icon')
|
||||
.setValue(status.icon)
|
||||
.onChange(async (value) => {
|
||||
status.icon = value || '❓';
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Color picker
|
||||
setting.addColorPicker(colorPicker => colorPicker
|
||||
.setValue(this.plugin.settings.statusColors[status.name] || '#ffffff')
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.statusColors[status.name] = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Description field
|
||||
setting.addText(text => text
|
||||
.setPlaceholder('Description')
|
||||
.setValue(status.description || '')
|
||||
.onChange(async (value) => {
|
||||
status.description = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Remove button
|
||||
setting.addButton(button => button
|
||||
.setButtonText('Remove')
|
||||
.setClass('status-remove-button')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.customStatuses.splice(index, 1);
|
||||
delete this.plugin.settings.statusColors[status.name];
|
||||
await this.plugin.saveSettings();
|
||||
renderStatuses();
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
renderStatuses();
|
||||
|
||||
// Add new status
|
||||
new Setting(containerEl)
|
||||
.setName('Add new status')
|
||||
.setDesc('Add a custom status with a name, icon, and color')
|
||||
.addButton(button => button
|
||||
.setButtonText('Add Status')
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const newStatus = {
|
||||
name: `status${this.plugin.settings.customStatuses.length + 1}`,
|
||||
icon: '⭐'
|
||||
};
|
||||
|
||||
this.plugin.settings.customStatuses.push(newStatus);
|
||||
this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; // Initial white color
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
renderStatuses();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display template settings section
|
||||
*/
|
||||
private displayTemplateSettings(containerEl: HTMLElement): void {
|
||||
new Setting(containerEl).setName('Status templates').setHeading();
|
||||
containerEl.createEl('p', {
|
||||
text: 'Enable predefined templates to quickly add common status workflows',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
|
||||
// Create templates container
|
||||
const templatesContainer = containerEl.createDiv({ cls: 'templates-container' });
|
||||
|
||||
// List each template with checkbox and preview
|
||||
PREDEFINED_TEMPLATES.forEach(template => {
|
||||
const templateEl = templatesContainer.createDiv({ cls: 'template-item' });
|
||||
|
||||
// Template header with checkbox and name
|
||||
const headerEl = templateEl.createDiv({ cls: 'template-header' });
|
||||
|
||||
// Checkbox for enabling/disabling template
|
||||
const isEnabled = this.plugin.settings.enabledTemplates.includes(template.id);
|
||||
const checkbox = headerEl.createEl('input', {
|
||||
type: 'checkbox',
|
||||
cls: 'template-checkbox'
|
||||
});
|
||||
checkbox.checked = isEnabled;
|
||||
|
||||
checkbox.addEventListener('change', async () => {
|
||||
if (checkbox.checked) {
|
||||
// Enable template
|
||||
if (!this.plugin.settings.enabledTemplates.includes(template.id)) {
|
||||
this.plugin.settings.enabledTemplates.push(template.id);
|
||||
}
|
||||
} else {
|
||||
// Disable template
|
||||
this.plugin.settings.enabledTemplates = this.plugin.settings.enabledTemplates.filter(
|
||||
(id: string) => id !== template.id
|
||||
);
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// Refresh UI
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
});
|
||||
|
||||
// Template name
|
||||
headerEl.createEl('span', {
|
||||
text: template.name,
|
||||
cls: 'template-name'
|
||||
});
|
||||
|
||||
// Template description
|
||||
templateEl.createEl('div', {
|
||||
text: template.description,
|
||||
cls: 'template-description'
|
||||
});
|
||||
|
||||
// Preview statuses
|
||||
const statusesEl = templateEl.createDiv({ cls: 'template-statuses' });
|
||||
|
||||
template.statuses.forEach(status => {
|
||||
const statusEl = statusesEl.createEl('div', { cls: 'template-status-chip' });
|
||||
|
||||
const colorDot = statusEl.createEl('span', { cls: 'status-color-dot' });
|
||||
colorDot.style.setProperty('--dot-color', status.color || '#ffffff')
|
||||
|
||||
// Status icon and name
|
||||
statusEl.createSpan({ text: `${status.icon} ${status.name}` });
|
||||
});
|
||||
});
|
||||
|
||||
// Import/Export buttons for templates
|
||||
const buttonsContainer = containerEl.createDiv({ cls: 'template-buttons' });
|
||||
|
||||
// Import button
|
||||
const importButton = buttonsContainer.createEl('button', {
|
||||
text: 'Import template',
|
||||
cls: 'mod-cta'
|
||||
});
|
||||
|
||||
importButton.addEventListener('click', () => {
|
||||
// Show notification about upcoming feature
|
||||
new Notice('Template import functionality coming soon!');
|
||||
});
|
||||
|
||||
// Export button
|
||||
const exportButton = buttonsContainer.createEl('button', {
|
||||
text: 'Export templates',
|
||||
cls: ''
|
||||
});
|
||||
|
||||
exportButton.addEventListener('click', () => {
|
||||
// Show notification about upcoming feature
|
||||
new Notice('Template export functionality coming soon!');
|
||||
});
|
||||
}
|
||||
}
|
||||
1349
styles.css
1349
styles.css
File diff suppressed because one or more lines are too long
70
styles/base.css
Normal file
70
styles/base.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Base styles and variables for Note Status Plugin
|
||||
*/
|
||||
:root {
|
||||
--status-transition-time: 0.22s;
|
||||
--status-border-radius: var(--radius-s, 4px);
|
||||
--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2));
|
||||
--status-icon-size: 16px;
|
||||
|
||||
/* Animation curves */
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes note-status-scale-in {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes note-status-slide-in-fade {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes note-status-fade-in-slide-down {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes note-status-pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(0.98); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.theme-dark .note-status-popover {
|
||||
box-shadow: var(--shadow-l);
|
||||
}
|
||||
|
||||
.theme-dark .note-status-option-selecting {
|
||||
box-shadow: 0 0 0 1px var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* High contrast improvements */
|
||||
@media (forced-colors: active) {
|
||||
.note-status-chip,
|
||||
.note-status-option.is-selected {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.note-status-action-button:focus-visible,
|
||||
.note-status-chip:focus-visible,
|
||||
.note-status-option:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.note-status-unified-dropdown,
|
||||
.note-status-bar,
|
||||
.note-status-pane {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
250
styles/components/dropdown.css
Normal file
250
styles/components/dropdown.css
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/*
|
||||
* Dropdown component styles
|
||||
*/
|
||||
.note-status-popover {
|
||||
position: absolute;
|
||||
background: var(--background-primary);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--status-box-shadow);
|
||||
z-index: calc(var(--layer-popover) + 10);
|
||||
max-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
width: auto !important;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity var(--status-transition-time) var(--ease-out),
|
||||
transform var(--status-transition-time) var(--ease-out);
|
||||
transform-origin: top left;
|
||||
pointer-events: all !important;
|
||||
}
|
||||
|
||||
.note-status-popover.note-status-popover-animate-in {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.note-status-popover.note-status-popover-animate-out {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Search container */
|
||||
.note-status-popover-search {
|
||||
padding: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--background-primary);
|
||||
z-index: 3;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Status options container */
|
||||
.note-status-options-container {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 250px;
|
||||
padding: var(--size-4-1, 4px);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--background-modifier-border) transparent;
|
||||
}
|
||||
|
||||
.note-status-options-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.note-status-options-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--background-modifier-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.note-status-options-container::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Empty status options */
|
||||
.note-status-empty-options {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--font-smaller);
|
||||
}
|
||||
|
||||
/* Status option items */
|
||||
.note-status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin: 2px 4px;
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.note-status-option:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.note-status-option.is-selected {
|
||||
background: var(--background-secondary-alt);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.note-status-option-selecting {
|
||||
background-color: var(--interactive-accent) !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
animation: note-status-pulse 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-option-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.note-status-option-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-smaller);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-status-option-check {
|
||||
margin-left: auto;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* Unified dropdown */
|
||||
.note-status-unified-dropdown {
|
||||
width: 280px;
|
||||
max-width: 90vw;
|
||||
border-radius: var(--radius-m);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-m);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary);
|
||||
z-index: 9999 !important;
|
||||
pointer-events: all !important;
|
||||
}
|
||||
|
||||
/* Animation for unified dropdown */
|
||||
.note-status-popover-animate-in {
|
||||
animation: note-status-dropdown-fade-in 0.22s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes note-status-dropdown-fade-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes note-status-dropdown-fade-out {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(0.95); }
|
||||
}
|
||||
|
||||
/* Popover header */
|
||||
.note-status-popover-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.note-status-popover-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.note-status-popover-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.note-status-popover-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: var(--background-primary-alt);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Status chips styling */
|
||||
.note-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
font-size: var(--font-smaller);
|
||||
transition: all 0.15s var(--ease-out);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
max-width: 180px;
|
||||
animation: note-status-scale-in 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-chip.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-status-chip.clickable:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--status-hover-shadow);
|
||||
}
|
||||
|
||||
.note-status-chip-removing {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.note-status-chip-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.note-status-chip-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-status-chip-remove {
|
||||
margin-left: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-chip-remove:hover {
|
||||
background: var(--text-accent);
|
||||
color: var(--text-on-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
58
styles/components/explorer.css
Normal file
58
styles/components/explorer.css
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Explorer integration styles
|
||||
*/
|
||||
.note-status-toolbar-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-status-toolbar-icon {
|
||||
color: var(--text-normal) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.note-status-count-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -8px;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
/* Explorer icons container */
|
||||
.note-status-icon-container {
|
||||
display: inline-flex;
|
||||
margin-left: var(--size-4-1, 4px);
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Individual status icons in explorer */
|
||||
.note-status-icon {
|
||||
font-size: var(--font-ui-smaller);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--status-icon-size);
|
||||
height: var(--status-icon-size);
|
||||
position: relative;
|
||||
z-index: 500; /* Higher than most elements */
|
||||
}
|
||||
|
||||
.nav-file-title .note-status-icon::after {
|
||||
bottom: 150%; /* Position above in file explorer */
|
||||
}
|
||||
62
styles/components/settings.css
Normal file
62
styles/components/settings.css
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Settings styles
|
||||
*/
|
||||
/* Template styling */
|
||||
.status-color-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--dot-color, #ffffff);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.template-buttons {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Template selection styling */
|
||||
.template-item {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--status-border-radius);
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: var(--background-primary-alt);
|
||||
}
|
||||
|
||||
.template-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.template-checkbox {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.template-statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.template-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--background-secondary);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
64
styles/components/status-bar.css
Normal file
64
styles/components/status-bar.css
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Status bar styles
|
||||
*/
|
||||
.note-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-s);
|
||||
transition: all 0.2s var(--ease-out);
|
||||
cursor: pointer;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.note-status-bar:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.note-status-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.note-status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--background-secondary-alt);
|
||||
font-size: var(--font-ui-smaller);
|
||||
max-width: 100px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.note-status-badge-icon {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.note-status-badge-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Status bar positioning and visibility */
|
||||
.status-bar .note-status-icon::after {
|
||||
bottom: auto;
|
||||
top: -30px;
|
||||
}
|
||||
|
||||
/* Auto-hide behavior */
|
||||
.note-status-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-status-bar.auto-hide {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-bar.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
313
styles/components/status-pane.css
Normal file
313
styles/components/status-pane.css
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* Status Pane Styles
|
||||
*/
|
||||
.note-status-pane {
|
||||
padding: var(--size-4-2, 8px);
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font-interface);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header elements */
|
||||
.note-status-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--background-secondary);
|
||||
padding-bottom: var(--size-4-2, 8px);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
margin-bottom: var(--size-4-2, 8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2, 8px);
|
||||
}
|
||||
|
||||
/* Search input styles */
|
||||
.note-status-search {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input-wrapper,
|
||||
.note-status-search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-icon,
|
||||
.note-status-search-icon {
|
||||
position: absolute;
|
||||
left: var(--size-4-1, 4px);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.note-status-search-input,
|
||||
.note-status-popover-search-input {
|
||||
width: 100%;
|
||||
padding: var(--input-padding);
|
||||
padding-left: calc(var(--size-4-1, 4px) * 3 + 18px);
|
||||
border: var(--input-border-width) solid var(--background-modifier-border);
|
||||
border-radius: var(--input-radius);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
outline: none;
|
||||
transition: all var(--status-transition-time) var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-search-input:focus,
|
||||
.note-status-popover-search-input:focus {
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.search-input-clear-button,
|
||||
.note-status-search-clear-button {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: var(--size-4-1, 4px);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity var(--status-transition-time) var(--ease-out);
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.search-input-clear-button.is-visible,
|
||||
.note-status-search-clear-button.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-input-clear-button:hover,
|
||||
.note-status-search-clear-button:hover {
|
||||
color: var(--text-normal);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
/* Actions toolbar */
|
||||
.status-pane-actions-container {
|
||||
display: flex;
|
||||
gap: var(--size-4-2, 8px);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--size-4-1, 4px);
|
||||
}
|
||||
|
||||
.note-status-view-toggle,
|
||||
.note-status-actions-refresh {
|
||||
padding: var(--size-4-1, 4px) var(--size-4-2, 8px);
|
||||
border: var(--input-border-width) solid var(--background-modifier-border);
|
||||
border-radius: var(--button-radius);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
transition: all var(--status-transition-time) var(--ease-out);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.note-status-view-toggle:hover,
|
||||
.note-status-actions-refresh:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
box-shadow: var(--shadow-s);
|
||||
}
|
||||
|
||||
.note-status-view-toggle:active,
|
||||
.note-status-actions-refresh:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Status Groups Container */
|
||||
.note-status-groups-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px; /* Prevents scrollbar from hugging the edge */
|
||||
}
|
||||
|
||||
/* Status Group Styling */
|
||||
.note-status-group {
|
||||
margin-bottom: var(--size-4-3, 12px);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-primary-alt);
|
||||
box-shadow: var(--shadow-xs);
|
||||
overflow: hidden;
|
||||
animation: note-status-fade-in-slide-down 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-group .nav-folder-title {
|
||||
cursor: pointer;
|
||||
padding: var(--size-4-1, 4px) var(--size-4-2, 8px);
|
||||
background: var(--background-secondary-alt);
|
||||
transition: background var(--status-transition-time) var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-group .nav-folder-title:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.note-status-group .nav-folder-title-content {
|
||||
font-weight: var(--font-semibold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-1, 4px);
|
||||
}
|
||||
|
||||
/* Collapse indicators */
|
||||
.note-status-collapse-indicator {
|
||||
margin-right: var(--size-4-2, 8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--status-transition-time) var(--ease-in-out);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-status-is-collapsed .note-status-collapse-indicator {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* File items */
|
||||
.note-status-group .nav-folder-children {
|
||||
padding: var(--size-4-1, 4px);
|
||||
background: var(--background-primary);
|
||||
transition: height var(--status-transition-time) var(--ease-out);
|
||||
}
|
||||
|
||||
.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-file {
|
||||
border-radius: var(--radius-s);
|
||||
transition: background var(--status-transition-time) var(--ease-out);
|
||||
margin-bottom: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-file:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.nav-file-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--size-4-1, 4px) var(--size-4-2, 8px);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.nav-file-icon {
|
||||
color: var(--text-muted);
|
||||
margin-right: var(--size-4-2, 8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-file-title-content {
|
||||
flex: 1;
|
||||
min-width: 0; /* Allows text to shrink properly */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Compact view */
|
||||
.note-status-compact-view .nav-file-title {
|
||||
padding: 2px var(--size-4-2, 8px);
|
||||
font-size: var(--font-smaller);
|
||||
}
|
||||
|
||||
.note-status-compact-view .nav-folder-children {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-status-compact-view .nav-file {
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.note-status-compact-view .nav-file:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Empty message and show unassigned button */
|
||||
.note-status-empty-message {
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.note-status-button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.note-status-show-unassigned-button {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-s, 4px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-ui-small);
|
||||
transition: all 0.2s var(--ease-out);
|
||||
box-shadow: var(--shadow-s);
|
||||
}
|
||||
|
||||
.note-status-show-unassigned-button:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-m);
|
||||
}
|
||||
|
||||
.note-status-show-unassigned-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
* Pagination styles
|
||||
*/
|
||||
.note-status-pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.note-status-pagination-button {
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-secondary);
|
||||
border: var(--input-border-width) solid var(--background-modifier-border);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--ease-out);
|
||||
font-size: var(--font-smaller);
|
||||
}
|
||||
|
||||
.note-status-pagination-button:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
box-shadow: var(--shadow-s);
|
||||
}
|
||||
|
||||
.note-status-pagination-info {
|
||||
font-size: var(--font-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
14
styles/index.css
Normal file
14
styles/index.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Note Status Plugin for Obsidian
|
||||
* Main CSS file importing all components
|
||||
*
|
||||
* Author: Aleix Soler
|
||||
*/
|
||||
|
||||
@import 'base.css';
|
||||
@import 'utils.css';
|
||||
@import 'components/status-bar.css';
|
||||
@import 'components/status-pane.css';
|
||||
@import 'components/dropdown.css';
|
||||
@import 'components/explorer.css';
|
||||
@import 'components/settings.css';
|
||||
106
styles/utils.css
Normal file
106
styles/utils.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Utility classes for Note Status Plugin
|
||||
*/
|
||||
.note-status-empty-indicator {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: var(--size-4-1, 4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-accent);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* Action buttons in modal */
|
||||
.note-status-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-s);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-status-action-button:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.note-status-action-button.note-status-button-active {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.note-status-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.note-status-loading span {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.note-status-loading span:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-top-color: var(--text-accent);
|
||||
border-radius: 50%;
|
||||
animation: note-status-loading-spinner 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes note-status-loading-spinner {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Positioning classes for dropdowns */
|
||||
.note-status-dummy-target {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 0;
|
||||
height: 0;
|
||||
left: var(--pos-x-px, 0);
|
||||
top: var(--pos-y-px, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.note-status-popover-fixed {
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
--pos-x: 0;
|
||||
--pos-y: 0;
|
||||
--max-height: 300px;
|
||||
left: var(--pos-x-px, 0);
|
||||
top: var(--pos-y-px, 0);
|
||||
max-height: var(--max-height-px, 300px);
|
||||
}
|
||||
|
||||
/* Position adjustment classes */
|
||||
.note-status-popover-right {
|
||||
left: auto !important;
|
||||
right: var(--right-offset-px, 10px);
|
||||
}
|
||||
|
||||
.note-status-popover-bottom {
|
||||
top: auto !important;
|
||||
bottom: var(--bottom-offset-px, 10px);
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { setTooltip } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
|
||||
/**
|
||||
* Handles the status bar functionality
|
||||
*/
|
||||
export class StatusBar {
|
||||
private statusBarEl: HTMLElement;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ['unknown'];
|
||||
|
||||
constructor(statusBarEl: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.statusBarEl = statusBarEl;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
// Add initial class
|
||||
this.statusBarEl.addClass('note-status-bar');
|
||||
|
||||
// Add right-click handler for force refresh
|
||||
this.statusBarEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
|
||||
});
|
||||
|
||||
// Initial render
|
||||
this.update(['unknown']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar with new statuses
|
||||
*/
|
||||
public update(statuses: string[]): void {
|
||||
this.currentStatuses = statuses;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the status bar based on current settings and statuses
|
||||
*/
|
||||
public render(): void {
|
||||
this.statusBarEl.empty();
|
||||
this.statusBarEl.removeClass('left', 'hidden', 'auto-hide', 'visible');
|
||||
this.statusBarEl.addClass('note-status-bar');
|
||||
|
||||
if (!this.settings.showStatusBar) {
|
||||
this.statusBarEl.addClass('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single vs. multiple status display
|
||||
if (this.currentStatuses.length === 1 || !this.settings.useMultipleStatuses) {
|
||||
// Display single status
|
||||
const primaryStatus = this.currentStatuses[0];
|
||||
|
||||
// Get status object to get description
|
||||
const statusObj = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
|
||||
const tooltipValue = statusObj?.description ? `${primaryStatus} - ${statusObj.description}` : primaryStatus;
|
||||
|
||||
// Create status text
|
||||
const statusText = this.statusBarEl.createEl('span', {
|
||||
text: `Status: ${primaryStatus}`,
|
||||
cls: `note-status-${primaryStatus}`
|
||||
});
|
||||
|
||||
// Add tooltip
|
||||
setTooltip(statusText, tooltipValue);
|
||||
|
||||
// Create status icon
|
||||
const statusIcon = this.statusBarEl.createEl('span', {
|
||||
text: this.statusService.getStatusIcon(primaryStatus),
|
||||
cls: `note-status-icon status-${primaryStatus}`
|
||||
});
|
||||
|
||||
// Add tooltip to icon too
|
||||
setTooltip(statusIcon, tooltipValue);
|
||||
} else {
|
||||
// Display multiple statuses
|
||||
// Create status text
|
||||
this.statusBarEl.createEl('span', {
|
||||
text: `Statuses: `,
|
||||
cls: 'note-status-label'
|
||||
});
|
||||
|
||||
// Create container for status badges
|
||||
const badgesContainer = this.statusBarEl.createEl('span', {
|
||||
cls: 'note-status-badges'
|
||||
});
|
||||
|
||||
// Add status badges
|
||||
this.currentStatuses.forEach(status => {
|
||||
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
|
||||
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}` : status;
|
||||
|
||||
const badge = badgesContainer.createEl('span', {
|
||||
cls: `note-status-badge status-${status}`
|
||||
});
|
||||
|
||||
// Add tooltip
|
||||
setTooltip(badge, tooltipValue);
|
||||
|
||||
badge.createEl('span', {
|
||||
text: this.statusService.getStatusIcon(status),
|
||||
cls: 'note-status-badge-icon'
|
||||
});
|
||||
|
||||
badge.createEl('span', {
|
||||
text: status,
|
||||
cls: 'note-status-badge-text'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle auto-hide behavior
|
||||
const onlyUnknown = this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown';
|
||||
if (this.settings.autoHideStatusBar && onlyUnknown) {
|
||||
this.statusBarEl.addClass('auto-hide');
|
||||
setTimeout(() => {
|
||||
if (onlyUnknown && this.settings.showStatusBar) {
|
||||
this.statusBarEl.addClass('hidden');
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
this.statusBarEl.addClass('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
// Clean up any event listeners or DOM elements
|
||||
this.statusBarEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,642 +0,0 @@
|
|||
import { App, setIcon, TFile, setTooltip } from 'obsidian';
|
||||
import { NoteStatusSettings, Status } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
|
||||
/**
|
||||
* Unified dropdown component for status selection
|
||||
* This component handles the UI for selecting statuses across multiple contexts
|
||||
*/
|
||||
export class StatusDropdownComponent {
|
||||
private app: App;
|
||||
private statusService: StatusService;
|
||||
private settings: NoteStatusSettings;
|
||||
private dropdownElement: HTMLElement | null = null;
|
||||
private currentStatuses: string[] = ['unknown'];
|
||||
private onStatusChange: (statuses: string[]) => void;
|
||||
private animationDuration = 220;
|
||||
private clickOutsideHandler: (e: MouseEvent) => void;
|
||||
private targetFile: TFile | null = null;
|
||||
public isOpen = false;
|
||||
|
||||
|
||||
constructor(app: App, statusService: StatusService, settings: NoteStatusSettings) {
|
||||
this.app = app;
|
||||
this.statusService = statusService;
|
||||
this.settings = settings;
|
||||
this.onStatusChange = () => {};
|
||||
|
||||
// Bind methods
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
this.clickOutsideHandler = this.handleClickOutside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current statuses
|
||||
*/
|
||||
public updateStatuses(statuses: string[] | string): void {
|
||||
// Normalize input to always be an array
|
||||
if (typeof statuses === 'string') {
|
||||
this.currentStatuses = [statuses];
|
||||
} else {
|
||||
this.currentStatuses = [...statuses]; // Create a copy to ensure it's updated
|
||||
}
|
||||
|
||||
// Update dropdown if it's open
|
||||
if (this.isOpen && this.dropdownElement) {
|
||||
this.refreshDropdownContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the target file for status updates
|
||||
*/
|
||||
public setTargetFile(file: TFile | null): void {
|
||||
this.targetFile = file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
|
||||
// Update dropdown if it's open
|
||||
if (this.isOpen && this.dropdownElement) {
|
||||
this.refreshDropdownContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for status changes
|
||||
*/
|
||||
public setOnStatusChange(callback: (statuses: string[]) => void): void {
|
||||
this.onStatusChange = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status change callback
|
||||
*/
|
||||
public getOnStatusChange(): (statuses: string[]) => void {
|
||||
return this.onStatusChange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the dropdown visibility
|
||||
*/
|
||||
public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
// Add small delay to ensure dropdown is fully closed before potentially reopening
|
||||
// This prevents state conflicts when clicking in rapid succession
|
||||
setTimeout(() => {
|
||||
// Check if we're still in the closing state to prevent reopening if user clicked elsewhere
|
||||
if (!this.isOpen && !this.dropdownElement) {
|
||||
this.open(targetEl, position);
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
this.open(targetEl, position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dropdown
|
||||
*/
|
||||
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
||||
// Make sure any existing dropdown is fully closed
|
||||
if (this.isOpen || this.dropdownElement) {
|
||||
this.close();
|
||||
// Add small delay before opening new dropdown to ensure proper cleanup
|
||||
setTimeout(() => {
|
||||
this.actuallyOpen(targetEl, position);
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
this.actuallyOpen(targetEl, position);
|
||||
}
|
||||
|
||||
private actuallyOpen(targetEl: HTMLElement, position?: { x: number, y: number }): void {
|
||||
this.isOpen = true;
|
||||
|
||||
// Create dropdown element
|
||||
this.createDropdownElement();
|
||||
|
||||
// Fill dropdown content
|
||||
this.refreshDropdownContent();
|
||||
|
||||
// Position the dropdown
|
||||
if (position) {
|
||||
this.positionAt(position.x, position.y);
|
||||
} else {
|
||||
this.positionRelativeTo(targetEl);
|
||||
}
|
||||
|
||||
// Add animation class
|
||||
this.dropdownElement?.addClass('note-status-popover-animate-in');
|
||||
|
||||
// Add event listeners
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.clickOutsideHandler);
|
||||
document.addEventListener('keydown', this.handleEscapeKey);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dropdown element
|
||||
*/
|
||||
private createDropdownElement(): void {
|
||||
this.dropdownElement = document.createElement('div');
|
||||
this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown');
|
||||
document.body.appendChild(this.dropdownElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dropdown
|
||||
*/
|
||||
public close(): void {
|
||||
if (!this.dropdownElement || !this.isOpen) return;
|
||||
|
||||
// Add exit animation
|
||||
this.dropdownElement.addClass('note-status-popover-animate-out');
|
||||
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('click', this.clickOutsideHandler);
|
||||
document.removeEventListener('keydown', this.handleEscapeKey);
|
||||
|
||||
// Set isOpen to false immediately to prevent multiple open/close conflicts
|
||||
this.isOpen = false;
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
if (this.dropdownElement) {
|
||||
this.dropdownElement.remove();
|
||||
this.dropdownElement = null;
|
||||
}
|
||||
}, this.animationDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the dropdown content
|
||||
*/
|
||||
private refreshDropdownContent(): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
// Clear content
|
||||
this.dropdownElement.empty();
|
||||
|
||||
// Create header
|
||||
this.createHeader();
|
||||
|
||||
// Create status chips section
|
||||
this.createStatusChips();
|
||||
|
||||
// Create search filter
|
||||
const searchInput = this.createSearchFilter();
|
||||
|
||||
// Create status options container
|
||||
const statusOptionsContainer = this.dropdownElement.createDiv({
|
||||
cls: 'note-status-options-container'
|
||||
});
|
||||
|
||||
// Get all available statuses
|
||||
const allStatuses = this.statusService.getAllStatuses()
|
||||
.filter(status => status.name !== 'unknown');
|
||||
|
||||
// Create a function to populate options with filtering
|
||||
const populateOptions = (filter = '') => {
|
||||
this.populateStatusOptions(statusOptionsContainer, allStatuses, filter);
|
||||
};
|
||||
|
||||
// Initial population
|
||||
populateOptions();
|
||||
|
||||
// Add search functionality
|
||||
searchInput.addEventListener('input', () => {
|
||||
populateOptions(searchInput.value);
|
||||
});
|
||||
|
||||
// Focus search input after a short delay
|
||||
setTimeout(() => {
|
||||
searchInput.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the dropdown header
|
||||
*/
|
||||
private createHeader(): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
const headerEl = this.dropdownElement.createDiv({ cls: 'note-status-popover-header' });
|
||||
|
||||
// Title with icon
|
||||
const titleEl = headerEl.createDiv({ cls: 'note-status-popover-title' });
|
||||
const iconContainer = titleEl.createDiv({ cls: 'note-status-popover-icon' });
|
||||
setIcon(iconContainer, 'tag');
|
||||
titleEl.createSpan({ text: 'Note status', cls: 'note-status-popover-label' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the status chips section
|
||||
*/
|
||||
private createStatusChips(): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
const chipsContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-chips' });
|
||||
|
||||
// Show 'No status' indicator if no statuses or only unknown status
|
||||
if (this.hasNoValidStatus()) {
|
||||
this.createEmptyStatusIndicator(chipsContainer);
|
||||
} else {
|
||||
// Add chip for each status
|
||||
this.createStatusChipElements(chipsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are no valid statuses
|
||||
*/
|
||||
private hasNoValidStatus(): boolean {
|
||||
return this.currentStatuses.length === 0 ||
|
||||
(this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty status indicator
|
||||
*/
|
||||
private createEmptyStatusIndicator(container: HTMLElement): void {
|
||||
container.createDiv({
|
||||
cls: 'note-status-empty-indicator',
|
||||
text: 'No status assigned'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chips for all current statuses
|
||||
*/
|
||||
private createStatusChipElements(container: HTMLElement): void {
|
||||
this.currentStatuses.forEach(status => {
|
||||
if (status === 'unknown') return; // Skip unknown status
|
||||
|
||||
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
|
||||
if (!statusObj) return;
|
||||
|
||||
this.createSingleStatusChip(container, status, statusObj);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single status chip
|
||||
*/
|
||||
private createSingleStatusChip(container: HTMLElement, status: string, statusObj: Status): void {
|
||||
const chipEl = container.createDiv({
|
||||
cls: `note-status-chip status-${status}`
|
||||
});
|
||||
|
||||
// Status icon
|
||||
chipEl.createSpan({
|
||||
text: statusObj.icon,
|
||||
cls: 'note-status-chip-icon'
|
||||
});
|
||||
|
||||
// Status name
|
||||
chipEl.createSpan({
|
||||
text: statusObj.name,
|
||||
cls: 'note-status-chip-text'
|
||||
});
|
||||
|
||||
// Add remove button regardless of the number of statuses
|
||||
// This allows removal even when there's only one status
|
||||
this.addRemoveButton(chipEl, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a remove button to a status chip
|
||||
*/
|
||||
private addRemoveButton(chipEl: HTMLElement, status: string): void {
|
||||
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
|
||||
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
|
||||
|
||||
// Add tooltip to the chip element
|
||||
setTooltip(chipEl, tooltipValue);
|
||||
|
||||
const removeBtn = chipEl.createDiv({
|
||||
cls: 'note-status-chip-remove',
|
||||
attr: {
|
||||
'aria-label': `Remove ${status} status`,
|
||||
'title': `Remove ${status} status`
|
||||
}
|
||||
});
|
||||
setIcon(removeBtn, 'x');
|
||||
|
||||
removeBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Add remove animation
|
||||
chipEl.addClass('note-status-chip-removing');
|
||||
|
||||
// Wait for animation to complete before actually removing
|
||||
setTimeout(async () => {
|
||||
if (this.targetFile) {
|
||||
// Remove status from single file
|
||||
await this.removeStatus(status);
|
||||
} else {
|
||||
// This is a batch operation - remove from all files
|
||||
// Call the onStatusChange callback with the status to be removed
|
||||
this.onStatusChange([status]);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a status from the target file
|
||||
*/
|
||||
private async removeStatus(status: string): Promise<void> {
|
||||
if (!this.targetFile) return;
|
||||
|
||||
// Remove this status
|
||||
await this.statusService.removeNoteStatus(status, this.targetFile);
|
||||
|
||||
// Get updated statuses
|
||||
const updatedStatuses = this.statusService.getFileStatuses(this.targetFile);
|
||||
|
||||
// Update current statuses
|
||||
this.currentStatuses = updatedStatuses;
|
||||
|
||||
// Refresh chips
|
||||
this.refreshDropdownContent();
|
||||
|
||||
// Call status change callback
|
||||
this.onStatusChange(updatedStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the search filter input
|
||||
*/
|
||||
private createSearchFilter(): HTMLInputElement {
|
||||
if (!this.dropdownElement) {
|
||||
throw new Error("Dropdown element not initialized");
|
||||
}
|
||||
|
||||
const searchContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-search' });
|
||||
const searchInput = searchContainer.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter statuses...',
|
||||
cls: 'note-status-popover-search-input'
|
||||
});
|
||||
|
||||
return searchInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate status options with optional filtering
|
||||
*/
|
||||
private populateStatusOptions(
|
||||
container: HTMLElement,
|
||||
statuses: Status[],
|
||||
filter = ''
|
||||
): void {
|
||||
container.empty();
|
||||
|
||||
const filteredStatuses = this.filterStatuses(statuses, filter);
|
||||
|
||||
if (filteredStatuses.length === 0) {
|
||||
this.createNoMatchingStatusesMessage(container, filter);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredStatuses.forEach(status => {
|
||||
this.createStatusOption(container, status);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter statuses based on search term
|
||||
*/
|
||||
private filterStatuses(statuses: Status[], filter: string): Status[] {
|
||||
if (!filter) return statuses;
|
||||
|
||||
return statuses.filter(status =>
|
||||
status.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
status.icon.includes(filter)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message for no matching statuses
|
||||
*/
|
||||
private createNoMatchingStatusesMessage(container: HTMLElement, filter: string): void {
|
||||
container.createDiv({
|
||||
cls: 'note-status-empty-options',
|
||||
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single status option element
|
||||
*/
|
||||
private createStatusOption(container: HTMLElement, status: Status): void {
|
||||
const isSelected = this.currentStatuses.includes(status.name);
|
||||
|
||||
const optionEl = container.createDiv({
|
||||
cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}`
|
||||
});
|
||||
|
||||
// Status icon
|
||||
optionEl.createSpan({
|
||||
text: status.icon,
|
||||
cls: 'note-status-option-icon'
|
||||
});
|
||||
|
||||
// Status name
|
||||
optionEl.createSpan({
|
||||
text: status.name,
|
||||
cls: 'note-status-option-text'
|
||||
});
|
||||
|
||||
// Add tooltip with description if available
|
||||
if (status.description) {
|
||||
setTooltip(optionEl, `${status.name} - ${status.description}`);
|
||||
}
|
||||
|
||||
// Check icon for selected status
|
||||
if (isSelected) {
|
||||
const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' });
|
||||
setIcon(checkIcon, 'check');
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
optionEl.addEventListener('click', () => this.handleStatusOptionClick(optionEl, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on a status option
|
||||
*/
|
||||
private handleStatusOptionClick(optionEl: HTMLElement, status: Status): void {
|
||||
try {
|
||||
// Add selection animation
|
||||
optionEl.addClass('note-status-option-selecting');
|
||||
|
||||
// Apply status changes after brief delay for animation
|
||||
setTimeout(async () => {
|
||||
if (this.targetFile) {
|
||||
await this.handleStatusChangeForTargetFile(status);
|
||||
} else {
|
||||
// This is for batch operations or when no specific file is targeted
|
||||
this.onStatusChange([status.name]);
|
||||
|
||||
// Usually we want to close after selection in this case
|
||||
if (!this.settings.useMultipleStatuses) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status change for a specific target file
|
||||
*/
|
||||
private async handleStatusChangeForTargetFile(status: Status): Promise<void> {
|
||||
if (!this.targetFile) return;
|
||||
|
||||
if (this.settings.useMultipleStatuses) {
|
||||
await this.statusService.toggleNoteStatus(status.name, this.targetFile);
|
||||
} else {
|
||||
await this.statusService.updateNoteStatuses([status.name], this.targetFile);
|
||||
// Close dropdown in single status mode
|
||||
this.close();
|
||||
}
|
||||
|
||||
// Get fresh status from file
|
||||
const freshStatuses = this.statusService.getFileStatuses(this.targetFile);
|
||||
|
||||
// Update current statuses
|
||||
this.currentStatuses = [...freshStatuses];
|
||||
|
||||
// Call status change callback
|
||||
this.onStatusChange(freshStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown at specific coordinates
|
||||
*/
|
||||
private positionAt(x: number, y: number): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
this.dropdownElement.addClass('note-status-popover-fixed');
|
||||
this.dropdownElement.style.setProperty('--pos-x-px', `${x}px`);
|
||||
this.dropdownElement.style.setProperty('--pos-y-px', `${y}px`);
|
||||
|
||||
// Ensure dropdown doesn't go off-screen
|
||||
setTimeout(() => this.adjustPositionToViewport(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the dropdown position to ensure it's visible in the viewport
|
||||
*/
|
||||
private adjustPositionToViewport(): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
const rect = this.dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
this.dropdownElement.addClass('note-status-popover-right');
|
||||
this.dropdownElement.style.setProperty('--right-offset-px', '10px');
|
||||
} else {
|
||||
this.dropdownElement.removeClass('note-status-popover-right');
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
this.dropdownElement.addClass('note-status-popover-bottom');
|
||||
this.dropdownElement.style.setProperty('--bottom-offset-px', '10px');
|
||||
} else {
|
||||
this.dropdownElement.removeClass('note-status-popover-bottom');
|
||||
}
|
||||
|
||||
// Set max height based on viewport
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
this.dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Position the dropdown relative to a target element
|
||||
*/
|
||||
private positionRelativeTo(targetEl: HTMLElement): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
// Apply positioning class
|
||||
this.dropdownElement.addClass('note-status-popover-fixed');
|
||||
|
||||
// Get target element's position
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
|
||||
// Position below the element - using CSS custom properties
|
||||
this.dropdownElement.style.setProperty('--pos-y-px', `${targetRect.bottom + 5}px`);
|
||||
|
||||
// Align to left edge of target by default
|
||||
this.dropdownElement.style.setProperty('--pos-x-px', `${targetRect.left}px`);
|
||||
|
||||
// Check if dropdown would go off-screen
|
||||
setTimeout(() => this.adjustRelativePosition(targetRect), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust position when positioned relative to an element
|
||||
*/
|
||||
private adjustRelativePosition(targetRect: DOMRect): void {
|
||||
if (!this.dropdownElement) return;
|
||||
|
||||
const rect = this.dropdownElement.getBoundingClientRect();
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
// Align to right edge instead
|
||||
this.dropdownElement.addClass('note-status-popover-right');
|
||||
this.dropdownElement.style.setProperty('--right-offset-px', `${window.innerWidth - targetRect.right}px`);
|
||||
} else {
|
||||
this.dropdownElement.removeClass('note-status-popover-right');
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
// Position above the element instead
|
||||
this.dropdownElement.addClass('note-status-popover-bottom');
|
||||
this.dropdownElement.style.setProperty('--bottom-offset-px', `${window.innerHeight - targetRect.top + 5}px`);
|
||||
} else {
|
||||
this.dropdownElement.removeClass('note-status-popover-bottom');
|
||||
}
|
||||
|
||||
// Set max height based on viewport
|
||||
const maxHeight = window.innerHeight - rect.top - 20;
|
||||
this.dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside the dropdown
|
||||
*/
|
||||
private handleClickOutside(e: MouseEvent): void {
|
||||
if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key to close dropdown
|
||||
*/
|
||||
private handleEscapeKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,582 +0,0 @@
|
|||
import { MarkdownView, Editor, Notice, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
import { StatusDropdownComponent } from './status-dropdown-component';
|
||||
|
||||
/**
|
||||
* Enhanced status dropdown with toolbar integration
|
||||
*/
|
||||
export class StatusDropdown {
|
||||
private app: any;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private currentStatuses: string[] = ['unknown'];
|
||||
private toolbarButton?: HTMLElement;
|
||||
private dropdownComponent: StatusDropdownComponent;
|
||||
|
||||
constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
|
||||
// Initialize the dropdown component
|
||||
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
|
||||
|
||||
// Configure dropdown component callbacks
|
||||
this.setupDropdownCallbacks();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the dropdown callbacks
|
||||
*/
|
||||
private setupDropdownCallbacks(): void {
|
||||
this.dropdownComponent.setOnStatusChange((statuses) => {
|
||||
// Update current statuses and toolbar button
|
||||
this.currentStatuses = [...statuses]; // Make sure to copy the array
|
||||
this.updateToolbarButton();
|
||||
|
||||
// Dispatch events for UI update
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the toolbar button in the Obsidian ribbon
|
||||
*/
|
||||
private initToolbarButton(): void {
|
||||
// Clear any existing button
|
||||
if (this.toolbarButton) {
|
||||
this.toolbarButton.remove();
|
||||
this.toolbarButton = undefined;
|
||||
}
|
||||
|
||||
// Wait for next tick to ensure the UI is fully rendered
|
||||
setTimeout(() => {
|
||||
const toolbarContainer = this.findToolbarContainer();
|
||||
|
||||
if (!toolbarContainer) {
|
||||
console.error('Note Status: Could not find toolbar container');
|
||||
return;
|
||||
}
|
||||
this.createToolbarButton(toolbarContainer);
|
||||
}, 500); // Waiting 500ms to ensure the UI is ready
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the toolbar container element
|
||||
*/
|
||||
private findToolbarContainer(): HTMLElement | null {
|
||||
return document.querySelector('.workspace-leaf .view-header .view-actions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the toolbar button element
|
||||
*/
|
||||
private createToolbarButton(toolbarContainer: HTMLElement): void {
|
||||
// Create the button element
|
||||
this.toolbarButton = document.createElement('button');
|
||||
this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action');
|
||||
this.toolbarButton.setAttribute('aria-label', 'Note status');
|
||||
|
||||
// Update initial button state
|
||||
this.updateToolbarButton();
|
||||
|
||||
// Add click handler
|
||||
this.toolbarButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.toggleStatusPopover();
|
||||
});
|
||||
|
||||
// Insert at a more reliable position - just prepend to the container
|
||||
try {
|
||||
toolbarContainer.prepend(this.toolbarButton);
|
||||
} catch (error) {
|
||||
console.error('Note Status: Error inserting toolbar button', error);
|
||||
// Fallback - just append it
|
||||
toolbarContainer.appendChild(this.toolbarButton);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the toolbar button appearance based on current statuses
|
||||
*/
|
||||
private updateToolbarButton(): void {
|
||||
if (!this.toolbarButton) return;
|
||||
|
||||
// Clear existing content
|
||||
this.toolbarButton.empty();
|
||||
|
||||
// Check if we have a valid status
|
||||
const hasValidStatus = this.currentStatuses.length > 0 &&
|
||||
!this.currentStatuses.every(status => status === 'unknown');
|
||||
|
||||
// Create badge container
|
||||
const badgeContainer = document.createElement('div');
|
||||
badgeContainer.addClass('note-status-toolbar-badge-container');
|
||||
|
||||
if (hasValidStatus) {
|
||||
// Add status badge with proper icon
|
||||
this.addStatusBadge(badgeContainer);
|
||||
} else {
|
||||
// Add default status icon when no valid status
|
||||
this.addDefaultStatusIcon(badgeContainer);
|
||||
}
|
||||
|
||||
this.toolbarButton.appendChild(badgeContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a status badge to the toolbar button
|
||||
*/
|
||||
private addStatusBadge(container: HTMLElement): void {
|
||||
// Show primary status icon and indicator for multiple statuses
|
||||
const primaryStatus = this.currentStatuses[0];
|
||||
const statusInfo = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
|
||||
|
||||
if (statusInfo) {
|
||||
// Primary status icon
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`);
|
||||
iconSpan.textContent = statusInfo.icon;
|
||||
container.appendChild(iconSpan);
|
||||
|
||||
// Add count indicator if multiple statuses
|
||||
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
|
||||
const countBadge = document.createElement('span');
|
||||
countBadge.addClass('note-status-count-badge');
|
||||
countBadge.textContent = `+${this.currentStatuses.length - 1}`;
|
||||
container.appendChild(countBadge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add default status icon when no valid status
|
||||
*/
|
||||
private addDefaultStatusIcon(container: HTMLElement): void {
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.addClass('note-status-toolbar-icon', 'status-unknown');
|
||||
|
||||
// Use the statusService to get the proper icon for 'unknown' status
|
||||
iconSpan.textContent = this.statusService.getStatusIcon('unknown');
|
||||
container.appendChild(iconSpan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the dropdown UI based on current statuses
|
||||
*/
|
||||
public update(currentStatuses: string[] | string): void {
|
||||
// Normalize input to always be an array
|
||||
if (typeof currentStatuses === 'string') {
|
||||
this.currentStatuses = [currentStatuses];
|
||||
} else {
|
||||
this.currentStatuses = [...currentStatuses]; // Create a copy to ensure it's updated
|
||||
}
|
||||
|
||||
// Update toolbar button with new status
|
||||
this.updateToolbarButton();
|
||||
|
||||
// Update dropdown component
|
||||
this.dropdownComponent.updateStatuses(this.currentStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.updateToolbarButton();
|
||||
this.dropdownComponent.updateSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the status popover
|
||||
*/
|
||||
private toggleStatusPopover(): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
this.openStatusDropdown({
|
||||
target: this.toolbarButton,
|
||||
files: [activeFile]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status dropdown in context menu
|
||||
*/
|
||||
public showInContextMenu(editor: Editor, view: MarkdownView): void {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
// Pass context to indicate this is from editor context menu
|
||||
this.openStatusDropdown({
|
||||
editor: editor,
|
||||
view: view,
|
||||
files: [activeFile],
|
||||
onStatusChange: async (statuses) => {
|
||||
if (statuses.length > 0) {
|
||||
// Use a direct file operation, not batch update
|
||||
if (this.settings.useMultipleStatuses) {
|
||||
await this.statusService.toggleNoteStatus(statuses[0], activeFile);
|
||||
} else {
|
||||
await this.statusService.updateNoteStatuses(statuses, activeFile);
|
||||
}
|
||||
|
||||
// Trigger UI updates
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses, file: activeFile.path }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position from cursor or fallback positions
|
||||
*/
|
||||
private getCursorPosition(editor: Editor, view: MarkdownView): { x: number, y: number } {
|
||||
try {
|
||||
// Get cursor position in the document
|
||||
const cursor = editor.getCursor('head');
|
||||
|
||||
// Try to find the DOM representation of the cursor position
|
||||
const lineElement = view.contentEl.querySelector(`.cm-line:nth-child(${cursor.line + 1})`);
|
||||
|
||||
if (lineElement) {
|
||||
const rect = lineElement.getBoundingClientRect();
|
||||
// Position near the current line with some offset
|
||||
return {
|
||||
x: rect.left + 20,
|
||||
y: rect.bottom + 5
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to editor element position
|
||||
const editorEl = view.contentEl.querySelector('.cm-editor');
|
||||
if (editorEl) {
|
||||
const rect = editorEl.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + 100, // Offset from left
|
||||
y: rect.top + 100 // Offset from top
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting position for dropdown:', error);
|
||||
}
|
||||
|
||||
// Last resort - use middle of viewport
|
||||
return {
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dummy target element for positioning
|
||||
*/
|
||||
private createDummyTarget(position: { x: number, y: number }): HTMLElement {
|
||||
const dummyTarget = document.createElement('div');
|
||||
dummyTarget.addClass('note-status-dummy-target');
|
||||
dummyTarget.style.setProperty('--pos-x-px', `${position.x}px`);
|
||||
dummyTarget.style.setProperty('--pos-y-px', `${position.y}px`);
|
||||
document.body.appendChild(dummyTarget);
|
||||
return dummyTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render method (kept for compatibility)
|
||||
*/
|
||||
public render(): void {
|
||||
// This is now a no-op, as the dropdown component handles everything internally
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropdown when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
// Clean up dropdown component
|
||||
this.dropdownComponent.dispose();
|
||||
|
||||
// Remove toolbar button
|
||||
if (this.toolbarButton) {
|
||||
this.toolbarButton.remove();
|
||||
this.toolbarButton = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal function to open the status dropdown in any context
|
||||
* @param options Configuration options for opening the dropdown
|
||||
*/
|
||||
public openStatusDropdown(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number, y: number };
|
||||
files?: TFile[];
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
mode?: 'replace' | 'add';
|
||||
onStatusChange?: (statuses: string[]) => void;
|
||||
}): void {
|
||||
// IMPORTANT: Force reset the dropdown component's state
|
||||
// Add this at the beginning of the method
|
||||
if (this.dropdownComponent.isOpen) {
|
||||
this.dropdownComponent.close();
|
||||
// Give it a moment to clean up before proceeding
|
||||
setTimeout(() => {
|
||||
this._openStatusDropdown(options);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
this._openStatusDropdown(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common statuses across multiple files
|
||||
*/
|
||||
private findCommonStatuses(files: TFile[]): string[] {
|
||||
if (files.length === 0) return ['unknown'];
|
||||
|
||||
// Get statuses for first file
|
||||
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
|
||||
|
||||
// Filter to only include statuses that exist on all files
|
||||
return firstFileStatuses.filter(status => {
|
||||
// Skip unknown status
|
||||
if (status === 'unknown') return false;
|
||||
|
||||
// Check if status exists on all files
|
||||
return files.every(file =>
|
||||
this.statusService.getFileStatuses(file).includes(status)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a status across multiple files
|
||||
*/
|
||||
private async toggleStatusForFiles(files: TFile[], status: string): Promise<void> {
|
||||
// Count how many files have this status
|
||||
const filesWithStatus = files.filter(file =>
|
||||
this.statusService.getFileStatuses(file).includes(status)
|
||||
);
|
||||
|
||||
// If more than half have the status, remove it; otherwise, add it
|
||||
const shouldRemove = filesWithStatus.length > files.length / 2;
|
||||
|
||||
for (const file of files) {
|
||||
if (shouldRemove) {
|
||||
await this.statusService.removeNoteStatus(status, file);
|
||||
} else {
|
||||
await this.statusService.addNoteStatus(status, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _openStatusDropdown(options: {
|
||||
target?: HTMLElement;
|
||||
position?: { x: number, y: number };
|
||||
files?: TFile[];
|
||||
editor?: Editor;
|
||||
view?: MarkdownView;
|
||||
mode?: 'replace' | 'add';
|
||||
onStatusChange?: (statuses: string[]) => void;
|
||||
}): void {
|
||||
// If no files provided, use active file
|
||||
const files = options.files || [this.app.workspace.getActiveFile()].filter(Boolean);
|
||||
if (!files.length) {
|
||||
new Notice('No files selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: Always reset state at the beginning of each operation
|
||||
// This ensures no lingering callbacks or files from previous operations
|
||||
this.dropdownComponent.setTargetFile(null);
|
||||
this.dropdownComponent.setOnStatusChange(() => {});
|
||||
|
||||
// Determine if we're handling single or multiple files
|
||||
const isSingleFile = files.length === 1;
|
||||
const targetFile = isSingleFile ? files[0] : null;
|
||||
|
||||
// Set target file (if single) or null (if multiple)
|
||||
this.dropdownComponent.setTargetFile(targetFile);
|
||||
|
||||
// Get current statuses appropriately
|
||||
let currentStatuses: string[];
|
||||
|
||||
if (targetFile) {
|
||||
// For single file, get its current statuses
|
||||
currentStatuses = this.statusService.getFileStatuses(targetFile);
|
||||
} else if (files.length > 1) {
|
||||
// For multiple files, find common statuses to display
|
||||
currentStatuses = this.findCommonStatuses(files);
|
||||
} else {
|
||||
currentStatuses = ['unknown'];
|
||||
}
|
||||
|
||||
// Update dropdown with current statuses
|
||||
this.dropdownComponent.updateStatuses(currentStatuses);
|
||||
|
||||
// Set custom callback for status changes if provided
|
||||
if (options.onStatusChange) {
|
||||
// Use the provided callback directly
|
||||
this.dropdownComponent.setOnStatusChange(options.onStatusChange);
|
||||
} else if (!isSingleFile) {
|
||||
// Create a local copy of files to avoid closure issues
|
||||
const filesForBatch = [...files];
|
||||
|
||||
// Set batch operation callback for multiple files
|
||||
// Use toggle behavior for batch operations like single files
|
||||
this.dropdownComponent.setOnStatusChange(async (statuses) => {
|
||||
if (statuses.length > 0) {
|
||||
// Get the last selected status for toggling
|
||||
const toggledStatus = statuses[statuses.length - 1];
|
||||
|
||||
// Toggle this status across all files
|
||||
await this.toggleStatusForFiles(filesForBatch, toggledStatus);
|
||||
|
||||
// Dispatch events for UI update
|
||||
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
|
||||
detail: {
|
||||
status: toggledStatus,
|
||||
fileCount: filesForBatch.length
|
||||
}
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Default callback for standard operations
|
||||
this.dropdownComponent.setOnStatusChange((statuses) => {
|
||||
// Just emit events for UI updates
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
});
|
||||
}
|
||||
|
||||
// For dropdown from editor
|
||||
if (options.editor && options.view) {
|
||||
const position = this.getCursorPosition(options.editor, options.view);
|
||||
const dummyTarget = this.createDummyTarget(position);
|
||||
this.dropdownComponent.open(dummyTarget, position);
|
||||
|
||||
// Clean up dummy target
|
||||
setTimeout(() => {
|
||||
if (dummyTarget.parentNode) {
|
||||
dummyTarget.parentNode.removeChild(dummyTarget);
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// For dropdown from toolbar button
|
||||
if (options.target) {
|
||||
if (options.position) {
|
||||
this.dropdownComponent.open(options.target, options.position);
|
||||
} else {
|
||||
const rect = options.target.getBoundingClientRect();
|
||||
const position = {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 5
|
||||
};
|
||||
this.dropdownComponent.open(options.target, position);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For direct position (context menus)
|
||||
if (options.position) {
|
||||
const dummyTarget = document.createElement('div');
|
||||
dummyTarget.addClass('note-status-dummy-target');
|
||||
dummyTarget.style.setProperty('--pos-x-px', `${options.position.x}px`);
|
||||
dummyTarget.style.setProperty('--pos-y-px', `${options.position.y}px`);
|
||||
document.body.appendChild(dummyTarget);
|
||||
|
||||
this.dropdownComponent.open(dummyTarget, options.position);
|
||||
|
||||
// Clean up dummy target
|
||||
setTimeout(() => {
|
||||
if (dummyTarget.parentNode) {
|
||||
dummyTarget.parentNode.removeChild(dummyTarget);
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to center position
|
||||
const center = {
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 3
|
||||
};
|
||||
const fallbackTarget = document.createElement('div');
|
||||
fallbackTarget.addClass('note-status-dummy-target');
|
||||
fallbackTarget.style.setProperty('--pos-x-px', `${center.x}px`);
|
||||
fallbackTarget.style.setProperty('--pos-y-px', `${center.y}px`);
|
||||
document.body.appendChild(fallbackTarget);
|
||||
|
||||
this.dropdownComponent.open(fallbackTarget, center);
|
||||
|
||||
// Clean up fallback target
|
||||
setTimeout(() => {
|
||||
if (fallbackTarget.parentNode) {
|
||||
fallbackTarget.parentNode.removeChild(fallbackTarget);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the toolbar button to the active leaf
|
||||
*/
|
||||
public addToolbarButtonToActiveLeaf(): void {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (!activeLeaf || !activeLeaf.view || !(activeLeaf.view instanceof MarkdownView)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the toolbar container
|
||||
const containerEl = activeLeaf.view.containerEl;
|
||||
const toolbarContainer = containerEl.querySelector('.view-header .view-actions');
|
||||
if (!toolbarContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if button already exists in this toolbar
|
||||
const existingButton = toolbarContainer.querySelector('.note-status-toolbar-button');
|
||||
if (existingButton) {
|
||||
this.toolbarButton = existingButton as HTMLElement;
|
||||
this.updateToolbarButton(); // Update existing button
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new button
|
||||
this.toolbarButton = document.createElement('button');
|
||||
this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action');
|
||||
this.toolbarButton.setAttribute('aria-label', 'Note status');
|
||||
|
||||
// Update the button state
|
||||
this.updateToolbarButton();
|
||||
|
||||
// Add click handler
|
||||
this.toolbarButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.toggleStatusPopover();
|
||||
});
|
||||
|
||||
// Add to toolbar at the beginning (left side) instead of the end
|
||||
if (toolbarContainer.firstChild) {
|
||||
toolbarContainer.insertBefore(this.toolbarButton, toolbarContainer.firstChild);
|
||||
} else {
|
||||
toolbarContainer.appendChild(this.toolbarButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
import { TFile, WorkspaceLeaf, View, Menu, Notice, setIcon } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
import NoteStatus from 'main';
|
||||
|
||||
/**
|
||||
* Status Pane View for managing note statuses
|
||||
*/
|
||||
export class StatusPaneView extends View {
|
||||
plugin: NoteStatus;
|
||||
searchInput: HTMLInputElement | null = null;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private paginationState: {
|
||||
itemsPerPage: number;
|
||||
currentPage: Record<string, number>;
|
||||
};
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
this.settings = plugin.settings;
|
||||
this.statusService = plugin.statusService;
|
||||
|
||||
// Initialize pagination
|
||||
this.paginationState = {
|
||||
itemsPerPage: 100, // Show 100 items per page by default
|
||||
currentPage: {} // Track current page for each status
|
||||
};
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return 'status-pane';
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return 'Status pane';
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return 'status-pane';
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await this.setupPane();
|
||||
}
|
||||
|
||||
async setupPane(): Promise<void> {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.addClass('note-status-pane', 'nav-files-container');
|
||||
|
||||
// Add a header container for better layout
|
||||
const headerContainer = containerEl.createDiv({ cls: 'note-status-header' });
|
||||
|
||||
// Search container with search input
|
||||
this.createSearchInput(headerContainer);
|
||||
|
||||
// Actions toolbar (view toggle, refresh)
|
||||
this.createActionToolbar(headerContainer);
|
||||
|
||||
// Set initial compact view state
|
||||
containerEl.toggleClass('compact-view', this.settings.compactView);
|
||||
|
||||
// Add a container for the groups
|
||||
const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' });
|
||||
|
||||
// Add loading indicator
|
||||
const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' });
|
||||
loadingIndicator.innerHTML = '<span>Loading notes...</span>';
|
||||
|
||||
// Use non-blocking render
|
||||
setTimeout(async () => {
|
||||
await this.renderGroups('');
|
||||
loadingIndicator.remove();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private createSearchInput(container: HTMLElement): void {
|
||||
const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' });
|
||||
const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' });
|
||||
|
||||
// Search icon
|
||||
const searchIcon = searchWrapper.createEl('span', { cls: 'search-input-icon' });
|
||||
setIcon(searchIcon, 'search');
|
||||
|
||||
// Create the search input
|
||||
this.searchInput = searchWrapper.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: 'Search notes...',
|
||||
cls: 'note-status-search-input search-input'
|
||||
});
|
||||
|
||||
// Add search event listener
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
this.renderGroups(this.searchInput!.value.toLowerCase());
|
||||
this.toggleClearButton();
|
||||
});
|
||||
|
||||
// Clear search button (hidden by default)
|
||||
const clearSearchBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' });
|
||||
setIcon(clearSearchBtn, 'x');
|
||||
|
||||
clearSearchBtn.addEventListener('click', () => {
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = '';
|
||||
this.renderGroups('');
|
||||
this.toggleClearButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toggleClearButton(): void {
|
||||
const clearBtn = this.containerEl.querySelector('.search-input-clear-button');
|
||||
if (clearBtn && this.searchInput) {
|
||||
clearBtn.toggleClass('is-visible', !!this.searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
private createActionToolbar(container: HTMLElement): void {
|
||||
const actionsContainer = container.createDiv({ cls: 'status-pane-actions-container' });
|
||||
|
||||
// Toggle compact view button
|
||||
const viewToggleButton = actionsContainer.createEl('button', {
|
||||
type: 'button',
|
||||
title: this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View',
|
||||
cls: 'note-status-view-toggle clickable-icon'
|
||||
});
|
||||
|
||||
setIcon(viewToggleButton, this.settings.compactView ? 'layout' : 'table');
|
||||
|
||||
viewToggleButton.addEventListener('click', async () => {
|
||||
this.settings.compactView = !this.settings.compactView;
|
||||
viewToggleButton.title = this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View';
|
||||
viewToggleButton.empty();
|
||||
setIcon(viewToggleButton, this.settings.compactView ? 'layout' : 'table');
|
||||
|
||||
// Trigger settings update
|
||||
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
|
||||
|
||||
this.containerEl.toggleClass('compact-view', this.settings.compactView);
|
||||
await this.renderGroups(this.searchInput?.value.toLowerCase() || '');
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
const refreshButton = actionsContainer.createEl('button', {
|
||||
type: 'button',
|
||||
title: 'Refresh statuses',
|
||||
cls: 'note-status-actions-refresh clickable-icon'
|
||||
});
|
||||
|
||||
setIcon(refreshButton, 'refresh-cw');
|
||||
|
||||
refreshButton.addEventListener('click', async () => {
|
||||
await this.renderGroups(this.searchInput?.value.toLowerCase() || '');
|
||||
new Notice('Status pane refreshed');
|
||||
});
|
||||
}
|
||||
|
||||
async renderGroups(searchQuery = ''): Promise<void> {
|
||||
const { containerEl } = this;
|
||||
const groupsContainer = containerEl.querySelector('.note-status-groups-container');
|
||||
if (!groupsContainer) return;
|
||||
|
||||
// Show loading indicator for non-empty search queries
|
||||
if (searchQuery) {
|
||||
groupsContainer.empty();
|
||||
const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' });
|
||||
loadingIndicator.innerHTML = `<span>Searching for "${searchQuery}"...</span>`;
|
||||
|
||||
// Let the UI update before continuing
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
} else {
|
||||
groupsContainer.empty();
|
||||
}
|
||||
|
||||
// Group files by status (with optimizations)
|
||||
const statusGroups = this.getFilteredStatusGroups(searchQuery);
|
||||
|
||||
// Remove the loading indicator if it exists
|
||||
const loadingIndicator = groupsContainer.querySelector('.note-status-loading');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.remove();
|
||||
}
|
||||
|
||||
// Render each status group
|
||||
Object.entries(statusGroups).forEach(([status, files]) => {
|
||||
if (files.length > 0) {
|
||||
// Skip unknown status if setting enabled
|
||||
if (status === 'unknown' && this.settings.excludeUnknownStatus) {
|
||||
return;
|
||||
}
|
||||
this.renderStatusGroup(groupsContainer as HTMLElement, status, files);
|
||||
}
|
||||
});
|
||||
|
||||
// If no groups were rendered, show a message
|
||||
if (groupsContainer.childElementCount === 0) {
|
||||
const emptyMessage = groupsContainer.createDiv({ cls: 'note-status-empty-indicator' });
|
||||
if (searchQuery) {
|
||||
emptyMessage.textContent = `No notes found matching "${searchQuery}"`;
|
||||
} else if (this.settings.excludeUnknownStatus) {
|
||||
// Clear any existing content and use a structured layout
|
||||
emptyMessage.empty();
|
||||
|
||||
// Add message text in its own container
|
||||
emptyMessage.createDiv({
|
||||
text: 'No notes with status found. Unassigned notes are currently hidden.',
|
||||
cls: 'note-status-empty-message'
|
||||
});
|
||||
|
||||
// Create separate container for the button
|
||||
const btnContainer = emptyMessage.createDiv({
|
||||
cls: 'note-status-button-container'
|
||||
});
|
||||
|
||||
// Add a styled button in its own container
|
||||
const showUnknownBtn = btnContainer.createEl('button', {
|
||||
text: 'Show unassigned notes',
|
||||
cls: 'note-status-show-unassigned-button'
|
||||
});
|
||||
|
||||
showUnknownBtn.addEventListener('click', async () => {
|
||||
this.settings.excludeUnknownStatus = false;
|
||||
await this.plugin.saveSettings();
|
||||
this.renderGroups(searchQuery);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized method to get status groups with filtering
|
||||
*/
|
||||
private getFilteredStatusGroups(searchQuery = ''): Record<string, TFile[]> {
|
||||
// Use the statusService but apply our own filtering for better performance
|
||||
const rawGroups = this.statusService.groupFilesByStatus(searchQuery);
|
||||
const filteredGroups: Record<string, TFile[]> = {};
|
||||
|
||||
// Filter out empty groups and respect the excludeUnknownStatus setting
|
||||
Object.entries(rawGroups).forEach(([status, files]) => {
|
||||
if (files.length > 0 && !(status === 'unknown' && this.settings.excludeUnknownStatus)) {
|
||||
filteredGroups[status] = files;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredGroups;
|
||||
}
|
||||
|
||||
private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void {
|
||||
const groupEl = container.createDiv({ cls: 'status-group nav-folder' });
|
||||
const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' });
|
||||
|
||||
// Create a container for the collapse button and title
|
||||
const collapseContainer = titleEl.createDiv({ cls: 'collapse-indicator' });
|
||||
setIcon(collapseContainer, 'chevron-down');
|
||||
|
||||
// Create a container for the title content
|
||||
const titleContentContainer = titleEl.createDiv({ cls: 'nav-folder-title-content' });
|
||||
|
||||
const statusIcon = this.statusService.getStatusIcon(status);
|
||||
titleContentContainer.createSpan({
|
||||
text: `${status} ${statusIcon} (${files.length})`,
|
||||
cls: `status-${status}`
|
||||
});
|
||||
|
||||
// Handle collapsing/expanding behavior
|
||||
const isCollapsed = this.settings.collapsedStatuses[status] ?? false;
|
||||
|
||||
if (isCollapsed) {
|
||||
groupEl.addClass('is-collapsed');
|
||||
collapseContainer.empty();
|
||||
setIcon(collapseContainer, 'chevron-right');
|
||||
}
|
||||
|
||||
titleEl.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed');
|
||||
|
||||
// Toggle the collapsed state
|
||||
if (isCurrentlyCollapsed) {
|
||||
groupEl.removeClass('is-collapsed');
|
||||
collapseContainer.empty();
|
||||
setIcon(collapseContainer, 'chevron-down');
|
||||
} else {
|
||||
groupEl.addClass('is-collapsed');
|
||||
collapseContainer.empty();
|
||||
setIcon(collapseContainer, 'chevron-right');
|
||||
}
|
||||
|
||||
// Update the settings
|
||||
this.settings.collapsedStatuses[status] = !isCurrentlyCollapsed;
|
||||
|
||||
// Trigger settings save
|
||||
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
|
||||
});
|
||||
|
||||
// Create and populate child elements
|
||||
const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' });
|
||||
|
||||
// Sort files by name
|
||||
files.sort((a, b) => a.basename.localeCompare(b.basename));
|
||||
|
||||
// Initialize pagination for this status if not already done
|
||||
if (!this.paginationState.currentPage[status]) {
|
||||
this.paginationState.currentPage[status] = 0;
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const currentPage = this.paginationState.currentPage[status];
|
||||
const itemsPerPage = this.paginationState.itemsPerPage;
|
||||
const totalPages = Math.ceil(files.length / itemsPerPage);
|
||||
const startIndex = currentPage * itemsPerPage;
|
||||
const endIndex = Math.min(startIndex + itemsPerPage, files.length);
|
||||
|
||||
// Create file list items for current page only
|
||||
const filesToRender = files.slice(startIndex, endIndex);
|
||||
filesToRender.forEach(file => {
|
||||
this.createFileListItem(childrenEl, file, status);
|
||||
});
|
||||
|
||||
// Add pagination controls if needed
|
||||
if (files.length > itemsPerPage) {
|
||||
this.addPaginationControls(childrenEl, status, currentPage, totalPages, files.length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add pagination controls to a group
|
||||
*/
|
||||
private addPaginationControls(
|
||||
container: HTMLElement,
|
||||
status: string,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
totalItems: number
|
||||
): void {
|
||||
const paginationEl = container.createDiv({ cls: 'note-status-pagination' });
|
||||
|
||||
// Add previous page button if not on first page
|
||||
if (currentPage > 0) {
|
||||
const prevButton = paginationEl.createEl('button', {
|
||||
text: 'Previous',
|
||||
cls: 'note-status-pagination-button'
|
||||
});
|
||||
|
||||
prevButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.paginationState.currentPage[status] = currentPage - 1;
|
||||
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
|
||||
});
|
||||
}
|
||||
|
||||
// Add page indicator
|
||||
paginationEl.createSpan({
|
||||
text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`,
|
||||
cls: 'note-status-pagination-info'
|
||||
});
|
||||
|
||||
// Add next page button if not on last page
|
||||
if (currentPage < totalPages - 1) {
|
||||
const nextButton = paginationEl.createEl('button', {
|
||||
text: 'Next',
|
||||
cls: 'note-status-pagination-button'
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.paginationState.currentPage[status] = currentPage + 1;
|
||||
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createFileListItem(container: HTMLElement, file: TFile, status: string): void {
|
||||
if (!file || !(file instanceof TFile)) return; // Skip if file is invalid
|
||||
|
||||
const fileEl = container.createDiv({ cls: 'nav-file' });
|
||||
const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' });
|
||||
|
||||
// Add file icon if in standard view
|
||||
if (!this.settings.compactView) {
|
||||
const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' });
|
||||
setIcon(fileIcon, 'file');
|
||||
}
|
||||
|
||||
// Add file name with proper class for styling
|
||||
fileTitleEl.createSpan({
|
||||
text: file.basename,
|
||||
cls: 'nav-file-title-content'
|
||||
});
|
||||
|
||||
// Add status indicator
|
||||
fileTitleEl.createSpan({
|
||||
cls: `note-status-icon nav-file-tag status-${status}`,
|
||||
text: this.statusService.getStatusIcon(status)
|
||||
});
|
||||
|
||||
// Add click handler to open the file
|
||||
fileEl.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.app.workspace.openLinkText(file.path, file.path, true);
|
||||
});
|
||||
|
||||
// Add context menu
|
||||
fileEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
this.showFileContextMenu(e, file);
|
||||
});
|
||||
}
|
||||
|
||||
private showFileContextMenu(e: MouseEvent, file: TFile): void {
|
||||
const menu = new Menu();
|
||||
|
||||
// Add status change options
|
||||
menu.addItem((item) =>
|
||||
item.setTitle('Change status')
|
||||
.setIcon('tag')
|
||||
.onClick(() => {
|
||||
// Use the position from the event
|
||||
this.plugin.statusContextMenu.showForFile(file, e);
|
||||
})
|
||||
);
|
||||
|
||||
// Add open options
|
||||
menu.addItem((item) =>
|
||||
item.setTitle('Open in new tab')
|
||||
.setIcon('lucide-external-link')
|
||||
.onClick(() => {
|
||||
this.app.workspace.openLinkText(file.path, file.path, 'tab');
|
||||
})
|
||||
);
|
||||
|
||||
menu.showAtMouseEvent(e);
|
||||
}
|
||||
|
||||
onClose(): Promise<void> {
|
||||
this.containerEl.empty();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update view when settings change
|
||||
*/
|
||||
updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.containerEl.toggleClass('compact-view', settings.compactView);
|
||||
|
||||
// Refresh the view with the current search query
|
||||
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
import { App, TFile, debounce, setTooltip } from 'obsidian';
|
||||
import { FileExplorerView, NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
|
||||
/**
|
||||
* Enhanced file explorer integration for displaying status icons
|
||||
* Includes performance optimizations and error handling
|
||||
*/
|
||||
export class ExplorerIntegration {
|
||||
private app: App;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private iconUpdateQueue: Set<string> = new Set();
|
||||
private isProcessingQueue = false;
|
||||
|
||||
// Debounced update function for better performance
|
||||
private debouncedUpdateAll = debounce(
|
||||
() => this.processUpdateQueue(),
|
||||
100,
|
||||
true
|
||||
);
|
||||
|
||||
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings reference and refresh icons
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
const previousShowIcons = this.settings.showStatusIconsInExplorer;
|
||||
this.settings = settings;
|
||||
|
||||
this.handleSettingsChange(previousShowIcons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings change and update UI accordingly
|
||||
*/
|
||||
private handleSettingsChange(previousShowIcons: boolean): void {
|
||||
// Update icons based on new settings
|
||||
if (previousShowIcons !== this.settings.showStatusIconsInExplorer) {
|
||||
if (this.settings.showStatusIconsInExplorer) {
|
||||
this.updateAllFileExplorerIcons();
|
||||
} else {
|
||||
this.removeAllFileExplorerIcons();
|
||||
}
|
||||
} else if (this.settings.showStatusIconsInExplorer) {
|
||||
// If setting hasn't changed but is enabled, refresh the icons
|
||||
// This handles cases where custom statuses or colors changed
|
||||
this.updateAllFileExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the file explorer view
|
||||
*/
|
||||
private findFileExplorerView(): FileExplorerView | null {
|
||||
// Try the standard method first
|
||||
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
|
||||
if (leaf && leaf.view) {
|
||||
return leaf.view as FileExplorerView;
|
||||
}
|
||||
|
||||
// If that fails, try to find it by searching all leaves
|
||||
for (const leaf of this.app.workspace.getLeavesOfType('')) {
|
||||
if (leaf.view && 'fileItems' in leaf.view) {
|
||||
return leaf.view as FileExplorerView;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the update queue
|
||||
*/
|
||||
public queueFileUpdate(file: TFile): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
|
||||
|
||||
this.iconUpdateQueue.add(file.path);
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the queue of files that need icon updates
|
||||
*/
|
||||
private async processUpdateQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
try {
|
||||
const fileExplorerView = this.findFileExplorerView();
|
||||
if (!fileExplorerView || !fileExplorerView.fileItems) {
|
||||
// Schedule retry if view not found
|
||||
setTimeout(() => this.debouncedUpdateAll(), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process files in the queue in batches to avoid blocking UI
|
||||
const batchSize = 50;
|
||||
const allPaths = Array.from(this.iconUpdateQueue);
|
||||
|
||||
for (let i = 0; i < allPaths.length; i += batchSize) {
|
||||
const batch = allPaths.slice(i, i + batchSize);
|
||||
|
||||
// Process this batch
|
||||
for (const filePath of batch) {
|
||||
const file = this.app.vault.getFileByPath(filePath);
|
||||
if (file && file instanceof TFile) {
|
||||
this.updateSingleFileIcon(file, fileExplorerView);
|
||||
}
|
||||
this.iconUpdateQueue.delete(filePath);
|
||||
}
|
||||
|
||||
// Let the UI breathe between batches
|
||||
if (i + batchSize < allPaths.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Note Status: Error processing file update queue', error);
|
||||
} finally {
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
// If more files were added while processing, process them too
|
||||
if (this.iconUpdateQueue.size > 0) {
|
||||
this.debouncedUpdateAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single file's icon in the file explorer
|
||||
*/
|
||||
private updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView): void {
|
||||
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
|
||||
|
||||
try {
|
||||
const fileItem = fileExplorerView.fileItems[file.path];
|
||||
if (!fileItem) {
|
||||
return; // File not in explorer view
|
||||
}
|
||||
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (!titleEl) {
|
||||
return; // No title element found
|
||||
}
|
||||
|
||||
// Get statuses for this file - use fresh metadata cache
|
||||
const statuses = this.statusService.getFileStatuses(file);
|
||||
|
||||
// Remove existing status icons if present (careful not to remove other elements)
|
||||
this.removeExistingIcons(titleEl);
|
||||
|
||||
// Hide unknown status if setting is enabled
|
||||
if (this.shouldHideUnknownStatus(statuses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add status icons WITHOUT disrupting the existing title
|
||||
this.addStatusIcons(titleEl, statuses);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Note Status: Error updating icon for ${file.path}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if unknown status should be hidden
|
||||
*/
|
||||
private shouldHideUnknownStatus(statuses: string[]): boolean {
|
||||
return this.settings.hideUnknownStatusInExplorer &&
|
||||
statuses.length === 1 &&
|
||||
statuses[0] === 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove existing status icons from an element
|
||||
*/
|
||||
private removeExistingIcons(element: HTMLElement): void {
|
||||
// Only select our specific status icon elements
|
||||
const existingIcons = element.querySelectorAll('.note-status-icon, .note-status-icon-container');
|
||||
|
||||
existingIcons.forEach(icon => {
|
||||
// Only remove elements with our classes to avoid removing title or other elements
|
||||
if (icon.classList.contains('note-status-icon') ||
|
||||
icon.classList.contains('note-status-icon-container')) {
|
||||
icon.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add status icons to a title element
|
||||
*/
|
||||
private addStatusIcons(titleEl: HTMLElement, statuses: string[]): void {
|
||||
// Create container for multiple icons - using createElement to avoid side effects
|
||||
const iconContainer = document.createElement('span');
|
||||
iconContainer.className = 'note-status-icon-container';
|
||||
|
||||
// Add all status icons
|
||||
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
|
||||
// Add all icons if using multiple statuses
|
||||
this.addMultipleStatusIcons(iconContainer, statuses);
|
||||
} else {
|
||||
// Just show primary status
|
||||
this.addPrimaryStatusIcon(iconContainer, statuses);
|
||||
}
|
||||
|
||||
// Only append if we added icons
|
||||
if (iconContainer.childElementCount > 0) {
|
||||
// Use appendChild to add to the end instead of replacing content
|
||||
titleEl.appendChild(iconContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple status icons to a container
|
||||
*/
|
||||
private addMultipleStatusIcons(container: HTMLElement, statuses: string[]): void {
|
||||
statuses.forEach(status => {
|
||||
this.addSingleStatusIcon(container, status);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add primary status icon to a container
|
||||
*/
|
||||
private addPrimaryStatusIcon(container: HTMLElement, statuses: string[]): void {
|
||||
const primaryStatus = statuses[0] || 'unknown';
|
||||
if (primaryStatus !== 'unknown' || !this.settings.autoHideStatusBar) {
|
||||
this.addSingleStatusIcon(container, primaryStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single status icon
|
||||
*/
|
||||
private addSingleStatusIcon(container: HTMLElement, status: string): void {
|
||||
// Create icon element instead of modifying container directly
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
|
||||
iconEl.textContent = this.statusService.getStatusIcon(status);
|
||||
|
||||
// Add tooltip with status name
|
||||
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
|
||||
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
|
||||
setTooltip(iconEl, tooltipValue);
|
||||
|
||||
// Append to container
|
||||
container.appendChild(iconEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single file's icon in the file explorer (public method)
|
||||
*/
|
||||
public updateFileExplorerIcons(file: TFile): void {
|
||||
if (!file || !this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
|
||||
|
||||
// Add direct immediate update for critical files (like active file)
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
const isActiveFile = activeFile && activeFile.path === file.path;
|
||||
|
||||
// For active file, update immediately and also queue
|
||||
if (isActiveFile) {
|
||||
this.updateSingleFileIconDirectly(file);
|
||||
}
|
||||
|
||||
// Also queue update for normal processing
|
||||
this.queueFileUpdate(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single file icon directly (for immediate updates)
|
||||
*/
|
||||
private updateSingleFileIconDirectly(file: TFile): void {
|
||||
try {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer) return;
|
||||
|
||||
this.updateSingleFileIcon(file, fileExplorer);
|
||||
} catch (error) {
|
||||
console.error('Note Status: Error updating file icon directly', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all file icons in the explorer - with performance optimizations
|
||||
*/
|
||||
public updateAllFileExplorerIcons(): void {
|
||||
// Remove all icons if setting is turned off
|
||||
if (!this.settings.showStatusIconsInExplorer) {
|
||||
this.removeAllFileExplorerIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Process files in batches
|
||||
const processFilesInBatches = async () => {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const batchSize = 100; // Process 100 files at a time
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize);
|
||||
batch.forEach(file => this.queueFileUpdate(file));
|
||||
|
||||
// Let the UI breathe between batches
|
||||
if (i + batchSize < files.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start processing
|
||||
processFilesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all status icons from the file explorer
|
||||
*/
|
||||
public removeAllFileExplorerIcons(): void {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer || !fileExplorer.fileItems) return;
|
||||
|
||||
// Use Object.values to avoid issues with concurrent modification
|
||||
Object.values(fileExplorer.fileItems).forEach((fileItem) => {
|
||||
const titleEl = fileItem.titleEl || fileItem.selfEl;
|
||||
if (!titleEl) return;
|
||||
|
||||
this.removeExistingIcons(titleEl);
|
||||
});
|
||||
|
||||
// Clear the update queue
|
||||
this.iconUpdateQueue.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected files from the file explorer
|
||||
*/
|
||||
public getSelectedFiles(): TFile[] {
|
||||
const fileExplorer = this.findFileExplorerView();
|
||||
if (!fileExplorer || !fileExplorer.fileItems) return [];
|
||||
|
||||
const selectedFiles: TFile[] = [];
|
||||
|
||||
Object.entries(fileExplorer.fileItems).forEach(([_, item]) => {
|
||||
if (item.el?.classList.contains('is-selected') &&
|
||||
item.file && item.file instanceof TFile &&
|
||||
item.file.extension === 'md') {
|
||||
selectedFiles.push(item.file);
|
||||
}
|
||||
});
|
||||
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when plugin is unloaded
|
||||
*/
|
||||
public unload(): void {
|
||||
this.removeAllFileExplorerIcons();
|
||||
this.debouncedUpdateAll.cancel();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import { App, Menu, TFile } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
import { StatusDropdown } from 'ui/components/status-dropdown';
|
||||
import { ExplorerIntegration } from 'ui/integrations/explorer-integration';
|
||||
|
||||
/**
|
||||
* Handles context menu interactions for status changes
|
||||
*/
|
||||
export class StatusContextMenu {
|
||||
private statusDropdown: StatusDropdown;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private explorerIntegration: ExplorerIntegration;
|
||||
public app: App;
|
||||
|
||||
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService, statusDropdown: StatusDropdown, explorerIntegration:ExplorerIntegration) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.statusService = statusService;
|
||||
this.statusDropdown = statusDropdown;
|
||||
this.explorerIntegration = explorerIntegration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings reference
|
||||
*/
|
||||
public updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the context menu for changing a status of one or more files
|
||||
*/
|
||||
public showForFiles(files: TFile[], position?: { x: number; y: number }): void {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// For a single file, show dropdown directly
|
||||
if (files.length === 1) {
|
||||
this.showSingleFileDropdown(files[0], position);
|
||||
return;
|
||||
}
|
||||
|
||||
// For multiple files, show menu first
|
||||
this.showMultipleFilesMenu(files, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show dropdown for a single file
|
||||
*/
|
||||
private showSingleFileDropdown(file: TFile, position?: { x: number; y: number }): void {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position: position,
|
||||
files: [file],
|
||||
onStatusChange: async (statuses) => {
|
||||
if (statuses.length > 0) {
|
||||
await this.handleStatusUpdateForFile(file, statuses);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show menu for multiple files
|
||||
*/
|
||||
private showMultipleFilesMenu(files: TFile[], position?: { x: number; y: number }): void {
|
||||
const menu = new Menu();
|
||||
|
||||
// Add title section
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Update ${files.length} files`)
|
||||
.setDisabled(true);
|
||||
return item;
|
||||
});
|
||||
|
||||
// Simplified toggle-based approach for multiple files
|
||||
menu.addItem((item) =>
|
||||
item
|
||||
.setTitle('Manage statuses...')
|
||||
.setIcon('tag')
|
||||
.onClick(() => {
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position: position,
|
||||
files: files
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Show the menu
|
||||
if (position) {
|
||||
menu.showAtPosition(position);
|
||||
} else {
|
||||
menu.showAtMouseEvent(new MouseEvent('contextmenu'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a context menu for a single file
|
||||
*/
|
||||
public showForFile(file: TFile, event: MouseEvent): void {
|
||||
// Ensure file is a valid TFile instance before proceeding
|
||||
if (!(file instanceof TFile) || file.extension !== 'md') {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = { x: event.clientX, y: event.clientY };
|
||||
|
||||
this.statusDropdown.openStatusDropdown({
|
||||
position: position,
|
||||
files: [file],
|
||||
onStatusChange: async (statuses) => {
|
||||
if (statuses.length > 0) {
|
||||
await this.handleStatusUpdateForFile(file, statuses);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status update for a specific file
|
||||
*/
|
||||
private async handleStatusUpdateForFile(file: TFile, statuses: string[]): Promise<void> {
|
||||
// Add instanceof check for safety
|
||||
if (!(file instanceof TFile) || file.extension !== 'md' || statuses.length === 0) return;
|
||||
|
||||
// Update the file with the selected status
|
||||
if (this.settings.useMultipleStatuses) {
|
||||
// If the status is already applied, toggle it off
|
||||
const currentStatuses = this.statusService.getFileStatuses(file);
|
||||
if (currentStatuses.includes(statuses[0])) {
|
||||
await this.statusService.removeNoteStatus(statuses[0], file);
|
||||
} else {
|
||||
// Add the status
|
||||
await this.statusService.addNoteStatus(statuses[0], file);
|
||||
}
|
||||
} else {
|
||||
// Replace with the single status - use the direct method, not batch
|
||||
await this.statusService.updateNoteStatuses([statuses[0]], file);
|
||||
}
|
||||
|
||||
// Force explorer icon update for this specific file
|
||||
this.app.metadataCache.trigger('changed', file);
|
||||
|
||||
// Directly update the explorer icon
|
||||
this.updateExplorerIcon(file);
|
||||
|
||||
// Dispatch events to update other UI components
|
||||
this.triggerUIUpdates(statuses, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update explorer icon for a file
|
||||
*/
|
||||
private updateExplorerIcon(file: TFile): void {
|
||||
setTimeout(() => {
|
||||
if (this.explorerIntegration) {
|
||||
this.explorerIntegration.updateFileExplorerIcons(file);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger UI updates after status changes
|
||||
*/
|
||||
private triggerUIUpdates(statuses: string[], file: TFile): void {
|
||||
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
|
||||
detail: { statuses, file: file.path }
|
||||
}));
|
||||
|
||||
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
|
||||
|
||||
// Force a full UI refresh to ensure all elements are updated
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
2
views/status-pane-view/index.ts
Normal file
2
views/status-pane-view/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { StatusPaneView } from './status-pane-view';
|
||||
export { StatusPaneViewController } from './status-pane-view-controller';
|
||||
170
views/status-pane-view/status-pane-view-controller.ts
Normal file
170
views/status-pane-view/status-pane-view-controller.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { TFile, WorkspaceLeaf, View, Notice, App } from 'obsidian';
|
||||
import { NoteStatusSettings } from '../../models/types';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
import NoteStatus from 'main';
|
||||
import { StatusPaneView } from './status-pane-view';
|
||||
|
||||
export class StatusPaneViewController extends View {
|
||||
private renderer: StatusPaneView;
|
||||
private settings: NoteStatusSettings;
|
||||
private statusService: StatusService;
|
||||
private plugin: NoteStatus;
|
||||
private searchQuery = '';
|
||||
private paginationState = {
|
||||
itemsPerPage: 100,
|
||||
currentPage: {} as Record<string, number>
|
||||
};
|
||||
|
||||
static async open(app: App): Promise<void> {
|
||||
const existing = app.workspace.getLeavesOfType('status-pane')[0];
|
||||
if (existing) {
|
||||
app.workspace.setActiveLeaf(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const leaf = app.workspace.getLeftLeaf(false);
|
||||
if (leaf) {
|
||||
await leaf.setViewState({ type: 'status-pane', active: true });
|
||||
}
|
||||
}
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
this.settings = plugin.settings;
|
||||
this.statusService = plugin.statusService;
|
||||
this.renderer = new StatusPaneView(this.statusService);
|
||||
}
|
||||
|
||||
getViewType(): string { return 'status-pane'; }
|
||||
|
||||
getDisplayText(): string { return 'Status pane'; }
|
||||
|
||||
getIcon(): string { return 'tag'; }
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await this.setupPane();
|
||||
}
|
||||
|
||||
onClose(): Promise<void> {
|
||||
this.containerEl.empty();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
private async setupPane(): Promise<void> {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.addClass('note-status-pane', 'nav-files-container');
|
||||
containerEl.toggleClass('note-status-compact-view', this.settings.compactView);
|
||||
|
||||
this.renderer.createHeader(containerEl, this.settings.compactView, {
|
||||
onSearch: (query) => {
|
||||
this.paginationState = {
|
||||
itemsPerPage: 100,
|
||||
currentPage: {} as Record<string, number>
|
||||
}
|
||||
this.searchQuery = query;
|
||||
this.renderGroups(query);
|
||||
},
|
||||
onToggleView: () => {
|
||||
this.settings.compactView = !this.settings.compactView;
|
||||
containerEl.toggleClass('note-status-compact-view', this.settings.compactView);
|
||||
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
|
||||
this.renderGroups(this.searchQuery);
|
||||
},
|
||||
onRefresh: async () => {
|
||||
await this.renderGroups(this.searchQuery);
|
||||
new Notice('Status pane refreshed');
|
||||
}
|
||||
});
|
||||
|
||||
const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' });
|
||||
const loadingIndicator = this.renderer.createLoadingIndicator(groupsContainer);
|
||||
|
||||
setTimeout(async () => {
|
||||
await this.renderGroups('');
|
||||
loadingIndicator.remove();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private async renderGroups(searchQuery = ''): Promise<void> {
|
||||
const groupsContainerEl = this.containerEl.querySelector('.note-status-groups-container') as HTMLElement;
|
||||
if (!groupsContainerEl) return;
|
||||
|
||||
if (searchQuery) {
|
||||
groupsContainerEl.empty();
|
||||
this.renderer.createLoadingIndicator(groupsContainerEl, `Searching for "${searchQuery}"...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
} else {
|
||||
groupsContainerEl.empty();
|
||||
}
|
||||
|
||||
const statusGroups = this.getFilteredStatusGroups(searchQuery);
|
||||
groupsContainerEl.empty();
|
||||
|
||||
const hasGroups = this.renderer.renderStatusGroups(
|
||||
groupsContainerEl,
|
||||
statusGroups,
|
||||
{
|
||||
excludeUnknown: this.settings.excludeUnknownStatus,
|
||||
isCompactView: this.settings.compactView,
|
||||
collapsedStatuses: this.settings.collapsedStatuses,
|
||||
pagination: this.paginationState,
|
||||
callbacks: {
|
||||
onFileClick: (file) => {
|
||||
this.app.workspace.openLinkText(file.path, file.path, true);
|
||||
},
|
||||
onStatusToggle: (status, collapsed) => {
|
||||
this.settings.collapsedStatuses[status] = collapsed;
|
||||
},
|
||||
onContextMenu: (e, file) => {
|
||||
},
|
||||
onPageChange: (status, page) => {
|
||||
this.paginationState.currentPage[status] = page;
|
||||
this.renderGroups(this.searchQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!hasGroups) {
|
||||
this.renderer.renderEmptyState(
|
||||
groupsContainerEl,
|
||||
searchQuery,
|
||||
this.settings.excludeUnknownStatus,
|
||||
async () => {
|
||||
this.settings.excludeUnknownStatus = false;
|
||||
await this.plugin.saveSettings();
|
||||
this.renderGroups(searchQuery);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getFilteredStatusGroups(searchQuery = ''): Record<string, TFile[]> {
|
||||
const rawGroups = this.statusService.groupFilesByStatus(searchQuery);
|
||||
const filteredGroups: Record<string, TFile[]> = {};
|
||||
|
||||
Object.entries(rawGroups).forEach(([status, files]) => {
|
||||
if (files.length > 0) {
|
||||
filteredGroups[status] = files;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredGroups;
|
||||
}
|
||||
|
||||
updateSettings(settings: NoteStatusSettings): void {
|
||||
this.settings = settings;
|
||||
this.containerEl.toggleClass('note-status-compact-view', settings.compactView);
|
||||
this.renderGroups(this.searchQuery);
|
||||
}
|
||||
|
||||
public update(): void {
|
||||
// TODO: needs to be improved/fixed, every change
|
||||
// this.renderGroups(this.searchQuery).then(() => {
|
||||
//
|
||||
// });
|
||||
}
|
||||
}
|
||||
245
views/status-pane-view/status-pane-view.ts
Normal file
245
views/status-pane-view/status-pane-view.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { TFile, setIcon } from 'obsidian';
|
||||
import { StatusService } from '../../services/status-service';
|
||||
|
||||
export class StatusPaneView{
|
||||
constructor(private statusService: StatusService) {}
|
||||
|
||||
createHeader(container: HTMLElement, isCompactView: boolean, callbacks: {
|
||||
onSearch: (query: string) => void,
|
||||
onToggleView: () => void,
|
||||
onRefresh: () => void
|
||||
}): HTMLElement {
|
||||
const header = container.createDiv({ cls: 'note-status-header' });
|
||||
|
||||
// Search input
|
||||
const searchContainer = header.createDiv({ cls: 'note-status-search search-input-container' });
|
||||
const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' });
|
||||
|
||||
const searchIcon = searchWrapper.createEl('span', { cls: 'search-input-icon' });
|
||||
setIcon(searchIcon, 'search');
|
||||
|
||||
const searchInput = searchWrapper.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: 'Search notes...',
|
||||
cls: 'note-status-search-input search-input'
|
||||
});
|
||||
|
||||
const clearBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' });
|
||||
setIcon(clearBtn, 'x');
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
callbacks.onSearch(searchInput.value.toLowerCase());
|
||||
clearBtn.toggleClass('is-visible', !!searchInput.value);
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
callbacks.onSearch('');
|
||||
clearBtn.toggleClass('is-visible', false);
|
||||
});
|
||||
|
||||
// Action buttons
|
||||
const actions = header.createDiv({ cls: 'status-pane-actions-container' });
|
||||
|
||||
const viewBtn = actions.createEl('button', {
|
||||
type: 'button',
|
||||
title: isCompactView ? 'Switch to Standard View' : 'Switch to Compact View',
|
||||
cls: 'note-status-view-toggle clickable-icon'
|
||||
});
|
||||
setIcon(viewBtn, isCompactView ? 'layout' : 'table');
|
||||
viewBtn.addEventListener('click', callbacks.onToggleView);
|
||||
|
||||
const refreshBtn = actions.createEl('button', {
|
||||
type: 'button',
|
||||
title: 'Refresh statuses',
|
||||
cls: 'note-status-actions-refresh clickable-icon'
|
||||
});
|
||||
setIcon(refreshBtn, 'refresh-cw');
|
||||
refreshBtn.addEventListener('click', callbacks.onRefresh);
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
renderStatusGroups(container: HTMLElement, statusGroups: Record<string, TFile[]>, options: {
|
||||
excludeUnknown: boolean,
|
||||
isCompactView: boolean,
|
||||
collapsedStatuses: Record<string, boolean>,
|
||||
pagination: {
|
||||
itemsPerPage: number,
|
||||
currentPage: Record<string, number>
|
||||
},
|
||||
callbacks: {
|
||||
onFileClick: (file: TFile) => void,
|
||||
onStatusToggle: (status: string, collapsed: boolean) => void,
|
||||
onContextMenu: (e: MouseEvent, file: TFile) => void,
|
||||
onPageChange: (status: string, page: number) => void
|
||||
}
|
||||
}): boolean {
|
||||
// Clear container first
|
||||
container.empty();
|
||||
|
||||
let hasGroups = false;
|
||||
|
||||
Object.entries(statusGroups).forEach(([status, files]) => {
|
||||
if (files.length > 0 && !(status === 'unknown' && options.excludeUnknown)) {
|
||||
this.renderGroup(container, status, files, options);
|
||||
hasGroups = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasGroups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private renderGroup(container: HTMLElement, status: string, files: TFile[], options: any): void {
|
||||
const groupEl = container.createDiv({ cls: 'note-status-group nav-folder' });
|
||||
const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' });
|
||||
const isCollapsed = options.collapsedStatuses[status] ?? false;
|
||||
|
||||
// Collapse indicator
|
||||
const collapseIcon = titleEl.createDiv({ cls: 'note-status-collapse-indicator' });
|
||||
setIcon(collapseIcon, isCollapsed ? 'chevron-right' : 'chevron-down');
|
||||
|
||||
// Title content
|
||||
const titleContent = titleEl.createDiv({ cls: 'nav-folder-title-content' });
|
||||
const statusIcon = this.statusService.getStatusIcon(status);
|
||||
titleContent.createSpan({
|
||||
text: `${status} ${statusIcon} (${files.length})`,
|
||||
cls: `status-${status}`
|
||||
});
|
||||
|
||||
// Set collapse state
|
||||
if (isCollapsed) {
|
||||
groupEl.addClass('note-status-is-collapsed');
|
||||
}
|
||||
|
||||
// Toggle collapse on click
|
||||
titleEl.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const newCollapsed = !groupEl.hasClass('note-status-is-collapsed');
|
||||
groupEl.toggleClass('note-status-is-collapsed', newCollapsed);
|
||||
collapseIcon.empty();
|
||||
setIcon(collapseIcon, newCollapsed ? 'chevron-right' : 'chevron-down');
|
||||
options.callbacks.onStatusToggle(status, newCollapsed);
|
||||
});
|
||||
|
||||
// Render content
|
||||
const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' });
|
||||
|
||||
// Pagination
|
||||
const currentPage = options.pagination.currentPage[status] || 0;
|
||||
const itemsPerPage = options.pagination.itemsPerPage;
|
||||
const totalPages = Math.ceil(files.length / itemsPerPage);
|
||||
const startIndex = currentPage * itemsPerPage;
|
||||
const endIndex = Math.min(startIndex + itemsPerPage, files.length);
|
||||
|
||||
// File items
|
||||
files.slice(startIndex, endIndex).forEach(file => {
|
||||
this.renderFileItem(childrenEl, file, status, options.isCompactView, options.callbacks);
|
||||
});
|
||||
|
||||
// Add pagination if needed
|
||||
if (files.length > itemsPerPage) {
|
||||
this.addPagination(childrenEl, status, currentPage, totalPages, files.length, options.callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFileItem(container: HTMLElement, file: TFile, status: string, isCompactView: boolean, callbacks: any): void {
|
||||
const fileEl = container.createDiv({ cls: 'nav-file' });
|
||||
const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' });
|
||||
|
||||
if (!isCompactView) {
|
||||
const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' });
|
||||
setIcon(fileIcon, 'file');
|
||||
}
|
||||
|
||||
fileTitleEl.createSpan({
|
||||
text: file.basename,
|
||||
cls: 'nav-file-title-content'
|
||||
});
|
||||
|
||||
fileTitleEl.createSpan({
|
||||
cls: `note-status-icon nav-file-tag status-${status}`,
|
||||
text: this.statusService.getStatusIcon(status)
|
||||
});
|
||||
|
||||
fileEl.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
callbacks.onFileClick(file);
|
||||
});
|
||||
|
||||
fileEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
callbacks.onContextMenu(e, file);
|
||||
});
|
||||
}
|
||||
|
||||
private addPagination(container: HTMLElement, status: string, currentPage: number, totalPages: number, totalItems: number, callbacks: any): void {
|
||||
const paginationEl = container.createDiv({ cls: 'note-status-pagination' });
|
||||
|
||||
if (currentPage > 0) {
|
||||
const prevButton = paginationEl.createEl('button', {
|
||||
text: 'Previous',
|
||||
cls: 'note-status-pagination-button'
|
||||
});
|
||||
|
||||
prevButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
callbacks.onPageChange(status, currentPage - 1);
|
||||
});
|
||||
}
|
||||
|
||||
paginationEl.createSpan({
|
||||
text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`,
|
||||
cls: 'note-status-pagination-info'
|
||||
});
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
const nextButton = paginationEl.createEl('button', {
|
||||
text: 'Next',
|
||||
cls: 'note-status-pagination-button'
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
callbacks.onPageChange(status, currentPage + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderEmptyState(container: HTMLElement, searchQuery: string, excludeUnknown: boolean, onShowUnassigned: () => void): void {
|
||||
const emptyMessage = container.createDiv({ cls: 'note-status-empty-indicator' });
|
||||
|
||||
if (searchQuery) {
|
||||
emptyMessage.textContent = `No notes found matching "${searchQuery}"`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (excludeUnknown) {
|
||||
emptyMessage.createDiv({
|
||||
text: 'No notes with status found. Unassigned notes are currently hidden.',
|
||||
cls: 'note-status-empty-message'
|
||||
});
|
||||
|
||||
const btnContainer = emptyMessage.createDiv({
|
||||
cls: 'note-status-button-container'
|
||||
});
|
||||
|
||||
const showUnknownBtn = btnContainer.createEl('button', {
|
||||
text: 'Show unassigned notes',
|
||||
cls: 'note-status-show-unassigned-button'
|
||||
});
|
||||
|
||||
showUnknownBtn.addEventListener('click', onShowUnassigned);
|
||||
}
|
||||
}
|
||||
|
||||
createLoadingIndicator(container: HTMLElement, text?: string): HTMLElement {
|
||||
const loadingIndicator = container.createDiv({ cls: 'note-status-loading' });
|
||||
loadingIndicator.innerHTML = `<span>${text || 'Loading notes...'}</span>`;
|
||||
return loadingIndicator;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue