feat: update minimal capture

This commit is contained in:
quorafind 2025-07-17 15:35:41 +08:00
parent c28eef166b
commit e2a8b23d79
22 changed files with 15709 additions and 2195 deletions

View file

@ -0,0 +1,262 @@
import { App, Editor, EditorPosition, EditorSuggestContext } from "obsidian";
import { MinimalQuickCaptureSuggest } from "../components/MinimalQuickCaptureSuggest";
import TaskProgressBarPlugin from "../index";
// Mock Obsidian modules
jest.mock("obsidian", () => ({
App: jest.fn(),
Editor: jest.fn(),
EditorPosition: jest.fn(),
EditorSuggest: class {
constructor() {}
},
setIcon: jest.fn(),
}));
// Mock plugin
const mockPlugin = {
app: {
workspace: {
getLastOpenFiles: () => ["test1.md", "test2.md"],
},
metadataCache: {
getTags: () => ({
"#work": 5,
"#personal": 3,
}),
},
},
settings: {
quickCapture: {
minimalModeSettings: {
suggestTrigger: "/",
},
},
preferMetadataFormat: "tasks",
},
} as any as TaskProgressBarPlugin;
describe("Backward Compatibility Tests", () => {
let suggest: MinimalQuickCaptureSuggest;
let app: App;
beforeEach(() => {
app = new App();
suggest = new MinimalQuickCaptureSuggest(app, mockPlugin);
});
test("should maintain original MinimalQuickCaptureSuggest interface", () => {
// Test that all original methods exist
expect(typeof suggest.setMinimalMode).toBe("function");
expect(typeof suggest.onTrigger).toBe("function");
expect(typeof suggest.getSuggestions).toBe("function");
expect(typeof suggest.renderSuggestion).toBe("function");
expect(typeof suggest.selectSuggestion).toBe("function");
});
test("should handle legacy @ trigger mapping to * for target", () => {
suggest.setMinimalMode(true);
const mockContext: EditorSuggestContext = {
query: "@",
start: { line: 0, ch: 0 },
end: { line: 0, ch: 1 },
editor: {} as Editor,
};
const suggestions = suggest.getSuggestions(mockContext);
// Should return target suggestions when @ is used
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions.some(s => s.id.includes("target") || s.replacement === "*")).toBe(true);
});
test("should provide fallback suggestions when new system returns empty", () => {
suggest.setMinimalMode(true);
// Test with an unknown trigger that would return empty from new system
const mockContext: EditorSuggestContext = {
query: "unknown",
start: { line: 0, ch: 0 },
end: { line: 0, ch: 1 },
editor: {} as Editor,
};
const suggestions = suggest.getSuggestions(mockContext);
// Should return fallback suggestions
expect(suggestions.length).toBe(4); // date, priority, target, tag
expect(suggestions.map(s => s.id)).toEqual(["date", "priority", "target", "tag"]);
});
test("should handle selectSuggestion with both new and legacy actions", () => {
const mockEditor = {
replaceRange: jest.fn(),
setCursor: jest.fn(),
} as any as Editor;
const mockCursor = { line: 0, ch: 1 };
// Mock the context
(suggest as any).context = {
editor: mockEditor,
end: mockCursor,
};
// Test with new system suggestion (has action)
const newSuggestion = {
id: "priority-high",
label: "High Priority",
icon: "arrow-up",
description: "High priority task",
replacement: "! ⏫",
trigger: "!",
action: jest.fn(),
};
suggest.selectSuggestion(newSuggestion, {} as MouseEvent);
expect(mockEditor.replaceRange).toHaveBeenCalledWith("! ⏫", { line: 0, ch: 0 }, { line: 0, ch: 1 });
expect(mockEditor.setCursor).toHaveBeenCalledWith({ line: 0, ch: 4 });
expect(newSuggestion.action).toHaveBeenCalledWith(mockEditor, { line: 0, ch: 4 });
});
test("should handle legacy modal-based actions", () => {
const mockEditor = {
replaceRange: jest.fn(),
setCursor: jest.fn(),
} as any as Editor;
const mockCursor = { line: 0, ch: 1 };
// Mock the context
(suggest as any).context = {
editor: mockEditor,
end: mockCursor,
};
// Mock the modal element and modal instance
const mockModal = {
showDatePickerAtCursor: jest.fn(),
showPriorityMenuAtCursor: jest.fn(),
showLocationMenuAtCursor: jest.fn(),
showTagSelectorAtCursor: jest.fn(),
};
const mockModalEl = {
closest: jest.fn().mockReturnValue({
__minimalQuickCaptureModal: mockModal,
}),
};
const mockEditorEl = {
cm: {
dom: mockModalEl,
},
coordsAtPos: jest.fn().mockReturnValue({ left: 100, top: 200 }),
};
(mockEditor as any).cm = { dom: mockModalEl };
(mockEditor as any).coordsAtPos = mockEditorEl.coordsAtPos;
// Test legacy date suggestion
const dateSuggestion = {
id: "date",
label: "Date",
icon: "calendar",
description: "Add date",
replacement: "~",
};
suggest.selectSuggestion(dateSuggestion, {} as MouseEvent);
expect(mockEditor.replaceRange).toHaveBeenCalledWith("~", { line: 0, ch: 0 }, { line: 0, ch: 1 });
expect(mockModal.showDatePickerAtCursor).toHaveBeenCalled();
});
test("should maintain original trigger character behavior", () => {
const mockEditor = {
getLine: jest.fn().mockReturnValue("test /"),
} as any as Editor;
const mockFile = {} as any;
const mockCursor = { line: 0, ch: 6 };
// Mock minimal mode context
(mockEditor as any).cm = {
dom: {
closest: jest.fn().mockReturnValue({}),
},
};
suggest.setMinimalMode(true);
const triggerInfo = suggest.onTrigger(mockCursor, mockEditor, mockFile);
expect(triggerInfo).toEqual({
start: { line: 0, ch: 5 },
end: { line: 0, ch: 6 },
query: "/",
});
});
test("should handle disabled state correctly", () => {
const mockEditor = {} as Editor;
const mockFile = {} as any;
const mockCursor = { line: 0, ch: 1 };
// When not in minimal mode, should return null
suggest.setMinimalMode(false);
const triggerInfo = suggest.onTrigger(mockCursor, mockEditor, mockFile);
expect(triggerInfo).toBeNull();
});
test("should render suggestions with correct DOM structure", () => {
const mockEl = {
addClass: jest.fn(),
createDiv: jest.fn().mockReturnValue({
createDiv: jest.fn(),
}),
} as any as HTMLElement;
const suggestion = {
id: "test",
label: "Test Label",
icon: "star",
description: "Test Description",
replacement: "test",
};
suggest.renderSuggestion(suggestion, mockEl);
expect(mockEl.addClass).toHaveBeenCalledWith("menu-item");
expect(mockEl.addClass).toHaveBeenCalledWith("tappable");
expect(mockEl.createDiv).toHaveBeenCalledWith("menu-item-icon");
});
test("should integrate with new suggest system while maintaining compatibility", () => {
suggest.setMinimalMode(true);
// Test all original trigger characters
const triggerChars = ["!", "~", "#"];
for (const trigger of triggerChars) {
const mockContext: EditorSuggestContext = {
query: trigger,
start: { line: 0, ch: 0 },
end: { line: 0, ch: 1 },
editor: {} as Editor,
};
const suggestions = suggest.getSuggestions(mockContext);
expect(suggestions.length).toBeGreaterThan(0);
// Should have suggestions that match the trigger
const hasMatchingSuggestion = suggestions.some(s =>
s.replacement.includes(trigger) || s.trigger === trigger
);
expect(hasMatchingSuggestion).toBe(true);
}
});
});

View file

@ -0,0 +1,243 @@
import { App, Editor, TFile } from "obsidian";
import { SuggestManager, UniversalEditorSuggest } from "../components/suggest";
import TaskProgressBarPlugin from "../index";
import { getSuggestOptionsByTrigger } from "../components/suggest/SpecialCharacterSuggests";
// Mock Obsidian modules
jest.mock("obsidian", () => ({
App: jest.fn(),
Editor: jest.fn(),
TFile: jest.fn(),
EditorSuggest: class {
constructor() {}
},
setIcon: jest.fn(),
}));
// Mock plugin with realistic data
const mockPlugin = {
app: {
workspace: {
getLastOpenFiles: () => [
"file1.md",
"file2.md",
"file3.md",
"file4.md",
"file5.md",
],
},
metadataCache: {
getTags: () => ({
"#work": 10,
"#personal": 8,
"#urgent": 5,
"#important": 7,
"#project": 12,
"#meeting": 3,
"#todo": 15,
"#review": 4,
}),
},
},
settings: {
preferMetadataFormat: "tasks",
},
} as any as TaskProgressBarPlugin;
describe("Suggest Performance Tests", () => {
let app: App;
let manager: SuggestManager;
beforeEach(() => {
app = new App();
(app as any).workspace = {
editorSuggest: {
suggests: [],
},
};
manager = new SuggestManager(app, mockPlugin);
});
afterEach(() => {
manager.cleanup();
});
test("should handle rapid suggest creation and destruction", () => {
const startTime = performance.now();
manager.startManaging();
// Create and destroy 100 suggests rapidly
for (let i = 0; i < 100; i++) {
const suggest = manager.createUniversalSuggest(`test-${i}`);
suggest.enable();
manager.removeManagedSuggest(`universal-test-${i}`);
}
manager.stopManaging();
const endTime = performance.now();
const duration = endTime - startTime;
// Should complete within reasonable time (adjust threshold as needed)
expect(duration).toBeLessThan(1000); // 1 second
expect(manager.getActiveSuggests().size).toBe(0);
});
test("should efficiently generate suggestions for all trigger characters", () => {
const triggerChars = ["!", "~", "*", "#"];
const iterations = 1000;
const startTime = performance.now();
for (let i = 0; i < iterations; i++) {
for (const trigger of triggerChars) {
const suggestions = getSuggestOptionsByTrigger(trigger, mockPlugin);
expect(suggestions.length).toBeGreaterThan(0);
}
}
const endTime = performance.now();
const duration = endTime - startTime;
// Should generate suggestions efficiently
expect(duration).toBeLessThan(500); // 500ms for 4000 operations
console.log(`Generated ${iterations * triggerChars.length} suggestions in ${duration}ms`);
});
test("should handle large number of active suggests", () => {
manager.startManaging();
const startTime = performance.now();
// Create 50 active suggests
const suggests: UniversalEditorSuggest[] = [];
for (let i = 0; i < 50; i++) {
const suggest = manager.createUniversalSuggest(`bulk-test-${i}`);
suggest.enable();
suggests.push(suggest);
}
expect(manager.getActiveSuggests().size).toBe(50);
// Cleanup all at once
manager.removeAllManagedSuggests();
const endTime = performance.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(100); // Should be very fast
expect(manager.getActiveSuggests().size).toBe(0);
console.log(`Managed 50 suggests in ${duration}ms`);
});
test("should efficiently handle context filtering", () => {
const mockEditor = {} as Editor;
const mockFile = {} as TFile;
// Create context filters
const filters = [];
for (let i = 0; i < 100; i++) {
const filter = (editor: Editor, file: TFile) => {
// Simulate some filtering logic
return editor === mockEditor && file === mockFile;
};
manager.addContextFilter(`filter-${i}`, filter);
filters.push(filter);
}
const startTime = performance.now();
// Test context filtering performance
for (let i = 0; i < 1000; i++) {
const suggest = manager.createUniversalSuggest(`context-test-${i % 10}`, {
contextFilter: filters[i % filters.length],
});
// Simulate context check
const config = suggest.getConfig();
if (config.contextFilter) {
config.contextFilter(mockEditor, mockFile);
}
}
const endTime = performance.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(200); // Should be reasonably fast
console.log(`Context filtering test completed in ${duration}ms`);
// Cleanup
for (let i = 0; i < 100; i++) {
manager.removeContextFilter(`filter-${i}`);
}
});
test("should handle memory efficiently during suggest lifecycle", () => {
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0;
manager.startManaging();
// Create and destroy suggests in cycles
for (let cycle = 0; cycle < 10; cycle++) {
// Create 20 suggests
for (let i = 0; i < 20; i++) {
const suggest = manager.createUniversalSuggest(`memory-test-${cycle}-${i}`);
suggest.enable();
}
// Remove all suggests
manager.removeAllManagedSuggests();
}
manager.stopManaging();
// Force garbage collection if available
if (global.gc) {
global.gc();
}
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0;
const memoryDiff = finalMemory - initialMemory;
// Memory usage should not grow significantly
// This is a rough check - actual values depend on environment
if (initialMemory > 0) {
expect(memoryDiff).toBeLessThan(1024 * 1024); // Less than 1MB growth
console.log(`Memory difference: ${memoryDiff} bytes`);
}
});
test("should maintain performance with workspace suggest array manipulation", () => {
const mockSuggests = Array.from({ length: 100 }, (_, i) => ({ id: `mock-${i}` }));
(app as any).workspace.editorSuggest.suggests = [...mockSuggests];
manager.startManaging();
const startTime = performance.now();
// Add suggests to beginning of array (high priority)
for (let i = 0; i < 50; i++) {
const suggest = manager.createUniversalSuggest(`priority-test-${i}`);
suggest.enable();
}
// Verify they were added to the beginning
const workspaceSuggests = (app as any).workspace.editorSuggest.suggests;
expect(workspaceSuggests.length).toBe(150); // 100 original + 50 new
// Remove all managed suggests
manager.removeAllManagedSuggests();
const endTime = performance.now();
const duration = endTime - startTime;
// Should handle array manipulation efficiently
expect(duration).toBeLessThan(50);
expect(workspaceSuggests.length).toBe(100); // Back to original
console.log(`Array manipulation completed in ${duration}ms`);
});
});

