diff options
| -rw-r--r-- | .obsidian/plugins/abacus-sorter/main.js | 14 | ||||
| -rw-r--r-- | .obsidian/plugins/table-trimmer/main.js | 334 | ||||
| -rw-r--r-- | .obsidian/plugins/table-trimmer/manifest.json | 9 |
3 files changed, 348 insertions, 9 deletions
diff --git a/.obsidian/plugins/abacus-sorter/main.js b/.obsidian/plugins/abacus-sorter/main.js index 966e1a3..53bdb19 100644 --- a/.obsidian/plugins/abacus-sorter/main.js +++ b/.obsidian/plugins/abacus-sorter/main.js @@ -1,8 +1,4 @@ -/* -Abacus Custom Sorter. It's a config-driven custom file explorer sorter for Obsidian. -Inspired by SebastianMC's Custom File Explorer sorting. -This plugin however does not use a bookmark dependency. -*/ +/* Abacus Custom Sorter. Config-driven custom sorting for Obsidian's File Explorer. Manual ordering is stored internally (no bookmarks dependency). */ var obsidian = require('obsidian'); @@ -327,7 +323,7 @@ function sortItems(items, spec, app, manualOrderList) { return result; } -// Manual Order Sorting (standalone, no spec)─ +// Manual Order Sorting (standalone, no spec) function applyManualOrder(items, manualOrderList) { if (!manualOrderList || manualOrderList.length === 0) return items; @@ -349,7 +345,7 @@ function applyManualOrder(items, manualOrderList) { return ordered.filter(Boolean).concat(unordered); } -// File Explorer Patcher─ +// File Explorer Patcher function getFileExplorerLeaf(app) { const leaves = app.workspace.getLeavesOfType('file-explorer'); @@ -449,7 +445,7 @@ function unpatchFileExplorer(plugin) { } } -// Settings Tab─ +// Settings Tab class AbacusSettingTab extends obsidian.PluginSettingTab { constructor(app, plugin) { @@ -578,7 +574,7 @@ class AbacusSettingTab extends obsidian.PluginSettingTab { } } -// Manual Order Modal─ +// Manual Order Modal class ManualOrderModal extends obsidian.Modal { constructor(app, plugin, folderPath, items) { diff --git a/.obsidian/plugins/table-trimmer/main.js b/.obsidian/plugins/table-trimmer/main.js new file mode 100644 index 0000000..f162293 --- /dev/null +++ b/.obsidian/plugins/table-trimmer/main.js @@ -0,0 +1,334 @@ +"use strict"; + +/* Table Blank Line Remover. Obsidian will not normally let you delete the blank line above a table in Live Preview. This plugin takes that section of the document and surgically removes the newline that gets reintroduced. */ + +const { Plugin, Notice } = require("obsidian"); + +const VERSION = "1.4.0"; + +// Guard against pathological scans through a note that is mostly blank lines. +const MAX_BLANK_SCAN = 100; + +/* ------------------------------------------------------------------ * + * Pure markdown helpers. + * + * They all take a `doc`: { count: number, get(i): string }, 0-indexed. + * ------------------------------------------------------------------ */ + +function isBlank(text) { + return text.trim() === ""; +} + +function isQuoted(text) { + return /^\s*>/.test(text); +} + +// `| --- | :--: |` and friends. Requires at least one pipe so a plain `---` +// horizontal rule is never mistaken for a table delimiter. +function isDelimiterRow(text) { + const trimmed = text.trim(); + if (!trimmed.includes("|") || !trimmed.includes("-")) return false; + if (/[^\s|:\-]/.test(trimmed)) return false; + const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|"); + return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell)); +} + +function isTableRowLine(text) { + return !isBlank(text) && text.includes("|") && !isQuoted(text); +} + +function isTableStart(doc, i) { + return ( + i >= 0 && + i + 1 < doc.count && + isTableRowLine(doc.get(i)) && + isDelimiterRow(doc.get(i + 1)) + ); +} + +// Walks fences from the top of the note. Only called once a table-shaped +// pattern has already matched, so it stays off the hot path. +function isInsideCodeBlock(doc, index) { + let open = null; + for (let i = 0; i < index; i++) { + const match = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(doc.get(i)); + if (!match) continue; + const char = match[1][0]; + const len = match[1].length; + const rest = match[2].trim(); + if (open) { + if (char === open.char && len >= open.len && rest === "") open = null; + } else if (!(char === "`" && rest.includes("`"))) { + open = { char, len }; + } + } + return open !== null; +} + +function tableStartsAt(doc, i) { + return isTableStart(doc, i) && !isInsideCodeBlock(doc, i); +} + +// Index of the header row of the table containing line `i`, or -1. +function tableStartFor(doc, i) { + if (i < 0 || i >= doc.count || !isTableRowLine(doc.get(i))) return -1; + let top = i; + while (top > 0 && isTableRowLine(doc.get(top - 1))) top--; + return tableStartsAt(doc, top) ? top : -1; +} + +function firstNonBlankBelow(doc, i) { + let j = i; + let scanned = 0; + while (j < doc.count && isBlank(doc.get(j))) { + j++; + if (++scanned > MAX_BLANK_SCAN) return -1; + } + return j < doc.count ? j : -1; +} + +function lastNonBlankAbove(doc, i) { + let j = i - 1; + while (j >= 0 && isBlank(doc.get(j))) j--; + return j; +} + +/* ------------------------------------------------------------------ * + * The edit: replace the lines around the table wholesale, leaving the + * blank line out. Scoped to the smallest region that still reads as a + * block to Obsidian rather than as a newline being deleted. + * ------------------------------------------------------------------ */ + +function blockRewrite(doc, plan) { + const n = plan.blankLine; + const t = plan.tableLine; + if (t < 0 || t >= doc.count) return null; + + let from; + let to; + if (plan.direction === "above") { + // From the heading down through the table's delimiter row. + const heading = lastNonBlankAbove(doc, n); + from = heading < 0 ? n : heading; + to = Math.min(doc.count - 1, t + 1); + } else { + // From the table down through the line after the blank. + from = t; + to = Math.min(doc.count - 1, n + 1); + } + if (n < from || n > to) return null; + + const kept = []; + for (let i = from; i <= to; i++) { + if (i !== n) kept.push(doc.get(i)); + } + return { + range: { from: { line: from, ch: 0 }, to: { line: to, ch: doc.get(to).length } }, + text: kept.join("\n"), + lines: to - from + 1, + }; +} + +/* ------------------------------------------------------------------ * + * Edit plans: { blankLine, caret, direction, tableLine }, where `caret` + * is already in post-deletion coordinates and `tableLine` is in + * pre-deletion coordinates. Null means "not our business". + * ------------------------------------------------------------------ */ + +function planBackspace(doc, cursor) { + const { line, ch } = cursor; + const text = doc.get(line); + + // Caret parked on the blank line itself. + if (isBlank(text) && line > 0) { + const above = firstNonBlankBelow(doc, line); + const aboveTable = above !== -1 && tableStartsAt(doc, above); + const belowStart = tableStartFor(doc, line - 1); + if (!aboveTable && belowStart === -1) return null; + return { + blankLine: line, + caret: { line: line - 1, ch: doc.get(line - 1).length }, + direction: aboveTable ? "above" : "below", + tableLine: aboveTable ? above : belowStart, + }; + } + + // Caret at the very start of the table's header row, blank line above it. + if (ch === 0 && line > 0 && isBlank(doc.get(line - 1)) && tableStartsAt(doc, line)) { + return { + blankLine: line - 1, + caret: { line: line - 1, ch: 0 }, + direction: "above", + tableLine: line, + }; + } + + return null; +} + +function planDelete(doc, cursor) { + const { line, ch } = cursor; + const text = doc.get(line); + if (line + 1 >= doc.count) return null; + + // Caret at end of the heading (or of an earlier blank), blank line next. + if (ch === text.length && isBlank(doc.get(line + 1))) { + const above = firstNonBlankBelow(doc, line + 1); + const aboveTable = above !== -1 && tableStartsAt(doc, above); + const belowStart = tableStartFor(doc, line); + if (!aboveTable && belowStart === -1) return null; + return { + blankLine: line + 1, + caret: { line, ch: text.length }, + direction: aboveTable ? "above" : "below", + tableLine: aboveTable ? above : belowStart, + }; + } + + // Caret on the blank line directly above the table: pull the table up. + if (isBlank(text) && tableStartsAt(doc, line + 1)) { + return { + blankLine: line, + caret: { line, ch: 0 }, + direction: "above", + tableLine: line + 1, + }; + } + + return null; +} + +function docFromEditor(editor) { + return { count: editor.lineCount(), get: (i) => editor.getLine(i) }; +} + +/* ------------------------------------------------------------------ * + * Plugin + * ------------------------------------------------------------------ */ + +class TableBlankLineRemover extends Plugin { + onload() { + console.log(`[TBLR] Table Blank Line Remover v${VERSION}`); + + // Capture phase: ahead of CodeMirror and Obsidian's own handling. + this.registerDomEvent(window, "keydown", (evt) => this.onKeyDown(evt), { capture: true }); + + this.addCommand({ + id: "tighten-headings-above-tables", + name: "Remove blank lines between headings and tables in this note", + editorCallback: (editor) => this.tightenHeadings(editor), + }); + } + + onKeyDown(evt) { + if (evt.key !== "Backspace" && evt.key !== "Delete") return; + if (evt.ctrlKey || evt.metaKey || evt.altKey || evt.isComposing) return; + + const editor = this.app.workspace.activeEditor && this.app.workspace.activeEditor.editor; + if (!editor) return; + + // Only act on the editor that actually owns the keystroke. + const contentDOM = editor.cm && editor.cm.contentDOM; + if (contentDOM && evt.target instanceof Node && !contentDOM.contains(evt.target)) return; + if (editor.somethingSelected()) return; + + const cursor = editor.getCursor(); + const planner = evt.key === "Backspace" ? planBackspace : planDelete; + const plan = planner(docFromEditor(editor), { line: cursor.line, ch: cursor.ch }); + if (!plan) return; + + // Take the key away from CodeMirror entirely. + evt.preventDefault(); + evt.stopPropagation(); + if (evt.stopImmediatePropagation) evt.stopImmediatePropagation(); + + this.applyPlan(editor, plan); + } + + // Cheap length probe: getValue() copies the whole note, which is too + // expensive to call on a keystroke. + docLength(editor) { + if (editor.cm && editor.cm.state) return editor.cm.state.doc.length; + return editor.getValue().length; + } + + applyPlan(editor, plan) { + const edit = blockRewrite(docFromEditor(editor), plan); + if (!edit) return false; + + const before = this.docLength(editor); + editor.replaceRange(edit.text, edit.range.from, edit.range.to); + + if (this.docLength(editor) === before) { + console.warn( + `[TBLR] the ${edit.lines}-line rewrite at line ${edit.range.from.line} left the document ` + + "unchanged; Obsidian compensated for it" + ); + return false; + } + + editor.setCursor(plan.caret); + return true; + } + + // Bulk pass. Deliberately limited to headings: a table pulled tight against + // a paragraph gets absorbed into that paragraph by the markdown parser, + // which is not a change anyone wants applied to a whole note at once. + tightenHeadings(editor) { + const doc = docFromEditor(editor); + const targets = []; + + for (let i = 0; i < doc.count; i++) { + if (!tableStartsAt(doc, i)) continue; + const above = lastNonBlankAbove(doc, i); + if (above < 0 || above === i - 1) continue; + if (!/^\s{0,3}#{1,6}\s/.test(doc.get(above))) continue; + targets.push({ table: i, firstBlank: above + 1 }); + } + + if (targets.length === 0) { + new Notice("No blank lines between headings and tables."); + return; + } + + // Bottom-up, one blank line at a time, so the line numbers above each + // removal stay valid. The table climbs by one after each success. + let removed = 0; + for (let t = targets.length - 1; t >= 0; t--) { + const { table, firstBlank } = targets[t]; + let shift = 0; + for (let i = table - 1; i >= firstBlank; i--) { + const ok = this.applyPlan(editor, { + blankLine: i, + caret: { line: i, ch: 0 }, + direction: "above", + tableLine: table - shift, + }); + if (ok) { + removed++; + shift++; + } + } + } + + new Notice( + `Removed ${removed} blank line${removed === 1 ? "" : "s"} above ` + + `${targets.length} table${targets.length === 1 ? "" : "s"}.` + ); + } +} + +module.exports = TableBlankLineRemover; +// Obsidian accepts either shape depending on how the file was built; expose both. +module.exports.default = TableBlankLineRemover; +module.exports._internals = { + isDelimiterRow, + isTableRowLine, + isTableStart, + isInsideCodeBlock, + tableStartsAt, + tableStartFor, + blockRewrite, + planBackspace, + planDelete, +}; diff --git a/.obsidian/plugins/table-trimmer/manifest.json b/.obsidian/plugins/table-trimmer/manifest.json new file mode 100644 index 0000000..65973d7 --- /dev/null +++ b/.obsidian/plugins/table-trimmer/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "table-blank-line-remover", + "name": "Table Trimmer", + "version": "1.4.0", + "minAppVersion": "1.4.0", + "description": "Lets you delete the blank line stuck above a table in Live Preview, without switching to source mode.", + "author": "Conway", + "isDesktopOnly": false +} |
