diff --git a/package.json b/package.json index bc4c9d4c..53d9b000 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,35 @@ ], "main": "./dist/extension", "contributes": { + "colors": [ + { + "id": "markdown.extension.editor.codeSpan.border", + "description": "Border color of code spans in the Markdown editor.", + "defaults": { + "dark": "editor.selectionBackground", + "light": "editor.selectionBackground", + "highContrast": "editor.selectionBackground" + } + }, + { + "id": "markdown.extension.editor.formattingMark.foreground", + "description": "Color of formatting marks (paragraphs, hard line breaks, links, etc.) in the Markdown editor.", + "defaults": { + "dark": "editorWhitespace.foreground", + "light": "editorWhitespace.foreground", + "highContrast": "diffEditor.insertedTextBorder" + } + }, + { + "id": "markdown.extension.editor.trailingSpace.background", + "description": "Background color of trailing space (U+0020) characters in the Markdown editor.", + "defaults": { + "dark": "diffEditor.diagonalFill", + "light": "diffEditor.diagonalFill", + "highContrast": "editorWhitespace.foreground" + } + } + ], "commands": [ { "command": "markdown.extension.toc.create", @@ -325,8 +354,8 @@ }, "markdown.extension.syntax.decorations": { "type": "boolean", - "default": true, - "description": "%config.syntax.decorations.description%" + "default": null, + "markdownDeprecationMessage": "%config.syntax.decorations.description%" }, "markdown.extension.syntax.decorationFileSizeLimit": { "type": "number", @@ -348,6 +377,42 @@ "default": false, "markdownDescription": "%config.tableFormatter.normalizeIndentation.description%" }, + "markdown.extension.theming.decoration.renderCodeSpan": { + "type": "boolean", + "default": true, + "markdownDescription": "%config.theming.decoration.renderCodeSpan.description%", + "scope": "application" + }, + "markdown.extension.theming.decoration.renderHardLineBreak": { + "type": "boolean", + "default": false, + "markdownDescription": "%config.theming.decoration.renderHardLineBreak.description%", + "scope": "application" + }, + "markdown.extension.theming.decoration.renderLink": { + "type": "boolean", + "default": false, + "markdownDescription": "%config.theming.decoration.renderLink.description%", + "scope": "application" + }, + "markdown.extension.theming.decoration.renderParagraph": { + "type": "boolean", + "default": false, + "markdownDescription": "%config.theming.decoration.renderParagraph.description%", + "scope": "application" + }, + "markdown.extension.theming.decoration.renderStrikethrough": { + "type": "boolean", + "default": true, + "markdownDescription": "%config.theming.decoration.renderStrikethrough.description%", + "scope": "application" + }, + "markdown.extension.theming.decoration.renderTrailingSpace": { + "type": "boolean", + "default": false, + "markdownDescription": "%config.theming.decoration.renderTrailingSpace.description%", + "scope": "application" + }, "markdown.extension.toc.downcaseLink": { "type": "boolean", "default": true, diff --git a/package.nls.json b/package.nls.json index 50103f4e..03714337 100644 --- a/package.nls.json +++ b/package.nls.json @@ -32,11 +32,17 @@ "config.print.onFileSave.description": "Print current document to HTML when file is saved.", "config.print.theme": "Theme of the exported HTML. Only affects code blocks.", "config.print.validateUrls.description": "Enable/disable URL validation when printing.", - "config.syntax.decorations.description": "Add syntax decorations in editors. (e.g. ~~strikethrough~~, `code span`)", + "config.syntax.decorations.description": "(**Deprecated**) Use `#markdown.extension.theming.decoration.renderCodeSpan#` instead. See for details.", "config.syntax.decorationFileSizeLimit.description": "If a file is larger than this size (in byte/B), we won't attempt to render syntax decorations.", - "config.syntax.plainTheme.description": "(**Experimental**) Report issue at .\n\nOnly take effect when `#markdown.extension.syntax.decorations#` is enabled.", + "config.syntax.plainTheme.description": "(**Experimental**) Report issue at .", "config.tableFormatter.enabled.description": "Enable [GitHub Flavored Markdown](https://github.github.com/gfm/) table formatter.", "config.tableFormatter.normalizeIndentation.description": "Normalize table indentation to closest multiple of configured editor tab size.", + "config.theming.decoration.renderCodeSpan.description": "Apply a border around a [code span](https://spec.commonmark.org/0.29/#code-spans).", + "config.theming.decoration.renderHardLineBreak.description": "(**Experimental**)", + "config.theming.decoration.renderLink.description": "(**Experimental**)", + "config.theming.decoration.renderParagraph.description": "(**Experimental**)", + "config.theming.decoration.renderStrikethrough.description": "Show a line through the middle of a [strikethrough](https://github.github.com/gfm/#strikethrough-extension-).", + "config.theming.decoration.renderTrailingSpace.description": "Shade the background of trailing space (U+0020) characters on a [line](https://spec.commonmark.org/0.29/#line).", "config.toc.downcaseLink.description": "Whether to **force** to downcase TOC links.", "config.toc.levels.description": "Range of levels for table of contents. Use `x..y` for level `x` to `y`.", "config.toc.omittedFromToc.description": "Lists of headings to omit by project file.\nExample:\n{ \"README.md\": [\"# Introduction\"] }", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 706ccfa3..e139f489 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -25,10 +25,12 @@ "config.print.absoluteImgPath.description": "将图片路径转换为绝对路径", "config.print.imgToBase64.description": "在打印到 HTML 时将图片转换为 base64", "config.print.onFileSave.description": "Markdown 文档保存后自动打印为 HTML。", - "config.syntax.decorations.description": "在编辑器中添加语法装饰。例如,~~strikethrough~~ 和 `code span`。", - "config.syntax.plainTheme.description": "(**实验性**)请在 报告问题。\n\n只有当 `#markdown.extension.syntax.decorations#` 启用时才生效。", + "config.syntax.plainTheme.description": "(**实验性**)请在 报告问题。", "config.tableFormatter.enabled.description": "启用 [GitHub Flavored Markdown](https://github.github.com/gfm/) 表格格式化。", "config.tableFormatter.normalizeIndentation.description": "使位于列表内的表格的缩进长度为制表符长度(`tabSize`)。", + "config.theming.decoration.renderCodeSpan.description": "在[行内代码 (code span)](https://spec.commonmark.org/0.29/#code-spans) 周围显示边框。", + "config.theming.decoration.renderStrikethrough.description": "显示[删除线 (strikethrough)](https://github.github.com/gfm/#strikethrough-extension-)。", + "config.theming.decoration.renderTrailingSpace.description": "为[行](https://spec.commonmark.org/0.29/#line)末端的空格 (U+0020) 字符添加底纹背景。", "config.toc.downcaseLink.description": "是否**强制**将目录中的链接转为小写。", "config.toc.levels.description": "目录级别的范围。例如 `2..5` 表示在目录中只包含 2 到 5 级标题。", "config.toc.omittedFromToc.description": "在指定文件的目录中省略这些标题。\n示例:\n{ \"README.md\": [\"# Introduction\"] }", diff --git a/src/configuration/KnownKey.ts b/src/configuration/KnownKey.ts new file mode 100644 index 00000000..b031e58a --- /dev/null +++ b/src/configuration/KnownKey.ts @@ -0,0 +1,43 @@ +"use strict"; + +/** + * Configuration keys that this product contributes. + * These values are relative to `markdown.extension`. + */ +type KnownKey = + | "completion.respectVscodeSearchExclude" + | "completion.root" + | "italic.indicator" + | "katex.macros" + | "list.indentationSize" + | "math.enabled" + | "orderedList.autoRenumber" + | "orderedList.marker" + | "preview.autoShowPreviewToSide" + | "print.absoluteImgPath" + | "print.imgToBase64" + | "print.includeVscodeStylesheets" + | "print.onFileSave" + | "print.theme" + | "print.validateUrls" + | "syntax.decorationFileSizeLimit" // To be superseded. + | "syntax.plainTheme" // To be superseded. + | "tableFormatter.enabled" + | "tableFormatter.normalizeIndentation" + | "theming.decoration.renderCodeSpan" // <- "syntax.decorations" + | "theming.decoration.renderHardLineBreak" + | "theming.decoration.renderLink" + | "theming.decoration.renderParagraph" + | "theming.decoration.renderStrikethrough" // <- "syntax.decorations" + | "theming.decoration.renderTrailingSpace" + | "toc.downcaseLink" + | "toc.levels" + | "toc.omittedFromToc" // To be superseded. + | "toc.orderedList" + | "toc.plaintext" + | "toc.slugifyMode" + | "toc.unorderedList.marker" + | "toc.updateOnSave" + ; + +export default KnownKey; diff --git a/src/configuration/defaultFallback.ts b/src/configuration/defaultFallback.ts new file mode 100644 index 00000000..28720226 --- /dev/null +++ b/src/configuration/defaultFallback.ts @@ -0,0 +1,35 @@ +"use strict"; + +import * as vscode from "vscode"; +import { IConfigurationFallbackMap } from "./manager"; + +/** + * Configuration keys that are no longer supported, + * and will be removed in the next major version. + */ +export const deprecated: readonly string[] = [ + "syntax.decorations", +]; + +export const fallbackMap: IConfigurationFallbackMap = { + + "theming.decoration.renderCodeSpan": (scope): boolean => { + const config = vscode.workspace.getConfiguration("markdown.extension", scope); + const old = config.get("syntax.decorations"); + if (old === null || old === undefined) { + return config.get("theming.decoration.renderCodeSpan")!; + } else { + return old; + } + }, + + "theming.decoration.renderStrikethrough": (scope): boolean => { + const config = vscode.workspace.getConfiguration("markdown.extension", scope); + const old = config.get("syntax.decorations"); + if (old === null || old === undefined) { + return config.get("theming.decoration.renderStrikethrough")!; + } else { + return old; + } + }, +}; diff --git a/src/configuration/manager.ts b/src/configuration/manager.ts new file mode 100644 index 00000000..57f752dc --- /dev/null +++ b/src/configuration/manager.ts @@ -0,0 +1,76 @@ +"use strict"; + +import * as vscode from "vscode"; +import type IDisposable from "../IDisposable"; +import type KnownKey from "./KnownKey"; +import { deprecated, fallbackMap } from "./defaultFallback"; + +export type IConfigurationFallbackMap = { readonly [key in KnownKey]?: (scope?: vscode.ConfigurationScope) => any; }; + +export interface IConfigurationManager extends IDisposable { + + /** + * Gets the value that an our own key denotes. + * @param key The configuration key. + * @param scope The scope, for which the configuration is asked. + */ + get(key: KnownKey, scope?: vscode.ConfigurationScope): T; + + /** + * Gets the value that an absolute identifier denotes. + * @param section The dot-separated identifier (usually a setting ID). + * @param scope The scope, for which the configuration is asked. + */ + getByAbsolute(section: string, scope?: vscode.ConfigurationScope): T | undefined; +} + +/** + * This is currently just a proxy that helps mapping our configuration keys. + */ +class ConfigurationManager implements IConfigurationManager { + + private readonly _fallback: IConfigurationFallbackMap; + + constructor(fallback: IConfigurationFallbackMap, deprecatedKeys: readonly string[]) { + this._fallback = Object.assign(Object.create(null), fallback); + this.showWarning(deprecatedKeys); + } + + public dispose(): void { } + + /** + * Shows an error message for each deprecated key, to help user migrate. + * This is async to avoid blocking instance creation. + */ + private async showWarning(deprecatedKeys: readonly string[]): Promise { + for (const key of deprecatedKeys) { + const value = vscode.workspace.getConfiguration("markdown.extension").get(key); + if (value !== undefined && value !== null) { + // We are not able to localize this string for now. + // Our NLS module needs to be configured before using, which is done in the extension entry point. + // This module may be directly or indirectly imported by the entry point. + // Thus, this module may be loaded before the NLS module is available. + vscode.window.showErrorMessage(`The setting 'markdown.extension.${key}' has been deprecated.`); + } + } + } + + public get(key: KnownKey, scope?: vscode.ConfigurationScope): T { + const fallback = this._fallback[key]; + if (fallback) { + return fallback(scope); + } else { + return vscode.workspace.getConfiguration("markdown.extension", scope).get(key)!; + } + } + + public getByAbsolute(section: string, scope?: vscode.ConfigurationScope): T | undefined { + if (section.startsWith("markdown.extension.")) { + return this.get(section.slice(19) as KnownKey, scope); + } else { + return vscode.workspace.getConfiguration(undefined, scope).get(section); + } + } +} + +export const configManager = new ConfigurationManager(fallbackMap, deprecated); diff --git a/src/extension.ts b/src/extension.ts index 25beffcb..73628703 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,13 @@ 'use strict'; import { ExtensionContext, languages, Uri, window, workspace } from 'vscode'; +import type { KatexOptions } from "katex"; +import { configManager } from "./configuration/manager"; +import { decorationManager } from "./theming/decorationManager"; import * as completion from './completion'; import * as formatting from './formatting'; import * as listEditing from './listEditing'; +import { commonMarkEngine, MarkdownIt, mdEngine } from "./markdownEngine"; import { config as configNls, localize } from './nls'; import resolveResource from "./nls/resolveResource"; import * as preview from './preview'; @@ -14,29 +18,34 @@ import * as toc from './toc'; export function activate(context: ExtensionContext) { configNls({ extensionContext: context }); + + context.subscriptions.push( + configManager, decorationManager, commonMarkEngine, mdEngine + ); + activateMdExt(context); if (workspace.getConfiguration('markdown.extension.math').get('enabled')) { // Make a deep copy as `macros` will be modified by KaTeX during initialization let userMacros = JSON.parse(JSON.stringify(workspace.getConfiguration('markdown.extension.katex').get('macros'))); - let katexOptions = { throwOnError: false }; + const katexOptions: KatexOptions = { throwOnError: false }; if (Object.keys(userMacros).length !== 0) { katexOptions['macros'] = userMacros; } return { - extendMarkdownIt(md) { + extendMarkdownIt(md: MarkdownIt): MarkdownIt { require('katex/contrib/mhchem/mhchem'); return md.use(require('markdown-it-task-lists')) .use(require('@neilsustc/markdown-it-katex'), katexOptions); } - } + }; } else { return { - extendMarkdownIt(md) { + extendMarkdownIt(md: MarkdownIt): MarkdownIt { return md.use(require('markdown-it-task-lists')); } - } + }; } } diff --git a/src/syntaxDecorations.ts b/src/syntaxDecorations.ts index 35ff1a6f..c0215ec2 100644 --- a/src/syntaxDecorations.ts +++ b/src/syntaxDecorations.ts @@ -1,168 +1,205 @@ -'use strict' +// This module is deprecated. +// No update will land here. +// It is superseded by `src/theming`, etc. -import { DecorationRangeBehavior, ExtensionContext, Position, Range, TextEditor, ThemeColor, window, workspace } from "vscode"; -import { isFileTooLarge, isInFencedCodeBlock, isMdEditor, mathEnvCheck } from "./util"; +"use strict"; -let decorTypes = { - "baseColor": window.createTextEditorDecorationType({ - "dark": { "color": "#EEFFFF" }, - "light": { "color": "000000" } - }), - "gray": window.createTextEditorDecorationType({ - "rangeBehavior": 1, - "dark": { "color": "#636363" }, - "light": { "color": "#CCC" } - }), - "lightBlue": window.createTextEditorDecorationType({ - "color": "#4080D0" - }), - "orange": window.createTextEditorDecorationType({ - "color": "#D2B640" - }), - "strikethrough": window.createTextEditorDecorationType({ - "rangeBehavior": 1, - "textDecoration": "line-through" - }), - "codeSpan": window.createTextEditorDecorationType({ - "rangeBehavior": DecorationRangeBehavior.ClosedClosed, - "border": "1px solid", - "borderColor": new ThemeColor("editor.selectionBackground"), - "borderRadius": "3px" - }) -}; +import * as vscode from "vscode"; +import { isInFencedCodeBlock, mathEnvCheck } from "./util"; -let decors = {} +//#region Constant -for (const decorTypeName of Object.keys(decorTypes)) { - decors[decorTypeName] = []; +const enum DecorationType { + baseColor, + gray, + lightBlue, + orange, } -let regexDecorTypeMapping = { - "(~~.+?~~)": ["strikethrough"], - "(?>> = { + [DecorationType.baseColor]: { + "dark": { "color": "#EEFFFF" }, + "light": { "color": "000000" }, + }, + [DecorationType.gray]: { + "rangeBehavior": 1, + "dark": { "color": "#636363" }, + "light": { "color": "#CCC" }, + }, + [DecorationType.lightBlue]: { + "color": "#4080D0", + }, + [DecorationType.orange]: { + "color": "#D2B640", + }, }; -let regexDecorTypeMappingPlainTheme = { +const regexDecorTypeMappingPlainTheme: ReadonlyArray<[RegExp, ReadonlyArray]> = [ // [alt](link) - "(^|[^!])(\\[)([^\\]\\n]*(?!\\].*\\[)[^\\[\\n]*)(\\]\\(.+?\\))": ["", "gray", "lightBlue", "gray"], + [ + /(^|[^!])(\[)([^\]\r\n]*?(?!\].*?\[)[^\[\r\n]*?)(\]\(.+?\))/, + [undefined, DecorationType.gray, DecorationType.lightBlue, DecorationType.gray] + ], // ![alt](link) - "(\\!\\[)([^\\]\\n]*(?!\\].*\\[)[^\\[\\n]*)(\\]\\(.+?\\))": ["gray", "orange", "gray"], + [ + /(\!\[)([^\]\r\n]*?(?!\].*?\[)[^\[\r\n]*?)(\]\(.+?\))/, + [DecorationType.gray, DecorationType.orange, DecorationType.gray] + ], // `code` - "(?\\/\\?\\s].*?[^\\*\\`\\!\\@\\#\\%\\^\\&\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s])(\\*)": ["gray", "baseColor", "gray"], + [ + /(\*)([^\*\`\!\@\#\%\^\&\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s].*?[^\*\`\!\@\#\%\^\&\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s])(\*)/, + [DecorationType.gray, DecorationType.baseColor, DecorationType.gray] + ], // _italic_ - "(_)([^\\*\\`\\!\\@\\#\\%\\^\\&\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s].*?[^\\*\\`\\!\\@\\#\\%\\^\\&\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s])(_)": ["gray", "baseColor", "gray"], + [ + /(_)([^\*\`\!\@\#\%\^\&\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s].*?[^\*\`\!\@\#\%\^\&\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s])(_)/, + [DecorationType.gray, DecorationType.baseColor, DecorationType.gray] + ], // **bold** - "(\\*\\*)([^\\*\\`\\!\\@\\#\\%\\^\\&\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s].*?[^\\*\\`\\!\\@\\#\\%\\^\\&\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s])(\\*\\*)": ["gray", "baseColor", "gray"] -} - -export function activate(_: ExtensionContext) { - workspace.onDidChangeConfiguration(event => { - if (event.affectsConfiguration('markdown.extension.syntax.decorations')) { - window.showInformationMessage("Please reload VSCode to make setting `syntax.decorations` take effect.") - } - }); + [ + /(\*\*)([^\*\`\!\@\#\%\^\&\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s].*?[^\*\`\!\@\#\%\^\&\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s])(\*\*)/, + [DecorationType.gray, DecorationType.baseColor, DecorationType.gray] + ], +]; + +//#endregion Constant + +/** + * Decoration type instances **currently in use**. + */ +const decorationHandles = new Map(); + +const decors = new Map(); + +export function activate(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration("markdown.extension.syntax.plainTheme")) { + triggerUpdateDecorations(vscode.window.activeTextEditor); + } + }), - if (!workspace.getConfiguration('markdown.extension.syntax').get('decorations')) return; + vscode.window.onDidChangeActiveTextEditor(triggerUpdateDecorations), - window.onDidChangeActiveTextEditor(updateDecorations); + vscode.workspace.onDidChangeTextDocument(event => { + const editor = vscode.window.activeTextEditor; + if (editor && event.document === editor.document) { + triggerUpdateDecorations(editor); + } + }), + ); - workspace.onDidChangeTextDocument(event => { - let editor = window.activeTextEditor; - if (editor !== undefined && event.document === editor.document) { - triggerUpdateDecorations(editor); - } - }); + triggerUpdateDecorations(vscode.window.activeTextEditor); +} - var timeout = null; - function triggerUpdateDecorations(editor) { - if (timeout) { - clearTimeout(timeout); - } - timeout = setTimeout(() => updateDecorations(editor), 200); +var timeout: NodeJS.Timeout | undefined; +// Debounce. +function triggerUpdateDecorations(editor: vscode.TextEditor | undefined) { + if (!editor || editor.document.languageId !== "markdown") { + return; } - let editor = window.activeTextEditor; - if (editor) { - updateDecorations(editor); + if (timeout) { + clearTimeout(timeout); } + timeout = setTimeout(() => updateDecorations(editor), 200); } -function updateDecorations(editor?: TextEditor) { - if (editor === undefined) { - editor = window.activeTextEditor; +/** + * Clears decorations in the text editor. + */ +function clearDecorations(editor: vscode.TextEditor) { + for (const handle of decorationHandles.values()) { + editor.setDecorations(handle, []); } +} - if (!isMdEditor(editor)) { +function updateDecorations(editor: vscode.TextEditor) { + // Remove everything if it's disabled. + if (!vscode.workspace.getConfiguration().get("markdown.extension.syntax.plainTheme", false)) { + for (const handle of decorationHandles.values()) { + handle.dispose(); + } + decorationHandles.clear(); return; } const doc = editor.document; - if (isFileTooLarge(doc)) { + if (doc.getText().length * 1.5 > vscode.workspace.getConfiguration().get("markdown.extension.syntax.decorationFileSizeLimit")!) { + clearDecorations(editor); // In case the editor is still visible. return; } - // Clean decorations - for (const decorTypeName of Object.keys(decorTypes)) { - decors[decorTypeName] = []; + // Reset decoration collection. + decors.clear(); + for (const typeName of Object.keys(decorationStyles)) { + decors.set(+typeName, []); } - // e.g. { "(~~.+?~~)": ["strikethrough"] } - let appliedMappings = workspace.getConfiguration('markdown.extension.syntax').get('plainTheme') ? - { ...regexDecorTypeMappingPlainTheme, ...regexDecorTypeMapping } : - regexDecorTypeMapping; - + // Analyze. doc.getText().split(/\r?\n/g).forEach((lineText, lineNum) => { // For each line if (isInFencedCodeBlock(doc, lineNum)) { return; } // Issue #412 // Trick. Match `[alt](link)` and `![alt](link)` first and remember those greyed out ranges - let noDecorRanges: [number, number][] = []; - - for (const reText of Object.keys(appliedMappings)) { - const decorTypeNames: string[] = appliedMappings[reText]; // e.g. ["strikethrough"] or ["gray", "baseColor", "gray"] - const regex = new RegExp(reText, 'g'); // e.g. "(~~.+?~~)" + const noDecorRanges: [number, number][] = []; - let match; - while ((match = regex.exec(lineText)) !== null) { + for (const [re, types] of regexDecorTypeMappingPlainTheme) { + const regex = new RegExp(re, "g"); - let startIndex = match.index; + for (const match of lineText.matchAll(regex)) { + let startIndex = match.index!; if (noDecorRanges.some(r => (startIndex > r[0] && startIndex < r[1]) || (startIndex + match[0].length > r[0] && startIndex + match[0].length < r[1]) )) { continue; } - for (let i = 0; i < decorTypeNames.length; i++) { + for (let i = 0; i < types.length; i++) { //// Skip if in math environment (See `completion.ts`) - if (mathEnvCheck(doc, new Position(lineNum, startIndex)) !== "") { + if (mathEnvCheck(doc, new vscode.Position(lineNum, startIndex)) !== "") { break; } - const decorTypeName = decorTypeNames[i]; - const caughtGroup = decorTypeName == "codeSpan" ? match[0] : match[i + 1]; + const typeName = types[i]; + const caughtGroup = match[i + 1]; - if (decorTypeName === "gray" && caughtGroup.length > 2) { + if (typeName === DecorationType.gray && caughtGroup.length > 2) { noDecorRanges.push([startIndex, startIndex + caughtGroup.length]); } - const range = new Range(lineNum, startIndex, lineNum, startIndex + caughtGroup.length); + const range = new vscode.Range(lineNum, startIndex, lineNum, startIndex + caughtGroup.length); startIndex += caughtGroup.length; //// Needed for `[alt](link)` rule. And must appear after `startIndex += caughtGroup.length;` - if (decorTypeName.length === 0) { + if (!typeName) { continue; } - decors[decorTypeName].push(range); + + // We've created these arrays at the beginning of the function. + decors.get(typeName)!.push(range); } } } }); - for (const decorTypeName of Object.keys(decors)) { - editor.setDecorations(decorTypes[decorTypeName], decors[decorTypeName]); + // Apply decorations. + for (const [typeName, ranges] of decors) { + let handle = decorationHandles.get(typeName); + + // Create a new decoration type instance if needed. + if (!handle) { + handle = vscode.window.createTextEditorDecorationType(decorationStyles[typeName]); + decorationHandles.set(typeName, handle); + } + + editor.setDecorations(handle, ranges); } } diff --git a/src/theming/constant.ts b/src/theming/constant.ts new file mode 100644 index 00000000..2b475ddc --- /dev/null +++ b/src/theming/constant.ts @@ -0,0 +1,96 @@ +"use strict"; + +import * as vscode from "vscode"; +import type ConfigKnownKey from "../configuration/KnownKey"; + +// Keys are sorted in alphabetical order. + +const enum Color { + EditorCodeSpanBorder, + EditorFormattingMarkForeground, + EditorTrailingSpaceBackground, +} + +const colors: Readonly> = { + [Color.EditorCodeSpanBorder]: new vscode.ThemeColor("markdown.extension.editor.codeSpan.border"), + [Color.EditorFormattingMarkForeground]: new vscode.ThemeColor("markdown.extension.editor.formattingMark.foreground"), + [Color.EditorTrailingSpaceBackground]: new vscode.ThemeColor("markdown.extension.editor.trailingSpace.background"), +}; + +const enum FontIcon { + DownwardsArrow, + DownwardsArrowWithCornerLeftwards, + Link, + Pilcrow, +} + +const fontIcons: Readonly>> = { + [FontIcon.DownwardsArrow]: { + contentText: "↓", + color: colors[Color.EditorFormattingMarkForeground], + }, + [FontIcon.DownwardsArrowWithCornerLeftwards]: { + contentText: "↵", + color: colors[Color.EditorFormattingMarkForeground], + }, + [FontIcon.Link]: { + contentText: "\u{1F517}\u{FE0E}", + color: colors[Color.EditorFormattingMarkForeground], + }, + [FontIcon.Pilcrow]: { + contentText: "¶", + color: colors[Color.EditorFormattingMarkForeground], + }, +}; + +export const enum DecorationClass { + CodeSpan, + HardLineBreak, + Link, + Paragraph, + Strikethrough, + TrailingSpace, +} + +/** + * Rendering styles for each decoration class. + */ +export const decorationStyles: Readonly>> = { + [DecorationClass.CodeSpan]: { + border: "1px solid", + borderColor: colors[Color.EditorCodeSpanBorder], + borderRadius: "3px", + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + }, + [DecorationClass.HardLineBreak]: { + after: fontIcons[FontIcon.DownwardsArrowWithCornerLeftwards], + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + }, + [DecorationClass.Link]: { + before: fontIcons[FontIcon.Link], + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + }, + [DecorationClass.Paragraph]: { + after: fontIcons[FontIcon.Pilcrow], + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + }, + [DecorationClass.Strikethrough]: { + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + textDecoration: "line-through", + }, + [DecorationClass.TrailingSpace]: { + backgroundColor: colors[Color.EditorTrailingSpaceBackground], + }, +}; + +/** + * DecorationClass -> Configuration key + */ +export const decorationClassConfigMap: Readonly> = { + [DecorationClass.CodeSpan]: "theming.decoration.renderCodeSpan", + [DecorationClass.HardLineBreak]: "theming.decoration.renderHardLineBreak", + [DecorationClass.Link]: "theming.decoration.renderLink", + [DecorationClass.Paragraph]: "theming.decoration.renderParagraph", + [DecorationClass.Strikethrough]: "theming.decoration.renderStrikethrough", + [DecorationClass.TrailingSpace]: "theming.decoration.renderTrailingSpace", +}; diff --git a/src/theming/decorationManager.ts b/src/theming/decorationManager.ts new file mode 100644 index 00000000..42d622c3 --- /dev/null +++ b/src/theming/decorationManager.ts @@ -0,0 +1,373 @@ +"use strict"; + +import * as vscode from "vscode"; +import LanguageIdentifier from "../contract/LanguageIdentifier"; +import type IDisposable from "../IDisposable"; +import { configManager } from "../configuration/manager"; +import { DecorationClass, decorationClassConfigMap, decorationStyles } from "./constant"; +import decorationWorkerRegistry from "./decorationWorkerRegistry"; + +/** + * Represents a set of decoration ranges. + */ +export interface IDecorationRecord { + + readonly target: DecorationClass; + + /** + * The ranges that the decoration applies to. + */ + readonly ranges: readonly vscode.Range[]; +} + +/** + * Represents a decoration analysis worker. + * + * A worker is a function, which analyzes a document, and returns a thenable that resolves to a `IDecorationRecord`. + * A worker **may** accept a `CancellationToken`, and then must **reject** the promise when the task is cancelled. + */ +export interface IFuncAnalysisWorker { + + (document: vscode.TextDocument, token: vscode.CancellationToken): Thenable; +} + +export type IWorkerRegistry = { readonly [target in DecorationClass]: IFuncAnalysisWorker; }; + +/** + * Represents the state of an asynchronous or long running operation. + */ +const enum TaskState { + Pending, + Fulfilled, + Cancelled, +} + +/** + * Represents a decoration analysis task. + * + * Such a task should be asynchronous. + */ +interface IDecorationAnalysisTask { + + /** + * The document that the task works on. + */ + readonly document: vscode.TextDocument; + + /** + * The thenable that represents the task. + * + * This is just a handle for caller to await. + * It must be guaranteed to be still **fulfilled** on cancellation. + */ + readonly executor: Thenable; + + /** + * The result. + * + * Only available when the task is `Fulfilled`. Otherwise, `undefined`. + */ + readonly result?: readonly IDecorationRecord[]; + + /** + * The state of the task. + */ + readonly state: TaskState; + + /** + * Cancels the task. + */ + cancel(): void; +} + +class DecorationAnalysisTask implements IDecorationAnalysisTask { + + private readonly _cts: vscode.CancellationTokenSource; + + private _result: readonly IDecorationRecord[] | undefined = undefined; + + public readonly document: vscode.TextDocument; + + public readonly executor: Thenable; + + public get result(): readonly IDecorationRecord[] | undefined { + return this._result; + } + + public get state(): TaskState { + if (this._cts.token.isCancellationRequested) { + return TaskState.Cancelled; + } else if (this._result) { + return TaskState.Fulfilled; + } else { + return TaskState.Pending; + } + } + + constructor(document: vscode.TextDocument, workers: IWorkerRegistry, targets: readonly DecorationClass[]) { + this.document = document; + + const token = + (this._cts = new vscode.CancellationTokenSource()).token; + + // The weird nesting is to defer the task creation to reduce runtime cost. + // The outermost is a so-called "cancellable promise". + // If you create a task and cancel it immediately, this design guarantees that most workers are not called. + // Otherwise, you will observe thousands of discarded microtasks quickly. + this.executor = new Promise((resolve, reject): void => { + token.onCancellationRequested(reject); + if (token.isCancellationRequested) { + reject(); + } + + resolve(Promise.all(targets.map>(target => workers[target](document, token)))); + }) + .then(result => this._result = result) // Copy the result and pass it down. + .catch(reason => { + // We'll adopt `vscode.CancellationError` when it matures. + // For now, falsy indicates cancellation, and we won't throw an exception for that. + if (reason) { + throw reason; + } + }); + } + + public cancel(): void { + this._cts.cancel(); + this._cts.dispose(); + } +} + +interface IDecorationManager extends IDisposable { } + +/** + * Represents a text editor decoration manager. + * + * For reliability reasons, do not leak any mutable content out of the manager. + * + * VS Code does not define a corresponding `*Provider` interface, so we implement it ourselves. + * The following scenarios are considered: + * + * * Activation. + * * Opening a document with/without corresponding editors. + * * Changing a document. + * * Closing a document. + * * Closing a Markdown editor, and immediately switching to an arbitrary editor. + * * Switching between arbitrary editors, including Markdown to Markdown. + * * Changing configuration after a decoration analysis task started. + * * Deactivation. + */ +class DecorationManager implements IDecorationManager { + + /** + * Decoration type instances **currently in use**. + */ + private readonly _decorationHandles = new Map(); + + /** + * Decoration analysis workers. + */ + private readonly _decorationWorkers: IWorkerRegistry; + + /** + * The keys of `_decorationWorkers`. + * This is for improving performance. + */ + private readonly _supportedClasses: readonly DecorationClass[]; + + /** + * Disposables which unregister contributions to VS Code. + */ + private readonly _disposables: vscode.Disposable[]; + + /** + * Decoration analysis tasks **currently in use**. + * This serves as both a task pool, and a result cache. + */ + private readonly _tasks = new Map(); + + /** + * This is exclusive to `applyDecoration()`. + */ + private _displayDebounceHandle: vscode.CancellationTokenSource | undefined; + + constructor(workers: IWorkerRegistry) { + this._decorationWorkers = Object.assign(Object.create(null), workers); + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Property_Accessors#property_names + this._supportedClasses = Object.keys(workers).map(Number); + + // Here are many different kinds of calls. Bind `this` context carefully. + + // Load all. + vscode.workspace.textDocuments.forEach(this.updateCache, this); + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + this.applyDecoration(activeEditor); + } + + // Register event listeners. + this._disposables = [ + vscode.workspace.onDidOpenTextDocument(this.updateCache, this), + + vscode.workspace.onDidChangeTextDocument(event => { + this.updateCache(event.document); + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && activeEditor.document === event.document) { + this.applyDecoration(activeEditor); + } + }), + + vscode.workspace.onDidCloseTextDocument(this.collectGarbage, this), + vscode.window.onDidChangeActiveTextEditor(editor => { if (editor) { this.applyDecoration(editor); } }), + ]; + } + + public dispose(): void { + // Unsubscribe event listeners. + for (const disposable of this._disposables) { + disposable.dispose(); + } + this._disposables.length = 0; + + // Stop rendering. + if (this._displayDebounceHandle) { + this._displayDebounceHandle.cancel(); + this._displayDebounceHandle.dispose(); + } + this._displayDebounceHandle = undefined; + + // Terminate tasks. + for (const task of this._tasks.values()) { + task.cancel(); + } + this._tasks.clear(); + + // Remove decorations. + for (const handle of this._decorationHandles.values()) { + handle.dispose(); + } + this._decorationHandles.clear(); + } + + /** + * Applies a set of decorations to the text editor asynchronously. + * + * This method is expected to be started frequently on volatile state. + * It begins with a short sync part, to make immediate response to event possible. + * Then, it internally creates an async job, to keep data access correct. + * It stops silently, if any condition is not met. + * + * For performance reasons, it only works on the **active** editor (not visible editors), + * although VS Code renders decorations as long as the editor is visible. + * Besides, we have a threshold to stop analyzing large documents. + * When it is reached, related task will be unavailable, thus by design, this method will quit. + */ + private applyDecoration(editor: vscode.TextEditor): void { + const document = editor.document; + if (document.languageId !== LanguageIdentifier.Markdown) { + return; + } + + // The task can be in any state (typically pending, fulfilled, obsolete) during this call. + // The editor can be suspended or even disposed at any time. + // Thus, we have to check at each stage. + + const task = this._tasks.get(document); + if (!task || task.state === TaskState.Cancelled) { + return; + } + + // Discard the previous operation, in case the user is switching between editors fast. + // Although I don't think a debounce can make much value. + if (this._displayDebounceHandle) { + this._displayDebounceHandle.cancel(); + this._displayDebounceHandle.dispose(); + } + + const debounceToken = + (this._displayDebounceHandle = new vscode.CancellationTokenSource()).token; + + // Queue the display refresh job. + (async (): Promise => { + if (task.state === TaskState.Pending) { + await task.executor; + } + + if (task.state !== TaskState.Fulfilled || debounceToken.isCancellationRequested) { + return; + } + + const results = task.result!; + + for (const { ranges, target } of results) { + let handle = this._decorationHandles.get(target); + + // Recheck applicability, since the user may happen to change settings. + if (configManager.get(decorationClassConfigMap[target])) { + // Create a new decoration type instance if needed. + if (!handle) { + handle = vscode.window.createTextEditorDecorationType(decorationStyles[target]); + this._decorationHandles.set(target, handle); + } + } else { + // Remove decorations if the type is disabled. + if (handle) { + handle.dispose(); + this._decorationHandles.delete(target); + } + continue; + } + + if ( + debounceToken.isCancellationRequested + || task.state !== TaskState.Fulfilled // Confirm the cache is still up-to-date. + || vscode.window.activeTextEditor !== editor // Confirm the editor is still active. + ) { + return; + } + + // Create a shallow copy for VS Code to use. This operation shouldn't cost much. + editor.setDecorations(handle, Array.from(ranges)); + } + })(); + } + + /** + * Terminates tasks that are linked to the document, and frees corresponding resources. + */ + private collectGarbage(document: vscode.TextDocument): void { + const task = this._tasks.get(document); + if (task) { + task.cancel(); + this._tasks.delete(document); + } + } + + /** + * Initiates and **queues** a decoration cache update task that is linked to the document. + */ + private updateCache(document: vscode.TextDocument): void { + if (document.languageId !== LanguageIdentifier.Markdown) { + return; + } + + // Discard previous tasks. Effectively mark existing cache as obsolete. + this.collectGarbage(document); + + // Stop if the document exceeds max length. + // The factor is for compatibility. There should be new logic someday. + if (document.getText().length * 1.5 > configManager.get("syntax.decorationFileSizeLimit")) { + return; + } + + // Create the new task. + this._tasks.set(document, new DecorationAnalysisTask( + document, + this._decorationWorkers, + // No worry. `applyDecoration()` should recheck applicability. + this._supportedClasses.filter(target => configManager.get(decorationClassConfigMap[target])) + )); + } +} + +export const decorationManager: IDecorationManager = new DecorationManager(decorationWorkerRegistry); diff --git a/src/theming/decorationWorkerRegistry.ts b/src/theming/decorationWorkerRegistry.ts new file mode 100644 index 00000000..40c73b06 --- /dev/null +++ b/src/theming/decorationWorkerRegistry.ts @@ -0,0 +1,254 @@ +"use strict"; + +import * as vscode from "vscode"; +import { commonMarkEngine, mdEngine } from "../markdownEngine"; +import { DecorationClass } from "./constant"; +import { IDecorationRecord, IWorkerRegistry } from "./decorationManager"; + +// ## Organization +// +// Sort in alphabetical order. +// Place a blank line between two entries. +// Place a trailing comma at the end of each entry. +// +// ## Template +// +// It's recommended to use this so-called "cancellable promise". +// If you have to write async function for some reason, +// remember to add cancellation checks based on your experience. +// +// ````typescript +// [DecorationClass.TrailingSpace]: (document, token) => { +// return new Promise((resolve, reject): void => { +// token.onCancellationRequested(reject); +// if (token.isCancellationRequested) { +// reject(); +// } +// +// const ranges: vscode.Range[] = []; +// +// resolve({ target: DecorationClass.TrailingSpace, ranges }); +// }); +// }, +// ```` + +/** + * The registry of decoration analysis workers. + */ +const decorationWorkerRegistry: IWorkerRegistry = { + + [DecorationClass.CodeSpan]: async (document, token): Promise => { + if (token.isCancellationRequested) { + throw undefined; + } + + const text = document.getText(); + + // Tokens in, for example, table doesn't have `map`. + // Tables themselves are strange enough. So, skipping them is acceptable. + const tokens = (await mdEngine.getDocumentToken(document)).tokens + .filter(t => t.type === "inline" && t.map); + + if (token.isCancellationRequested) { + throw undefined; + } + + const ranges: vscode.Range[] = []; + + for (const { content, children, map } of tokens) { + const initOffset = text.indexOf(content, document.offsetAt(new vscode.Position(map![0], 0))); + + let beginOffset = initOffset; + let endOffset = initOffset; + for (const t of children!) { + if (t.type !== "code_inline") { + beginOffset += t.content.length; // Not accurate, but enough. + continue; + } + + // The `content` is "normalized", not raw. + // Thus, in some cases, we need to perform a fuzzy search, and the result cannot be precise. + let codeSpanText = t.markup + t.content + t.markup; + let cursor = text.indexOf(codeSpanText, beginOffset); + + if (cursor === -1) { // There may be one space on both sides. + codeSpanText = t.markup + " " + t.content + " " + t.markup; + cursor = text.indexOf(codeSpanText, beginOffset); + } + + if (cursor !== -1) { // Good. + beginOffset = cursor; + endOffset = beginOffset + codeSpanText.length; + } else { + beginOffset = text.indexOf(t.markup, beginOffset); + + // See if the first piece of `content` can help us. + const searchPos = beginOffset + t.markup.length; + const searchText = t.content.slice(0, t.content.indexOf(" ")); + cursor = text.indexOf(searchText, searchPos); + + endOffset = cursor !== -1 + ? text.indexOf(t.markup, cursor + searchText.length) + t.markup.length + : text.indexOf(t.markup, searchPos) + t.markup.length; + } + + ranges.push(new vscode.Range( + document.positionAt(beginOffset), + document.positionAt(endOffset) + )); + + beginOffset = endOffset; + } + + if (token.isCancellationRequested) { + throw undefined; + } + } + + return { target: DecorationClass.CodeSpan, ranges }; + }, + + [DecorationClass.HardLineBreak]: (document, token) => { + return new Promise((resolve, reject): void => { + token.onCancellationRequested(reject); + if (token.isCancellationRequested) { + reject(); + } + + // Use commonMarkEngine for reliability, at the expense of accuracy. + const tokens = commonMarkEngine.getDocumentToken(document).tokens.filter(t => t.type === "inline"); + + const ranges: vscode.Range[] = []; + + for (const { children, map } of tokens) { + let lineIndex = map![0]; + for (const t of children!) { + switch (t.type) { + case "softbreak": + lineIndex++; + break; + + case "hardbreak": + const pos = document.lineAt(lineIndex).range.end; + ranges.push(new vscode.Range(pos, pos)); + lineIndex++; + break; + } + } + } + + resolve({ target: DecorationClass.HardLineBreak, ranges }); + }); + }, + + [DecorationClass.Link]: (document, token) => { + return new Promise((resolve, reject): void => { + token.onCancellationRequested(reject); + if (token.isCancellationRequested) { + reject(); + } + + // A few kinds of inline links. + const ranges: vscode.Range[] = Array.from( + document.getText().matchAll(/(? { + const pos = document.positionAt(m.index!); + return new vscode.Range(pos, pos); + } + ); + + resolve({ target: DecorationClass.Link, ranges }); + }); + }, + + [DecorationClass.Paragraph]: async (document, token): Promise => { + if (token.isCancellationRequested) { + throw undefined; + } + + const { tokens } = (await mdEngine.getDocumentToken(document)); + + if (token.isCancellationRequested) { + throw undefined; + } + + const ranges: vscode.Range[] = []; + + for (const t of tokens) { + if (t.type === "paragraph_open") { + const pos = document.lineAt(t.map![1] - 1).range.end; + ranges.push(new vscode.Range(pos, pos)); + } + } + + return { target: DecorationClass.Paragraph, ranges }; + }, + + [DecorationClass.Strikethrough]: async (document, token): Promise => { + if (token.isCancellationRequested) { + throw undefined; + } + + const searchRanges = (await mdEngine.getDocumentToken(document)).tokens + .filter(t => t.type === "inline" && t.map).map(t => t.map!); // Tokens in, for example, table doesn't have `map`. + + if (token.isCancellationRequested) { + throw undefined; + } + + const ranges: vscode.Range[] = []; + + for (const [begin, end] of searchRanges) { + const beginOffset = document.offsetAt(new vscode.Position(begin, 0)); + const text = document.getText(new vscode.Range(begin, 0, end, 0)); + + // GitHub's definition is pretty strict. I've tried my best to simulate it. + ranges.push(...Array.from( + text.matchAll(/(? { + return new vscode.Range( + document.positionAt(beginOffset + m.index!), + document.positionAt(beginOffset + m.index! + m[0].length) + ); + } + )); + + if (token.isCancellationRequested) { + throw undefined; + } + } + + return { target: DecorationClass.Strikethrough, ranges }; + }, + + [DecorationClass.TrailingSpace]: (document, token) => { + return new Promise((resolve, reject): void => { + token.onCancellationRequested(reject); + if (token.isCancellationRequested) { + reject(); + } + + const text = document.getText(); + + const ranges: vscode.Range[] = Array.from( + text.matchAll(/ +(?=[\r\n])/g), m => { + return new vscode.Range( + document.positionAt(m.index!), + document.positionAt(m.index! + m[0].length) + ); + } + ); + + // Process the end of file case specially. + const eof = text.match(/ +$/); + if (eof) { + ranges.push(new vscode.Range( + document.positionAt(eof.index!), + document.positionAt(eof.index! + eof[0].length) + )); + } + + resolve({ target: DecorationClass.TrailingSpace, ranges }); + }); + }, +}; + +export default decorationWorkerRegistry;