View file

@ -0,0 +1,207 @@
import { App, Editor, EditorPosition, TFile } from "obsidian";
import { UniversalEditorSuggest, SuggestManager } from "../components/suggest";
import TaskProgressBarPlugin from "../index";
import { getSuggestOptionsByTrigger } from "../components/suggest/SpecialCharacterSuggests";
// Mock Obsidian modules
jest.mock("obsidian", () => ({
App: jest.fn(),
Editor: jest.fn(),
EditorPosition: jest.fn(),
TFile: jest.fn(),
EditorSuggest: class {
constructor() {}
},
setIcon: jest.fn(),
}));
// Mock plugin
const mockPlugin = {
app: {} as App,
settings: {
preferMetadataFormat: "tasks",
},
} as TaskProgressBarPlugin;
describe("UniversalEditorSuggest", () => {
let suggest: UniversalEditorSuggest;
let app: App;
beforeEach(() => {
app = new App();
suggest = new UniversalEditorSuggest(app, mockPlugin, {
triggerChars: ["!", "~", "*", "#"],
});
});
test("should initialize with correct trigger characters", () => {
const config = suggest.getConfig();
expect(config.triggerChars).toEqual(["!", "~", "*", "#"]);
});
test("should enable and disable correctly", () => {
suggest.enable();
expect(suggest["isEnabled"]).toBe(true);
suggest.disable();
expect(suggest["isEnabled"]).toBe(false);
});
test("should add and remove suggest options", () => {
const customOption = {
id: "custom",
label: "Custom",
icon: "star",
description: "Custom option",
replacement: "%",
trigger: "%",
};
suggest.addSuggestOption(customOption);
const config = suggest.getConfig();
expect(config.triggerChars).toContain("%");
suggest.removeSuggestOption("custom");
// Note: This test would need access to internal suggestOptions to verify removal
});
});
describe("SuggestManager", () => {
let manager: SuggestManager;
let app: App;
beforeEach(() => {
app = new App();
// Mock the workspace.editorSuggest.suggests array
(app as any).workspace = {
editorSuggest: {
suggests: [],
},
};
manager = new SuggestManager(app, mockPlugin);
});
test("should start and stop managing correctly", () => {
expect(manager.isCurrentlyManaging()).toBe(false);
manager.startManaging();
expect(manager.isCurrentlyManaging()).toBe(true);
manager.stopManaging();
expect(manager.isCurrentlyManaging()).toBe(false);
});
test("should add suggests with priority", () => {
const mockSuggest = {} as any;
manager.startManaging();
manager.addSuggestWithPriority(mockSuggest, "test");
const activeSuggests = manager.getActiveSuggests();
expect(activeSuggests.has("test")).toBe(true);
expect(activeSuggests.get("test")).toBe(mockSuggest);
});
test("should remove managed suggests", () => {
const mockSuggest = {} as any;
manager.startManaging();
manager.addSuggestWithPriority(mockSuggest, "test");
expect(manager.getActiveSuggests().has("test")).toBe(true);
manager.removeManagedSuggest("test");
expect(manager.getActiveSuggests().has("test")).toBe(false);
});
test("should cleanup properly", () => {
manager.startManaging();
manager.addSuggestWithPriority({} as any, "test1");
manager.addSuggestWithPriority({} as any, "test2");
expect(manager.getActiveSuggests().size).toBe(2);
manager.cleanup();
expect(manager.isCurrentlyManaging()).toBe(false);
expect(manager.getActiveSuggests().size).toBe(0);
});
});
describe("SpecialCharacterSuggests", () => {
test("should return priority suggestions for ! trigger", () => {
const suggestions = getSuggestOptionsByTrigger("!", mockPlugin);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].trigger).toBe("!");
expect(suggestions.some(s => s.id.includes("priority"))).toBe(true);
});
test("should return date suggestions for ~ trigger", () => {
const suggestions = getSuggestOptionsByTrigger("~", mockPlugin);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].trigger).toBe("~");
expect(suggestions.some(s => s.id.includes("date"))).toBe(true);
});
test("should return target suggestions for * trigger", () => {
const suggestions = getSuggestOptionsByTrigger("*", mockPlugin);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].trigger).toBe("*");
expect(suggestions.some(s => s.id.includes("target"))).toBe(true);
});
test("should return tag suggestions for # trigger", () => {
const suggestions = getSuggestOptionsByTrigger("#", mockPlugin);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].trigger).toBe("#");
expect(suggestions.some(s => s.id.includes("tag"))).toBe(true);
});
test("should return empty array for unknown trigger", () => {
const suggestions = getSuggestOptionsByTrigger("?", mockPlugin);
expect(suggestions).toEqual([]);
});
});
describe("Integration Tests", () => {
test("should create universal suggest for minimal modal context", () => {
const app = new App();
(app as any).workspace = {
editorSuggest: {
suggests: [],
},
};
const manager = new SuggestManager(app, mockPlugin);
manager.startManaging();
const mockEditor = {} as Editor;
const suggest = manager.enableForMinimalModal(mockEditor);
expect(suggest).toBeInstanceOf(UniversalEditorSuggest);
expect(manager.getActiveSuggests().has("universal-minimal-modal")).toBe(true);
manager.cleanup();
});
test("should handle context filters correctly", () => {
const app = new App();
(app as any).workspace = {
editorSuggest: {
suggests: [],
},
};
const manager = new SuggestManager(app, mockPlugin);
// Add custom context filter
const testFilter = (editor: Editor, file: TFile) => true;
manager.addContextFilter("test", testFilter);
const config = manager.getConfig();
expect(config.contextFilters["test"]).toBe(testFilter);
// Remove context filter
manager.removeContextFilter("test");
const updatedConfig = manager.getConfig();
expect(updatedConfig.contextFilters["test"]).toBeUndefined();
});
});

View file

