mirror of
https://github.com/mattang687/obsidian-checkbox-sort.git
synced 2026-07-22 05:45:27 +00:00
382 lines
18 KiB
TypeScript
382 lines
18 KiB
TypeScript
import { Plugin, MarkdownView, PluginSettingTab, Setting, App } from 'obsidian';
|
|
import { ViewPlugin } from '@codemirror/view';
|
|
import { Extension } from '@codemirror/state';
|
|
|
|
interface CheckboxSortSettings {
|
|
enableGlobalCheckboxSort: boolean;
|
|
debugMode: boolean;
|
|
}
|
|
|
|
const DEFAULT_SETTINGS: CheckboxSortSettings = {
|
|
enableGlobalCheckboxSort: true, // Default to enabled
|
|
debugMode: false // Default disabled
|
|
}
|
|
|
|
function checkboxClickHandlerExtension(onClick: (lineNumber: number) => void): Extension {
|
|
return ViewPlugin.define(view => {
|
|
const handleClick = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target instanceof HTMLInputElement &&
|
|
target.type === 'checkbox' &&
|
|
target.classList.contains('task-list-item-checkbox'))
|
|
{
|
|
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
if (pos !== null) {
|
|
const line = view.state.doc.lineAt(pos);
|
|
const lineNumber = line.number - 1; // 0-based
|
|
try {
|
|
onClick(lineNumber);
|
|
} catch (e) {
|
|
console.error("Error executing onClick callback:", e);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
view.dom.addEventListener('click', handleClick);
|
|
return {
|
|
destroy() {
|
|
view.dom.removeEventListener('click', handleClick);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
export default class ObsidianCheckboxSort extends Plugin {
|
|
settings!: CheckboxSortSettings;
|
|
|
|
debugLog(message: any, ...optionalParams: any[]) {
|
|
if (this.settings.debugMode) {
|
|
console.debug(message, ...optionalParams);
|
|
}
|
|
}
|
|
|
|
async onload() {
|
|
// Load settings
|
|
await this.loadSettings();
|
|
|
|
// Add the settings tab
|
|
this.addSettingTab(new CheckboxSortSettingTab(this.app, this));
|
|
|
|
// Register the editor extension (remains the same)
|
|
const extension = checkboxClickHandlerExtension((lineNumber) => {
|
|
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
// Pass the whole view object now to access file context
|
|
if (view && view.file) { // Ensure view and file exist
|
|
this.sortCheckboxesAroundClick(view, lineNumber);
|
|
} else {
|
|
console.error("CALLBACK: Could not get MarkdownView to process checkbox click.");
|
|
}
|
|
});
|
|
try {
|
|
this.registerEditorExtension(extension);
|
|
this.debugLog("Checkbox Sort editor extension registered successfully.");
|
|
} catch (e) {
|
|
console.error("Failed to register Checkbox Sort editor extension:", e);
|
|
}
|
|
}
|
|
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
// --- sortCheckboxesAroundClick ---
|
|
async sortCheckboxesAroundClick(view: MarkdownView, clickedLineNumber: number) {
|
|
// Get editor and file from view
|
|
const editor = view.editor;
|
|
const file = view.file; // Needed for frontmatter check
|
|
|
|
// --- Determine Effective Setting (Global -> Frontmatter -> List Marker) ---
|
|
let fileLevelSortingEnabled = this.settings!.enableGlobalCheckboxSort;
|
|
let settingSource = "Global Default";
|
|
|
|
// Hierarchy: List Marker > Frontmatter > Global Setting
|
|
// File may not be available in detached views - crucial for frontmatter check
|
|
// getFileCache() is null until file is persisted to disk
|
|
if (file) {
|
|
// Critical: metadataCache only contains persisted files - new/unsaved files
|
|
// won't have frontmatter available until first save
|
|
const fileCache = this.app.metadataCache.getFileCache(file);
|
|
const frontmatter = fileCache?.frontmatter;
|
|
if (frontmatter && frontmatter['checkbox-sort'] !== undefined && typeof frontmatter['checkbox-sort'] === 'boolean') {
|
|
fileLevelSortingEnabled = frontmatter['checkbox-sort'];
|
|
settingSource = "File Frontmatter";
|
|
}
|
|
}
|
|
this.debugLog(`Starting effective setting based on Global/Frontmatter: ${fileLevelSortingEnabled} (Source: ${settingSource})`);
|
|
|
|
// --- Now, check for List Marker Override ---
|
|
// Need to find blockStartLine *before* this check
|
|
const totalLines = editor.lineCount(); // Ensure totalLines is available
|
|
let effectiveSortingEnabled = fileLevelSortingEnabled; // Start with file level result
|
|
|
|
// Need clickedLineText, currentIndent, listItemRegex before finding blockStartLine
|
|
const clickedLineText = editor.getLine(clickedLineNumber);
|
|
const currentIndent = this.getIndentationLevel(clickedLineText);
|
|
const listItemRegex = /^\s*[-*+]\s+/;
|
|
|
|
// Find contiguous list items at same indent level - these form a sortable group
|
|
// Nested lists (higher indent) are considered part of parent item's subtree
|
|
let blockStartLine = clickedLineNumber;
|
|
for (let i = clickedLineNumber - 1; i >= 0; i--) { // Scan Up
|
|
const line = editor.getLine(i); const indent = this.getIndentationLevel(line);
|
|
if (!listItemRegex.test(line)) break;
|
|
if (indent === currentIndent) { blockStartLine = i; } else if (indent < currentIndent) { break; }
|
|
}
|
|
this.debugLog(`Peer block starts at line: ${blockStartLine}. Scanning for list marker...`);
|
|
|
|
|
|
// 2. Scan for List Marker Override (Scan upwards from clicked line until marker or top)
|
|
const enableMarker = '%%checkbox-sort: true%%'; // Hardcoded for now
|
|
const disableMarker = '%%checkbox-sort: false%%'; // Hardcoded for now
|
|
let listMarkerFound: boolean | null = null;
|
|
let foundMarkerLine = -1;
|
|
|
|
this.debugLog(`Scanning upwards from line ${clickedLineNumber - 1} for list marker...`);
|
|
for (let scanLineNum = clickedLineNumber - 1; scanLineNum >= 0; scanLineNum--) {
|
|
const lineContent = editor.getLine(scanLineNum).trim();
|
|
|
|
// Check for specific markers
|
|
if (lineContent.includes(enableMarker)) {
|
|
listMarkerFound = true;
|
|
settingSource = "List Marker (Enable)";
|
|
foundMarkerLine = scanLineNum;
|
|
this.debugLog(`Found enable marker at line ${scanLineNum}`);
|
|
break; // Closest marker wins
|
|
}
|
|
if (lineContent.includes(disableMarker)) {
|
|
listMarkerFound = false;
|
|
settingSource = "List Marker (Disable)";
|
|
foundMarkerLine = scanLineNum;
|
|
this.debugLog(`Found disable marker at line ${scanLineNum}`);
|
|
break; // Closest marker wins
|
|
}
|
|
|
|
// Stop scanning if we hit non-list content - markers only apply to subsequent list items
|
|
// in the same list structure. A paragraph/heading breaks the list context.
|
|
if (!listItemRegex.test(lineContent)) {
|
|
this.debugLog(`Scan hit non-list item line at ${scanLineNum}, stopping marker search.`);
|
|
break; // Stop scanning upwards
|
|
}
|
|
}
|
|
|
|
// 3. Apply List Marker Override if found
|
|
if (listMarkerFound !== null) {
|
|
effectiveSortingEnabled = listMarkerFound;
|
|
this.debugLog(`List marker found at line ${foundMarkerLine}. Effective sorting overridden to: ${effectiveSortingEnabled} (Source: ${settingSource})`);
|
|
} else {
|
|
this.debugLog(`No list marker found above line ${clickedLineNumber}. Effective sorting remains: ${effectiveSortingEnabled} (Source: ${settingSource})`);
|
|
}
|
|
|
|
|
|
// --- Final Effective Setting Check ---
|
|
if (!effectiveSortingEnabled) {
|
|
this.debugLog(`Checkbox sorting disabled by ${settingSource} setting. Aborting sort logic.`);
|
|
// Perform basic tick/untick in place? Let's skip for now.
|
|
return;
|
|
}
|
|
// --- End Effective Setting Check ---
|
|
|
|
|
|
this.debugLog(`sortCheckboxesAroundClick: Proceeding with sort for line: ${clickedLineNumber}`);
|
|
try {
|
|
const clickedLineText = editor.getLine(clickedLineNumber);
|
|
const currentIndent = this.getIndentationLevel(clickedLineText);
|
|
const listItemRegex = /^\s*[-*+]\s+/;
|
|
|
|
// --- We already have totalLines, clickedLineText, currentIndent, listItemRegex ---
|
|
// --- Need remaining variables for the logic block ---
|
|
const tickedCheckboxRegex = /^\s*[-*+]\s+\[x\]/;
|
|
|
|
const isCurrentlyTicked = tickedCheckboxRegex.test(clickedLineText);
|
|
const isNowTicked = !isCurrentlyTicked;
|
|
|
|
if (!listItemRegex.test(clickedLineText)) {
|
|
console.warn(`Line ${clickedLineNumber} is not a list item. Aborting action.`);
|
|
return;
|
|
}
|
|
|
|
this.debugLog(`Click detected on line ${clickedLineNumber}. Current state: ${isCurrentlyTicked ? 'Ticked' : 'Unticked'}. New state: ${isNowTicked ? 'Ticked' : 'Unticked'}. Indent: ${currentIndent}`);
|
|
|
|
|
|
// --- Step 1: Find Peer Block Boundaries (Peers at same indent) ---
|
|
let blockStartLine = clickedLineNumber;
|
|
for (let i = clickedLineNumber - 1; i >= 0; i--) { // Scan Up
|
|
const line = editor.getLine(i);
|
|
const indent = this.getIndentationLevel(line);
|
|
if (!listItemRegex.test(line)) break;
|
|
if (indent === currentIndent) {
|
|
blockStartLine = i;
|
|
} else if (indent < currentIndent) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let blockEndLine = clickedLineNumber; // This will store the line number of the last PEER
|
|
for (let i = clickedLineNumber + 1; i < totalLines; i++) { // Scan Down
|
|
const line = editor.getLine(i); const indent = this.getIndentationLevel(line);
|
|
if (!listItemRegex.test(line)) break;
|
|
if (indent === currentIndent) { blockEndLine = i; } else if (indent < currentIndent) { break; }
|
|
}
|
|
this.debugLog(`Peer block boundaries (inclusive): ${blockStartLine} to ${blockEndLine}`);
|
|
|
|
// Step 2: Iterate through PEERS, collect item trees, sort into lists
|
|
const untickedItemsData: { text: string, originalLine: number }[] = [];
|
|
const tickedItemsData: { text: string, originalLine: number }[] = [];
|
|
|
|
let i = blockStartLine;
|
|
while (i <= blockEndLine) {
|
|
const currentPeerLineText = editor.getLine(i);
|
|
const currentPeerIndent = this.getIndentationLevel(currentPeerLineText);
|
|
|
|
if (!listItemRegex.test(currentPeerLineText) || currentPeerIndent !== currentIndent) {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Find the end of the tree for this peer item 'i'
|
|
let peerTreeEndLine = i;
|
|
for (let j = i + 1; j < totalLines; j++) {
|
|
const descLineText = editor.getLine(j);
|
|
const descIndent = this.getIndentationLevel(descLineText);
|
|
// Tree ends if not list item or indent is not greater than the PEER's indent
|
|
if (!listItemRegex.test(descLineText) || descIndent <= currentPeerIndent) { break; }
|
|
peerTreeEndLine = j;
|
|
}
|
|
this.debugLog(`Peer at ${i}: Tree detected from line ${i} to ${peerTreeEndLine}`);
|
|
|
|
// Process each peer's entire subtree (nested items) as a single unit
|
|
// to preserve hierarchical relationships during sorting
|
|
let peerTreeText = "";
|
|
for (let k = i; k <= peerTreeEndLine; k++) {
|
|
// Add newline unless it's the absolute last line of the file
|
|
peerTreeText += editor.getLine(k) + (k === totalLines - 1 ? "" : "\n");
|
|
}
|
|
|
|
// Use inverse of current state for sorting because the click hasn't
|
|
// been processed by Obsidian yet - we're intercepting the raw event
|
|
let isPeerTickedForSorting: boolean;
|
|
// Handle clicked item first - its state is inverted from current rendering
|
|
// because we're intercepting the click before Obsidian updates it
|
|
if (i === clickedLineNumber) {
|
|
isPeerTickedForSorting = isNowTicked;
|
|
// Modify the first line of the extracted text block to reflect the new state
|
|
const lines = peerTreeText.split('\n');
|
|
if (lines.length > 0) {
|
|
if (isNowTicked) lines[0] = lines[0].replace('[ ]', '[x]');
|
|
else lines[0] = lines[0].replace('[x]', '[ ]');
|
|
peerTreeText = lines.join('\n');
|
|
this.debugLog(`Updated text for clicked item (lines ${i}-${peerTreeEndLine}) for new state: ${isNowTicked?'Ticked':'Unticked'}`);
|
|
}
|
|
} else { // Not the clicked item, use its current state
|
|
isPeerTickedForSorting = tickedCheckboxRegex.test(currentPeerLineText);
|
|
}
|
|
|
|
// Add data block to the appropriate list
|
|
const itemData = { text: peerTreeText, originalLine: i };
|
|
if (isPeerTickedForSorting) {
|
|
tickedItemsData.push(itemData);
|
|
} else {
|
|
untickedItemsData.push(itemData);
|
|
}
|
|
|
|
// Advance loop counter past the processed tree
|
|
i = peerTreeEndLine + 1;
|
|
} // End of peer processing loop
|
|
|
|
// --- Step 3: Prepare final text and replacement range ---
|
|
const finalBlockText = untickedItemsData.map(d => d.text).join('') +
|
|
tickedItemsData.map(d => d.text).join('');
|
|
|
|
// Determine the range of the entire original block structure to delete
|
|
// Find the actual end of the whole structure, including children of the last peer
|
|
let overallBlockEndLine = blockEndLine; // Start with last peer line
|
|
for(let i = blockEndLine + 1; i < totalLines; ++i) {
|
|
const line = editor.getLine(i);
|
|
const indent = this.getIndentationLevel(line);
|
|
// Stop if not list item or indent goes back to less than the base indent
|
|
if (!listItemRegex.test(line) || indent < currentIndent) break;
|
|
overallBlockEndLine = i;
|
|
}
|
|
this.debugLog(`Entire structure to replace spans ${blockStartLine} to ${overallBlockEndLine}`);
|
|
|
|
const deleteFrom = { line: blockStartLine, ch: 0 };
|
|
const deleteTo = (overallBlockEndLine === totalLines - 1)
|
|
? { line: overallBlockEndLine, ch: editor.getLine(overallBlockEndLine).length }
|
|
: { line: overallBlockEndLine + 1, ch: 0 };
|
|
|
|
// Maintain proper newline structure - don't add trailing newline if replacing up to EOF,
|
|
// but ensure separation when inserting before existing content
|
|
let textToInsert = finalBlockText;
|
|
if (!textToInsert.endsWith('\n') && overallBlockEndLine < totalLines - 1) {
|
|
textToInsert += '\n';
|
|
}
|
|
if (textToInsert.endsWith('\n') && overallBlockEndLine === totalLines - 1) {
|
|
textToInsert = textToInsert.slice(0,-1);
|
|
}
|
|
|
|
// --- Step 4: Perform Transaction if needed ---
|
|
const originalBlockText = editor.getRange(deleteFrom, deleteTo);
|
|
if(textToInsert.trim() === originalBlockText.trim()) {
|
|
this.debugLog("No changes needed.");
|
|
return;
|
|
}
|
|
this.debugLog(`Replacing lines ${blockStartLine}-${overallBlockEndLine} with new sorted block.`);
|
|
editor.transaction({ changes: [{ from: deleteFrom, to: deleteTo, text: textToInsert }] });
|
|
this.debugLog(`Sorted checkbox block containing original line ${clickedLineNumber}.`);
|
|
|
|
} catch(e) {
|
|
console.error(`sortCheckboxesAroundClick: Error processing line ${clickedLineNumber}:`, e);
|
|
}
|
|
}
|
|
|
|
// --- getIndentationLevel --- (remains the same)
|
|
getIndentationLevel(line: string): number {
|
|
const match = line.match(/^\s*/);
|
|
return match ? match[0].length : 0;
|
|
}
|
|
|
|
onunload() {
|
|
// No specific setting cleanup needed here
|
|
}
|
|
}
|
|
|
|
class CheckboxSortSettingTab extends PluginSettingTab {
|
|
plugin: ObsidianCheckboxSort;
|
|
|
|
constructor(app: App, plugin: ObsidianCheckboxSort) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
display(): void {
|
|
const {containerEl} = this;
|
|
|
|
containerEl.empty();
|
|
|
|
new Setting(containerEl)
|
|
.setName('Enable checkbox sorting globally')
|
|
.setDesc('If enabled, clicking checkboxes will sort them within their peer group (unticked first, then ticked). This can be overridden by file frontmatter or list markers later.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.enableGlobalCheckboxSort)
|
|
.onChange(async (value) => {
|
|
this.plugin.debugLog('Global sort setting changed:', value);
|
|
this.plugin.settings.enableGlobalCheckboxSort = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Enable debug mode')
|
|
.setDesc('Show detailed debugging information in console')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.debugMode)
|
|
.onChange(async (value) => {
|
|
this.plugin.settings.debugMode = value;
|
|
await this.plugin.saveSettings();
|
|
}));
|
|
}
|
|
}
|