diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f087ce..b36e9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Change Log -All notable changes to Typst Companion will be documented in this file. +All notable changes to Typst Companion will be documented here. + +## 0.0.3 + +- Added toggle Bold with `ctrl/cmd + b`. +- Added toggle Italic with `ctrl/cmd + i`. +- Added toggle Underline with `ctrl/cmd + u`. +- Added increase/decrease Header level with `ctrl/cmd + shift + ]` and `ctrl/cmd + shift + ]`, respectively. +- Added 'Typst Companion: Toggle List' command to command palette. + +## 0.0.2 + +- New logo. ## 0.0.1 diff --git a/README.md b/README.md index 69bec17..01aec8a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ A VS Code extension that adds Markdown-like editing niceties **on top of and in - `Enter` while in a list context (either ordered or unordered) continues the existing list at the current level of indentation (with correct numbering, if ordered). - `Tab` and `Shift+Tab` while in a list context (either ordered or unordered) indents and out-dents bullets intuitively (and re-numbers ordered lists if appropriate). - Reordering lines inside an ordered list automatically updates the list numbers accordingly. +- Keyboard Shortcuts for: + - Toggle Bold, Italics, and Underline (`ctrl/cmd + b|i|u`) + - Increase and decrease header level (`ctrl/cmd + shift + ]|[`) ## Requirements @@ -16,9 +19,15 @@ I *strongly* encourage installing Nathan Varner's [Typst LSP](https://github.com ## Release Notes -### 0.0.1 +### 0.0.3 -Initial release of Typst Companion. +- Added toggle Bold with `ctrl/cmd + b`. +- Added toggle Italic with `ctrl/cmd + i`. +- Added toggle Underline with `ctrl/cmd + u`. +- Added increase/decrease Header level with `ctrl/cmd + shift + ]` and `ctrl/cmd + shift + ]`, respectively. +- Added 'Typst Companion: Toggle List' command to command palette. + +For previous versions, see the [CHANGELOG on GitHub](https://github.com/CFiggers/typst-companion/blob/main/CHANGELOG.md). ## Prior Art diff --git a/package.json b/package.json index 97a191a..b5eab06 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "typst-companion", "displayName": "Typst Companion", "description": "Adds Markdown-like editing niceties on top of Typst LSP.", - "version": "0.0.2", + "version": "0.0.3", "author": "Caleb Figgers", "publisher": "CalebFiggers", "icon": "icons/typst-companion-icon.png", @@ -78,6 +78,13 @@ } } ], + "commands": [ + { + "command": "typst-companion.extension.editing.toggleList", + "enablement": "editorLangId == typst", + "title": "Typst Companion: Toggle List" + } + ], "keybindings": [ { "command": "typst-companion.extension.onEnterKey", @@ -145,6 +152,36 @@ "key": "ctrl+[", "mac": "cmd+[", "when": "editorTextFocus && editorLangId == typst && !suggestWidgetVisible" + }, + { + "command": "typst-companion.extension.editing.toggleBold", + "key": "ctrl+b", + "mac": "cmd+b", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst" + }, + { + "command": "typst-companion.extension.editing.toggleItalic", + "key": "ctrl+i", + "mac": "cmd+i", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst" + }, + { + "command": "typst-companion.extension.editing.toggleUnderline", + "key": "ctrl+u", + "mac": "cmd+u", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst" + }, + { + "command": "typst-companion.extension.editing.toggleHeadingUp", + "key": "ctrl+shift+]", + "mac": "ctrl+shift+]", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst" + }, + { + "command": "typst-companion.extension.editing.toggleHeadingDown", + "key": "ctrl+shift+[", + "mac": "ctrl+shift+[", + "when": "editorTextFocus && !editorReadonly && editorLangId == typst" } ] }, diff --git a/src/extension.ts b/src/extension.ts index 890e96f..8a0fb3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { contextServiceManager } from "./editor-context-service/manager" import * as listEditing from './listEditing'; +import * as formatting from './format'; export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( @@ -13,6 +14,9 @@ export function activate(context: vscode.ExtensionContext) { // Override `Enter`, `Tab` and `Backspace` keys listEditing.activate(context); + // Shortcuts + formatting.activate(context); + } export function deactivate() {} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..35d898b --- /dev/null +++ b/src/format.ts @@ -0,0 +1,368 @@ +// From https://github.com/yzhang-gh/vscode-markdown/ , under the following license: +// +// MIT License + +// Copyright (c) 2017 张宇 + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +'use strict'; + +import { commands, env, ExtensionContext, Position, Range, Selection, SnippetString, TextDocument, TextEditor, window, workspace, WorkspaceEdit } from 'vscode'; +import { fixMarker } from './listEditing'; + +export function activate(context: ExtensionContext) { + context.subscriptions.push( + commands.registerCommand('typst-companion.extension.editing.toggleBold', toggleBold), + commands.registerCommand('typst-companion.extension.editing.toggleItalic', toggleItalic), + commands.registerCommand('typst-companion.extension.editing.toggleUnderline', toggleUnderline), + commands.registerCommand('typst-companion.extension.editing.toggleHeadingUp', toggleHeadingUp), + commands.registerCommand('typst-companion.extension.editing.toggleHeadingDown', toggleHeadingDown), + commands.registerCommand('typst-companion.extension.editing.toggleList', toggleList) + ); +} + +function toggleItalic() { + return styleByWrapping("_"); +} + +function toggleBold() { + return styleByWrapping("*"); +} + +function toggleUnderline() { + return styleByWrapping("#underline[", "]"); +} + +async function toggleHeadingUp() { + const editor = window.activeTextEditor!; + let lineIndex = editor.selection.active.line; + let lineText = editor.document.lineAt(lineIndex).text; + + return await editor.edit((editBuilder) => { + if (!lineText.startsWith('=')) { // Not a heading + editBuilder.insert(new Position(lineIndex, 0), '= '); + } + else if (!lineText.startsWith('======')) { // Already a heading (but not level 6) + editBuilder.insert(new Position(lineIndex, 0), '='); + } + }); +} + +function toggleHeadingDown() { + const editor = window.activeTextEditor!; + let lineIndex = editor.selection.active.line; + let lineText = editor.document.lineAt(lineIndex).text; + + editor.edit((editBuilder) => { + if (lineText.startsWith('= ')) { // Heading level 1 + editBuilder.delete(new Range(new Position(lineIndex, 0), new Position(lineIndex, 2))); + } + else if (lineText.startsWith('=')) { // Heading (but not level 1) + editBuilder.delete(new Range(new Position(lineIndex, 0), new Position(lineIndex, 1))); + } + }); +} + +function toggleList() { + const editor = window.activeTextEditor!; + const doc = editor.document; + let batchEdit = new WorkspaceEdit(); + + for (const selection of editor.selections) { + if (selection.isEmpty) { + toggleListSingleLine(doc, selection.active.line, batchEdit); + } else { + for (let i = selection.start.line; i <= selection.end.line; i++) { + toggleListSingleLine(doc, i, batchEdit); + } + } + } + + return workspace.applyEdit(batchEdit).then(() => fixMarker(editor)); +} + +function toggleListSingleLine(doc: TextDocument, line: number, wsEdit: WorkspaceEdit) { + const lineText = doc.lineAt(line).text; + const indentation = lineText.trim().length === 0 ? lineText.length : lineText.indexOf(lineText.trim()); + const lineTextContent = lineText.slice(indentation); + const currentMarker = getCurrentListStart(lineTextContent); + const nextMarker = getNextListStart(currentMarker); + + // 1. delete current list marker + wsEdit.delete(doc.uri, new Range(line, indentation, line, getMarkerEndCharacter(currentMarker, lineText))); + + // 2. insert next list marker + if (nextMarker !== ListMarker.EMPTY) + wsEdit.insert(doc.uri, new Position(line, indentation), nextMarker); +} + +/** + * List candidate markers enum + */ +enum ListMarker { + EMPTY = "", + DASH = "- ", + STAR = "* ", + PLUS = "+ ", + NUM = "1. ", + NUM_CLOSING_PARETHESES = "1) " +} + +function getListMarker(listMarker: string): ListMarker { + if ("- " === listMarker) { + return ListMarker.DASH; + } else if ("* " === listMarker) { + return ListMarker.STAR; + } else if ("+ " === listMarker) { + return ListMarker.PLUS; + } else if ("1. " === listMarker) { + return ListMarker.NUM; + } else if ("1) " === listMarker) { + return ListMarker.NUM_CLOSING_PARETHESES; + } else { + return ListMarker.EMPTY; + } +} + +const listMarkerSimpleListStart = [ListMarker.DASH, ListMarker.STAR, ListMarker.PLUS] +const listMarkerDefaultMarkerArray = [ListMarker.DASH, ListMarker.STAR, ListMarker.PLUS, ListMarker.NUM, ListMarker.NUM_CLOSING_PARETHESES] +const listMarkerNumRegex = /^\d+\. /; +const listMarkerNumClosingParethesesRegex = /^\d+\) /; + +function getMarkerEndCharacter(currentMarker: ListMarker, lineText: string): number { + const indentation = lineText.trim().length === 0 ? lineText.length : lineText.indexOf(lineText.trim()); + const lineTextContent = lineText.slice(indentation); + + let endCharacter = indentation; + if (listMarkerSimpleListStart.includes(currentMarker)) { + // `- `, `* `, `+ ` + endCharacter += 2; + } else if (listMarkerNumRegex.test(lineTextContent)) { + // number + const lenOfDigits = /^(\d+)\./.exec(lineText.trim())![1].length; + endCharacter += lenOfDigits + 2; + } else if (listMarkerNumClosingParethesesRegex.test(lineTextContent)) { + // number with ) + const lenOfDigits = /^(\d+)\)/.exec(lineText.trim())![1].length; + endCharacter += lenOfDigits + 2; + } + return endCharacter; +} + +/** + * get list start marker + */ +function getCurrentListStart(lineTextContent: string): ListMarker { + if (lineTextContent.startsWith(ListMarker.DASH)) { + return ListMarker.DASH; + } else if (lineTextContent.startsWith(ListMarker.STAR)) { + return ListMarker.STAR; + } else if (lineTextContent.startsWith(ListMarker.PLUS)) { + return ListMarker.PLUS; + } else if (listMarkerNumRegex.test(lineTextContent)) { + return ListMarker.NUM; + } else if (listMarkerNumClosingParethesesRegex.test(lineTextContent)) { + return ListMarker.NUM_CLOSING_PARETHESES; + } else { + return ListMarker.EMPTY; + } +} + +/** + * get next candidate marker from configArray + */ +function getNextListStart(current: ListMarker): ListMarker { + const configArray = getCandidateMarkers(); + let next = configArray[0]; + const index = configArray.indexOf(current); + if (index >= 0 && index < configArray.length - 1) + next = configArray[index + 1]; + return next; +} + +/** + * get candidate markers array from configuration + */ +function getCandidateMarkers(): ListMarker[] { + // read configArray from configuration and append space + let configArray = workspace.getConfiguration('typst-companion.extension.list.toggle').get('candidate-markers'); + if (!(configArray instanceof Array)) + return listMarkerDefaultMarkerArray; + + // append a space after trim, markers must end with a space and remove unknown markers + let listMarkerArray = configArray.map((e) => getListMarker(e + " ")).filter((e) => listMarkerDefaultMarkerArray.includes(e)); + // push empty in the configArray for init status without list marker + listMarkerArray.push(ListMarker.EMPTY); + + return listMarkerArray; +} + +// Read PR #1052 before touching this please! +function styleByWrapping(startPattern: string, endPattern = startPattern) { + const editor = window.activeTextEditor!; + let selections = editor.selections; + + let batchEdit = new WorkspaceEdit(); + let shifts: [Position, number][] = []; + let newSelections: Selection[] = selections.slice(); + + for (const [i, selection] of selections.entries()) { + + let cursorPos = selection.active; + const shift = shifts.map(([pos, s]) => (selection.start.line == pos.line && selection.start.character >= pos.character) ? s : 0) + .reduce((a, b) => a + b, 0); + + if (selection.isEmpty) { + const context = getContext(editor, cursorPos, startPattern, endPattern); + + // No selected text + if ( + startPattern === endPattern && + ["**", "*", "__", "_"].includes(startPattern) && + context === `${startPattern}text|${endPattern}` + ) { + // `**text|**` to `**text**|` + let newCursorPos = cursorPos.with({ character: cursorPos.character + shift + endPattern.length }); + newSelections[i] = new Selection(newCursorPos, newCursorPos); + continue; + } else if (context === `${startPattern}|${endPattern}`) { + // `**|**` to `|` + let start = cursorPos.with({ character: cursorPos.character - startPattern.length }); + let end = cursorPos.with({ character: cursorPos.character + endPattern.length }); + wrapRange(editor, batchEdit, shifts, newSelections, i, shift, cursorPos, new Range(start, end), false, startPattern, endPattern); + } else { + // Select word under cursor + let wordRange = editor.document.getWordRangeAtPosition(cursorPos); + if (wordRange == undefined) { + wordRange = selection; + } + // One special case: toggle strikethrough in task list + const currentTextLine = editor.document.lineAt(cursorPos.line); + if (startPattern === '~~' && /^\s*[\*\+\-] (\[[ x]\] )? */g.test(currentTextLine.text)) { + wordRange = currentTextLine.range.with(new Position(cursorPos.line, currentTextLine.text.match(/^\s*[\*\+\-] (\[[ x]\] )? */g)![0].length)); + } + wrapRange(editor, batchEdit, shifts, newSelections, i, shift, cursorPos, wordRange, false, startPattern, endPattern); + } + } else { + // Text selected + wrapRange(editor, batchEdit, shifts, newSelections, i, shift, cursorPos, selection, true, startPattern, endPattern); + } + } + + return workspace.applyEdit(batchEdit).then(() => { + editor.selections = newSelections; + }); +} + +/** + * Add or remove `startPattern`/`endPattern` according to the context + * @param editor + * @param options The undo/redo behavior + * @param cursor cursor position + * @param range range to be replaced + * @param isSelected is this range selected + * @param startPtn + * @param endPtn + */ +function wrapRange(editor: TextEditor, wsEdit: WorkspaceEdit, shifts: [Position, number][], newSelections: Selection[], i: number, shift: number, cursor: Position, range: Range, isSelected: boolean, startPtn: string, endPtn: string) { + let text = editor.document.getText(range); + const prevSelection = newSelections[i]; + const ptnLength = (startPtn + endPtn).length; + + let newCursorPos = cursor.with({ character: cursor.character + shift }); + let newSelection: Selection; + if (isWrapped(text, startPtn, endPtn)) { + // remove start/end patterns from range + wsEdit.replace(editor.document.uri, range, text.substr(startPtn.length, text.length - ptnLength)); + + shifts.push([range.end, -ptnLength]); + + // Fix cursor position + if (!isSelected) { + if (!range.isEmpty) { // means quick styling + if (cursor.character == range.end.character) { + newCursorPos = cursor.with({ character: cursor.character + shift - ptnLength }); + } else { + newCursorPos = cursor.with({ character: cursor.character + shift - startPtn.length }); + } + } else { // means `**|**` -> `|` + newCursorPos = cursor.with({ character: cursor.character + shift + startPtn.length }); + } + newSelection = new Selection(newCursorPos, newCursorPos); + } else { + newSelection = new Selection( + prevSelection.start.with({ character: prevSelection.start.character + shift }), + prevSelection.end.with({ character: prevSelection.end.character + shift - ptnLength }) + ); + } + } else { + // add start/end patterns around range + wsEdit.replace(editor.document.uri, range, startPtn + text + endPtn); + + shifts.push([range.end, ptnLength]); + + // Fix cursor position + if (!isSelected) { + if (!range.isEmpty) { // means quick styling + if (cursor.character == range.end.character) { + newCursorPos = cursor.with({ character: cursor.character + shift + ptnLength }); + } else { + newCursorPos = cursor.with({ character: cursor.character + shift + startPtn.length }); + } + } else { // means `|` -> `**|**` + newCursorPos = cursor.with({ character: cursor.character + shift + startPtn.length }); + } + newSelection = new Selection(newCursorPos, newCursorPos); + } else { + newSelection = new Selection( + prevSelection.start.with({ character: prevSelection.start.character + shift }), + prevSelection.end.with({ character: prevSelection.end.character + shift + ptnLength }) + ); + } + } + + newSelections[i] = newSelection; +} + +function isWrapped(text: string, startPattern: string, endPattern: string): boolean { + return text.startsWith(startPattern) && text.endsWith(endPattern); +} + +function getContext(editor: TextEditor, cursorPos: Position, startPattern: string, endPattern: string): string { + let startPositionCharacter = cursorPos.character - startPattern.length; + let endPositionCharacter = cursorPos.character + endPattern.length; + + if (startPositionCharacter < 0) { + startPositionCharacter = 0; + } + + let leftText = editor.document.getText(new Range(cursorPos.line, startPositionCharacter, cursorPos.line, cursorPos.character)); + let rightText = editor.document.getText(new Range(cursorPos.line, cursorPos.character, cursorPos.line, endPositionCharacter)); + + if (rightText == endPattern) { + if (leftText == startPattern) { + return `${startPattern}|${endPattern}`; + } else { + return `${startPattern}text|${endPattern}`; + } + } + return '|'; +} \ No newline at end of file