@ -0,0 +1,110 @@
import { clearAllMarks } from "../components/MarkdownRenderer";
describe("Task Mark Cleanup", () => {
describe("clearAllMarks function", () => {
test("should remove priority marks", () => {
const input = "Complete this task ! ⏫";
const expected = "Complete this task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should remove emoji priority marks", () => {
const input = "Important task 🔺";
const expected = "Important task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should remove letter priority marks", () => {
const input = "High priority task [#A]";
const expected = "High priority task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should remove date marks", () => {
const input = "Task with date 📅 2024-01-15";
const expected = "Task with date";
expect(clearAllMarks(input)).toBe(expected);
});
test("should remove multiple marks", () => {
const input = "Complex task ! 📅 2024-01-15 ⏫ #tag";
const expected = "Complex task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should preserve meaningful content", () => {
const input = "Write documentation for the API";
const expected = "Write documentation for the API";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle empty content", () => {
const input = "";
const expected = "";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle content with only marks", () => {
const input = "! ⏫ 📅 2024-01-15";
const expected = "";
expect(clearAllMarks(input)).toBe(expected);
});
test("should preserve links and code", () => {
const input = "Check [[Important Note]] and `code snippet` ! ⏫";
const expected = "Check [[Important Note]] and `code snippet`";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle mixed content", () => {
const input =
"Review [documentation](https://example.com) ! 📅 2024-01-15";
const expected = "Review [documentation](https://example.com)";
expect(clearAllMarks(input)).toBe(expected);
});
test("should remove tilde date prefix marks", () => {
const input = "Complete task ~ 2024-01-15";
const expected = "Complete task 2024-01-15";
expect(clearAllMarks(input)).toBe(expected);
});
test("should remove target location marks", () => {
const input = "Meeting target: office 📁";
const expected = "Meeting office";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle complex example from user", () => {
const input = "今天要过去吃饭 #123-123-123 ~ 📅 2025-07-18";
const expected = "今天要过去吃饭 #123-123-123 2025-07-18";
expect(clearAllMarks(input)).toBe(expected);
});
});
describe("Task line scenarios", () => {
test("should handle task with priority mark in middle", () => {
const input = "Complete this ! important task";
const expected = "Complete this important task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle task with multiple priority marks", () => {
const input = "Very ! important ⏫ task";
const expected = "Very important task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle task with trailing marks", () => {
const input = "Simple task !";
const expected = "Simple task";
expect(clearAllMarks(input)).toBe(expected);
});
test("should handle task with leading marks", () => {
const input = "! Important task";
const expected = "Important task";
expect(clearAllMarks(input)).toBe(expected);
});
});
});

View file

@ -290,13 +290,7 @@ export interface QuickCaptureSettings {
// Minimal mode settings
enableMinimalMode: boolean;
minimalModeSettings: {
showDateButton: boolean;
showPriorityButton: boolean;
showLocationButton: boolean;
showTagButton: boolean;
defaultLocation: "fixed" | "daily-note";
autoAddTaskPrefix: boolean; // 自动添加 - [ ] 前缀
suggestTrigger: string; // 触发字符,默认 "/"
suggestTrigger: string;
};
}
@ -816,12 +810,6 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
},
enableMinimalMode: false,
minimalModeSettings: {
showDateButton: true,
showPriorityButton: true,
showLocationButton: true,
showTagButton: true,
defaultLocation: "fixed",
autoAddTaskPrefix: true,
suggestTrigger: "/",
},
},

View file

@ -6,7 +6,7 @@ import {
moment,
EditorPosition,
Menu,
Scope,
setIcon,
} from "obsidian";
import {
createEmbeddableMarkdownEditor,
@ -18,6 +18,7 @@ import { t } from "../translations/helper";
import { MinimalQuickCaptureSuggest } from "./MinimalQuickCaptureSuggest";
import { DatePickerPopover } from "./date-picker/DatePickerPopover";
import { TagSuggest } from "./AutoComplete";
import { SuggestManager, UniversalEditorSuggest } from "./suggest";
interface TaskMetadata {
startDate?: Date;
@ -43,19 +44,24 @@ export class MinimalQuickCaptureModal extends Modal {
private locationButton: HTMLButtonElement | null = null;
private tagButton: HTMLButtonElement | null = null;
// Suggest instance
// Suggest instances
private minimalSuggest: MinimalQuickCaptureSuggest;
private suggestManager: SuggestManager;
private universalSuggest: UniversalEditorSuggest | null = null;
constructor(app: App, plugin: TaskProgressBarPlugin) {
super(app);
this.plugin = plugin;
this.minimalSuggest = plugin.minimalQuickCaptureSuggest;
// Initialize suggest manager
this.suggestManager = new SuggestManager(app, plugin);
// Initialize default metadata with fallback
const minimalSettings =
this.plugin.settings.quickCapture.minimalModeSettings;
this.taskMetadata.location =
minimalSettings?.defaultLocation || "fixed";
this.plugin.settings.quickCapture.targetType || "fixed";
this.taskMetadata.targetFile = this.getTargetFile();
}
@ -67,6 +73,9 @@ export class MinimalQuickCaptureModal extends Modal {
// Store modal instance reference for suggest system
(this.modalEl as any).__minimalQuickCaptureModal = this;
// Start managing suggests with high priority
this.suggestManager.startManaging();
// Set up the suggest system
if (this.minimalSuggest) {
this.minimalSuggest.setMinimalMode(true);
@ -75,11 +84,28 @@ export class MinimalQuickCaptureModal extends Modal {
// Create the interface
this.createMinimalInterface(contentEl);
// Register the suggest - EditorSuggest should be registered at plugin level, not here
// We'll register it in the plugin's onload method instead
// Enable universal suggest for minimal modal after editor is created
setTimeout(() => {
if (this.markdownEditor?.editor?.editor) {
this.universalSuggest =
this.suggestManager.enableForMinimalModal(
this.markdownEditor.editor.editor
);
this.universalSuggest.enable();
}
}, 100);
}
onClose() {
// Clean up universal suggest
if (this.universalSuggest) {
this.universalSuggest.disable();
this.universalSuggest = null;
}
// Stop managing suggests and restore original order
this.suggestManager.stopManaging();
// Clean up suggest
if (this.minimalSuggest) {
this.minimalSuggest.setMinimalMode(false);
@ -144,9 +170,6 @@ export class MinimalQuickCaptureModal extends Modal {
onChange: (update) => {
this.capturedContent = this.markdownEditor?.value || "";
// Check if we should show the suggestion menu
this.handleSuggestTrigger();
},
}
);
@ -163,70 +186,51 @@ export class MinimalQuickCaptureModal extends Modal {
cls: "quick-actions-left",
});
// Date button
if (settings.showDateButton !== false) {
this.dateButton = leftContainer.createEl("button", {
cls: "quick-action-button",
attr: { "aria-label": t("Set date") },
});
this.dateButton.innerHTML = "📅";
this.dateButton.addEventListener("click", () =>
this.showDatePicker()
);
this.updateButtonState(
this.dateButton,
!!this.taskMetadata.dueDate
);
}
this.dateButton = leftContainer.createEl("button", {
cls: ["quick-action-button", "clickable-icon"],
attr: { "aria-label": t("Set date") },
});
setIcon(this.dateButton, "calendar");
this.dateButton.addEventListener("click", () => this.showDatePicker());
this.updateButtonState(this.dateButton, !!this.taskMetadata.dueDate);
// Priority button
if (settings.showPriorityButton !== false) {
this.priorityButton = leftContainer.createEl("button", {
cls: "quick-action-button",
attr: { "aria-label": t("Set priority") },
});
this.priorityButton.innerHTML = "!";
this.priorityButton.addEventListener("click", () =>
this.showPriorityMenu()
);
this.updateButtonState(
this.priorityButton,
!!this.taskMetadata.priority
);
}
this.priorityButton = leftContainer.createEl("button", {
cls: ["quick-action-button", "clickable-icon"],
attr: { "aria-label": t("Set priority") },
});
setIcon(this.priorityButton, "zap");
this.priorityButton.addEventListener("click", () =>
this.showPriorityMenu()
);
this.updateButtonState(
this.priorityButton,
!!this.taskMetadata.priority
);
// Location button
if (settings.showLocationButton !== false) {
this.locationButton = leftContainer.createEl("button", {
cls: "quick-action-button",
attr: { "aria-label": t("Set location") },
});
this.locationButton.innerHTML = "📁";
this.locationButton.addEventListener("click", () =>
this.showLocationMenu()
);
this.updateButtonState(
this.locationButton,
this.taskMetadata.location !==
(settings.defaultLocation || "fixed")
);
}
this.locationButton = leftContainer.createEl("button", {
cls: ["quick-action-button", "clickable-icon"],
attr: { "aria-label": t("Set location") },
});
setIcon(this.locationButton, "folder");
this.locationButton.addEventListener("click", () =>
this.showLocationMenu()
);
this.updateButtonState(
this.locationButton,
this.taskMetadata.location !==
(this.plugin.settings.quickCapture.targetType || "fixed")
);
// Tag button
if (settings.showTagButton !== false) {
this.tagButton = leftContainer.createEl("button", {
cls: "quick-action-button",
attr: { "aria-label": t("Add tags") },
});
this.tagButton.innerHTML = "#";
this.tagButton.addEventListener("click", () =>
this.showTagSelector()
);
this.updateButtonState(
this.tagButton,
!!(this.taskMetadata.tags && this.taskMetadata.tags.length > 0)
);
}
this.tagButton = leftContainer.createEl("button", {
cls: ["quick-action-button", "clickable-icon"],
attr: { "aria-label": t("Add tags") },
});
setIcon(this.tagButton, "tag");
this.tagButton.addEventListener("click", () => {});
this.updateButtonState(
this.tagButton,
!!(this.taskMetadata.tags && this.taskMetadata.tags.length > 0)
);
}
private createMainButtons(container: HTMLElement) {
@ -251,104 +255,15 @@ export class MinimalQuickCaptureModal extends Modal {
}
/**
* Handle suggestion trigger check
* Show menu at specified coordinates
*/
private handleSuggestTrigger(): void {
if (!this.markdownEditor || !this.minimalSuggest) return;
// Get current cursor position
const cursor = (this.markdownEditor.editor.editor as any).getCursor();
// Check if we should show the suggestion menu
if (
this.minimalSuggest.shouldShowMenu(
this.markdownEditor.editor.editor as any,
cursor
)
) {
// Add a small delay to ensure the character is fully processed
setTimeout(() => {
this.minimalSuggest.showMenuAtCursor(
this.markdownEditor!.editor.editor as any,
cursor
);
}, 10);
}
}
/**
* Show menu with proper scope handling to prevent enter key from propagating
*/
private showMenuWithScope(menu: Menu, x: number, y: number): void {
// Create a new scope for the menu
const menuScope = new Scope();
// Override the Enter key to prevent it from propagating to the editor
menuScope.register([], "Enter", (evt: KeyboardEvent) => {
evt.preventDefault();
evt.stopPropagation();
return false;
});
// Also register Escape key to properly close menu
menuScope.register([], "Escape", (evt: KeyboardEvent) => {
evt.preventDefault();
evt.stopPropagation();
menu.hide();
return false;
});
// Push the scope when menu is shown
this.app.keymap.pushScope(menuScope);
// Show the menu
private showMenuAtCoords(menu: Menu, x: number, y: number): void {
menu.showAtMouseEvent(
new MouseEvent("click", {
clientX: x,
clientY: y,
})
);
// Pop the scope when menu is hidden
// We need to hook into the menu's onHide event
const originalOnHide = menu.onHide;
menu.onHide = () => {
this.app.keymap.popScope(menuScope);
if (originalOnHide) {
originalOnHide.call(menu);
}
};
// Add a stronger event listener directly to the menu DOM element
// This is a backup method to ensure Enter key doesn't propagate
const menuEl = (menu as any).dom;
if (menuEl) {
const handleKeydown = (evt: KeyboardEvent) => {
if (evt.key === "Enter") {
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation();
// Find the selected item and trigger its click
const selectedItem = menuEl.querySelector('.menu-item.selected');
if (selectedItem) {
(selectedItem as HTMLElement).click();
}
}
};
menuEl.addEventListener('keydown', handleKeydown, true);
// Clean up the event listener when menu is hidden
const originalHide = menu.onHide;
menu.onHide = () => {
menuEl.removeEventListener('keydown', handleKeydown, true);
this.app.keymap.popScope(menuScope);
if (originalHide) {
originalHide.call(menu);
}
};
}
}
// Methods called by MinimalQuickCaptureSuggest
@ -381,7 +296,7 @@ export class MinimalQuickCaptureModal extends Modal {
if (cursor && this.markdownEditor) {
this.replaceAtCursor(
cursor,
`📅 ${this.formatDate(quickDate.date)}`
this.formatDate(quickDate.date)
);
}
});
@ -400,9 +315,9 @@ export class MinimalQuickCaptureModal extends Modal {
// Show menu at cursor position if provided, otherwise at button
if (coords) {
this.showMenuWithScope(menu, coords.left, coords.top);
this.showMenuAtCoords(menu, coords.left, coords.top);
} else if (this.dateButton) {
this.showMenuWithScope(
this.showMenuAtCoords(
menu,
this.dateButton.offsetLeft,
this.dateButton.offsetTop
@ -442,9 +357,9 @@ export class MinimalQuickCaptureModal extends Modal {
// Show menu at cursor position if provided, otherwise at button
if (coords) {
this.showMenuWithScope(menu, coords.left, coords.top);
this.showMenuAtCoords(menu, coords.left, coords.top);
} else if (this.priorityButton) {
this.showMenuWithScope(
this.showMenuAtCoords(
menu,
this.priorityButton.offsetLeft,
this.priorityButton.offsetTop
@ -469,13 +384,13 @@ export class MinimalQuickCaptureModal extends Modal {
this.updateButtonState(
this.locationButton!,
this.taskMetadata.location !==
(this.plugin.settings.quickCapture.minimalModeSettings
?.defaultLocation || "fixed")
(this.plugin.settings.quickCapture.targetType ||
"fixed")
);
// If called from suggest, replace the 📁 with file icon
// If called from suggest, replace the 📁 with file text
if (cursor && this.markdownEditor) {
this.replaceAtCursor(cursor, "📄");
this.replaceAtCursor(cursor, t("Fixed location"));
}
});
});
@ -489,22 +404,22 @@ export class MinimalQuickCaptureModal extends Modal {
this.updateButtonState(
this.locationButton!,
this.taskMetadata.location !==
(this.plugin.settings.quickCapture.minimalModeSettings
?.defaultLocation || "fixed")
(this.plugin.settings.quickCapture?.targetType ||
"fixed")
);
// If called from suggest, replace the 📁 with calendar icon
// If called from suggest, replace the 📁 with daily note text
if (cursor && this.markdownEditor) {
this.replaceAtCursor(cursor, "📅");
this.replaceAtCursor(cursor, t("Daily note"));
}
});
});
// Show menu at cursor position if provided, otherwise at button
if (coords) {
this.showMenuWithScope(menu, coords.left, coords.top);
this.showMenuAtCoords(menu, coords.left, coords.top);
} else if (this.locationButton) {
this.showMenuWithScope(
this.showMenuAtCoords(
menu,
this.locationButton.offsetLeft,
this.locationButton.offsetTop
@ -512,57 +427,7 @@ export class MinimalQuickCaptureModal extends Modal {
}
}
public showTagSelectorAtCursor(cursorCoords: any, cursor: EditorPosition) {
this.showTagSelector(cursor, cursorCoords);
}
public showTagSelector(cursor?: EditorPosition, coords?: any) {
// Create a temporary input for tag selection
const tagInput = document.createElement("input");
tagInput.type = "text";
tagInput.placeholder = t("Enter tags (comma separated)");
tagInput.className = "quick-capture-tag-input";
// Position the input at cursor position if provided
if (coords) {
tagInput.style.position = "absolute";
tagInput.style.left = `${coords.left}px`;
tagInput.style.top = `${coords.top}px`;
tagInput.style.zIndex = "1000";
}
// Add to modal temporarily
this.contentEl.appendChild(tagInput);
// Setup tag suggest
new TagSuggest(this.app, tagInput, this.plugin);
// Focus and handle completion
tagInput.focus();
tagInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const tags = tagInput.value
.split(",")
.map((t) => t.trim())
.filter((t) => t);
this.taskMetadata.tags = tags;
this.updateButtonState(this.tagButton!, tags.length > 0);
// If called from suggest, replace the # with tags
if (cursor && this.markdownEditor) {
this.replaceAtCursor(
cursor,
tags.map((t) => `#${t}`).join(" ")
);
}
// Remove temp input
this.contentEl.removeChild(tagInput);
} else if (e.key === "Escape") {
this.contentEl.removeChild(tagInput);
}
});
}
public showTagSelectorAtCursor(cursorCoords: any, cursor: EditorPosition) {}
private replaceAtCursor(cursor: EditorPosition, replacement: string) {
if (!this.markdownEditor) return;
@ -601,23 +466,51 @@ export class MinimalQuickCaptureModal extends Modal {
private processMinimalContent(content: string): string {
if (!content.trim()) return "";
const settings =
this.plugin.settings.quickCapture.minimalModeSettings || {};
const lines = content.split("\n");
const processedLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("- [")) {
// Clean any temporary marks from user input before creating task
const cleanedContent = this.cleanTemporaryMarks(trimmed);
return `- [ ] ${cleanedContent}`;
}
return line;
});
return processedLines.join("\n");
}
// Auto-add task prefix if enabled
if (settings.autoAddTaskPrefix !== false) {
const lines = content.split("\n");
const processedLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("- [")) {
return `- [ ] ${trimmed}`;
}
return line;
});
return processedLines.join("\n");
}
/**
* Clean temporary marks from user input that might conflict with formal metadata
*/
private cleanTemporaryMarks(content: string): string {
let cleaned = content;
return content;
// Remove standalone exclamation marks that users might type for priority
cleaned = cleaned.replace(/\s*!\s*/g, " ");
// Remove standalone tilde marks that users might type for date
cleaned = cleaned.replace(/\s*~\s*/g, " ");
// Remove standalone priority symbols that users might type
cleaned = cleaned.replace(/\s*[🔺⏫🔼🔽⏬️]\s*/g, " ");
// Remove standalone date symbols that users might type
cleaned = cleaned.replace(/\s*[📅🛫⏳✅➕❌]\s*/g, " ");
// Remove location/folder symbols that users might type
cleaned = cleaned.replace(/\s*[📁🏠🏢🏪🏫🏬🏭🏯🏰]\s*/g, " ");
// Remove other metadata symbols that users might type
cleaned = cleaned.replace(/\s*[🆔⛔🏁🔁]\s*/g, " ");
// Remove target/location prefix patterns (like @location, target:)
cleaned = cleaned.replace(/\s*@\w*\s*/g, " ");
cleaned = cleaned.replace(/\s*target:\s*/gi, " ");
// Clean up multiple spaces and trim
cleaned = cleaned.replace(/\s+/g, " ").trim();
return cleaned;
}
private addMetadataToContent(content: string): string {

View file

@ -6,11 +6,11 @@ import {
EditorSuggestContext,
EditorSuggestTriggerInfo,
TFile,
Menu,
Scope,
setIcon,
} from "obsidian";
import TaskProgressBarPlugin from "../index";
import { t } from "../translations/helper";
import { getSuggestOptionsByTrigger } from "./suggest/SpecialCharacterSuggests";
interface SuggestOption {
id: string;
@ -18,15 +18,16 @@ interface SuggestOption {
icon: string;
description: string;
replacement: string;
trigger?: string;
action?: (editor: Editor, cursor: EditorPosition) => void;
}
export class MinimalQuickCaptureSuggest {
app: App;
export class MinimalQuickCaptureSuggest extends EditorSuggest<SuggestOption> {
plugin: TaskProgressBarPlugin;
private isMinimalMode: boolean = false;
constructor(app: App, plugin: TaskProgressBarPlugin) {
this.app = app;
super(app);
this.plugin = plugin;
}
@ -39,18 +40,22 @@ export class MinimalQuickCaptureSuggest {
}
/**
* Check if we should show the suggestion menu at the current cursor position
* Get the trigger regex for the suggestion
*/
shouldShowMenu(editor: Editor, cursor: EditorPosition): boolean {
onTrigger(
cursor: EditorPosition,
editor: Editor,
file: TFile
): EditorSuggestTriggerInfo | null {
// Only trigger in minimal mode
if (!this.isMinimalMode) {
return false;
return null;
}
// Check if we're in a minimal quick capture context
const editorEl = (editor as any).cm?.dom as HTMLElement;
if (!editorEl || !editorEl.closest(".quick-capture-modal.minimal")) {
return false;
return null;
}
// Get the current line
@ -60,150 +65,148 @@ export class MinimalQuickCaptureSuggest {
?.suggestTrigger || "/";
// Check if the cursor is right after the trigger character
return cursor.ch > 0 && line.charAt(cursor.ch - 1) === triggerChar;
if (cursor.ch > 0 && line.charAt(cursor.ch - 1) === triggerChar) {
return {
start: { line: cursor.line, ch: cursor.ch - 1 },
end: cursor,
query: triggerChar,
};
}
return null;
}
/**
* Show the suggestion menu at the cursor position
* Get suggestions based on the trigger
*/
showMenuAtCursor(editor: Editor, cursor: EditorPosition): void {
const settings =
this.plugin.settings.quickCapture.minimalModeSettings || {};
// Get cursor position in screen coordinates for menu positioning
const cursorCoords = (editor as any).coordsAtPos?.(cursor) || { left: 0, top: 0 };
// Create the menu
const menu = new Menu();
// Add menu items based on settings
if (settings.showDateButton !== false) {
menu.addItem((item) => {
item.setTitle(`📅 ${t("Date")}`);
item.setIcon("calendar");
item.onClick(() => {
this.handleSuggestionSelection(editor, cursor, "date", "~");
});
});
getSuggestions(context: EditorSuggestContext): SuggestOption[] {
// Map old @ to new * for target location
const triggerChar = context.query === "@" ? "*" : context.query;
// Get suggestions from the new system
const suggestions = getSuggestOptionsByTrigger(
triggerChar,
this.plugin
);
// For backward compatibility, return simple trigger suggestions if no specific ones found
if (suggestions.length === 0) {
return [
{
id: "date",
label: t("Date"),
icon: "calendar",
description: t("Add date"),
replacement: "~",
trigger: "~",
},
{
id: "priority",
label: t("Priority"),
icon: "zap",
description: t("Set priority"),
replacement: "!",
trigger: "!",
},
{
id: "target",
label: t("Target Location"),
icon: "folder",
description: t("Set target location"),
replacement: "*",
trigger: "*",
},
{
id: "tag",
label: t("Tag"),
icon: "tag",
description: t("Add tags"),
replacement: "#",
trigger: "#",
},
];
}
if (settings.showPriorityButton !== false) {
menu.addItem((item) => {
item.setTitle(`! ${t("Priority")}`);
item.setIcon("zap");
item.onClick(() => {
this.handleSuggestionSelection(editor, cursor, "priority", "!");
});
});
}
return suggestions;
}
if (settings.showLocationButton !== false) {
menu.addItem((item) => {
item.setTitle(`📁 ${t("Location")}`);
item.setIcon("folder");
item.onClick(() => {
this.handleSuggestionSelection(editor, cursor, "location", "📁");
});
});
}
/**
* Render suggestion using Obsidian Menu DOM structure
*/
renderSuggestion(suggestion: SuggestOption, el: HTMLElement): void {
el.addClass("menu-item");
el.addClass("tappable");
if (settings.showTagButton !== false) {
menu.addItem((item) => {
item.setTitle(`# ${t("Tag")}`);
item.setIcon("tag");
item.onClick(() => {
this.handleSuggestionSelection(editor, cursor, "tag", "#");
});
});
}
// Create icon element
const iconEl = el.createDiv("menu-item-icon");
setIcon(iconEl, suggestion.icon);
// Show the menu at cursor position with proper scope handling
this.showMenuWithScope(menu, cursorCoords.left, cursorCoords.top);
// Create title element
const titleEl = el.createDiv("menu-item-title");
titleEl.textContent = suggestion.label;
}
/**
* Handle suggestion selection
*/
private handleSuggestionSelection(
editor: Editor,
cursor: EditorPosition,
suggestionId: string,
replacement: string
selectSuggestion(
suggestion: SuggestOption,
evt: MouseEvent | KeyboardEvent
): void {
const triggerChar =
this.plugin.settings.quickCapture.minimalModeSettings
?.suggestTrigger || "/";
const editor = this.context?.editor;
const cursor = this.context?.end;
if (!editor || !cursor) return;
// Replace the trigger character with the replacement
const startPos = { line: cursor.line, ch: cursor.ch - 1 };
const endPos = cursor;
editor.replaceRange(replacement, startPos, endPos);
editor.replaceRange(suggestion.replacement, startPos, endPos);
// Move cursor to after the replacement
const newCursor = {
line: cursor.line,
ch: cursor.ch - 1 + replacement.length,
ch: cursor.ch - 1 + suggestion.replacement.length,
};
editor.setCursor(newCursor);
// Get the modal instance and trigger the appropriate action
// Execute custom action if provided (new system)
if (suggestion.action) {
suggestion.action(editor, newCursor);
return;
}
// Fallback to legacy modal-based actions
const editorEl = (editor as any).cm?.dom as HTMLElement;
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
const modal = (modalEl as any).__minimalQuickCaptureModal;
this.close();
if (!modal) return;
// Get cursor position for menu positioning
const cursorCoords = (editor as any).coordsAtPos?.(newCursor) || { left: 0, top: 0 };
const cursorCoords = (editor as any).coordsAtPos?.(newCursor) || {
left: 0,
top: 0,
};
// Handle different suggestion types
switch (suggestionId) {
// Handle different suggestion types (legacy support)
switch (suggestion.id) {
case "date":
modal.showDatePickerAtCursor(cursorCoords, newCursor);
modal.showDatePickerAtCursor?.(cursorCoords, newCursor);
break;
case "priority":
modal.showPriorityMenuAtCursor(cursorCoords, newCursor);
modal.showPriorityMenuAtCursor?.(cursorCoords, newCursor);
break;
case "target":
case "location":
modal.showLocationMenuAtCursor(cursorCoords, newCursor);
modal.showLocationMenuAtCursor?.(cursorCoords, newCursor);
break;
case "tag":
modal.showTagSelectorAtCursor(cursorCoords, newCursor);
modal.showTagSelectorAtCursor?.(cursorCoords, newCursor);
break;
}
}
/**
* Show menu with proper scope handling to prevent enter key from propagating
*/
private showMenuWithScope(menu: Menu, x: number, y: number): void {
// Create a new scope for the menu
const menuScope = new Scope();
// Override the Enter key to prevent it from propagating to the editor
menuScope.register([], "Enter", (evt: KeyboardEvent) => {
evt.preventDefault();
evt.stopPropagation();
return false;
});
// Push the scope when menu is shown
this.app.keymap.pushScope(menuScope);
// Show the menu
menu.showAtMouseEvent(new MouseEvent("click", {
clientX: x,
clientY: y
}));
// Pop the scope when menu is hidden
const originalOnHide = menu.onHide;
menu.onHide = () => {
this.app.keymap.popScope(menuScope);
if (originalOnHide) {
originalOnHide.call(menu);
}
};
}
}

View file

@ -26,6 +26,7 @@ import {
ParsedTimeResult,
LineParseResult,
} from "../utils/TimeParsingService";
import { SuggestManager, UniversalEditorSuggest } from "./suggest";
interface TaskMetadata {
startDate?: Date;
@ -106,6 +107,10 @@ export class QuickCaptureModal extends Modal {
// Debounce timer for real-time parsing
private parseDebounceTimer?: number;
// Suggest management
private suggestManager: SuggestManager;
private universalSuggest: UniversalEditorSuggest | null = null;
constructor(
app: App,
plugin: TaskProgressBarPlugin,
@ -115,6 +120,9 @@ export class QuickCaptureModal extends Modal {
super(app);
this.plugin = plugin;
// Initialize suggest manager
this.suggestManager = new SuggestManager(app, plugin);
// Initialize target file path based on target type
if (this.plugin.settings.quickCapture.targetType === "daily-note") {
const dateStr = moment().format(
@ -150,12 +158,26 @@ export class QuickCaptureModal extends Modal {
const { contentEl } = this;
this.modalEl.toggleClass("quick-capture-modal", true);
// Start managing suggests with high priority
this.suggestManager.startManaging();
if (this.useFullFeaturedMode) {
this.modalEl.toggleClass(["quick-capture-modal", "full"], true);
this.createFullFeaturedModal(contentEl);
} else {
this.createSimpleModal(contentEl);
}
// Enable universal suggest after editor is created
setTimeout(() => {
if (this.markdownEditor?.editor?.editor) {
this.universalSuggest =
this.suggestManager.enableForQuickCaptureModal(
this.markdownEditor.editor.editor
);
this.universalSuggest.enable();
}
}, 100);
}
createSimpleModal(contentEl: HTMLElement) {
@ -636,7 +658,9 @@ export class QuickCaptureModal extends Modal {
// Don't add metadata to sub-tasks, but still clean time expressions
// Preserve the original indentation from the original line
const originalIndent = indentMatch[1];
const cleanedContent = cleanedLine.trim();
const cleanedContent = this.cleanTemporaryMarks(
cleanedLine.trim()
);
processedLines.push(originalIndent + cleanedContent);
} else if (isTaskOrList) {
// If it's a task, add line-specific metadata
@ -649,10 +673,12 @@ export class QuickCaptureModal extends Modal {
const listPrefix = cleanedLine
.trim()
.match(/^(-|\d+\.|\*|\+)/)?.[0];
const restOfLine = cleanedLine
.trim()
.substring(listPrefix?.length || 0)
.trim();
const restOfLine = this.cleanTemporaryMarks(
cleanedLine
.trim()
.substring(listPrefix?.length || 0)
.trim()
);
// Use the specified status or default to empty checkbox
const statusMark = this.taskMetadata.status || " ";
@ -665,7 +691,8 @@ export class QuickCaptureModal extends Modal {
// Not a list item or task, convert to task and add line-specific metadata
// Use the specified status or default to empty checkbox
const statusMark = this.taskMetadata.status || " ";
const taskLine = `- [${statusMark}] ${cleanedLine}`;
const cleanedContent = this.cleanTemporaryMarks(cleanedLine);
const taskLine = `- [${statusMark}] ${cleanedContent}`;
processedLines.push(
this.addLineMetadataToTask(taskLine, lineParseResult)
);
@ -1012,6 +1039,40 @@ export class QuickCaptureModal extends Modal {
this.taskMetadata.manuallySet[field] = true;
}
/**
* Clean temporary marks from user input that might conflict with formal metadata
*/
private cleanTemporaryMarks(content: string): string {
let cleaned = content;
// Remove standalone exclamation marks that users might type for priority
cleaned = cleaned.replace(/\s*!\s*/g, " ");
// Remove standalone tilde marks that users might type for date
cleaned = cleaned.replace(/\s*~\s*/g, " ");
// Remove standalone priority symbols that users might type
cleaned = cleaned.replace(/\s*[🔺⏫🔼🔽⏬️]\s*/g, " ");
// Remove standalone date symbols that users might type
cleaned = cleaned.replace(/\s*[📅🛫⏳✅➕❌]\s*/g, " ");
// Remove location/folder symbols that users might type
cleaned = cleaned.replace(/\s*[📁🏠🏢🏪🏫🏬🏭🏯🏰]\s*/g, " ");
// Remove other metadata symbols that users might type
cleaned = cleaned.replace(/\s*[🆔⛔🏁🔁]\s*/g, " ");
// Remove target/location prefix patterns (like @location, target:)
cleaned = cleaned.replace(/\s*@\w*\s*/g, " ");
cleaned = cleaned.replace(/\s*target:\s*/gi, " ");
// Clean up multiple spaces and trim
cleaned = cleaned.replace(/\s+/g, " ").trim();
return cleaned;
}
/**
* Perform real-time parsing with debouncing
*/
@ -1115,6 +1176,15 @@ export class QuickCaptureModal extends Modal {
onClose() {
const { contentEl } = this;
// Clean up universal suggest
if (this.universalSuggest) {
this.universalSuggest.disable();
this.universalSuggest = null;
}
// Stop managing suggests and restore original order
this.suggestManager.stopManaging();
// Clear debounce timer
if (this.parseDebounceTimer) {
clearTimeout(this.parseDebounceTimer);

View file

@ -208,119 +208,6 @@ export function renderQuickCaptureSettingsTab(
})
);
// Minimal mode settings
new Setting(containerEl).setName(t("Minimal Mode")).setHeading();
new Setting(containerEl)
.setName(t("Enable minimal mode"))
.setDesc(t("Enable simplified single-line quick capture with inline suggestions"))
.addToggle((toggle) =>
toggle
.setValue(settingTab.plugin.settings.quickCapture.enableMinimalMode)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.enableMinimalMode = value;
settingTab.applySettingsUpdate();
// Refresh the settings display to show/hide minimal mode options
setTimeout(() => {
settingTab.display();
}, 100);
})
);
if (!settingTab.plugin.settings.quickCapture.enableMinimalMode) return;
// Auto-add task prefix
new Setting(containerEl)
.setName(t("Auto-add task prefix"))
.setDesc(t("Automatically add '- [ ]' prefix to captured content"))
.addToggle((toggle) =>
toggle
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.autoAddTaskPrefix)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.autoAddTaskPrefix = value;
settingTab.applySettingsUpdate();
})
);
// Suggest trigger character
new Setting(containerEl)
.setName(t("Suggest trigger character"))
.setDesc(t("Character to trigger the suggestion menu"))
.addText((text) =>
text
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.suggestTrigger)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.suggestTrigger = value || "/";
settingTab.applySettingsUpdate();
})
);
// Default location
new Setting(containerEl)
.setName(t("Default location"))
.setDesc(t("Default location for capturing tasks"))
.addDropdown((dropdown) =>
dropdown
.addOption("fixed", t("Fixed file"))
.addOption("daily-note", t("Daily note"))
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.defaultLocation)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.defaultLocation = value as "fixed" | "daily-note";
settingTab.applySettingsUpdate();
})
);
// Quick action buttons
new Setting(containerEl).setName(t("Quick Action Buttons")).setHeading();
new Setting(containerEl)
.setName(t("Show date button"))
.setDesc(t("Show date selection button in minimal mode"))
.addToggle((toggle) =>
toggle
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.showDateButton)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.showDateButton = value;
settingTab.applySettingsUpdate();
})
);
new Setting(containerEl)
.setName(t("Show priority button"))
.setDesc(t("Show priority selection button in minimal mode"))
.addToggle((toggle) =>
toggle
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.showPriorityButton)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.showPriorityButton = value;
settingTab.applySettingsUpdate();
})
);
new Setting(containerEl)
.setName(t("Show location button"))
.setDesc(t("Show location selection button in minimal mode"))
.addToggle((toggle) =>
toggle
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.showLocationButton)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.showLocationButton = value;
settingTab.applySettingsUpdate();
})
);
new Setting(containerEl)
.setName(t("Show tag button"))
.setDesc(t("Show tag selection button in minimal mode"))
.addToggle((toggle) =>
toggle
.setValue(settingTab.plugin.settings.quickCapture.minimalModeSettings.showTagButton)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.showTagButton = value;
settingTab.applySettingsUpdate();
})
);
new Setting(containerEl)
.setName(t("Append to file"))
.setDesc(t("How to add captured content to the target location"))
@ -336,4 +223,55 @@ export function renderQuickCaptureSettingsTab(
settingTab.applySettingsUpdate();
})
);
// Minimal mode settings
new Setting(containerEl).setName(t("Minimal Mode")).setHeading();
new Setting(containerEl)
.setName(t("Enable minimal mode"))
.setDesc(
t(
"Enable simplified single-line quick capture with inline suggestions"
)
)
.addToggle((toggle) =>
toggle
.setValue(
settingTab.plugin.settings.quickCapture.enableMinimalMode
)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.enableMinimalMode =
value;
settingTab.applySettingsUpdate();
// Refresh the settings display to show/hide minimal mode options
setTimeout(() => {
settingTab.display();
}, 100);
})
);
if (!settingTab.plugin.settings.quickCapture.enableMinimalMode) return;
if (!settingTab.plugin.settings.quickCapture.minimalModeSettings) {
settingTab.plugin.settings.quickCapture.minimalModeSettings = {
suggestTrigger: "/",
};
}
// Suggest trigger character
new Setting(containerEl)
.setName(t("Suggest trigger character"))
.setDesc(t("Character to trigger the suggestion menu"))
.addText((text) =>
text
.setValue(
settingTab.plugin.settings.quickCapture.minimalModeSettings
.suggestTrigger
)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.minimalModeSettings.suggestTrigger =
value || "/";
settingTab.applySettingsUpdate();
})
);
}

