From e9ea8f4464df3a533a5640cbb76b514acf46af64 Mon Sep 17 00:00:00 2001 From: Jacob Levernier Date: Tue, 3 Aug 2021 13:03:00 -0400 Subject: [PATCH] Split out code (#2) * Drafted mock of Obsidian editor. * Implemented mock of MarkdownView and Editor, and got several example tests working. * Fixed bug whereby blank lines were not being correctly identified, vs. lines with no leading indentation among indented lines. * Improved cursor/selection location after toggleBlockquote, and continued adding tests. * Got tests running again. * Added
-wrapped quote command. * Removed extra console.log() call. --- __mocks__/obsidian.ts | 104 ++++++++++++++++++ __tests__/mocks.tests.ts | 19 ++++ __tests__/paste-text.tests.ts | 23 ++++ __tests__/toggle-quote.tests.ts | 163 ++++++++++++++++++++++++++++ jest.config.js | 9 ++ main.ts | 170 ++++-------------------------- package.json | 9 +- src/paste-html-blockquote-text.ts | 28 +++++ src/paste-text.ts | 25 +++++ src/toggle-quote.ts | 150 ++++++++++++++++++++++++++ tsconfig.json | 2 + 11 files changed, 550 insertions(+), 152 deletions(-) create mode 100644 __mocks__/obsidian.ts create mode 100644 __tests__/mocks.tests.ts create mode 100644 __tests__/paste-text.tests.ts create mode 100644 __tests__/toggle-quote.tests.ts create mode 100644 jest.config.js create mode 100644 src/paste-html-blockquote-text.ts create mode 100644 src/paste-text.ts create mode 100644 src/toggle-quote.ts diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts new file mode 100644 index 0000000..abde7d1 --- /dev/null +++ b/__mocks__/obsidian.ts @@ -0,0 +1,104 @@ +// Mock several classes from Obsidian, following +// https://github.com/obsidianmd/obsidian-api/blob/master/obsidian.d.ts: + +class Notice { + msg: string + + constructor( + msg: string + ) { + this.msg = msg; + } +} + +class Editor { + content: string[] + selectionStart: {line: number, ch: number} + selectionEnd: {line: number, ch: number} + selection: {line: number, ch: number}[] + + constructor( + content: string[], + selectionStart: {line: number, ch: number}, + selectionEnd: {line: number, ch: number}, + ) { + this.content = content; + this.selection = [selectionStart, selectionEnd]; + } + + getCursor() { + return this.selection[0]; + } + + getLine(line: number) { + return this.content[line]; + } + + getRange( + start: {line: number, ch: number}, + end: {line: number, ch: number} + ) { + const contentInRange = this.content.slice(start.line, end.line + 1); + contentInRange[0] = contentInRange[0].slice(start.ch); + contentInRange[contentInRange.length - 1] = contentInRange[contentInRange.length - 1].slice(0, end.ch); + + return contentInRange.join('\n'); + } + + setSelection( + start: {line: number, ch: number}, + end: {line: number, ch: number} + ) { + this.selection = [start, end] + } + + replaceSelection(text: string) { + this.content.splice( + this.selection[0].line, + this.selection[1].line - this.selection[0].line, + ...( + this.content[this.selection[0].line].slice(0, this.selection[0].ch) + + text + + this.content[this.selection[1].line].slice(this.selection[1].ch) + ).split('\n') + ) + } + + replaceRange( + text: string, + start: {line: number, ch: number}, + end: {line: number, ch: number} + ) { + this.content.splice( + start.line, + end.line - start.line + 1, + ...( + this.content[start.line].slice(0, start.ch) + + text + + this.content[end.line].slice(end.ch) + ).split('\n') + ) + } +} + +export class MarkdownView { + content: string[] + selectionStart: {line: number, ch: number} + selectionEnd: {line: number, ch: number} + editor: Editor + + constructor( + content: string[], + selectionStart: {line: number, ch: number}, + selectionEnd: {line: number, ch: number}, + ) { + this.editor = new Editor( + content, + selectionStart, + selectionEnd + ); + } +} + +export {}; + diff --git a/__tests__/mocks.tests.ts b/__tests__/mocks.tests.ts new file mode 100644 index 0000000..157ec90 --- /dev/null +++ b/__tests__/mocks.tests.ts @@ -0,0 +1,19 @@ +import { MarkdownView } from '../__mocks__/obsidian'; + +describe('Examining mocks', () => { + const view = new MarkdownView( + [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Sed venenatis lectus et leo viverra, ac viverra purus rutrum.', + '', + 'Etiam semper massa ut est faucibus, eu luctus arcu porttitor.' + ], + {line: 0, ch: 0}, + {line: 0, ch: 0} + ); + + test('correctly creates a mock view', () => { + // console.log(`"${JSON.stringify(view)}`); + expect(JSON.stringify(view)).toEqual('{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":0},{"line":0,"ch":0}]}}'); + }); +}); diff --git a/__tests__/paste-text.tests.ts b/__tests__/paste-text.tests.ts new file mode 100644 index 0000000..7c20a15 --- /dev/null +++ b/__tests__/paste-text.tests.ts @@ -0,0 +1,23 @@ +import { MarkdownView } from '../__mocks__/obsidian'; + +import { pasteText } from '../src/paste-text'; + +// describe('Examining toggle-blockquote-at-current-indentation', () => { +// beforeAll(() => { +// const view = new MarkdownView( +// [ +// 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', +// 'Sed venenatis lectus et leo viverra, ac viverra purus rutrum.', +// '', +// 'Etiam semper massa ut est faucibus, eu luctus arcu porttitor.' +// ], +// {line: 0, ch: 0}, +// {line: 0, ch: 0}, +// {line: 0, ch: 0} +// ); +// }); + +// test('Adds blockquote to single line', () => { +// return +// }) +// }); diff --git a/__tests__/toggle-quote.tests.ts b/__tests__/toggle-quote.tests.ts new file mode 100644 index 0000000..e955353 --- /dev/null +++ b/__tests__/toggle-quote.tests.ts @@ -0,0 +1,163 @@ +import * as obsidian from "obsidian"; + +// Following https://stackoverflow.com/a/52366601, +// get Jest to understand that our mock of +// MarkdownView is quite different in its type +// definition from the actual obsidian MarkdownView +// (in that ours just implements some of the real +// class' methods): +const MarkdownView = obsidian.MarkdownView; + +import { toggleQuote } from "../src/toggle-quote"; + +const defaultViewSettings = { + content: [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Sed venenatis lectus et leo viverra, ac viverra purus rutrum.", + "", + "Etiam semper massa ut est faucibus, eu luctus arcu porttitor.", + ], + selectionStart: { line: 0, ch: 0 }, + selectionEnd: { line: 0, ch: 0 }, +}; + +const defaultPrefix = "> "; + +describe("Examining toggle-blockquote-at-current-indentation", () => { + // beforeAll(() => { + // }); + + test("Adds and removes blockquote from single line with cursor at beginning of line", async () => { + const view = new MarkdownView(...Object.values(defaultViewSettings)); + + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":0},{"line":0,"ch":0}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["> Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":2},{"line":0,"ch":2}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":0},{"line":0,"ch":0}]}}' + ); + }); + + test("Adds and removes blockquote from single line with cursor at middle of line", async () => { + const view = new MarkdownView( + defaultViewSettings.content, + { line: 0, ch: 5 }, + { line: 0, ch: 5 } + ); + + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":5},{"line":0,"ch":5}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["> Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":7},{"line":0,"ch":7}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":5},{"line":0,"ch":5}]}}' + ); + }); + + test("Adds and removes blockquote from single line that begins with whitespace with cursor at beginning of line", async () => { + const view = new MarkdownView( + [ + ' ' + defaultViewSettings.content[0], + ...defaultViewSettings.content.slice(1) + ], + { line: 0, ch: 10 }, + { line: 0, ch: 10 } + ); + + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":[" Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":10},{"line":0,"ch":10}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":[" > Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":12},{"line":0,"ch":12}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":[" Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":10},{"line":0,"ch":10}]}}' + ); + }); + + test("Adds and removes blockquote from single line with single-line selection", async () => { + const view = new MarkdownView( + defaultViewSettings.content, + { line: 0, ch: 5 }, + { line: 0, ch: 10 } + ); + + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":5},{"line":0,"ch":10}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["> Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":7},{"line":0,"ch":12}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":5},{"line":0,"ch":10}]}}' + ); + }); + + test("Adds and removes blockquote from single line that starts with whitespace, with selection that is entirely in the whitespace", async () => { + const view = new MarkdownView( + [ + " " + defaultViewSettings.content[0], + ...defaultViewSettings.content.slice(1), + ], + { line: 0, ch: 0 }, + { line: 0, ch: 4 } + ); + + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":[" Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":0},{"line":0,"ch":4}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":[" > Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":0},{"line":0,"ch":4}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":[" Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":0},{"line":0,"ch":4}]}}' + ); + }); + + test("Adds and removes blockquote from multiple lines with partial-line selection", async () => { + const view = new MarkdownView( + defaultViewSettings, + { line: 0, ch: 5 }, + { line: 1, ch: 5 } + ); + + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":5},{"line":1,"ch":5}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["> Lorem ipsum dolor sit amet, consectetur adipiscing elit.","> Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":7},{"line":1,"ch":7}]}}' + ); + + await toggleQuote(view, defaultPrefix); + expect(JSON.stringify(view)).toEqual( + '{"editor":{"content":["Lorem ipsum dolor sit amet, consectetur adipiscing elit.","Sed venenatis lectus et leo viverra, ac viverra purus rutrum.","","Etiam semper massa ut est faucibus, eu luctus arcu porttitor."],"selection":[{"line":0,"ch":5},{"line":1,"ch":7}]}}' + ); + }); +}); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..78aa654 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + transform: { + "^.+\\.ts?$": "ts-jest", + }, + testEnvironment: "jsdom", + // testRegex: "__tests__/.*\\.test?\\.ts$", + testMatch: ["**/__tests__/*.ts"], + moduleFileExtensions: ["ts", "js"], +}; diff --git a/main.ts b/main.ts index 23db7f6..4b50ae6 100644 --- a/main.ts +++ b/main.ts @@ -2,6 +2,10 @@ import { App, MarkdownView, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; +import { toggleQuote } from './src/toggle-quote'; +import { pasteText } from './src/paste-text'; +import { pasteHTMLBlockquoteText } from "./src/paste-html-blockquote-text"; + interface PastetoIndentationPluginSettings { blockquotePrefix: string; } @@ -10,15 +14,6 @@ const DEFAULT_SETTINGS: PastetoIndentationPluginSettings = { blockquotePrefix: '> ' } -// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping, -// which, as a code snippet, is in the public domain, per -// https://developer.mozilla.org/en-US/docs/MDN/About#copyrights_and_licenses -// (as of 2021-07-15): -function escapeRegExp(string: string) { - // $& means the whole matched string: - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - export default class PastetoIndentationPlugin extends Plugin { settings: PastetoIndentationPluginSettings; @@ -34,7 +29,7 @@ export default class PastetoIndentationPlugin extends Plugin { let view = this.app.workspace.getActiveViewOfType(MarkdownView); if (view) { if (!checking) { - this.pasteText(view); + pasteText(view); } return true; } @@ -49,7 +44,7 @@ export default class PastetoIndentationPlugin extends Plugin { let view = this.app.workspace.getActiveViewOfType(MarkdownView); if (view) { if (!checking && view instanceof MarkdownView) { - this.pasteText(view, this.settings.blockquotePrefix); + pasteText(view, this.settings.blockquotePrefix); } return true; } @@ -58,159 +53,34 @@ export default class PastetoIndentationPlugin extends Plugin { }); this.addCommand({ - id: 'toggle-blockquote-at-current-indentation', - name: 'Toggle blockquote at current indentation', + id: "paste-html-wrapped-blockquote", + name: "Paste HTML-wrapped blockquote to current indentation", checkCallback: (checking: boolean) => { let view = this.app.workspace.getActiveViewOfType(MarkdownView); if (view) { if (!checking && view instanceof MarkdownView) { - this.toggleQuote(view, this.settings.blockquotePrefix); + pasteHTMLBlockquoteText(view); } return true; } return false; - } + }, }); - } - - async pasteText(view: MarkdownView, prefix: string = '') { - const editor = view.editor; - const clipboardText = await navigator.clipboard.readText(); - if (clipboardText !== '') { - const currentCursor = editor.getCursor(); - const currentLineText = editor.getLine( - currentCursor.line - ); - const leadingWhitespace = currentLineText.match(/^(\s*).*/)[1]; - const clipboardTextIndented = clipboardText.replaceAll( - /\n/g, '\n' + leadingWhitespace + prefix); - const replacementText = prefix + - clipboardTextIndented; - editor.replaceSelection(replacementText); - - return; - } - new Notice('The clipboard is currently empty.'); - } - - async toggleQuote( - view: MarkdownView, - prefix: string = this.settings.blockquotePrefix - ) { - const editor = view.editor; - const escapedPrefix = escapeRegExp(prefix); - const currentSelectionStart = editor.getCursor('from'); - const currentSelectionEnd = editor.getCursor('to'); - - const replacementRange = [ - {line: currentSelectionStart.line, ch: 0}, - { - line: currentSelectionEnd.line, - ch: editor.getLine(currentSelectionEnd.line).length - } - ] - - const fullSelectedLines = editor.getRange( - replacementRange[0], - replacementRange[1] - ).split('\n'); - - const leadingWhitespaces = fullSelectedLines.map( - (e: string) => { - const whitespaceMatch = e.match(new RegExp(`^(\\s*)`)); - return whitespaceMatch !== null ? whitespaceMatch[1] : ''; - } - ); - // This is in its own variable to aid in debugging: - const filteredLeadingWhitespaces = (leadingWhitespaces - .filter((e: string) => { - // Get rid of blank lines, which might be part of multi-line - // passages: - return e !== '' - }) || - // Account for if all lines actually *are* unindented, and we thus - // filtered all lines out immediately above: - [''] - ) - .map((e: string) => e.length) - const minLeadingWhitespaceLength = Math.min(...filteredLeadingWhitespaces); - - // Determine whether *every* line is Prefixed or not. If not, we will - // add the prefix to every line; if so, we will remove it from every line. - const isEveryLinePrefixed = fullSelectedLines.every( - (e: string) => { - const prefixMatch = e.match( - new RegExp(`^\\s{${minLeadingWhitespaceLength}}${escapedPrefix}`) - ); - if (prefixMatch !== null) { + this.addCommand({ + id: 'toggle-blockquote-at-current-indentation', + name: 'Toggle blockquote at current indentation', + checkCallback: (checking: boolean) => { + let view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (view) { + if (!checking && view instanceof MarkdownView) { + toggleQuote(view, this.settings.blockquotePrefix); + } return true; } return false; } - ); - - // Make an educated guess about using tabs vs spaces (lacking access to the - // "Use Tabs" setting value in Obsidian for now) by just repurposing the - // first actual instance of leading whitespace: - const exampleLeadingWhitespace = leadingWhitespaces - .filter(e => e.length === minLeadingWhitespaceLength); - // Update the text in-place: - for (const [i, text] of fullSelectedLines.entries()) { - if (isEveryLinePrefixed === true) { - if (text === '') { - fullSelectedLines[i] = exampleLeadingWhitespace.length > 0 ? - exampleLeadingWhitespace[0] : - ' '.repeat(minLeadingWhitespaceLength); - continue - } - fullSelectedLines[i] = text.replace( - new RegExp(`^(\\s{${minLeadingWhitespaceLength}})${escapedPrefix}`), - '$1' - ) - continue - } - - if (text === '') { - fullSelectedLines[i] = (exampleLeadingWhitespace.length > 0 ? - exampleLeadingWhitespace[0] : - ' '.repeat(minLeadingWhitespaceLength)) + prefix; - continue - } - - // If the prefix is already in the correct place, do not add to it: - if (!text.match( - new RegExp(`^\\s{${minLeadingWhitespaceLength}}${escapedPrefix}`) - )) { - fullSelectedLines[i] = text.replace( - new RegExp(`^(\\s{${minLeadingWhitespaceLength}})`), - `$1${prefix}` - ) - } - } - - editor.replaceRange( - fullSelectedLines.join('\n'), - replacementRange[0], - replacementRange[1] - ); - - editor.setSelection( - { - line: currentSelectionStart.line, - ch: isEveryLinePrefixed ? - currentSelectionStart.ch - prefix.length: - currentSelectionStart.ch + prefix.length - }, - { - line: currentSelectionEnd.line, - ch: isEveryLinePrefixed ? - currentSelectionEnd.ch - prefix.length: - currentSelectionEnd.ch + prefix.length - } - ); - - return + }); } async loadSettings() { diff --git a/package.json b/package.json index 33bb449..7d1e7c6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "rollup --config rollup.config.js -w", "build": "rollup --config rollup.config.js --environment BUILD:production", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest --passWithNoTests", + "test-dev": "jest --passWithNoTests --watch --verbose --silent=false '__tests__/toggle-quote.ts'" }, "keywords": [], "author": "Jacob Levernier", @@ -15,14 +16,18 @@ "@rollup/plugin-commonjs": "^18.0.0", "@rollup/plugin-node-resolve": "^11.2.1", "@rollup/plugin-typescript": "^8.2.1", + "@types/jest": "^26.0.24", "@types/node": "^14.14.37", "chai": "^4.3.4", + "jest": "^27.0.6", "mocha": "^9.0.2", "obsidian": "^0.12.0", "rollup": "^2.32.1", "spectron": "^15.0.0", + "ts-jest": "^27.0.3", "tslib": "^2.2.0", - "typescript": "^4.2.4" + "tslint": "^6.1.3", + "typescript": "^4.3.5" }, "dependencies": {} } diff --git a/src/paste-html-blockquote-text.ts b/src/paste-html-blockquote-text.ts new file mode 100644 index 0000000..47d69e3 --- /dev/null +++ b/src/paste-html-blockquote-text.ts @@ -0,0 +1,28 @@ +import { MarkdownView, Notice } from "obsidian"; + +export const pasteHTMLBlockquoteText = async (view: MarkdownView) => { + const editor = view.editor; + const clipboardText = await navigator.clipboard.readText(); + if (clipboardText !== "") { + const currentCursor = editor.getCursor(); + const currentLineText = editor.getLine(currentCursor.line); + const leadingWhitespace = currentLineText.match(/^(\s*).*/)[1]; + const padding = ' '; + const clipboardTextIndented = clipboardText.replaceAll( + /\n/g, + `\n${leadingWhitespace}${padding}` + ).replace( + /(\n\s*)*$/, + '' + ); + const replacementText = + `
\n${leadingWhitespace}${padding}` + + clipboardTextIndented + + `\n${leadingWhitespace}
`; + editor.replaceSelection(replacementText); + + return; + } + + new Notice("The clipboard is currently empty."); +}; diff --git a/src/paste-text.ts b/src/paste-text.ts new file mode 100644 index 0000000..2fcba64 --- /dev/null +++ b/src/paste-text.ts @@ -0,0 +1,25 @@ +import { MarkdownView, Notice } from 'obsidian'; + +export const pasteText = async ( + view: MarkdownView, + prefix: string = '' +) => { + const editor = view.editor; + const clipboardText = await navigator.clipboard.readText(); + if (clipboardText !== '') { + const currentCursor = editor.getCursor(); + const currentLineText = editor.getLine( + currentCursor.line + ); + const leadingWhitespace = currentLineText.match(/^(\s*).*/)[1]; + const clipboardTextIndented = clipboardText.replaceAll( + /\n/g, '\n' + leadingWhitespace + prefix); + const replacementText = prefix + + clipboardTextIndented; + editor.replaceSelection(replacementText); + + return; + } + + new Notice('The clipboard is currently empty.'); +} diff --git a/src/toggle-quote.ts b/src/toggle-quote.ts new file mode 100644 index 0000000..5f050f3 --- /dev/null +++ b/src/toggle-quote.ts @@ -0,0 +1,150 @@ +import { MarkdownView } from 'obsidian'; + +// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping, +// which, as a code snippet, is in the public domain, per +// https://developer.mozilla.org/en-US/docs/MDN/About#copyrights_and_licenses +// (as of 2021-07-15): +function escapeRegExp(string: string) { + // $& means the whole matched string: + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export const toggleQuote = async( + view: MarkdownView, + prefix: string +) => { + const editor = view.editor; + const escapedPrefix = escapeRegExp(prefix); + const currentSelectionStart = editor.getCursor('from'); + const currentSelectionEnd = editor.getCursor('to'); + + const replacementRange = [ + {line: currentSelectionStart.line, ch: 0}, + { + line: currentSelectionEnd.line, + ch: editor.getLine(currentSelectionEnd.line).length + } + ] + + const fullSelectedLines = editor.getRange( + replacementRange[0], + replacementRange[1] + ).split('\n'); + + const leadingWhitespaces = fullSelectedLines.map( + (e: string) => { + const whitespaceMatch = e.match(new RegExp(`^(\\s*)`)); + return whitespaceMatch !== null ? whitespaceMatch[1] : ''; + } + ); + // This is in its own variable to aid in debugging: + let filteredLeadingWhitespaces = leadingWhitespaces + .filter((e: string, i: number) => { + // Get rid of blank lines, which might be part of multi-line + // passages: + return fullSelectedLines[i] !== '' + }); + + // Account for if all lines actually *are* unindented, and we thus + // filtered all lines out immediately above: + const filteredLeadingLengths = (filteredLeadingWhitespaces.length > 0 ? + filteredLeadingWhitespaces : + [''] + ) + .map((e: string) => e.length) + const minLeadingWhitespaceLength = Math.min(...filteredLeadingLengths); + + // Determine whether *every* line is Prefixed or not. If not, we will + // add the prefix to every line; if so, we will remove it from every line. + const isEveryLinePrefixed = fullSelectedLines.every( + (e: string) => { + const prefixMatch = e.match( + new RegExp(`^\\s{${minLeadingWhitespaceLength}}${escapedPrefix}`) + ); + if (prefixMatch !== null) { + return true; + } + return false; + } + ); + + // Make an educated guess about using tabs vs spaces (lacking access to the + // "Use Tabs" setting value in Obsidian for now) by just repurposing the + // first actual instance of leading whitespace: + const exampleLeadingWhitespace = leadingWhitespaces + .filter(e => e.length === minLeadingWhitespaceLength); + // Update the text in-place: + for (const [i, text] of fullSelectedLines.entries()) { + if (isEveryLinePrefixed === true) { + if (text === '') { + fullSelectedLines[i] = exampleLeadingWhitespace.length > 0 ? + exampleLeadingWhitespace[0] : + ' '.repeat(minLeadingWhitespaceLength); + continue + } + fullSelectedLines[i] = text.replace( + new RegExp(`^(\\s{${minLeadingWhitespaceLength}})${escapedPrefix}`), + '$1' + ) + continue + } + + if (text === '') { + fullSelectedLines[i] = (exampleLeadingWhitespace.length > 0 ? + exampleLeadingWhitespace[0] : + ' '.repeat(minLeadingWhitespaceLength)) + prefix; + continue + } + + // If the prefix is already in the correct place, do not add to it: + if (!text.match( + new RegExp(`^\\s{${minLeadingWhitespaceLength}}${escapedPrefix}`) + )) { + fullSelectedLines[i] = text.replace( + new RegExp(`^(\\s{${minLeadingWhitespaceLength}})`), + `$1${prefix}` + ) + } + } + + editor.replaceRange( + fullSelectedLines.join('\n'), + replacementRange[0], + replacementRange[1] + ); + + let newSelectionStartCh; + if (currentSelectionStart.ch < minLeadingWhitespaceLength) { + newSelectionStartCh = currentSelectionStart.ch; + } else { + if (isEveryLinePrefixed) { + newSelectionStartCh = currentSelectionStart.ch - prefix.length; + } else { + newSelectionStartCh = currentSelectionStart.ch + prefix.length; + } + } + + let newSelectionEndCh; + if (currentSelectionEnd.ch < minLeadingWhitespaceLength) { + newSelectionEndCh = currentSelectionEnd.ch; + } else { + if (isEveryLinePrefixed) { + newSelectionEndCh = currentSelectionEnd.ch - prefix.length; + } else { + newSelectionEndCh = currentSelectionEnd.ch + prefix.length; + } + } + + editor.setSelection( + { + line: currentSelectionStart.line, + ch: newSelectionStartCh + }, + { + line: currentSelectionEnd.line, + ch: newSelectionEndCh + } + ); + + return +} diff --git a/tsconfig.json b/tsconfig.json index 6ba6db3..4de9188 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ "noImplicitAny": true, "moduleResolution": "node", "importHelpers": true, + "esModuleInterop": true, + "types": ["node", "jest"], "lib": [ "dom", "es5",