"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 } = require("obsidian"); // Guard against pathological scans through a note that is mostly blank lines. const MAX_BLANK_SCAN = 100; // The markdown helpers below 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) }; } class TableBlankLineRemover extends Plugin { onload() { // Capture phase: ahead of CodeMirror and Obsidian's own handling. this.registerDomEvent(window, "keydown", (evt) => this.onKeyDown(evt), { capture: true }); } 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; } } module.exports = TableBlankLineRemover;