View file

@ -0,0 +1,196 @@
# Universal Suggest System
This document describes the new Universal Suggest System that provides unified, high-priority suggest functionality for special characters in the Task Progress Bar plugin.
## Overview
The Universal Suggest System replaces the previous scattered suggest implementations with a unified architecture that:
- Provides dynamic priority management through workspace suggest array manipulation
- Supports special characters: `!` (priority), `~` (date), `*` (target), `#` (tags)
- Maintains backward compatibility with existing MinimalQuickCaptureSuggest
- Offers extensible architecture for custom suggest implementations
## Architecture
### Core Components
1. **UniversalEditorSuggest**: Base class extending EditorSuggest<T>
2. **SuggestManager**: Manages dynamic suggest registration and priority
3. **SpecialCharacterSuggests**: Provides specific suggestions for each trigger character
### Key Features
- **Dynamic Priority**: Suggests are inserted at the beginning of `app.workspace.editorSuggest.suggests` array
- **Context Filtering**: Supports context-specific suggest activation
- **Lifecycle Management**: Automatic cleanup when modals close
- **Extensible Options**: Easy to add new trigger characters and suggestions
## Usage
### Basic Setup
```typescript
import { SuggestManager, UniversalEditorSuggest } from "./components/suggest";
// Initialize in plugin onload
this.globalSuggestManager = new SuggestManager(this.app, this);
// In modal onOpen
this.suggestManager.startManaging();
const suggest = this.suggestManager.enableForMinimalModal(editor);
suggest.enable();
// In modal onClose
this.suggestManager.stopManaging();
```
### Custom Suggest Options
```typescript
// Add custom suggest option
const customOption = {
id: "custom-action",
label: "Custom Action",
icon: "star",
description: "Perform custom action",
replacement: "% ",
trigger: "%",
action: (editor, cursor) => {
// Custom action implementation
console.log("Custom action triggered");
}
};
suggest.addSuggestOption(customOption);
```
### Context Filtering
```typescript
// Add context-specific filter
manager.addContextFilter("my-context", (editor, file) => {
return file.path.includes("specific-folder");
});
// Create suggest with context
const suggest = manager.createUniversalSuggest("my-context");
```
## Special Characters
### Priority (`!`)
- `! 🔺` - Highest priority
- `! ⏫` - High priority
- `! 🔼` - Medium priority
- `! 🔽` - Low priority
- `! ⏬` - Lowest priority
### Date (`~`)
- `~ YYYY-MM-DD` - Today's date
- `~ YYYY-MM-DD` - Tomorrow's date
- `~ ` - Open date picker
- `~ ⏰ ` - Scheduled date format
### Target (`*`)
- `* Inbox` - Save to inbox
- `* Daily` - Save to daily note
- `* Current` - Save to current file
- `* ` - Open file picker
- `* filename` - Recent files
### Tags (`#`)
- `# important` - Important tag
- `# urgent` - Urgent tag
- `# work` - Work tag
- `# personal` - Personal tag
- `# ` - Open tag picker
- `# existing-tag` - Existing vault tags
## Migration Guide
### From MinimalQuickCaptureSuggest
The existing MinimalQuickCaptureSuggest continues to work with enhanced functionality:
```typescript
// Old usage (still works)
const suggest = new MinimalQuickCaptureSuggest(app, plugin);
suggest.setMinimalMode(true);
// New enhanced suggestions are automatically available
// @ is automatically mapped to * for target location
```
### From Custom Suggest Implementations
Replace custom EditorSuggest implementations:
```typescript
// Old
class CustomSuggest extends EditorSuggest<string> {
// Custom implementation
}
// New
const suggest = manager.createUniversalSuggest("custom", {
triggerChars: ["@"],
contextFilter: (editor, file) => /* custom logic */
});
```
## Performance Considerations
- Suggests are created and destroyed dynamically to minimize memory usage
- Context filters are evaluated efficiently
- Workspace suggest array manipulation is optimized for frequent operations
- Cleanup is automatic when modals close
## Testing
Run the test suite to verify functionality:
```bash
npm test -- UniversalSuggest.test.ts
npm test -- SuggestPerformance.test.ts
npm test -- SuggestBackwardCompatibility.test.ts
```
## Troubleshooting
### Common Issues
1. **Suggests not appearing**: Ensure `startManaging()` is called before creating suggests
2. **Priority not working**: Check that suggests are added with `addSuggestWithPriority()`
3. **Context filtering**: Verify context filters return boolean values
4. **Memory leaks**: Always call `stopManaging()` or `cleanup()` when done
### Debug Mode
Enable debug logging:
```typescript
const manager = new SuggestManager(app, plugin, {
enableDynamicPriority: true,
// Add debug logging
});
// Log current suggest order
manager.debugLogSuggestOrder();
```
## Future Enhancements
- Support for custom trigger character patterns
- Advanced context filtering with regex support
- Suggest caching for improved performance
- Integration with other plugin suggest systems
- Visual suggest priority indicators
## API Reference
See the TypeScript definitions in the source files for complete API documentation:
- `UniversalEditorSuggest.ts` - Core suggest implementation
- `SuggestManager.ts` - Suggest lifecycle management
- `SpecialCharacterSuggests.ts` - Built-in special character handlers

