diff --git a/README.md b/README.md index a7f6cf5e..7c579458 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,20 @@ To reveal the LaTeX syntax, move your cursor over it. You can also choose to del ![conceal demo](https://raw.githubusercontent.com/artisticat1/obsidian-latex-suite/main/gifs/conceal.png) -### Tabout +### Tabout & Reverse Tabout +#### Tabout To make it easier to navigate and exit equations, - Pressing Tab while the cursor is at the end of an equation will move the cursor outside the `$` symbols. -- Otherwise, pressing Tab will advance the cursor to the next closing bracket: `)`, `]`, `}`, `>`, or `|`. +- If the cursor is inside a `\left ... \right` pair, pressing Tab will jump to after the `\right` command and its corresponding delimiter. +- Otherwise, pressing Tab will advance the cursor to the next closing bracket: `)`, `]`, `}`, `\rangle`, or `\rvert`. +#### Reverse Tabout +To navigate equations in the opposite direction: + +- Pressing Shift + Tab while the cursor is at the beginning of an equation will move the cursor before the opening `$` symbol. +- If the cursor is inside a `\left ... \right` pair, pressing Shift + Tab will jump to before the `\left` command. +- Otherwise, pressing Shift + Tab will move the cursor to the previous opening bracket: `(`, `[`, `{`, `\langle`, or `\lvert`. ### Preview inline math When your cursor is inside inline math, a popup window showing the rendered math will be displayed. diff --git a/src/features/tabout.ts b/src/features/tabout.ts index 0b83ea11..852c58c0 100644 --- a/src/features/tabout.ts +++ b/src/features/tabout.ts @@ -1,33 +1,157 @@ import { EditorView } from "@codemirror/view"; import { replaceRange, setCursor, getCharacterAtPos } from "src/utils/editor_utils"; import { Context } from "src/utils/context"; +import { getLatexSuiteConfig } from "src/snippets/codemirror/config"; -export const tabout = (view: EditorView, ctx: Context):boolean => { - if (!ctx.mode.inMath()) return false; +let sortedLeftCommands: string[] = []; +let sortedRightCommands: string[] = []; +let sortedDelimiters: string[] = []; +let sortedOpeningSymbols: string[] = []; +let sortedClosingSymbols: string[] = []; + + +const isCommandEnd = (str: string): boolean => { + return /\\[a-zA-Z]+\\*?$/.test(str); +} + + +const isMatchingCommand = (text: string, command: string, startIndex: number): boolean => { + if (!text.startsWith(command, startIndex)) { + return false; + } + + const nextChar = text.charAt(startIndex + command.length); + const isEndOfCommand = !/[a-zA-Z]/.test(nextChar); + + return isEndOfCommand; +} + + +const isMatchingToken = (text: string, token: string, startIndex: number): boolean => { + if (isCommandEnd(token)) { + return isMatchingCommand(text, token, startIndex); + } + else { + return text.startsWith(token, startIndex); + } +} + + +const findTokenLength = (sortedTokens: string[], text: string, startIndex: number): number => { + const matchedToken = sortedTokens.find((token) => isMatchingToken(text, token, startIndex)); + + if (matchedToken) { + return matchedToken.length; + } + + return 0; +} + + +const findCommandWithDelimiterLength = (sortedCommands: string[], text: string, startIndex: number): number => { + const matchedCommand = sortedCommands.find((command) => isMatchingCommand(text, command, startIndex)); + + if (!matchedCommand) { + return 0; + } + + const afterCommandIndex = startIndex + matchedCommand.length; + + let whitespaceCount = 0; + while (/\s/.test(text.charAt(afterCommandIndex + whitespaceCount))) { + whitespaceCount++; + } + const delimiterStartIndex = afterCommandIndex + whitespaceCount; + + const matchedDelimiter = sortedDelimiters.find((delimiter) => isMatchingToken(text, delimiter, delimiterStartIndex)); + + if (!matchedDelimiter) { + return 0; + } + + return matchedCommand.length + whitespaceCount + matchedDelimiter.length; +} + + +const findLeftDelimiterLength = (text: string, startIndex: number): number => { + const leftDelimiterLength = findCommandWithDelimiterLength(sortedLeftCommands, text, startIndex); + if (leftDelimiterLength) return leftDelimiterLength; + + const openingSymbolLength = findTokenLength(sortedOpeningSymbols, text, startIndex); + if (openingSymbolLength) return openingSymbolLength; + + return 0; +} + + +const findRightDelimiterLength = (text: string, startIndex: number): number => { + const rightDelimiterLength = findCommandWithDelimiterLength(sortedRightCommands, text, startIndex); + if (rightDelimiterLength) return rightDelimiterLength; + + const closingSymbolLength = findTokenLength(sortedClosingSymbols, text, startIndex); + if (closingSymbolLength) return closingSymbolLength; + + return 0; +} + + +export const tabout = (view: EditorView, ctx: Context): boolean => { + if (!ctx.mode.inMath()) return false; const result = ctx.getBounds(); if (!result) return false; + + const start = result.start; const end = result.end; const pos = view.state.selection.main.to; + const d = view.state.doc; const text = d.toString(); - // Move to the next closing bracket: }, ), ], >, |, or \\rangle - const rangle = "\\rangle"; + sortedLeftCommands = getLatexSuiteConfig(view).sortedTaboutLeftCommands; + sortedRightCommands = getLatexSuiteConfig(view).sortedTaboutRightCommands; + sortedDelimiters = getLatexSuiteConfig(view).sortedTaboutDelimiters; + sortedClosingSymbols = getLatexSuiteConfig(view).sortedTaboutClosingSymbols; + + // Move to the next closing bracket + let i = start; + while (i < end) { + const rightDelimiterLength = findRightDelimiterLength(text, i); + if (rightDelimiterLength > 0) { + i += rightDelimiterLength; - for (let i = pos; i < end; i++) { - if (["}", ")", "]", ">", "|", "$"].contains(text.charAt(i))) { - setCursor(view, i+1); + if (i > pos) { + setCursor(view, i); + return true; + } - return true; + continue; } - else if (text.slice(i, i + rangle.length) === rangle) { - setCursor(view, i + rangle.length); - return true; + // Attempt to match only the right command if matching right command + delimiter fails + const rightCommandLength = findTokenLength(sortedRightCommands, text, i); + if (rightCommandLength > 0) { + i += rightCommandLength; + + if (i > pos) { + setCursor(view, i); + return true; + } + + continue; } + + // Skip left command + delimiter + const leftDelimiterLength = findCommandWithDelimiterLength(sortedLeftCommands, text, i); + if (leftDelimiterLength > 0) { + i += leftDelimiterLength; + + continue; + } + + i++; } @@ -47,7 +171,7 @@ export const tabout = (view: EditorView, ctx: Context):boolean => { } else { // First, locate the $$ symbol - const dollarLine = d.lineAt(end+2); + const dollarLine = d.lineAt(end + 2); // If there's no line after the equation, create one @@ -69,6 +193,115 @@ export const tabout = (view: EditorView, ctx: Context):boolean => { } +export const reverseTabout = (view: EditorView, ctx: Context): boolean => { + if (!ctx.mode.inMath()) return false; + + const result = ctx.getBounds(); + if (!result) return false; + + const start = result.start; + const end = result.end; + + const pos = view.state.selection.main.to; + + const d = view.state.doc; + const text = d.toString(); + + sortedLeftCommands = getLatexSuiteConfig(view).sortedTaboutLeftCommands; + sortedRightCommands = getLatexSuiteConfig(view).sortedTaboutRightCommands; + sortedDelimiters = getLatexSuiteConfig(view).sortedTaboutDelimiters; + sortedOpeningSymbols = getLatexSuiteConfig(view).sortedTaboutOpeningSymbols; + + const textBtwnStartAndCursor = d.sliceString(start, pos); + const isAtStart = textBtwnStartAndCursor.trim().length === 0; + + // Move out of the equation. + if (isAtStart) { + if (ctx.mode.inlineMath || ctx.mode.codeMath) { + setCursor(view, start - 1); + } + else { + let whitespaceCount = 0; + while (/[ ]/.test(text.charAt(start - 2 - whitespaceCount - 1))) { + whitespaceCount++; + } + if (text.charAt(start - 2 - whitespaceCount - 1) == "\n") { + setCursor(view, start - 2 - whitespaceCount - 1); + } + else { + setCursor(view, start - 2); + } + } + + return true; + } + + // Move to the previous openinging bracket + let previous_i = start; + let i = start; + while (i < end) { + const leftDelimiterLength = findLeftDelimiterLength(text, i); + if (leftDelimiterLength > 0) { + if (i >= pos) { + setCursor(view, previous_i); + + return true; + } + + previous_i = i; + i += leftDelimiterLength; + + if (i >= pos) { + setCursor(view, previous_i); + + return true; + } + + continue; + } + + // Attempt to match only the left command if matching left command + delimiter fails + const leftCommandLength = findTokenLength(sortedLeftCommands, text, i); + if (leftCommandLength > 0) { + if (i >= pos) { + setCursor(view, previous_i); + + return true; + } + + previous_i = i; + i += leftCommandLength; + + if (i >= pos) { + setCursor(view, previous_i); + + return true; + } + + // This helps users easily identify and correct missing delimiters. + // Set cursor to the next to the left coomand + previous_i = i; + + continue; + } + + // Skip right command + delimiter + const rightDelimiterLength = findCommandWithDelimiterLength(sortedRightCommands, text, i); + if (rightDelimiterLength > 0) { + i += rightDelimiterLength; + + continue; + } + + i++; + } + + setCursor(view, previous_i); + + return true; +} + + export const shouldTaboutByCloseBracket = (view: EditorView, keyPressed: string) => { const sel = view.state.selection.main; if (!sel.empty) return; @@ -83,4 +316,4 @@ export const shouldTaboutByCloseBracket = (view: EditorView, keyPressed: string) else { return false; } -} \ No newline at end of file +} diff --git a/src/latex_suite.ts b/src/latex_suite.ts index 0f7f01fa..decb929d 100644 --- a/src/latex_suite.ts +++ b/src/latex_suite.ts @@ -2,7 +2,7 @@ import { EditorView, ViewUpdate } from "@codemirror/view"; import { runSnippets } from "./features/run_snippets"; import { runAutoFraction } from "./features/autofraction"; -import { tabout, shouldTaboutByCloseBracket } from "./features/tabout"; +import { tabout, reverseTabout, shouldTaboutByCloseBracket } from "./features/tabout"; import { runMatrixShortcuts } from "./features/matrix_shortcuts"; import { Context } from "./utils/context"; @@ -98,9 +98,17 @@ export const handleKeydown = (key: string, shiftKey: boolean, ctrlKey: boolean, } if (settings.taboutEnabled) { - if (key === "Tab" || shouldTaboutByCloseBracket(view, key)) { - success = tabout(view, ctx); + if (key === "Tab") { + if (settings.reverseTaboutEnabled && shiftKey) { + success = reverseTabout(view, ctx); + } + else { + success = tabout(view, ctx); + } + if (success) return true; + } + if (shouldTaboutByCloseBracket(view, key)) { if (success) return true; } } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 7c9adecb..8a0c00ee 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -24,6 +24,7 @@ interface LatexSuiteBasicSettings { autofractionBreakingChars: string; matrixShortcutsEnabled: boolean; taboutEnabled: boolean; + reverseTaboutEnabled: boolean; autoEnlargeBrackets: boolean; wordDelimiters: string; } @@ -34,6 +35,11 @@ interface LatexSuiteBasicSettings { interface LatexSuiteRawSettings { autofractionExcludedEnvs: string; matrixShortcutsEnvNames: string; + taboutOpeningSymbols: string; + taboutClosingSymbols: string; + taboutLeftCommands: string, + taboutRightCommands: string, + taboutDelimiters: string; autoEnlargeBracketsTriggers: string; forceMathLanguages: string; } @@ -41,6 +47,11 @@ interface LatexSuiteRawSettings { interface LatexSuiteParsedSettings { autofractionExcludedEnvs: Environment[]; matrixShortcutsEnvNames: string[]; + sortedTaboutOpeningSymbols: string[]; + sortedTaboutClosingSymbols: string[]; + sortedTaboutLeftCommands: string[]; + sortedTaboutRightCommands: string[]; + sortedTaboutDelimiters: string[]; autoEnlargeBracketsTriggers: string[]; forceMathLanguages: string[]; } @@ -73,6 +84,7 @@ export const DEFAULT_SETTINGS: LatexSuitePluginSettings = { autofractionBreakingChars: "+-=\t", matrixShortcutsEnabled: true, taboutEnabled: true, + reverseTaboutEnabled: true, autoEnlargeBrackets: true, wordDelimiters: "., +-\\n\t:;!?\\/{}[]()=~$", @@ -83,6 +95,11 @@ export const DEFAULT_SETTINGS: LatexSuitePluginSettings = { ["\\\\pu{", "}"] ]`, matrixShortcutsEnvNames: "pmatrix, cases, align, gather, bmatrix, Bmatrix, vmatrix, Vmatrix, array, matrix", + taboutOpeningSymbols: "(, [, \\lbrack, \\{, \\lbrace, \\langle, \\lvert, \\lVert, \\lfloor, \\lceil, \\ulcorner, {", + taboutClosingSymbols: "), ], \\rbrack, \\}, \\rbrace, \\rangle, \\rvert, \\rVert, \\rfloor, \\rceil, \\urcorner, }", + taboutLeftCommands: "\\left, \\bigl, \\Bigl, \\biggl, \\Biggl", + taboutRightCommands: "\\right, \\bigr, \\Bigr, \\biggr, \\Biggr", + taboutDelimiters: "(, ), [, ], \\lbrack, \\rbrack, \\{, \\}, \\lbrace, \\rbrace, <, >, \\langle, \\rangle, \\lt, \\gt, |, \\vert, \\lvert, \\rvert, \\|, \\Vert, \\lVert, \\rVert, \\lfloor, \\rfloor, \\lceil, \\rceil, \\ulcorner, \\urcorner, /, \\\\, \\backslash, \\uparrow, \\downarrow, \\Uparrow, \\Downarrow, .", autoEnlargeBracketsTriggers: "sum, int, frac, prod, bigcup, bigcap", forceMathLanguages: "math", } @@ -116,6 +133,11 @@ export function processLatexSuiteSettings(snippets: Snippet[], settings: LatexSu snippets: snippets, autofractionExcludedEnvs: getAutofractionExcludedEnvs(settings.autofractionExcludedEnvs), matrixShortcutsEnvNames: strToArray(settings.matrixShortcutsEnvNames), + sortedTaboutOpeningSymbols: strToArray(settings.taboutOpeningSymbols).sort((a, b) => b.length - a.length), + sortedTaboutClosingSymbols: strToArray(settings.taboutClosingSymbols).sort((a, b) => b.length - a.length), + sortedTaboutLeftCommands: strToArray(settings.taboutLeftCommands).sort((a, b) => b.length - a.length), + sortedTaboutRightCommands: strToArray(settings.taboutRightCommands).sort((a, b) => b.length - a.length), + sortedTaboutDelimiters: strToArray(settings.taboutDelimiters).sort((a, b) => b.length - a.length), autoEnlargeBracketsTriggers: strToArray(settings.autoEnlargeBracketsTriggers), forceMathLanguages: strToArray(settings.forceMathLanguages), } diff --git a/src/settings/settings_tab.ts b/src/settings/settings_tab.ts index fa825915..7ca8f3f5 100644 --- a/src/settings/settings_tab.ts +++ b/src/settings/settings_tab.ts @@ -340,8 +340,85 @@ export class LatexSuiteSettingTab extends PluginSettingTab { .setValue(this.plugin.settings.taboutEnabled) .onChange(async (value) => { this.plugin.settings.taboutEnabled = value; + + reverseTaboutSetting.settingEl.toggleClass("hidden", !value); + + await this.plugin.saveSettings(); + })); + + const reverseTaboutSetting = new Setting(containerEl) + .setName("Reverse Tabout") + .setDesc("Whether reverse tabout is enabled.") + .addToggle(toggle => toggle + .setValue(this.plugin.settings.reverseTaboutEnabled) + .onChange(async (value) => { + this.plugin.settings.reverseTaboutEnabled = value; + + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName("Opening brackets") + .setDesc("A list of opening brackets for reverse tabout, separated by commas.") + .addText(text => text + .setPlaceholder(DEFAULT_SETTINGS.taboutOpeningSymbols) + .setValue(this.plugin.settings.taboutOpeningSymbols) + .onChange(async (value) => { + this.plugin.settings.taboutOpeningSymbols = value; + + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName("Closing brackets") + .setDesc("A list of closing brackets for tabout, separated by commas.") + .addText(text => text + .setPlaceholder(DEFAULT_SETTINGS.taboutClosingSymbols) + .setValue(this.plugin.settings.taboutClosingSymbols) + .onChange(async (value) => { + this.plugin.settings.taboutClosingSymbols = value; + await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName("Left Commands") + .setDesc("A list of left-side LaTeX delimiter commands, separated by commas.") + .addText(text => text + .setPlaceholder(DEFAULT_SETTINGS.taboutLeftCommands) + .setValue(this.plugin.settings.taboutLeftCommands) + .onChange(async (value) => { + this.plugin.settings.taboutLeftCommands = value; + + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName("Right Commands") + .setDesc("A list of right-side LaTeX delimiter commands, separated by commas.") + .addText(text => text + .setPlaceholder(DEFAULT_SETTINGS.taboutRightCommands) + .setValue(this.plugin.settings.taboutRightCommands) + .onChange(async (value) => { + this.plugin.settings.taboutRightCommands = value; + + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName("Delimiters") + .setDesc("A list of valid delimiters that can follow left-side or right-side LaTeX commands, separated by commas.") + .addText(text => text + .setPlaceholder(DEFAULT_SETTINGS.taboutDelimiters) + .setValue(this.plugin.settings.taboutDelimiters) + .onChange(async (value) => { + this.plugin.settings.taboutDelimiters = value; + + await this.plugin.saveSettings(); + })); + + const taboutEnabled = this.plugin.settings.taboutEnabled; + reverseTaboutSetting.settingEl.toggleClass("hidden", !taboutEnabled); } private displayAutoEnlargeBracketsSettings() {