View file

@ -0,0 +1,341 @@
import { Editor, EditorPosition, Notice } from "obsidian";
import TaskProgressBarPlugin from "../../index";
import { SuggestOption } from "./UniversalEditorSuggest";
import { t } from "../../translations/helper";
/**
* Priority suggest options based on existing priority system
*/
export function createPrioritySuggestOptions(): SuggestOption[] {
return [
{
id: "priority-highest",
label: t("Highest Priority"),
icon: "arrow-up",
description: t("🔺 Highest priority task"),
replacement: "! 🔺",
trigger: "!",
action: (editor: Editor, cursor: EditorPosition) => {
// Priority is already inserted, no additional action needed
new Notice(t("Highest priority set"));
},
},
{
id: "priority-high",
label: t("High Priority"),
icon: "arrow-up",
description: t("⏫ High priority task"),
replacement: "! ⏫",
trigger: "!",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("High priority set"));
},
},
{
id: "priority-medium",
label: t("Medium Priority"),
icon: "minus",
description: t("🔼 Medium priority task"),
replacement: "! 🔼",
trigger: "!",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Medium priority set"));
},
},
{
id: "priority-low",
label: t("Low Priority"),
icon: "arrow-down",
description: t("🔽 Low priority task"),
replacement: "! 🔽",
trigger: "!",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Low priority set"));
},
},
{
id: "priority-lowest",
label: t("Lowest Priority"),
icon: "arrow-down",
description: t("⏬ Lowest priority task"),
replacement: "! ⏬",
trigger: "!",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Lowest priority set"));
},
},
];
}
/**
* Date suggest options for common date patterns
*/
export function createDateSuggestOptions(): SuggestOption[] {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const formatDate = (date: Date) => {
return date.toISOString().split("T")[0];
};
return [
{
id: "date-today",
label: t("Today"),
icon: "calendar-days",
description: t("Set due date to today"),
replacement: `~ ${formatDate(today)}`,
trigger: "~",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Due date set to today"));
},
},
{
id: "date-tomorrow",
label: t("Tomorrow"),
icon: "calendar-plus",
description: t("Set due date to tomorrow"),
replacement: `~ ${formatDate(tomorrow)}`,
trigger: "~",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Due date set to tomorrow"));
},
},
{
id: "date-picker",
label: t("Pick Date"),
icon: "calendar",
description: t("Open date picker"),
replacement: "~ ",
trigger: "~",
action: (editor: Editor, cursor: EditorPosition) => {
// This will trigger the date picker modal
// Implementation will be added when integrating with existing date picker
new Notice(t("Date picker opened"));
},
},
{
id: "date-scheduled",
label: t("Scheduled Date"),
icon: "calendar-clock",
description: t("Set scheduled date"),
replacement: "~ ⏰ ",
trigger: "~",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Scheduled date format added"));
},
},
];
}
/**
* Target location suggest options
*/
export function createTargetSuggestOptions(
plugin: TaskProgressBarPlugin
): SuggestOption[] {
const options: SuggestOption[] = [
{
id: "target-inbox",
label: t("Inbox"),
icon: "inbox",
description: t("Save to inbox"),
replacement: "* Inbox",
trigger: "*",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Target set to Inbox"));
},
},
{
id: "target-daily",
label: t("Daily Note"),
icon: "calendar-days",
description: t("Save to today's daily note"),
replacement: "* Daily",
trigger: "*",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Target set to Daily Note"));
},
},
{
id: "target-current",
label: t("Current File"),
icon: "file-text",
description: t("Save to current file"),
replacement: "* Current",
trigger: "*",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Target set to Current File"));
},
},
{
id: "target-picker",
label: t("Choose File"),
icon: "folder-open",
description: t("Open file picker"),
replacement: "* ",
trigger: "*",
action: (editor: Editor, cursor: EditorPosition) => {
// This will trigger the file picker modal
new Notice(t("File picker opened"));
},
},
];
// Add recent files if available
const recentFiles = plugin.app.workspace.getLastOpenFiles();
recentFiles.slice(0, 3).forEach((filePath, index) => {
const fileName =
filePath.split("/").pop()?.replace(".md", "") || filePath;
options.push({
id: `target-recent-${index}`,
label: fileName,
icon: "file",
description: t("Save to recent file"),
replacement: `* ${fileName}`,
trigger: "*",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Target set to") + ` ${fileName}`);
},
});
});
return options;
}
/**
* Tag suggest options
*/
export function createTagSuggestOptions(
plugin: TaskProgressBarPlugin
): SuggestOption[] {
const options: SuggestOption[] = [
{
id: "tag-important",
label: t("Important"),
icon: "star",
description: t("Mark as important"),
replacement: "# important",
trigger: "#",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Tagged as important"));
},
},
{
id: "tag-urgent",
label: t("Urgent"),
icon: "zap",
description: t("Mark as urgent"),
replacement: "# urgent",
trigger: "#",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Tagged as urgent"));
},
},
{
id: "tag-work",
label: t("Work"),
icon: "briefcase",
description: t("Work related task"),
replacement: "# work",
trigger: "#",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Tagged as work"));
},
},
{
id: "tag-personal",
label: t("Personal"),
icon: "user",
description: t("Personal task"),
replacement: "# personal",
trigger: "#",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Tagged as personal"));
},
},
{
id: "tag-picker",
label: t("Choose Tag"),
icon: "tag",
description: t("Open tag picker"),
replacement: "# ",
trigger: "#",
action: (editor: Editor, cursor: EditorPosition) => {
// This will trigger the tag picker modal
new Notice(t("Tag picker opened"));
},
},
];
// Add existing tags from vault
try {
const allTags = plugin.app.metadataCache.getTags();
const tagNames = Object.keys(allTags)
.map((tag) => tag.replace("#", ""))
.filter(
(tag) =>
!["important", "urgent", "work", "personal"].includes(tag)
)
.slice(0, 5); // Limit to 5 most common tags
tagNames.forEach((tagName, index) => {
options.push({
id: `tag-existing-${index}`,
label: `#${tagName}`,
icon: "tag",
description: t("Existing tag"),
replacement: `# ${tagName}`,
trigger: "#",
action: (editor: Editor, cursor: EditorPosition) => {
new Notice(t("Tagged with") + ` #${tagName}`);
},
});
});
} catch (error) {
console.warn("Failed to load existing tags:", error);
}
return options;
}
/**
* Create all suggest options for a given plugin instance
*/
export function createAllSuggestOptions(plugin: TaskProgressBarPlugin): {
priority: SuggestOption[];
date: SuggestOption[];
target: SuggestOption[];
tag: SuggestOption[];
} {
return {
priority: createPrioritySuggestOptions(),
date: createDateSuggestOptions(),
target: createTargetSuggestOptions(plugin),
tag: createTagSuggestOptions(plugin),
};
}
/**
* Get suggest options by trigger character
*/
export function getSuggestOptionsByTrigger(
trigger: string,
plugin: TaskProgressBarPlugin
): SuggestOption[] {
const allOptions = createAllSuggestOptions(plugin);
switch (trigger) {
case "!":
return allOptions.priority;
case "~":
return allOptions.date;
case "*":
return allOptions.target;
case "#":
return allOptions.tag;
default:
return [];
}
}

View file

@ -0,0 +1,237 @@
import { App, Editor, EditorSuggest, TFile } from "obsidian";
import TaskProgressBarPlugin from "../../index";
import { UniversalEditorSuggest, UniversalSuggestConfig } from "./UniversalEditorSuggest";
export interface SuggestManagerConfig {
enableDynamicPriority: boolean;
defaultTriggerChars: string[];
contextFilters: {
[key: string]: (editor: Editor, file: TFile) => boolean;
};
}
/**
* Manages dynamic suggest registration and priority in workspace
*/
export class SuggestManager {
private app: App;
private plugin: TaskProgressBarPlugin;
private config: SuggestManagerConfig;
private activeSuggests: Map<string, EditorSuggest<any>> = new Map();
private originalSuggestsOrder: EditorSuggest<any>[] = [];
private isManaging: boolean = false;
constructor(app: App, plugin: TaskProgressBarPlugin, config?: Partial<SuggestManagerConfig>) {
this.app = app;
this.plugin = plugin;
this.config = {
enableDynamicPriority: true,
defaultTriggerChars: ["!", "~", "*", "#"],
contextFilters: {},
...config,
};
}
/**
* Start managing suggests with dynamic priority
*/
startManaging(): void {
if (this.isManaging) return;
this.isManaging = true;
// Store original order for restoration
this.originalSuggestsOrder = [...this.app.workspace.editorSuggest.suggests];
}
/**
* Stop managing and restore original order
*/
stopManaging(): void {
if (!this.isManaging) return;
// Remove all our managed suggests
this.removeAllManagedSuggests();
// Restore original order if needed
if (this.originalSuggestsOrder.length > 0) {
this.app.workspace.editorSuggest.suggests = [...this.originalSuggestsOrder];
}
this.isManaging = false;
this.originalSuggestsOrder = [];
}
/**
* Add a suggest with high priority (insert at beginning)
*/
addSuggestWithPriority(suggest: EditorSuggest<any>, id: string): void {
if (!this.isManaging) {
console.warn("SuggestManager: Not managing, call startManaging() first");
return;
}
// Remove if already exists
this.removeManagedSuggest(id);
// Add to our tracking
this.activeSuggests.set(id, suggest);
// Insert at the beginning for high priority
this.app.workspace.editorSuggest.suggests.unshift(suggest);
}
/**
* Remove a managed suggest
*/
removeManagedSuggest(id: string): void {
const suggest = this.activeSuggests.get(id);
if (!suggest) return;
// Remove from workspace
const index = this.app.workspace.editorSuggest.suggests.indexOf(suggest);
if (index !== -1) {
this.app.workspace.editorSuggest.suggests.splice(index, 1);
}
// Remove from our tracking
this.activeSuggests.delete(id);
}
/**
* Remove all managed suggests
*/
removeAllManagedSuggests(): void {
for (const [id] of this.activeSuggests) {
this.removeManagedSuggest(id);
}
}
/**
* Create and add a universal suggest for specific context
*/
createUniversalSuggest(
contextId: string,
config: Partial<UniversalSuggestConfig> = {}
): UniversalEditorSuggest {
const suggestConfig: UniversalSuggestConfig = {
triggerChars: this.config.defaultTriggerChars,
contextFilter: this.config.contextFilters[contextId],
priority: 1,
...config,
};
const suggest = new UniversalEditorSuggest(this.app, this.plugin, suggestConfig);
// Add with priority
this.addSuggestWithPriority(suggest, `universal-${contextId}`);
return suggest;
}
/**
* Enable suggests for a specific editor context
*/
enableForEditor(editor: Editor, contextId: string = "default"): UniversalEditorSuggest {
const suggest = this.createUniversalSuggest(contextId, {
contextFilter: (ed, file) => ed === editor,
});
suggest.enable();
return suggest;
}
/**
* Disable suggests for a specific context
*/
disableForContext(contextId: string): void {
this.removeManagedSuggest(`universal-${contextId}`);
}
/**
* Enable suggests for minimal quick capture modal
*/
enableForMinimalModal(editor: Editor): UniversalEditorSuggest {
return this.createUniversalSuggest("minimal-modal", {
contextFilter: (ed, file) => {
// Check if we're in a minimal quick capture context
const editorEl = (ed as any).cm?.dom as HTMLElement;
return editorEl?.closest(".quick-capture-modal.minimal") !== null;
},
});
}
/**
* Enable suggests for regular quick capture modal
*/
enableForQuickCaptureModal(editor: Editor): UniversalEditorSuggest {
return this.createUniversalSuggest("quick-capture-modal", {
contextFilter: (ed, file) => {
// Check if we're in a quick capture context
const editorEl = (ed as any).cm?.dom as HTMLElement;
return editorEl?.closest(".quick-capture-modal") !== null;
},
});
}
/**
* Add a custom context filter
*/
addContextFilter(
contextId: string,
filter: (editor: Editor, file: TFile) => boolean
): void {
this.config.contextFilters[contextId] = filter;
}
/**
* Remove a context filter
*/
removeContextFilter(contextId: string): void {
delete this.config.contextFilters[contextId];
}
/**
* Get all active suggests
*/
getActiveSuggests(): Map<string, EditorSuggest<any>> {
return new Map(this.activeSuggests);
}
/**
* Check if currently managing
*/
isCurrentlyManaging(): boolean {
return this.isManaging;
}
/**
* Get current configuration
*/
getConfig(): SuggestManagerConfig {
return { ...this.config };
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<SuggestManagerConfig>): void {
this.config = { ...this.config, ...newConfig };
}
/**
* Debug: Log current suggest order
*/
debugLogSuggestOrder(): void {
console.log("Current suggest order:", this.app.workspace.editorSuggest.suggests);
console.log("Managed suggests:", Array.from(this.activeSuggests.keys()));
}
/**
* Cleanup method for proper disposal
*/
cleanup(): void {
this.stopManaging();
this.activeSuggests.clear();
this.config.contextFilters = {};
}
}

View file

@ -0,0 +1,209 @@
import {
App,
Editor,
EditorPosition,
EditorSuggest,
EditorSuggestContext,
EditorSuggestTriggerInfo,
TFile,
setIcon,
} from "obsidian";
import TaskProgressBarPlugin from "../../index";
import { t } from "../../translations/helper";
import { getSuggestOptionsByTrigger } from "./SpecialCharacterSuggests";
export interface SuggestOption {
id: string;
label: string;
icon: string;
description: string;
replacement: string;
trigger: string;
action?: (editor: Editor, cursor: EditorPosition) => void;
}
export interface UniversalSuggestConfig {
triggerChars: string[];
contextFilter?: (editor: Editor, file: TFile) => boolean;
priority?: number;
}
/**
* Universal EditorSuggest that handles multiple special characters
* and provides dynamic priority management
*/
export class UniversalEditorSuggest extends EditorSuggest<SuggestOption> {
plugin: TaskProgressBarPlugin;
private config: UniversalSuggestConfig;
private suggestOptions: SuggestOption[] = [];
private isEnabled: boolean = false;
constructor(
app: App,
plugin: TaskProgressBarPlugin,
config: UniversalSuggestConfig
) {
super(app);
this.plugin = plugin;
this.config = config;
this.initializeSuggestOptions();
}
/**
* Initialize suggest options for all supported special characters
*/
private initializeSuggestOptions(): void {
// Initialize with empty array - options will be loaded dynamically
this.suggestOptions = [];
}
/**
* Enable this suggest instance
*/
enable(): void {
this.isEnabled = true;
}
/**
* Disable this suggest instance
*/
disable(): void {
this.isEnabled = false;
}
/**
* Check if suggestion should be triggered
*/
onTrigger(
cursor: EditorPosition,
editor: Editor,
file: TFile
): EditorSuggestTriggerInfo | null {
// Only trigger if enabled
if (!this.isEnabled) {
return null;
}
// Apply context filter if provided
if (
this.config.contextFilter &&
!this.config.contextFilter(editor, file)
) {
return null;
}
// Get the current line
const line = editor.getLine(cursor.line);
// Check if cursor is right after any of our trigger characters
if (cursor.ch > 0) {
const charBefore = line.charAt(cursor.ch - 1);
if (this.config.triggerChars.includes(charBefore)) {
return {
start: { line: cursor.line, ch: cursor.ch - 1 },
end: cursor,
query: charBefore,
};
}
}
return null;
}
/**
* Get suggestions based on the trigger character
*/
getSuggestions(context: EditorSuggestContext): SuggestOption[] {
const triggerChar = context.query;
// Get dynamic suggestions based on trigger character
return getSuggestOptionsByTrigger(triggerChar, this.plugin);
}
/**
* Render suggestion in the popup
*/
renderSuggestion(suggestion: SuggestOption, el: HTMLElement): void {
const container = el.createDiv({ cls: "universal-suggest-item" });
// Icon
const iconEl = container.createDiv({ cls: "universal-suggest-icon" });
setIcon(iconEl, suggestion.icon);
// Content
const contentEl = container.createDiv({
cls: "universal-suggest-content",
});
contentEl.createDiv({
cls: "universal-suggest-label",
text: suggestion.label,
});
contentEl.createDiv({
cls: "universal-suggest-description",
text: suggestion.description,
});
}
/**
* Handle suggestion selection
*/
selectSuggestion(
suggestion: SuggestOption,
evt: MouseEvent | KeyboardEvent
): void {
const editor = this.context?.editor;
const cursor = this.context?.end;
if (!editor || !cursor) return;
// Replace the trigger character with the replacement
const startPos = { line: cursor.line, ch: cursor.ch - 1 };
const endPos = cursor;
editor.replaceRange(suggestion.replacement, startPos, endPos);
// Move cursor to after the replacement
const newCursor = {
line: cursor.line,
ch: cursor.ch - 1 + suggestion.replacement.length,
};
editor.setCursor(newCursor);
// Execute custom action if provided
if (suggestion.action) {
suggestion.action(editor, newCursor);
}
}
/**
* Add a custom suggest option
*/
addSuggestOption(option: SuggestOption): void {
this.suggestOptions.push(option);
if (!this.config.triggerChars.includes(option.trigger)) {
this.config.triggerChars.push(option.trigger);
}
}
/**
* Remove a suggest option by id
*/
removeSuggestOption(id: string): void {
this.suggestOptions = this.suggestOptions.filter(
(option) => option.id !== id
);
}
/**
* Get current configuration
*/
getConfig(): UniversalSuggestConfig {
return { ...this.config };
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<UniversalSuggestConfig>): void {
this.config = { ...this.config, ...newConfig };
}
}

View file

@ -0,0 +1,14 @@
export {
UniversalEditorSuggest,
type SuggestOption,
type UniversalSuggestConfig,
} from "./UniversalEditorSuggest";
export { SuggestManager, type SuggestManagerConfig } from "./SuggestManager";
export {
createPrioritySuggestOptions,
createDateSuggestOptions,
createTargetSuggestOptions,
createTagSuggestOptions,
createAllSuggestOptions,
getSuggestOptionsByTrigger,
} from "./SpecialCharacterSuggests";

View file

@ -0,0 +1,142 @@
import { EditorView } from "@codemirror/view";
import { Extension, Transaction } from "@codemirror/state";
import { clearAllMarks } from "../components/MarkdownRenderer";
/**
* Extension to handle cleanup of task marks when text is selected and deleted
* This ensures that when users select text containing task metadata (like priority marks)
* and delete it, the marks are properly cleaned up
*/
export function taskMarkCleanupExtension(): Extension {
return EditorView.updateListener.of((update) => {
// Only process transactions that have changes
if (!update.docChanged) return;
// Check if this is a user deletion operation
const tr = update.transactions[0];
if (!tr || !isUserDeletion(tr)) return;
// Process each change to see if we need to clean up marks
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
// Only handle deletions (where text was removed)
if (fromA >= toA) return;
const deletedText = tr.startState.doc.sliceString(fromA, toA);
const insertedText = inserted.toString();
// Check if the deleted text contains task marks
if (containsTaskMarks(deletedText)) {
// Get the line containing the change
const line = update.state.doc.lineAt(fromB);
const lineText = line.text;
// Check if this is a task line
if (isTaskLine(lineText)) {
// Clean the line of any orphaned marks
const cleanedLine = cleanOrphanedMarks(lineText);
if (cleanedLine !== lineText) {
// Apply the cleanup
update.view.dispatch({
changes: {
from: line.from,
to: line.to,
insert: cleanedLine
}
});
}
}
}
});
});
}
/**
* Check if a transaction represents a user deletion operation
*/
function isUserDeletion(tr: Transaction): boolean {
// Check if this is a user input event
if (!tr.isUserEvent("input.delete") && !tr.isUserEvent("input.deleteBackward")) {
return false;
}
// Check if there are actual deletions
let hasDeletions = false;
tr.changes.iterChanges((fromA, toA) => {
if (fromA < toA) {
hasDeletions = true;
}
});
return hasDeletions;
}
/**
* Check if text contains task marks that might need cleanup
*/
function containsTaskMarks(text: string): boolean {
// Check for priority marks
const priorityRegex = /(?:🔺|⏫|🔼|🔽|⏬️|\[#[A-C]\]|!)/;
if (priorityRegex.test(text)) return true;
// Check for date marks
const dateRegex = /(?:📅|🛫|⏳|✅||❌)/;
if (dateRegex.test(text)) return true;
// Check for other metadata marks
const metadataRegex = /(?:🆔|⛔|🏁|🔁|@|#)/;
if (metadataRegex.test(text)) return true;
return false;
}
/**
* Check if a line is a task line
*/
function isTaskLine(line: string): boolean {
const taskRegex = /^\s*[-*+]\s*\[[^\]]*\]/;
return taskRegex.test(line);
}
/**
* Clean orphaned marks from a task line
* This removes marks that are no longer properly associated with content
*/
function cleanOrphanedMarks(line: string): string {
// First, extract the task marker part
const taskMarkerMatch = line.match(/^(\s*[-*+]\s*\[[^\]]*\]\s*)/);
if (!taskMarkerMatch) return line;
const taskMarker = taskMarkerMatch[1];
const content = line.substring(taskMarker.length);
// Use the existing clearAllMarks function to clean the content
const cleanedContent = clearAllMarks(content);
// If the content is now empty or just whitespace, remove orphaned marks
if (!cleanedContent.trim()) {
// Remove any trailing marks that are now orphaned
const cleanedLine = taskMarker.trim();
return cleanedLine;
}
// Reconstruct the line with cleaned content
return taskMarker + cleanedContent;
}
/**
* Check if marks in the line are orphaned (not properly associated with content)
*/
function hasOrphanedMarks(line: string): boolean {
// Extract content after task marker
const taskMarkerMatch = line.match(/^\s*[-*+]\s*\[[^\]]*\]\s*(.*)/);
if (!taskMarkerMatch) return false;
const content = taskMarkerMatch[1];
// Check if there are marks but no meaningful content
const hasMarks = containsTaskMarks(content);
const hasContent = content.replace(/[🔺⏫🔼🔽⏬️📅🛫⏳✅➕❌🆔⛔🏁🔁@#!\[\]]/g, '').trim().length > 0;
return hasMarks && !hasContent;
}

View file

@ -67,6 +67,7 @@ import { Task } from "./types/task";
import { QuickCaptureModal } from "./components/QuickCaptureModal";
import { MinimalQuickCaptureModal } from "./components/MinimalQuickCaptureModal";
import { MinimalQuickCaptureSuggest } from "./components/MinimalQuickCaptureSuggest";
import { SuggestManager } from "./components/suggest";
import { MarkdownView } from "obsidian";
import { Notice } from "obsidian";
import { t } from "./translations/helper";
@ -92,6 +93,7 @@ import { monitorTaskCompletedExtension } from "./editor-ext/monitorTaskCompleted
import { sortTasksInDocument } from "./commands/sortTaskCommands";
import { taskGutterExtension } from "./editor-ext/TaskGutterHandler";
import { autoDateManagerExtension } from "./editor-ext/autoDateManager";
import { taskMarkCleanupExtension } from "./editor-ext/taskMarkCleanup";
import { ViewManager } from "./pages/ViewManager";
import { IcsManager } from "./utils/ics/IcsManager";
import { VersionManager } from "./utils/VersionManager";
@ -184,10 +186,13 @@ export default class TaskProgressBarPlugin extends Plugin {
// ICS manager instance
icsManager: IcsManager;
// Minimal quick capture suggest
minimalQuickCaptureSuggest: MinimalQuickCaptureSuggest;
// Global suggest manager
globalSuggestManager: SuggestManager;
// Version manager instance
versionManager: VersionManager;
@ -218,6 +223,9 @@ export default class TaskProgressBarPlugin extends Plugin {
this.versionManager = new VersionManager(this.app, this);
this.addChild(this.versionManager);
// Initialize global suggest manager
this.globalSuggestManager = new SuggestManager(this.app, this);
// Initialize rebuild progress manager
this.rebuildProgressManager = new RebuildProgressManager();
@ -1025,10 +1033,14 @@ export default class TaskProgressBarPlugin extends Plugin {
quickCaptureExtension(this.app, this),
]);
}
// Initialize minimal quick capture suggest
if (this.settings.quickCapture.enableMinimalMode) {
this.minimalQuickCaptureSuggest = new MinimalQuickCaptureSuggest(this.app, this);
this.minimalQuickCaptureSuggest = new MinimalQuickCaptureSuggest(
this.app,
this
);
this.registerEditorSuggest(this.minimalQuickCaptureSuggest);
}
// Add task filter extension
@ -1042,9 +1054,17 @@ export default class TaskProgressBarPlugin extends Plugin {
autoDateManagerExtension(this.app, this),
]);
}
// Add task mark cleanup extension (always enabled)
this.registerEditorExtension([taskMarkCleanupExtension()]);
}
onunload() {
// Clean up global suggest manager
if (this.globalSuggestManager) {
this.globalSuggestManager.cleanup();
}
// Clean up task manager when plugin is unloaded
if (this.taskManager) {
this.taskManager.onunload();
@ -1055,36 +1075,37 @@ export default class TaskProgressBarPlugin extends Plugin {
async loadSettings() {
const savedData = await this.loadData();
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
savedData
);
this.settings = Object.assign({}, DEFAULT_SETTINGS, savedData);
// Migrate old inheritance settings to new structure
this.migrateInheritanceSettings(savedData);
}
private migrateInheritanceSettings(savedData: any) {
// Check if old inheritance settings exist and new ones don't
if (savedData?.projectConfig?.metadataConfig &&
!savedData?.fileMetadataInheritance) {
if (
savedData?.projectConfig?.metadataConfig &&
!savedData?.fileMetadataInheritance
) {
const oldConfig = savedData.projectConfig.metadataConfig;
// Migrate to new structure
this.settings.fileMetadataInheritance = {
enabled: true,
inheritFromFrontmatter: oldConfig.inheritFromFrontmatter ?? true,
inheritFromFrontmatterForSubtasks: oldConfig.inheritFromFrontmatterForSubtasks ?? false
inheritFromFrontmatter:
oldConfig.inheritFromFrontmatter ?? true,
inheritFromFrontmatterForSubtasks:
oldConfig.inheritFromFrontmatterForSubtasks ?? false,
};
// Remove old inheritance settings from project config
if (this.settings.projectConfig?.metadataConfig) {
delete (this.settings.projectConfig.metadataConfig as any).inheritFromFrontmatter;
delete (this.settings.projectConfig.metadataConfig as any).inheritFromFrontmatterForSubtasks;
delete (this.settings.projectConfig.metadataConfig as any)
.inheritFromFrontmatter;
delete (this.settings.projectConfig.metadataConfig as any)
.inheritFromFrontmatterForSubtasks;
}
// Save the migrated settings
this.saveSettings();
}

View file

@ -246,3 +246,5 @@ settings:
default-light: '#f1f1f1'
default-dark: '#f1f1f1'
*/
@import url("universal-suggest.css");

View file

@ -38,8 +38,6 @@
justify-content: space-between;
align-items: center;
padding: var(--size-4-2);
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
}
.quick-actions-left {
@ -52,26 +50,6 @@
gap: var(--size-2-1);
}
.quick-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background-color: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
}
.quick-action-button:hover {
background-color: var(--background-modifier-hover);
border-color: var(--interactive-accent);
}
.quick-action-button.active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
@ -133,7 +111,6 @@
display: flex;
align-items: center;
gap: var(--size-2-1);
width: 100%;
}
.suggestion-icon {

View file

@ -0,0 +1,85 @@
/* Universal Suggest Styles */
.universal-suggest-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.1s ease;
}
.universal-suggest-item:hover {
background-color: var(--background-modifier-hover);
}
.universal-suggest-item.is-selected {
background-color: var(--background-modifier-active-hover);
}
.universal-suggest-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.universal-suggest-content {
flex: 1;
min-width: 0;
}
.universal-suggest-label {
font-weight: 500;
color: var(--text-normal);
margin-bottom: 2px;
}
.universal-suggest-description {
font-size: 0.85em;
color: var(--text-muted);
line-height: 1.3;
}
/* Special character trigger highlighting */
.cm-editor .cm-line .universal-suggest-trigger {
background-color: var(--background-modifier-accent);
color: var(--text-accent);
border-radius: 2px;
padding: 1px 2px;
}
/* Suggest popup container */
.suggestion-container .universal-suggest-item {
border-bottom: 1px solid var(--background-modifier-border);
}
.suggestion-container .universal-suggest-item:last-child {
border-bottom: none;
}
/* Dark theme adjustments */
.theme-dark .universal-suggest-item:hover {
background-color: var(--background-modifier-hover);
}
.theme-dark .universal-suggest-item.is-selected {
background-color: var(--background-modifier-active-hover);
}
/* High contrast mode */
@media (prefers-contrast: high) {
.universal-suggest-item {
border: 1px solid var(--background-modifier-border);
margin-bottom: 2px;
}
.universal-suggest-item:hover,
.universal-suggest-item.is-selected {
border-color: var(--text-accent);
}
}

File diff suppressed because it is too large Load diff

View file

@ -130,24 +130,6 @@ export class TaskGeniusIconManager extends Component {
}
}
css =
`
input[type=checkbox]:checked {
background-color: unset;
border: none;
}
input[type=checkbox] {
border: none;
}
@media (hover: hover) {
input[type=checkbox]:checked:hover {
background-color: unset;
border: none;
}
}
` + css;
return css;
}
@ -282,11 +264,20 @@ input[type=checkbox] {
if (!isSpace) {
return `
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox],
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox],
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox] {
border: none;
}
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox]:checked,
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox]:checked,
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox]:checked {
--checkbox-color: ${fillColor};
--checkbox-color-hover: ${fillColor};
background-color: unset;
border: none;
}
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox]:checked:after,
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox]:checked:after,
@ -298,12 +289,20 @@ input[type=checkbox] {
`;
} else {
return `
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox],
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox],
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox] {
border: none;
}
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox],
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox],
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox] {
--checkbox-color: ${fillColor};
--checkbox-color-hover: ${fillColor};
background-color: unset;
border: none;
}
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox]:after,
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox]:after,

11130
styles.css

File diff suppressed because one or more lines are too long