├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── assets ├── 01_single.gif ├── 02_multi.gif ├── 03_tokens.gif ├── 04_style.gif ├── 05_code.gif ├── 06_custom_code.gif └── 07_latex.gif ├── esbuild.config.mjs ├── global.d.ts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── components │ ├── BaseComponent.ts │ ├── CustomLanguageBlock.module.scss │ ├── CustomLanguageBlock.ts │ ├── CustomLanguageList.module.scss │ ├── CustomLanguageList.ts │ ├── Foldout.module.scss │ ├── Foldout.ts │ ├── IconButton.module.scss │ ├── IconButton.ts │ ├── Text.ts │ ├── TextInput.module.scss │ ├── TextInput.ts │ └── index.ts ├── extensions │ ├── fields.ts │ ├── index.ts │ └── plugin.ts ├── main.ts ├── settings │ ├── defaults.ts │ ├── index.ts │ ├── language.ts │ ├── styles.scss │ ├── tab.ts │ ├── types.ts │ └── utils.ts ├── styles.scss ├── toggle │ ├── controller.ts │ ├── index.ts │ └── types.ts └── utility │ ├── ListDict.ts │ ├── animation │ ├── AnimationGroup.ts │ ├── defaults.ts │ └── index.ts │ ├── appearanceUtils.ts │ ├── commentUtils.ts │ ├── editorUtils.ts │ ├── generalUtils.ts │ ├── index.ts │ └── typeUtils.ts ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": ["@typescript-eslint"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parserOptions": { 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 17 | "@typescript-eslint/ban-ts-comment": "off", 18 | "no-prototype-builtins": "off", 19 | "@typescript-eslint/no-empty-function": "off" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [MrGVSV] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/ginov'] 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | styles.css 15 | dist/ 16 | 17 | # Exclude sourcemaps 18 | *.map 19 | 20 | # obsidian 21 | data.json 22 | 23 | # Exclude macOS Finder (System Explorer) View States 24 | .DS_Store 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Gino Valente 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Better Comment Toggle 2 | 3 | *Improved comment toggling in Obsidian.* 4 | 5 | ## Features 6 | 7 | - Easily toggle comments line-by-line or over a selected range of lines 8 | - Configure the start and end tokens for toggled comments 9 | - Change the appearance of commented lines 10 | - Support for both code and math blocks (including custom languages) 11 | - Maintains indentation 12 | 13 | ## Demos 14 | 15 |

16 | Toggling lines one-by-oneToggling a selected range of linesUsing custom comment tokensUsing a custom comment appearanceToggling a comment inside a code blockToggling a comment inside a math block 22 |

23 | 24 | ## Details 25 | 26 | This plugin is purely WYSWIG. That is, there are no hidden HTML tags or other metadata embedded into your notes. The comments exist as you see them in the notes. Any additional styling (e.g. font color) is purely cosmetic and exists only in the editor. 27 | 28 | The default comment style is set to HTML (``), which is the syntax specified by [CommonMark](https://spec.commonmark.org/0.30/#example-624). However, you can configure this plugin to use Obsidian-style comments (`%% %%`), or even define your own! 29 | 30 | ### Tips 31 | 32 | It's recommended to replace the existing keybinding for Obsidian's comment toggling command with the one provided by this plugin: 33 | 34 | - +/ (Mac) 35 | - Ctrl+/ (Windows) 36 | 37 | 38 | ## Support 39 | 40 | This plugin is totally free to use! I have a lot of fun making stuff like this, so I never expect any type of financial compensation. But if you enjoy the plugin and are feeling generous, I certainly won't say no to a cup of coffee! 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /assets/01_single.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/01_single.gif -------------------------------------------------------------------------------- /assets/02_multi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/02_multi.gif -------------------------------------------------------------------------------- /assets/03_tokens.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/03_tokens.gif -------------------------------------------------------------------------------- /assets/04_style.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/04_style.gif -------------------------------------------------------------------------------- /assets/05_code.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/05_code.gif -------------------------------------------------------------------------------- /assets/06_custom_code.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/06_custom_code.gif -------------------------------------------------------------------------------- /assets/07_latex.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MrGVSV/obsidian-better-comment-toggle/f387366958a80d55f218e4d3abd08f1d6fed923d/assets/07_latex.gif -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | import { postcssModules, sassPlugin } from 'esbuild-sass-plugin'; 5 | import * as fs from 'fs'; 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = process.argv[2] === 'production'; 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ['src/main.ts'], 20 | bundle: true, 21 | plugins: [ 22 | sassPlugin({ 23 | transform: postcssModules({ 24 | hashPrefix: 'better-comment-toggle--', 25 | localsConvention: 'camelCaseOnly', 26 | }), 27 | }), 28 | { 29 | name: 'main-css-to-styles-css', 30 | setup: (build) => 31 | build.onEnd(() => { 32 | fs.renameSync('main.css', 'styles.css'); 33 | }), 34 | }, 35 | ], 36 | external: [ 37 | 'obsidian', 38 | 'electron', 39 | '@codemirror/autocomplete', 40 | '@codemirror/collab', 41 | '@codemirror/commands', 42 | '@codemirror/language', 43 | '@codemirror/lint', 44 | '@codemirror/search', 45 | '@codemirror/state', 46 | '@codemirror/view', 47 | '@lezer/common', 48 | '@lezer/highlight', 49 | '@lezer/lr', 50 | ...builtins, 51 | ], 52 | format: 'cjs', 53 | target: 'es2018', 54 | logLevel: 'info', 55 | sourcemap: prod ? false : 'inline', 56 | treeShaking: true, 57 | outfile: 'main.js', 58 | }); 59 | 60 | if (prod) { 61 | await context.rebuild(); 62 | process.exit(0); 63 | } else { 64 | await context.watch(); 65 | } 66 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss'; 2 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "better-comment-toggle", 3 | "name": "Better Comment Toggle", 4 | "version": "1.0.3", 5 | "minAppVersion": "0.15.0", 6 | "description": "Improved comment toggling.", 7 | "author": "Gino Valente", 8 | "authorUrl": "https://github.com/MrGVSV", 9 | "fundingUrl": { 10 | "Buy Me a Coffee": "https://www.buymeacoffee.com/ginov", 11 | "GitHub Sponsor": "https://github.com/sponsors/MrGVSV" 12 | }, 13 | "isDesktopOnly": false 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-better-comment-toggle", 3 | "version": "1.0.3", 4 | "description": "Improved comment toggling.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "lint": "eslint . --ext .ts" 11 | }, 12 | "keywords": [], 13 | "author": "Gino Valente", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/MrGVSV/obsidian-better-markdown-comments.git" 18 | }, 19 | "devDependencies": { 20 | "@codemirror/commands": "6.2.4", 21 | "@codemirror/language": "6.8.0", 22 | "@types/node": "^16.11.6", 23 | "@typescript-eslint/eslint-plugin": "5.29.0", 24 | "@typescript-eslint/parser": "5.29.0", 25 | "builtin-modules": "3.3.0", 26 | "esbuild": "0.18.14", 27 | "esbuild-sass-plugin": "^2.10.0", 28 | "nanoid": "^4.0.2", 29 | "obsidian": "latest", 30 | "postcss": "^8.4.26", 31 | "postcss-modules": "^6.0.0", 32 | "prettier": "3.0.0", 33 | "tslib": "2.4.0", 34 | "typescript": "4.9.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/BaseComponent.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseComponent { 2 | public rootEl: HTMLElement; 3 | 4 | public constructor(container: HTMLElement) { 5 | this.rootEl = this.init(container); 6 | } 7 | 8 | /** 9 | * Initialize the component. 10 | * @returns The root element of the component. 11 | */ 12 | protected abstract init(container: HTMLElement): HTMLElement; 13 | 14 | /** 15 | * Add one or more classes to the root element of this component. 16 | * @param cls 17 | */ 18 | public addClass(...cls: string[]): this { 19 | this.rootEl.addClass(...cls); 20 | return this; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/CustomLanguageBlock.module.scss: -------------------------------------------------------------------------------- 1 | @import '../styles'; 2 | 3 | @mixin mobile-only { 4 | @media only screen and (max-width: 800px) { 5 | @content; 6 | } 7 | } 8 | 9 | .container { 10 | display: grid; 11 | grid-template-columns: 1fr min-content; 12 | row-gap: 0; 13 | align-items: start; 14 | 15 | @include mobile-only { 16 | display: flex; 17 | flex-wrap: wrap; 18 | justify-content: flex-end; 19 | width: 100%; 20 | } 21 | } 22 | 23 | .foldout { 24 | border-radius: var(--radius-s); 25 | z-index: 5; 26 | 27 | @include mobile-only { 28 | width: 100%; 29 | border-bottom-right-radius: 0; 30 | } 31 | } 32 | 33 | .summary-section { 34 | display: inline-grid; 35 | grid-template-columns: 1fr 2.5fr; 36 | column-gap: var(--size-4-2); 37 | align-items: center; 38 | 39 | background-color: var(--background-primary-alt); 40 | border-radius: var(--radius-s); 41 | outline: var(--border-width) solid var(--background-modifier-border); 42 | 43 | margin: 0 0 0 var(--size-4-2); 44 | width: calc(100% - var(--font-ui-small) - var(--size-4-3)); 45 | 46 | .regex { 47 | display: inline-flex; 48 | align-items: center; 49 | 50 | background-color: var(--background-primary); 51 | border-right: var(--border-width) solid var(--background-modifier-border); 52 | 53 | overflow: hidden; 54 | 55 | padding: var(--size-4-1) var(--size-4-2); 56 | height: 100%; 57 | 58 | &-icon { 59 | opacity: 0.5; 60 | margin-right: 1ch; 61 | 62 | svg { 63 | width: var(--font-ui-small); 64 | height: var(--font-ui-small); 65 | } 66 | } 67 | &-text { 68 | display: inline-block; 69 | 70 | color: var(--code-normal); 71 | font-size: var(--font-ui-small); 72 | font-family: var(--font-monospace); 73 | 74 | text-align: left; 75 | overflow: hidden; 76 | white-space: nowrap; 77 | text-overflow: ellipsis; 78 | 79 | width: 100%; 80 | } 81 | } 82 | 83 | .sample { 84 | display: inline-block; 85 | font-size: var(--font-ui-smaller); 86 | text-align: left; 87 | margin-left: var(--size-4-2); 88 | } 89 | } 90 | 91 | .fields { 92 | grid-column: 1 / 3; 93 | display: grid; 94 | grid-template-columns: repeat(3, 1fr); 95 | column-gap: var(--size-4-2); 96 | 97 | overflow: hidden; 98 | 99 | margin-left: var(--size-4-4); 100 | 101 | .field-name { 102 | color: var(--text-faint); 103 | font-size: var(--font-ui-smaller); 104 | 105 | overflow: hidden; 106 | white-space: nowrap; 107 | text-overflow: ellipsis; 108 | } 109 | 110 | .field { 111 | min-width: 0; 112 | } 113 | } 114 | 115 | .buttons { 116 | display: flex; 117 | flex-direction: row; 118 | justify-content: center; 119 | align-items: center; 120 | gap: var(--size-4-2); 121 | 122 | margin-top: var(--size-4-4); 123 | margin-left: var(--size-4-2); 124 | 125 | @include mobile-only { 126 | margin-top: 0; 127 | padding: var(--size-4-1); 128 | border-radius: 0 0 var(--radius-s) var(--radius-s); 129 | background-color: var(--background-primary-alt); 130 | } 131 | 132 | .control-button { 133 | flex: 0; 134 | aspect-ratio: 1 / 1; 135 | 136 | &.disabled { 137 | opacity: 0.25; 138 | pointer-events: none; 139 | } 140 | } 141 | } 142 | 143 | .add-button { 144 | z-index: 10; 145 | 146 | &:not(.mobile-add) { 147 | border-radius: 0; 148 | padding: var(--size-2-1); 149 | min-height: var(--size-4-3); 150 | 151 | svg { 152 | display: none; 153 | } 154 | } 155 | 156 | &.mobile-add { 157 | display: none; 158 | } 159 | 160 | @include mobile-only { 161 | &.mobile-add { 162 | display: block; 163 | } 164 | 165 | &:not(.mobile-add) { 166 | display: none; 167 | } 168 | } 169 | 170 | &-hidden { 171 | 172 | &:not(.mobile-add) { 173 | visibility: hidden; 174 | } 175 | 176 | &.mobile-add { 177 | @include disabled; 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/components/CustomLanguageBlock.ts: -------------------------------------------------------------------------------- 1 | import { setIcon } from 'obsidian'; 2 | import styles from './CustomLanguageBlock.module.scss'; 3 | import { Foldout } from './Foldout'; 4 | import { TextInput } from './TextInput'; 5 | import { CustomLanguage } from '../settings/types'; 6 | import { emptyLang } from '../settings/defaults'; 7 | import { IconButton } from './IconButton'; 8 | import { BaseComponent } from './BaseComponent'; 9 | 10 | /** 11 | * Component for a single custom language block. 12 | */ 13 | export class CustomLanguageBlock extends BaseComponent { 14 | private lang: CustomLanguage; 15 | private regexEl: HTMLElement; 16 | private sampleEl: HTMLElement; 17 | private upBtnComponent: IconButton; 18 | private downBtnComponent: IconButton; 19 | private deleteBtnComponent: IconButton; 20 | private addBtnComponents: IconButton[]; 21 | private regexInputComponent: TextInput; 22 | private startInputComponent: TextInput; 23 | private endInputComponent: TextInput; 24 | 25 | protected init(container: HTMLElement): HTMLElement { 26 | this.lang = emptyLang(); 27 | 28 | const block = container.createDiv(styles.container); 29 | 30 | new Foldout(block) 31 | .setSummary(() => { 32 | const frag = new DocumentFragment(); 33 | 34 | const summary = frag.createEl('figure', styles.summarySection); 35 | 36 | const regexContainer = summary.createEl('figcaption', styles.regex); 37 | const icon = regexContainer.createDiv(styles.regexIcon); 38 | setIcon(icon, 'regex'); 39 | icon.role = 'img'; 40 | 41 | this.regexEl = regexContainer.createDiv(styles.regexText); 42 | 43 | this.sampleEl = summary.createSpan({ 44 | cls: [styles.sample, styles.code, styles.comment], 45 | }); 46 | 47 | return frag; 48 | }) 49 | .addChild(() => { 50 | const frag = new DocumentFragment(); 51 | 52 | const content = frag.createDiv(styles.fields); 53 | 54 | this.regexInputComponent = new TextInput(content).setName('Regex').setPlaceholder('lang'); 55 | this.regexInputComponent.inputEl.addClass(styles.field); 56 | this.startInputComponent = new TextInput(content).setName('Comment Start').setPlaceholder(''); 59 | this.endInputComponent.inputEl.addClass(styles.field); 60 | 61 | return frag; 62 | }) 63 | .rootEl.addClass(styles.foldout); 64 | 65 | const buttons = block.createDiv(styles.buttons); 66 | 67 | this.upBtnComponent = new IconButton(buttons).setIcon('chevron-up').setTooltip('Shift up'); 68 | this.upBtnComponent.extraSettingsEl.addClass(styles.controlButton); 69 | 70 | this.downBtnComponent = new IconButton(buttons).setIcon('chevron-down').setTooltip('Shift down'); 71 | this.downBtnComponent.extraSettingsEl.addClass(styles.controlButton); 72 | 73 | this.deleteBtnComponent = new IconButton(buttons).setIcon('x').setTooltip('Delete'); 74 | this.deleteBtnComponent.extraSettingsEl.addClass(styles.controlButton); 75 | 76 | this.addBtnComponents = []; 77 | 78 | const mobileAddBtn = new IconButton(buttons).setIcon('plus').setTooltip('Insert'); 79 | mobileAddBtn.extraSettingsEl.addClass(styles.addButton, styles.controlButton, styles.mobileAdd); 80 | 81 | const normalAddBtn = new IconButton(block).setIcon('plus').setTooltip('Insert'); 82 | normalAddBtn.extraSettingsEl.addClass(styles.addButton); 83 | 84 | this.addBtnComponents = [mobileAddBtn, normalAddBtn]; 85 | 86 | return block; 87 | } 88 | 89 | public setLang(lang: CustomLanguage): this { 90 | this.lang = lang; 91 | 92 | const { regex, commentStart, commentEnd } = lang; 93 | 94 | this.regexInputComponent.setValue(regex); 95 | this.regexEl.setText(regex); 96 | 97 | this.startInputComponent.setValue(commentStart); 98 | this.endInputComponent.setValue(commentEnd); 99 | 100 | this.sampleEl.setText(`${commentStart} This is a comment ${commentEnd}`); 101 | return this; 102 | } 103 | 104 | public onChange(callback: (lang: CustomLanguage, self: this) => any): this { 105 | this.regexInputComponent.onChange((value) => { 106 | this.lang.regex = value.trim(); 107 | this.setLang(this.lang); 108 | callback(this.lang, this); 109 | }); 110 | 111 | this.startInputComponent.onChange((value) => { 112 | this.lang.commentStart = value.trim(); 113 | this.setLang(this.lang); 114 | callback(this.lang, this); 115 | }); 116 | 117 | this.endInputComponent.onChange((value) => { 118 | this.lang.commentEnd = value.trim(); 119 | this.setLang(this.lang); 120 | callback(this.lang, this); 121 | }); 122 | 123 | return this; 124 | } 125 | 126 | public onShiftUp(callback: (self: this) => any): this { 127 | this.upBtnComponent.onClick(() => callback(this)); 128 | return this; 129 | } 130 | 131 | public onShiftDown(callback: (self: this) => any): this { 132 | this.downBtnComponent.onClick(() => callback(this)); 133 | return this; 134 | } 135 | 136 | public onDelete(callback: (self: this) => any): this { 137 | this.deleteBtnComponent.onClick(() => callback(this)); 138 | return this; 139 | } 140 | 141 | public onAdd(callback: (self: this) => any): this { 142 | for (const component of this.addBtnComponents) { 143 | component.onClick(() => callback(this)); 144 | } 145 | return this; 146 | } 147 | 148 | public setShiftUpDisabled(disabled: boolean): this { 149 | this.upBtnComponent.setDisabled(disabled); 150 | return this; 151 | } 152 | 153 | public setShiftDownDisabled(disabled: boolean): this { 154 | this.downBtnComponent.setDisabled(disabled); 155 | return this; 156 | } 157 | 158 | public setDeleteDisabled(disabled: boolean): this { 159 | this.deleteBtnComponent.setDisabled(disabled); 160 | return this; 161 | } 162 | 163 | public setAddButtonHidden(hidden: boolean): this { 164 | for (const component of this.addBtnComponents) { 165 | component.extraSettingsEl.toggleAttribute('disabled', hidden); 166 | component.extraSettingsEl.toggleClass(styles.addButtonHidden, hidden); 167 | } 168 | return this; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/components/CustomLanguageList.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | list-style: none; 3 | padding-left: 0; 4 | 5 | .item { 6 | transform-origin: top center; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/components/CustomLanguageList.ts: -------------------------------------------------------------------------------- 1 | import styles from './CustomLanguageList.module.scss'; 2 | import { CustomLanguage } from '../settings/types'; 3 | import { CustomLanguageBlock } from './CustomLanguageBlock'; 4 | import { BaseComponent } from './BaseComponent'; 5 | import { AnimationGroup, AnimationOptions, ListDict } from '../utility'; 6 | import { emptyLang } from '../settings/defaults'; 7 | 8 | interface LangItem { 9 | el: HTMLElement; 10 | block: CustomLanguageBlock; 11 | lang: CustomLanguage; 12 | } 13 | 14 | /** 15 | * Component for a list of {@link CustomLanguageBlock} items. 16 | */ 17 | export class CustomLanguageList extends BaseComponent { 18 | private items: ListDict; 19 | private onChangeCallback: (languages: CustomLanguage[]) => any = () => {}; 20 | 21 | protected init(container: HTMLElement): HTMLElement { 22 | const root = container.createEl('ol', styles.list); 23 | this.items = new ListDict((item) => item.el); 24 | return root; 25 | } 26 | 27 | public languages(): CustomLanguage[] { 28 | return this.items.map((item) => item.lang); 29 | } 30 | 31 | public setLanguages(languages: CustomLanguage[]): this { 32 | this.items.clear(); 33 | 34 | for (const lang of languages) { 35 | this.pushLang(lang); 36 | } 37 | 38 | return this; 39 | } 40 | 41 | public onChange(callback: (languages: CustomLanguage[]) => any): this { 42 | this.onChangeCallback = callback; 43 | 44 | return this; 45 | } 46 | 47 | public pushLang(lang: CustomLanguage): this { 48 | this.insert(this.items.length, lang); 49 | return this; 50 | } 51 | 52 | /** 53 | * Create and insert a new language block at the given index. 54 | * 55 | * @param index The index to insert the new block at. 56 | * If undefined, the block will be inserted at the end of the list. 57 | * @param language The language of the block. 58 | * If undefined, sets the block to an empty language. 59 | */ 60 | private insert(index?: number, language?: CustomLanguage) { 61 | index = index ?? this.items.length; 62 | 63 | // === Create === // 64 | const el = this.rootEl.createEl('li', styles.item); 65 | const lang = language ?? emptyLang(); 66 | const block = new CustomLanguageBlock(el) 67 | .setLang(lang) 68 | .onChange((lang, self) => { 69 | self.setLang(lang); 70 | this.onChangeCallback(this.languages()); 71 | }) 72 | .onAdd(() => { 73 | const index = this.items.indexOf(el); 74 | if (index === undefined) { 75 | return; 76 | } 77 | 78 | this.insert(index + 1); 79 | this.onChangeCallback(this.languages()); 80 | }) 81 | .onShiftUp(() => { 82 | this.shift(el, 'up'); 83 | this.onChangeCallback(this.languages()); 84 | }) 85 | .onShiftDown(() => { 86 | this.shift(el, 'down'); 87 | this.onChangeCallback(this.languages()); 88 | }) 89 | .onDelete(() => { 90 | this.remove(el); 91 | this.onChangeCallback(this.languages()); 92 | }); 93 | 94 | const item: LangItem = { el, block, lang: lang }; 95 | 96 | // === Reposition === // 97 | if (this.rootEl.childNodes.length > 0 && index > 0) { 98 | const curr = this.rootEl.childNodes.item(index); 99 | curr.before(item.el); 100 | } 101 | 102 | // === Insert === // 103 | this.items.insert(index, item); 104 | this.refreshIndexes(index - 1, index, index + 1); 105 | 106 | // === Animate === // 107 | new AnimationGroup( 108 | new KeyframeEffect( 109 | item.el, 110 | [ 111 | { 112 | // from 113 | opacity: '0', 114 | height: '0', 115 | transform: 'scaleY(0)', 116 | }, 117 | { 118 | // to 119 | opacity: '1', 120 | height: `${item.el.clientHeight}px`, 121 | transform: 'scaleY(1)', 122 | }, 123 | ], 124 | AnimationOptions.fasterEaseOut, 125 | ), 126 | ).play(); 127 | } 128 | 129 | private remove(item: HTMLLIElement) { 130 | const index = this.items.indexOf(item); 131 | if (index === undefined) { 132 | return; 133 | } 134 | 135 | this.items.remove(item); 136 | 137 | // Removing the first or last item requires that the new first/last item(s) are refreshed 138 | this.refreshFirstAndLast(); 139 | 140 | // Animate after changes have been applied 141 | new AnimationGroup( 142 | new KeyframeEffect( 143 | item, 144 | [ 145 | { 146 | // from 147 | opacity: '1', 148 | height: `${item.clientHeight}px`, 149 | transform: 'scaleY(1)', 150 | }, 151 | { 152 | // to 153 | opacity: '0', 154 | height: '0', 155 | transform: 'scaleY(0)', 156 | }, 157 | ], 158 | AnimationOptions.fasterEaseOut, 159 | ), 160 | ).play(() => { 161 | item.remove(); 162 | }); 163 | } 164 | 165 | private shift(el: HTMLLIElement, direction: 'up' | 'down') { 166 | const index = this.items.indexOf(el); 167 | 168 | if ( 169 | index === undefined || 170 | (index === 0 && direction === 'up') || 171 | (index === this.items.length - 1 && direction === 'down') 172 | ) { 173 | return; 174 | } 175 | 176 | const offset = direction === 'up' ? -1 : 1; 177 | const item = this.items.at(index)!; 178 | const sibling = this.items.at(index + offset)!; 179 | 180 | const [first, second] = direction === 'up' ? [item, sibling] : [sibling, item]; 181 | this.items.swap(first.el, second.el); 182 | this.refreshItems(first.el, second.el); 183 | 184 | // Animate after changes have been applied 185 | new AnimationGroup( 186 | new KeyframeEffect( 187 | first.el, 188 | [ 189 | { 190 | // from 191 | transform: 'translateY(0)', 192 | }, 193 | { 194 | // to 195 | transform: `translateY(-${second.el.offsetHeight}px)`, 196 | }, 197 | ], 198 | AnimationOptions.fastEaseOut, 199 | ), 200 | new KeyframeEffect( 201 | second.el, 202 | [ 203 | { 204 | // from 205 | transform: 'translateY(0)', 206 | }, 207 | { 208 | // to 209 | transform: `translateY(${first.el.offsetHeight}px)`, 210 | }, 211 | ], 212 | AnimationOptions.fastEaseOut, 213 | ), 214 | ).play(() => { 215 | const active = this.rootEl.doc.activeElement as HTMLElement | SVGElement; 216 | // Swap the elements in the DOM 217 | this.rootEl.insertBefore(first.el, second.el); 218 | // Refocus the active element 219 | active.focus(); 220 | }); 221 | } 222 | 223 | /** 224 | * Refresh the first and last list items. 225 | * 226 | * This will update the rendered state of the items in the DOM. 227 | * 228 | * This is a convenience around {@link refreshIndexes} to be used whenever 229 | * only the first and last items need to be refreshed. 230 | */ 231 | private refreshFirstAndLast() { 232 | if (this.items.length === 0) { 233 | return; 234 | } 235 | 236 | this.refreshItems(this.items.at(0)!.el); 237 | 238 | if (this.items.length === 1) { 239 | return; 240 | } 241 | 242 | this.refreshItems(this.items.at(this.items.length - 1)!.el); 243 | } 244 | 245 | /** 246 | * Refresh the given list items. 247 | * 248 | * This will update the rendered state of the items in the DOM. 249 | */ 250 | private refreshItems(...els: HTMLElement[]) { 251 | for (const el of els) { 252 | const index = this.items.indexOf(el); 253 | if (index === undefined) { 254 | continue; 255 | } 256 | 257 | this.refreshIndexes(index); 258 | } 259 | } 260 | 261 | /** 262 | * Refresh the list items at the given indexes. 263 | * 264 | * This will update the rendered state of the items in the DOM. 265 | */ 266 | private refreshIndexes(...indexes: number[]) { 267 | for (const index of indexes) { 268 | if (index < 0 || index >= this.items.length) { 269 | continue; 270 | } 271 | 272 | const item = this.items.at(index); 273 | if (item === undefined) { 274 | continue; 275 | } 276 | 277 | const { block } = item; 278 | block.setShiftUpDisabled(index === 0); 279 | block.setShiftDownDisabled(index === this.items.length - 1); 280 | block.setAddButtonHidden(index === this.items.length - 1); 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/components/Foldout.module.scss: -------------------------------------------------------------------------------- 1 | .foldout { 2 | background-color: var(--background-primary-alt); 3 | } 4 | 5 | .summary { 6 | background-color: var(--background-primary-alt); 7 | border-radius: var(--radius-s); 8 | 9 | padding: var(--size-4-3) var(--size-4-4) var(--size-4-3) var(--size-4-2); 10 | 11 | &::before { 12 | content: '▶︎' / ''; 13 | display: inline-block; 14 | max-width: var(--font-ui-small); 15 | font-size: var(--font-ui-small); 16 | transition: transform 150ms ease-in-out; 17 | } 18 | 19 | &::marker { 20 | content: ''; 21 | display: none; 22 | } 23 | 24 | &:focus-visible { 25 | box-shadow: 0 0 0 3px var(--background-modifier-border-focus); 26 | } 27 | } 28 | 29 | .foldout[open] > .summary { 30 | &::before { 31 | transform: rotate(90deg); 32 | } 33 | } 34 | 35 | .content { 36 | border: 1px solid transparent; 37 | padding: 0 var(--size-4-4) var(--size-4-3) var(--size-4-2); 38 | border-radius: var(--radius-s); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Foldout.ts: -------------------------------------------------------------------------------- 1 | import styles from './Foldout.module.scss'; 2 | import { BaseComponent } from './BaseComponent'; 3 | 4 | /** 5 | * A component used for creating foldable regions. 6 | * 7 | * Under the hood, this just uses a stylized `
` element. 8 | */ 9 | export class Foldout extends BaseComponent { 10 | public summaryEl: HTMLElement; 11 | public contentEl: HTMLElement; 12 | 13 | protected init(container: HTMLElement): HTMLElement { 14 | const root = container.createEl('details', styles.foldout); 15 | this.summaryEl = root.createEl('summary', styles.summary); 16 | this.contentEl = root.createDiv(styles.content); 17 | return root; 18 | } 19 | 20 | public setSummary(summary: string | HTMLElement | DocumentFragment | (() => DocumentFragment)): this { 21 | if (typeof summary === 'string') { 22 | this.summaryEl.append(summary); 23 | return this; 24 | } 25 | 26 | this.summaryEl.append(typeof summary === 'function' ? summary() : summary); 27 | return this; 28 | } 29 | 30 | public addChild(child: HTMLElement | DocumentFragment | (() => DocumentFragment)): this { 31 | this.contentEl.appendChild(typeof child === 'function' ? child() : child); 32 | return this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/IconButton.module.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | &:focus-visible { 3 | box-shadow: 0 0 0 3px var(--background-modifier-border-focus); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/IconButton.ts: -------------------------------------------------------------------------------- 1 | import { ExtraButtonComponent } from 'obsidian'; 2 | import styles from './IconButton.module.scss'; 3 | import globalStyles from '../styles.scss'; 4 | 5 | /** 6 | * Wrapper component around {@link ExtraButtonComponent} that improves accessibility 7 | * and resolves a few other issues. 8 | */ 9 | export class IconButton extends ExtraButtonComponent { 10 | constructor(containerEl: HTMLElement) { 11 | super(containerEl); 12 | 13 | this.extraSettingsEl.role = 'button'; 14 | this.extraSettingsEl.tabIndex = 0; 15 | this.extraSettingsEl.addClass(styles.btn); 16 | 17 | this.extraSettingsEl.addEventListener('keypress', (e) => { 18 | if (e.key === 'Enter' || e.key === ' ') { 19 | e.preventDefault(); 20 | this.extraSettingsEl.click(); 21 | } 22 | }); 23 | } 24 | 25 | public override setDisabled(disabled: boolean): this { 26 | this.extraSettingsEl.toggleClass(globalStyles.disabled, disabled); 27 | this.extraSettingsEl.setAttr('aria-disabled', disabled); 28 | return super.setDisabled(disabled); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Text.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from './BaseComponent'; 2 | 3 | type TextVariant = 'setting-item-name' | 'setting-item-description'; 4 | type TextInput = string | ((frag: DocumentFragment) => DocumentFragment); 5 | 6 | /** 7 | * Component for displaying text. 8 | */ 9 | export class Text extends BaseComponent { 10 | constructor( 11 | container: HTMLElement, 12 | text: TextInput = '', 13 | private variant: TextVariant = 'setting-item-description', 14 | ) { 15 | super(container); 16 | 17 | this.setText(text); 18 | this.setVariant(variant); 19 | } 20 | 21 | protected init(container: HTMLElement): HTMLElement { 22 | return container.createDiv(); 23 | } 24 | 25 | public setText(text: TextInput): this { 26 | this.rootEl.setText(computeText(text)); 27 | return this; 28 | } 29 | 30 | public setVariant(variant: TextVariant): this { 31 | this.rootEl.removeClass(this.variant); 32 | this.variant = variant; 33 | this.rootEl.addClass(variant); 34 | return this; 35 | } 36 | } 37 | 38 | function computeText(text: TextInput) { 39 | return typeof text === 'function' ? text(new DocumentFragment()) : text; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/TextInput.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | overflow: hidden; 6 | 7 | .name { 8 | display: block; 9 | 10 | color: var(--text-faint); 11 | font-size: var(--font-ui-smaller); 12 | 13 | text-align: start; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | white-space: nowrap; 17 | 18 | margin: var(--size-4-1) 0; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/TextInput.ts: -------------------------------------------------------------------------------- 1 | import { AbstractTextComponent, TextComponent } from 'obsidian'; 2 | import styles from './TextInput.module.scss'; 3 | import { nanoid } from 'nanoid'; 4 | import { BaseComponent } from './BaseComponent'; 5 | 6 | export class TextInput extends BaseComponent implements AbstractTextComponent { 7 | public nameEl: HTMLElement; 8 | public textComponent: TextComponent; 9 | 10 | protected init(container: HTMLElement): HTMLElement { 11 | const root = container.createDiv(styles.container); 12 | 13 | const id = nanoid(); 14 | this.nameEl = root.createEl('label', { cls: styles.name, attr: { for: id } }); 15 | 16 | this.textComponent = new TextComponent(root); 17 | this.textComponent.inputEl.id = id; 18 | 19 | return root; 20 | } 21 | 22 | public get disabled(): boolean { 23 | return this.textComponent.disabled; 24 | } 25 | 26 | public get inputEl(): HTMLInputElement { 27 | return this.textComponent.inputEl; 28 | } 29 | 30 | public setName(name: string): this { 31 | this.nameEl.setText(name); 32 | return this; 33 | } 34 | 35 | public getValue(): string { 36 | return this.textComponent.getValue(); 37 | } 38 | 39 | public onChange(callback: (value: string) => any): this { 40 | this.textComponent.onChange(callback); 41 | return this; 42 | } 43 | 44 | public onChanged(): void { 45 | this.textComponent.onChanged(); 46 | } 47 | 48 | public registerOptionListener(listeners: Record string>, key: string): this { 49 | this.textComponent.registerOptionListener(listeners, key); 50 | return this; 51 | } 52 | 53 | public setDisabled(disabled: boolean): this { 54 | this.textComponent.setDisabled(disabled); 55 | return this; 56 | } 57 | 58 | public setPlaceholder(placeholder: string): this { 59 | this.textComponent.setPlaceholder(placeholder); 60 | return this; 61 | } 62 | 63 | public setValue(value: string): this { 64 | this.textComponent.setValue(value); 65 | return this; 66 | } 67 | 68 | public then(cb: (component: this) => any): this { 69 | cb(this); 70 | return this; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomLanguageBlock } from './CustomLanguageBlock'; 2 | export { CustomLanguageList } from './CustomLanguageList'; 3 | export { Foldout } from './Foldout'; 4 | export { IconButton } from './IconButton'; 5 | export { Text } from './Text'; 6 | export { TextInput } from './TextInput'; 7 | -------------------------------------------------------------------------------- /src/extensions/fields.ts: -------------------------------------------------------------------------------- 1 | import { StateEffect, StateField } from '@codemirror/state'; 2 | import { CommentAppearance, DEFAULT_SETTINGS } from '../settings'; 3 | 4 | export const setEnableAppearance = StateEffect.define(); 5 | export const enableAppearanceField = StateField.define({ 6 | create: () => false, 7 | update: (value, transaction) => { 8 | for (const effect of transaction.effects) { 9 | if (effect.is(setEnableAppearance)) { 10 | return effect.value; 11 | } 12 | } 13 | 14 | return value; 15 | }, 16 | }); 17 | 18 | export const setAppearanceSettings = StateEffect.define>(); 19 | export const appearanceSettingsField = StateField.define({ 20 | create: () => ({ ...DEFAULT_SETTINGS.appearance }), 21 | update: (value, transaction) => { 22 | for (const effect of transaction.effects) { 23 | if (effect.is(setAppearanceSettings)) { 24 | value = { 25 | ...value, 26 | ...effect.value, 27 | }; 28 | } 29 | } 30 | 31 | return value; 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/extensions/index.ts: -------------------------------------------------------------------------------- 1 | export { CommentViewPlugin } from './plugin'; 2 | -------------------------------------------------------------------------------- /src/extensions/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Decoration, DecorationSet, EditorView, PluginValue, ViewPlugin, ViewUpdate } from '@codemirror/view'; 2 | import { Extension, RangeSetBuilder } from '@codemirror/state'; 3 | import { syntaxTree } from '@codemirror/language'; 4 | import { CommentAppearance, Settings } from '../settings'; 5 | import { buildStyleString } from '../utility'; 6 | import { appearanceSettingsField, enableAppearanceField, setAppearanceSettings, setEnableAppearance } from './fields'; 7 | 8 | export class CommentViewPlugin implements PluginValue { 9 | private decorations: DecorationSet; 10 | 11 | constructor(view: EditorView) { 12 | this.decorations = this.buildDecorations(view); 13 | } 14 | 15 | /** 16 | * Create the extension for the plugin, with all its required fields. 17 | */ 18 | public static createExtension(settings: Settings): Extension { 19 | return [ 20 | enableAppearanceField.init(() => settings.overrideAppearance), 21 | appearanceSettingsField.init(() => settings.appearance), 22 | ViewPlugin.fromClass(CommentViewPlugin, { 23 | decorations: (value) => value.decorations, 24 | }), 25 | ]; 26 | } 27 | 28 | /** 29 | * Update the appearance settings for the plugin in the given view. 30 | */ 31 | public static updateAppearance(view: EditorView, appearance: CommentAppearance) { 32 | view.dispatch({ 33 | effects: [setAppearanceSettings.of(appearance)], 34 | }); 35 | } 36 | 37 | /** 38 | * Update the enabled state for the plugin in the given view. 39 | */ 40 | public static updateEnabled(view: EditorView, enabled: boolean) { 41 | view.dispatch({ 42 | effects: [setEnableAppearance.of(enabled)], 43 | }); 44 | } 45 | 46 | update(update: ViewUpdate) { 47 | if (update.docChanged || update.viewportChanged) { 48 | this.decorations = this.buildDecorations(update.view); 49 | return; 50 | } 51 | 52 | for (const effect of update.transactions.flatMap((trn) => trn.effects)) { 53 | if (effect.is(setAppearanceSettings) || effect.is(setEnableAppearance)) { 54 | this.decorations = this.buildDecorations(update.view); 55 | return; 56 | } 57 | } 58 | } 59 | 60 | destroy() {} 61 | 62 | buildDecorations(view: EditorView): DecorationSet { 63 | if (!view.state.field(enableAppearanceField)) { 64 | return this.emptyDecorationSet; 65 | } 66 | 67 | const builder = new RangeSetBuilder(); 68 | 69 | const appearance = view.state.field(appearanceSettingsField); 70 | const style = buildStyleString(appearance); 71 | 72 | for (const { from, to } of view.visibleRanges) { 73 | syntaxTree(view.state).iterate({ 74 | from, 75 | to, 76 | enter: (node) => { 77 | if (node.type.name !== 'comment') { 78 | return; 79 | } 80 | 81 | builder.add( 82 | node.from, 83 | node.to, 84 | Decoration.mark({ 85 | attributes: { 86 | style, 87 | }, 88 | }), 89 | ); 90 | }, 91 | }); 92 | } 93 | 94 | return builder.finish(); 95 | } 96 | 97 | private get emptyDecorationSet(): DecorationSet { 98 | return new RangeSetBuilder().finish(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownView, Plugin } from 'obsidian'; 2 | import { comparePos, extractEditorView, Ordering, shouldDenyComment } from './utility'; 3 | import { LineCommentController } from './toggle'; 4 | import { DEFAULT_SETTINGS, Settings, SettingsTab } from './settings'; 5 | import { EditorView } from '@codemirror/view'; 6 | import { CommentViewPlugin } from './extensions'; 7 | 8 | export default class BetterMarkdownCommentsPlugin extends Plugin { 9 | public settings: Settings; 10 | 11 | async onload() { 12 | await this.loadSettings(); 13 | this.addSettingTab(new SettingsTab(this.app, this)); 14 | 15 | this.addCommand({ 16 | id: 'toggle', 17 | icon: 'percent', 18 | name: 'Toggle Comment', 19 | editorCallback: (editor: Editor, _view: MarkdownView) => { 20 | this.onToggleComment(editor); 21 | }, 22 | }); 23 | 24 | this.registerEditorExtension(CommentViewPlugin.createExtension(this.settings)); 25 | } 26 | 27 | onunload() {} 28 | 29 | async loadSettings() { 30 | const storedSettings: Settings | null = await this.loadData(); 31 | const defaults = DEFAULT_SETTINGS as Settings; 32 | 33 | this.settings = { 34 | ...defaults, 35 | ...storedSettings, 36 | appearance: { 37 | ...defaults.appearance, 38 | ...storedSettings?.appearance, 39 | }, 40 | }; 41 | } 42 | 43 | async saveSettings() { 44 | await this.saveData(this.settings); 45 | } 46 | 47 | /** 48 | * Refresh the appearance of comments. 49 | * 50 | * @see {@link refreshAppearanceOverride} to refresh the override setting. 51 | */ 52 | public refreshAppearance() { 53 | const editorView = this.activeEditorView; 54 | if (!editorView) { 55 | return; 56 | } 57 | 58 | CommentViewPlugin.updateAppearance(editorView, this.settings.appearance); 59 | } 60 | 61 | /** 62 | * Refresh the appearance override of comments. 63 | * 64 | * @see {@link refreshAppearance} to refresh the appearance setting. 65 | */ 66 | public refreshAppearanceOverride() { 67 | const editorView = this.activeEditorView; 68 | if (!editorView) { 69 | return; 70 | } 71 | 72 | CommentViewPlugin.updateEnabled(editorView, this.settings.overrideAppearance); 73 | } 74 | 75 | private get activeEditorView(): EditorView | undefined { 76 | const editor = this.app.workspace.activeEditor?.editor; 77 | return editor ? extractEditorView(editor) : undefined; 78 | } 79 | 80 | private onToggleComment(editor: Editor) { 81 | const controller = new LineCommentController(editor, this.settings); 82 | 83 | // Get the current range 84 | const from = editor.getCursor('from'); 85 | const to = editor.getCursor('to'); 86 | const anchor = editor.getCursor('anchor'); 87 | const head = editor.getCursor('head'); 88 | 89 | const hasSelection = comparePos(anchor, head) !== Ordering.Equal; 90 | 91 | if (!hasSelection && editor.getLine(from.line).trim().length === 0) { 92 | // Allow turning single empty lines into comments 93 | const { commentStart } = controller.toggle(from.line); 94 | editor.transaction({ 95 | changes: controller.takeChanges(), 96 | selection: { 97 | from: { 98 | line: from.line, 99 | ch: commentStart.length + 1, 100 | }, 101 | }, 102 | }); 103 | return; 104 | } 105 | 106 | const rangeState = controller.rangeState(from.line, to.line); 107 | const selection = { anchor, head }; 108 | 109 | for (let line = from.line; line <= to.line; line++) { 110 | // === Skip Empty Lines === // 111 | if (shouldDenyComment(editor, line)) { 112 | continue; 113 | } 114 | 115 | // === Toggle Line === // 116 | const { before, after, commentStart } = controller.toggle(line, { 117 | forceComment: !rangeState || rangeState === 'mixed', 118 | }); 119 | const wasChanged = before.isCommented !== after.isCommented; 120 | const headBefore = { ...selection.head }; 121 | 122 | // If the comment string is empty, it shouldn't affect selection 123 | const commentLength = commentStart.length > 0 ? commentStart.length + 1 : 0; 124 | 125 | // === Update Selection === // 126 | // --- Anchor --- // 127 | if (line === anchor.line && wasChanged) { 128 | selection.anchor.ch = Math.clamp( 129 | anchor.ch + (after.isCommented ? commentLength : -commentLength), 130 | 0, 131 | after.text.length, 132 | ); 133 | } 134 | 135 | // --- Head --- // 136 | if (line === head.line && wasChanged) { 137 | selection.head.ch = Math.clamp( 138 | head.ch + (after.isCommented ? commentLength : -commentLength), 139 | 0, 140 | after.text.length, 141 | ); 142 | } 143 | 144 | // === Drop Cursor === // 145 | if ( 146 | this.settings.dropCursor && 147 | !hasSelection && 148 | selection.head.line !== editor.lastLine() && 149 | commentLength > 0 150 | ) { 151 | const text = editor.getLine(line); 152 | 153 | selection.head.line = Math.min(selection.head.line + 1, editor.lastLine()); 154 | selection.head.ch = Math.min( 155 | // If at start of line -> keep at start of next line 156 | headBefore.ch === 0 157 | ? 0 158 | : // If at end of line -> keep at end of next line 159 | headBefore.ch === before.text.length || headBefore.ch === text.length 160 | ? Infinity 161 | : // If just commented -> account for start comment token 162 | after.isCommented 163 | ? selection.head.ch - commentLength 164 | : selection.head.ch, 165 | editor.getLine(selection.head.line).length, 166 | ); 167 | 168 | selection.anchor.line = selection.head.line; 169 | selection.anchor.ch = selection.head.ch; 170 | } 171 | } 172 | 173 | editor.transaction({ 174 | changes: controller.takeChanges(), 175 | selection: { 176 | from: selection.anchor, 177 | to: selection.head, 178 | }, 179 | }); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/settings/defaults.ts: -------------------------------------------------------------------------------- 1 | import { CustomLanguage, Settings } from './types'; 2 | import { DeepReadonly } from '../utility'; 3 | 4 | export const DEFAULT_SETTINGS: DeepReadonly = { 5 | overrideAppearance: false, 6 | commentStyle: 'html', 7 | customCommentStart: '', 9 | dropCursor: false, 10 | appearance: { 11 | showBackground: false, 12 | backgroundColor: '#191919', 13 | color: '#565E67', 14 | fontTheme: 'default', 15 | customFont: 'initial', 16 | italic: false, 17 | weight: 400, 18 | showOutline: false, 19 | outlineColor: '#FFFFFF', 20 | }, 21 | customLanguages: [], 22 | }; 23 | 24 | /** 25 | * Generates an empty {@link CustomLanguage}. 26 | */ 27 | export function emptyLang(): CustomLanguage { 28 | return { 29 | regex: '', 30 | commentStart: '', 31 | commentEnd: '', 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/settings/index.ts: -------------------------------------------------------------------------------- 1 | export { DEFAULT_SETTINGS } from './defaults'; 2 | export { SettingsTab } from './tab'; 3 | export type { Settings, CommentAppearance } from './types'; 4 | -------------------------------------------------------------------------------- /src/settings/language.ts: -------------------------------------------------------------------------------- 1 | interface LanguageData { 2 | commentStart: string; 3 | commentEnd: string; 4 | } 5 | 6 | // This is a subset of the ones listed here: https://prismjs.com/#supported-languages 7 | // (since Obsidian uses PrismJS for syntax highlighting). 8 | // If you want to add a language, please open a PR. 9 | const languages = { 10 | applescript: { 11 | commentStart: '--', 12 | commentEnd: '', 13 | }, 14 | arduino: { 15 | commentStart: '//', 16 | commentEnd: '', 17 | }, 18 | bash: { 19 | commentStart: '#', 20 | commentEnd: '', 21 | }, 22 | basic: { 23 | commentStart: "'", 24 | commentEnd: '', 25 | }, 26 | c: { 27 | commentStart: '//', 28 | commentEnd: '', 29 | }, 30 | cpp: { 31 | commentStart: '//', 32 | commentEnd: '', 33 | }, 34 | csharp: { 35 | commentStart: '//', 36 | commentEnd: '', 37 | }, 38 | cs: { 39 | commentStart: '//', 40 | commentEnd: '', 41 | }, 42 | css: { 43 | commentStart: '/*', 44 | commentEnd: '*/', 45 | }, 46 | d: { 47 | commentStart: '//', 48 | commentEnd: '', 49 | }, 50 | dart: { 51 | commentStart: '//', 52 | commentEnd: '', 53 | }, 54 | elixir: { 55 | commentStart: '#', 56 | commentEnd: '', 57 | }, 58 | elm: { 59 | commentStart: '--', 60 | commentEnd: '', 61 | }, 62 | erlang: { 63 | commentStart: '%', 64 | commentEnd: '', 65 | }, 66 | fsharp: { 67 | commentStart: '//', 68 | commentEnd: '', 69 | }, 70 | gdscript: { 71 | commentStart: '#', 72 | commentEnd: '', 73 | }, 74 | glsl: { 75 | commentStart: '//', 76 | commentEnd: '', 77 | }, 78 | go: { 79 | commentStart: '//', 80 | commentEnd: '', 81 | }, 82 | gradle: { 83 | commentStart: '//', 84 | commentEnd: '', 85 | }, 86 | graphql: { 87 | commentStart: '#', 88 | commentEnd: '', 89 | }, 90 | groovy: { 91 | commentStart: '//', 92 | commentEnd: '', 93 | }, 94 | haskell: { 95 | commentStart: '--', 96 | commentEnd: '', 97 | }, 98 | hs: { 99 | commentStart: '--', 100 | commentEnd: '', 101 | }, 102 | html: { 103 | commentStart: '', 105 | }, 106 | java: { 107 | commentStart: '//', 108 | commentEnd: '', 109 | }, 110 | javascript: { 111 | commentStart: '//', 112 | commentEnd: '', 113 | }, 114 | js: { 115 | commentStart: '//', 116 | commentEnd: '', 117 | }, 118 | jsx: { 119 | commentStart: '//', 120 | commentEnd: '', 121 | }, 122 | // Technically not allowed, but we'll provide it anyway 123 | json: { 124 | commentStart: '//', 125 | commentEnd: '', 126 | }, 127 | julia: { 128 | commentStart: '#', 129 | commentEnd: '', 130 | }, 131 | kotlin: { 132 | commentStart: '//', 133 | commentEnd: '', 134 | }, 135 | kt: { 136 | commentStart: '//', 137 | commentEnd: '', 138 | }, 139 | latex: { 140 | commentStart: '%', 141 | commentEnd: '', 142 | }, 143 | less: { 144 | commentStart: '/*', 145 | commentEnd: '*/', 146 | }, 147 | lisp: { 148 | commentStart: ';', 149 | commentEnd: '', 150 | }, 151 | lua: { 152 | commentStart: '--', 153 | commentEnd: '', 154 | }, 155 | markdown: { 156 | commentStart: '', 158 | }, 159 | md: { 160 | commentStart: '', 162 | }, 163 | mermaid: { 164 | commentStart: '%%', 165 | commentEnd: '', 166 | }, 167 | nim: { 168 | commentStart: '#', 169 | commentEnd: '', 170 | }, 171 | objc: { 172 | commentStart: '//', 173 | commentEnd: '', 174 | }, 175 | objectivec: { 176 | commentStart: '//', 177 | commentEnd: '', 178 | }, 179 | pascal: { 180 | commentStart: '//', 181 | commentEnd: '', 182 | }, 183 | perl: { 184 | commentStart: '#', 185 | commentEnd: '', 186 | }, 187 | php: { 188 | commentStart: '//', 189 | commentEnd: '', 190 | }, 191 | protobuf: { 192 | commentStart: '//', 193 | commentEnd: '', 194 | }, 195 | python: { 196 | commentStart: '#', 197 | commentEnd: '', 198 | }, 199 | py: { 200 | commentStart: '#', 201 | commentEnd: '', 202 | }, 203 | r: { 204 | commentStart: '#', 205 | commentEnd: '', 206 | }, 207 | rb: { 208 | commentStart: '#', 209 | commentEnd: '', 210 | }, 211 | ruby: { 212 | commentStart: '#', 213 | commentEnd: '', 214 | }, 215 | rust: { 216 | commentStart: '//', 217 | commentEnd: '', 218 | }, 219 | sass: { 220 | commentStart: '/*', 221 | commentEnd: '*/', 222 | }, 223 | scala: { 224 | commentStart: '//', 225 | commentEnd: '', 226 | }, 227 | scss: { 228 | commentStart: '/*', 229 | commentEnd: '*/', 230 | }, 231 | sh: { 232 | commentStart: '#', 233 | commentEnd: '', 234 | }, 235 | shell: { 236 | commentStart: '#', 237 | commentEnd: '', 238 | }, 239 | smalltalk: { 240 | commentStart: '"', 241 | commentEnd: '"', 242 | }, 243 | sql: { 244 | commentStart: '--', 245 | commentEnd: '', 246 | }, 247 | swift: { 248 | commentStart: '//', 249 | commentEnd: '', 250 | }, 251 | toml: { 252 | commentStart: '#', 253 | commentEnd: '', 254 | }, 255 | ts: { 256 | commentStart: '//', 257 | commentEnd: '', 258 | }, 259 | tsx: { 260 | commentStart: '//', 261 | commentEnd: '', 262 | }, 263 | typescript: { 264 | commentStart: '//', 265 | commentEnd: '', 266 | }, 267 | wasm: { 268 | commentStart: ';;', 269 | commentEnd: '', 270 | }, 271 | wgsl: { 272 | commentStart: '//', 273 | commentEnd: '', 274 | }, 275 | xml: { 276 | commentStart: '', 278 | }, 279 | yaml: { 280 | commentStart: '#', 281 | commentEnd: '', 282 | }, 283 | yml: { 284 | commentStart: '#', 285 | commentEnd: '', 286 | }, 287 | zig: { 288 | commentStart: '//', 289 | commentEnd: '', 290 | }, 291 | } satisfies Record; 292 | 293 | export const Languages = new Map(Object.entries(languages)); 294 | -------------------------------------------------------------------------------- /src/settings/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../styles'; 2 | 3 | .heading { 4 | font-size: var(--font-ui-large); 5 | font-weight: var(--h2-size); 6 | 7 | &:not(:first-child) { 8 | margin-top: 1.75em; 9 | } 10 | } 11 | 12 | .code-block-section { 13 | display: flex; 14 | flex-direction: column; 15 | 16 | .customLanguageList { 17 | margin-bottom: 0; 18 | } 19 | 20 | .add-button { 21 | margin-bottom: var(--size-4-2); 22 | } 23 | } 24 | 25 | .card { 26 | padding: var(--size-4-6) var(--size-4-4); 27 | margin: var(--size-4-5) 0; 28 | border: var(--border-width) solid var(--background-modifier-border); 29 | border-radius: var(--radius-s); 30 | } 31 | 32 | .hide-top-border { 33 | border-top: none; 34 | } 35 | 36 | .empty { 37 | padding: 0; 38 | } 39 | -------------------------------------------------------------------------------- /src/settings/tab.ts: -------------------------------------------------------------------------------- 1 | import { App, ButtonComponent, PluginSettingTab, Setting } from 'obsidian'; 2 | import BetterMarkdownCommentsPlugin from '../main'; 3 | import { buildCommentString, buildStyleString, getCommentTokens } from '../utility'; 4 | import { CommentFontTheme, CommentStyle, SettingsPath } from './types'; 5 | import { isDefaultSettings, restoreSettings } from './utils'; 6 | import styles from './styles.scss'; 7 | import globalStyles from '../styles.scss'; 8 | import { CustomLanguageList, IconButton, Text } from '../components'; 9 | import { emptyLang } from './defaults'; 10 | 11 | export class SettingsTab extends PluginSettingTab { 12 | private readonly plugin: BetterMarkdownCommentsPlugin; 13 | private readonly containerStack: HTMLElement[]; 14 | private example?: HTMLSpanElement; 15 | 16 | constructor(app: App, plugin: BetterMarkdownCommentsPlugin) { 17 | super(app, plugin); 18 | this.plugin = plugin; 19 | this.containerStack = [this.containerEl]; 20 | } 21 | 22 | display(): void { 23 | this.container.empty(); 24 | 25 | this.container.createEl('h1', { text: 'General', cls: styles.heading }); 26 | this.createGeneralSection(); 27 | 28 | this.container.createEl('h1', { text: 'Appearance', cls: styles.heading }); 29 | this.createExampleSection(); 30 | this.createAppearanceSection(); 31 | 32 | this.container.createEl('h1', { text: 'Code Blocks', cls: styles.heading }); 33 | this.createCodeBlockSection(); 34 | } 35 | 36 | /** 37 | * The currently active container element in the {@link containerStack}. 38 | * 39 | * This should be preferred over {@link containerEl} when adding any element 40 | * as it will always reflect the active container. 41 | */ 42 | private get container(): HTMLElement { 43 | return this.containerStack[this.containerStack.length - 1]; 44 | } 45 | 46 | private createGeneralSection() { 47 | this.add( 48 | ['commentStyle'], 49 | (setting, apply) => { 50 | const desc = new DocumentFragment(); 51 | desc.appendText('The style of comment to use when toggling.'); 52 | desc.createEl('br'); 53 | desc.createEl('br'); 54 | desc.createEl('span', { 55 | text: 'Note: ', 56 | attr: { style: 'font-weight: var(--font-semibold);' }, 57 | }); 58 | desc.appendText('Changing this value will not update existing comments.'); 59 | 60 | return setting 61 | .setName('Comment style') 62 | .setDesc(desc) 63 | .addDropdown((dropdown) => { 64 | dropdown 65 | .addOptions({ 66 | html: 'HTML ()', 67 | obsidian: 'Obsidian (%% %%)', 68 | custom: 'Custom', 69 | } as Record) 70 | .setValue(this.plugin.settings.commentStyle) 71 | .onChange(async (value) => { 72 | this.plugin.settings.commentStyle = value as CommentStyle; 73 | await apply(); 74 | this.display(); 75 | }); 76 | }); 77 | }, 78 | { refreshAppearance: false }, 79 | ); 80 | 81 | if (this.plugin.settings.commentStyle === 'custom') { 82 | this.add( 83 | ['customCommentStart', 'customCommentEnd'], 84 | (setting, apply) => { 85 | const desc = new DocumentFragment(); 86 | desc.appendText('The start and end tokens used to comment out a line.'); 87 | desc.createEl('br'); 88 | desc.createEl('br'); 89 | desc.createEl('span', { 90 | text: 'Note: ', 91 | attr: { style: 'font-weight: var(--font-semibold);' }, 92 | }); 93 | desc.appendText( 94 | 'Custom tokens that would not otherwise render as a valid comment in Markdown can still be used but will not be hidden from reader view.', 95 | ); 96 | 97 | return setting 98 | .setName('Custom comment style') 99 | .setDesc(desc) 100 | .addText((text) => { 101 | text.setPlaceholder('Comment start (e.g. "")') 111 | .setValue(this.plugin.settings.customCommentEnd ?? '') 112 | .onChange(async (value) => { 113 | this.plugin.settings.customCommentEnd = value.trim(); 114 | await apply(); 115 | }), 116 | ); 117 | }, 118 | { refreshAppearance: false }, 119 | ); 120 | } 121 | 122 | this.add( 123 | ['dropCursor'], 124 | (setting, apply) => 125 | setting 126 | .setName('Drop cursor') 127 | .setDesc('Automatically drop the cursor to the next line after toggling a comment.') 128 | .addToggle((toggle) => { 129 | toggle.setValue(this.plugin.settings.dropCursor).onChange(async (value) => { 130 | this.plugin.settings.dropCursor = value; 131 | await apply(); 132 | }); 133 | }), 134 | { refreshAppearance: false }, 135 | ); 136 | } 137 | 138 | private createExampleSection() { 139 | const exampleCard = this.createCard(() => { 140 | const exampleContainer = this.container.createDiv({ 141 | cls: 'cm-line', 142 | }); 143 | this.example = exampleContainer.createEl('span'); 144 | this.updateExample(); 145 | }); 146 | exampleCard.setAttribute('role', 'figure'); 147 | exampleCard.setAttribute('aria-label', 'Example of what a comment will look like.'); 148 | } 149 | 150 | private createAppearanceSection() { 151 | const { overrideAppearance, appearance } = this.plugin.settings; 152 | 153 | this.add( 154 | ['overrideAppearance'], 155 | (setting, apply) => { 156 | const elt = setting 157 | .setName('Override appearance') 158 | .setDesc('Allow this plugin to override the current comment appearance.') 159 | .addToggle((toggle) => { 160 | toggle.setValue(overrideAppearance).onChange(async (value) => { 161 | this.plugin.settings.overrideAppearance = value; 162 | await apply(); 163 | this.display(); 164 | }); 165 | }).settingEl; 166 | 167 | elt.addClass(styles.hideTopBorder); 168 | 169 | return setting; 170 | }, 171 | { 172 | refreshAppearance: false, 173 | onApply: () => { 174 | this.plugin.refreshAppearanceOverride(); 175 | }, 176 | }, 177 | ); 178 | 179 | this.createSection(() => { 180 | // Create empty setting to add a border to the section 181 | const empty = new Setting(this.container).setDisabled(true).settingEl; 182 | empty.addClass(styles.empty); 183 | empty.toggleAttribute('inert', true); 184 | 185 | this.add(['appearance.color'], (setting, apply) => 186 | setting.setName('Comment color').addColorPicker((picker) => { 187 | picker.setValue(appearance.color).onChange(async (value) => { 188 | appearance.color = value; 189 | await apply(); 190 | }); 191 | }), 192 | ).setDisabled(!overrideAppearance); 193 | 194 | this.add(['appearance.showBackground', 'appearance.backgroundColor'], (setting, apply) => 195 | setting 196 | .setName('Comment background') 197 | .addToggle((toggle) => { 198 | toggle.setValue(appearance.showBackground).onChange(async (value) => { 199 | appearance.showBackground = value; 200 | await apply(); 201 | }); 202 | }) 203 | .addColorPicker((picker) => { 204 | picker.setValue(appearance.backgroundColor).onChange(async (value) => { 205 | appearance.backgroundColor = value; 206 | await apply(); 207 | }); 208 | }), 209 | ).setDisabled(!overrideAppearance); 210 | 211 | this.add(['appearance.showOutline', 'appearance.outlineColor'], (setting, apply) => 212 | setting 213 | .setName('Comment outline') 214 | .addToggle((toggle) => { 215 | toggle.setValue(appearance.showOutline).onChange(async (value) => { 216 | appearance.showOutline = value; 217 | await apply(); 218 | }); 219 | }) 220 | .addColorPicker((picker) => { 221 | picker.setValue(appearance.outlineColor).onChange(async (value) => { 222 | appearance.outlineColor = value; 223 | await apply(); 224 | }); 225 | }), 226 | ).setDisabled(!overrideAppearance); 227 | 228 | this.add(['appearance.fontTheme'], (setting, apply) => { 229 | const desc = new DocumentFragment(); 230 | desc.appendText('The font to use for comments.'); 231 | 232 | return setting 233 | .setName('Comment font') 234 | .setDesc(desc) 235 | .addDropdown((dropdown) => { 236 | dropdown 237 | .addOptions({ 238 | default: 'Default', 239 | monospace: 'Monospace', 240 | custom: 'Custom', 241 | } as Record) 242 | .setValue(this.plugin.settings.appearance.fontTheme) 243 | .onChange(async (value) => { 244 | this.plugin.settings.appearance.fontTheme = value as CommentFontTheme; 245 | await apply(); 246 | this.display(); 247 | }); 248 | }); 249 | }).setDisabled(!overrideAppearance); 250 | 251 | if (this.plugin.settings.appearance.fontTheme === 'custom') { 252 | this.add(['appearance.customFont'], (setting, apply) => { 253 | const desc = new DocumentFragment(); 254 | desc.appendText('The font-family to use for comments.'); 255 | 256 | return setting 257 | .setName('Custom font') 258 | .setDesc(desc) 259 | .addText((text) => { 260 | text.setPlaceholder('Arial, sans-serif') 261 | .setValue(this.plugin.settings.appearance.customFont) 262 | .onChange(async (value) => { 263 | // Prevent injecting extra properties into the CSS 264 | this.plugin.settings.appearance.customFont = value.trim().split(';')[0]; 265 | await apply(); 266 | }); 267 | }); 268 | }).setDisabled(!overrideAppearance); 269 | } 270 | 271 | this.add(['appearance.italic'], (setting, apply) => 272 | setting.setName('Italicize comments').addToggle((toggle) => { 273 | toggle.setValue(appearance.italic).onChange(async (value) => { 274 | appearance.italic = value; 275 | await apply(); 276 | }); 277 | }), 278 | ).setDisabled(!overrideAppearance); 279 | 280 | this.add(['appearance.weight'], (setting, apply) => 281 | setting.setName('Comment font weight').addSlider((slider) => { 282 | slider 283 | .setLimits(100, 900, 100) 284 | .setDynamicTooltip() 285 | .setValue(appearance.weight) 286 | .onChange(async (value) => { 287 | appearance.weight = value; 288 | await apply(); 289 | }); 290 | }), 291 | ).setDisabled(!overrideAppearance); 292 | }, overrideAppearance); 293 | } 294 | 295 | private createCodeBlockSection() { 296 | this.createSection(() => { 297 | this.container.addClass(styles.codeBlockSection); 298 | 299 | new Text(this.container).setText('Custom languages').setVariant('setting-item-name'); 300 | new Text(this.container) 301 | .setText((frag) => { 302 | frag.createDiv({ 303 | cls: 'setting-item-description', 304 | text: 'Use the list below to define the comment style for custom code block languages.', 305 | }); 306 | frag.createDiv({ 307 | cls: 'setting-item-description', 308 | text: 'Languages are defined using case-insensitive regex and are checked in ascending order (first in the list has the highest priority).', 309 | }).appendText(' The first one to match the language of the code block will be used.'); 310 | 311 | return frag; 312 | }) 313 | .setVariant('setting-item-description'); 314 | 315 | const list = new CustomLanguageList(this.container) 316 | .setLanguages(this.plugin.settings.customLanguages) 317 | .onChange(async (languages) => { 318 | this.plugin.settings.customLanguages = languages; 319 | await this.plugin.saveSettings(); 320 | }) 321 | .addClass(styles.customLanguageList); 322 | 323 | new ButtonComponent(this.container) 324 | .setButtonText('Add Language') 325 | .onClick(async () => { 326 | list.pushLang(emptyLang()); 327 | this.plugin.settings.customLanguages.push(emptyLang()); 328 | await this.plugin.saveSettings(); 329 | }) 330 | .buttonEl.addClass(styles.addButton); 331 | }); 332 | } 333 | 334 | /** 335 | * Add a setting to the current {@link container} element. 336 | * 337 | * This will automatically handle saving the setting and refreshing the appearance. 338 | * It will also automatically add a reset button to the setting. 339 | * 340 | * @param paths The setting path(s) this setting affects. 341 | * @param init A function to initialize the setting. 342 | * @param options Custom configuration for the setting. 343 | */ 344 | private add( 345 | paths: SettingsPath[], 346 | init: (setting: Setting, apply: () => Promise) => Setting, 347 | options: AddSettingOptions = {}, 348 | ) { 349 | const { refreshAppearance = true, onApply } = options; 350 | 351 | const listeners: (() => void)[] = []; 352 | 353 | const setting = init(new Setting(this.container), async () => { 354 | await this.plugin.saveSettings(); 355 | refreshAppearance && this.plugin.refreshAppearance(); 356 | onApply?.(); 357 | this.updateExample(); 358 | listeners.forEach((cb) => cb()); 359 | }); 360 | 361 | const resetBtn = new IconButton(setting.controlEl) 362 | .setIcon('reset') 363 | .setTooltip('Restore default') 364 | .onClick(async () => { 365 | restoreSettings(this.plugin.settings, ...paths); 366 | await this.plugin.saveSettings(); 367 | this.plugin.refreshAppearance(); 368 | this.display(); 369 | }); 370 | 371 | const refresh = () => { 372 | const isDisabled = isDefaultSettings(this.plugin.settings, ...paths); 373 | resetBtn.setDisabled(isDisabled); 374 | resetBtn.extraSettingsEl.style.pointerEvents = isDisabled ? 'none' : 'auto'; 375 | resetBtn.extraSettingsEl.style.filter = isDisabled ? 'opacity(0.5)' : 'unset'; 376 | resetBtn.extraSettingsEl.setAttr('aria-disabled', isDisabled); 377 | }; 378 | listeners.push(refresh); 379 | refresh(); 380 | 381 | return setting; 382 | } 383 | 384 | /** 385 | * Create a card element. 386 | */ 387 | private createCard(create: () => void): HTMLDivElement { 388 | const card = this.container.createDiv(styles.card); 389 | this.containerStack.push(card); 390 | create(); 391 | this.containerStack.pop(); 392 | return card; 393 | } 394 | 395 | /** 396 | * Create a section element. 397 | * 398 | * This will automatically handle the styling for disabled sections. 399 | */ 400 | private createSection(create: () => void, enabled = true): HTMLDivElement { 401 | const section = this.container.createDiv(); 402 | section.toggleClass(globalStyles.disabled, !enabled); 403 | section.setAttribute('aria-disabled', (!enabled).toString()); 404 | section.toggleAttribute('inert', !enabled); 405 | 406 | this.containerStack.push(section); 407 | create(); 408 | this.containerStack.pop(); 409 | 410 | return section; 411 | } 412 | 413 | /** 414 | * Update the example comment with the current settings. 415 | */ 416 | private updateExample() { 417 | if (this.example) { 418 | const [commentStart, commentEnd] = getCommentTokens(this.plugin.settings, null); 419 | this.example.textContent = buildCommentString('This is a comment', commentStart, commentEnd); 420 | this.example.addClass('cm-comment'); 421 | this.plugin.settings.overrideAppearance && 422 | this.example.setAttr('style', buildStyleString(this.plugin.settings.appearance)); 423 | } 424 | } 425 | } 426 | 427 | interface AddSettingOptions { 428 | /** Callback for when a setting is applied. */ 429 | onApply?: () => void; 430 | /** 431 | * Whether to refresh the appearance after a setting is applied. 432 | * 433 | * @default true 434 | */ 435 | refreshAppearance?: boolean; 436 | } 437 | -------------------------------------------------------------------------------- /src/settings/types.ts: -------------------------------------------------------------------------------- 1 | import { Flatten } from '../utility'; 2 | 3 | export interface Settings { 4 | commentStyle: CommentStyle; 5 | customCommentStart: string; 6 | customCommentEnd: string; 7 | /** 8 | * If true, the cursor will be dropped to the next line after toggling a comment. 9 | */ 10 | dropCursor: boolean; 11 | /** 12 | * If true, overrides the appearance of commented lines with {@link appearance}. 13 | */ 14 | overrideAppearance: boolean; 15 | appearance: CommentAppearance; 16 | customLanguages: CustomLanguage[]; 17 | } 18 | 19 | export interface CommentAppearance { 20 | showBackground: boolean; 21 | backgroundColor: string; 22 | color: string; 23 | fontTheme: CommentFontTheme; 24 | customFont: string; 25 | italic: boolean; 26 | weight: number; 27 | showOutline: boolean; 28 | outlineColor: string; 29 | } 30 | 31 | export interface CustomLanguage { 32 | regex: string; 33 | commentStart: string; 34 | commentEnd: string; 35 | } 36 | 37 | /** 38 | * The various styles (or, "flavors") of comments that can be used. 39 | * 40 | * - `html`: HTML comments (``). 41 | * - `obsidian`: Obsidian comments (`%% %%`). 42 | * - `custom`: Custom comments, specified by {@link Settings.customCommentStart} and {@link Settings.customCommentEnd}. 43 | */ 44 | export type CommentStyle = 'html' | 'obsidian' | 'custom'; 45 | 46 | export type CommentFontTheme = 'default' | 'monospace' | 'custom'; 47 | 48 | export type FlatSettings = Flatten; 49 | export type SettingsPath = keyof FlatSettings; 50 | -------------------------------------------------------------------------------- /src/settings/utils.ts: -------------------------------------------------------------------------------- 1 | import { FlatSettings, Settings, SettingsPath } from './types'; 2 | import { DEFAULT_SETTINGS } from './defaults'; 3 | import { DeepReadonly } from '../utility'; 4 | 5 | /** 6 | * Returns the value of the given setting path. 7 | */ 8 | export function getSetting

(settings: DeepReadonly, path: P): FlatSettings[P] { 9 | const parts = path.split('.'); 10 | let obj: unknown = settings; 11 | for (const part of parts) { 12 | obj = (obj as Record)[part]; 13 | } 14 | 15 | return obj as FlatSettings[P]; 16 | } 17 | 18 | /** 19 | * Sets the given setting path to the given value. 20 | */ 21 | export function setSetting

(settings: Settings, path: P, value: FlatSettings[P]) { 22 | const parts = path.split('.'); 23 | const key = parts.pop(); 24 | if (key === undefined) { 25 | throw new Error('Invalid settings path'); 26 | } 27 | 28 | let obj: unknown = settings; 29 | for (const part of parts) { 30 | obj = (obj as Record)[part]; 31 | } 32 | 33 | (obj as Record)[key] = value; 34 | } 35 | 36 | /** 37 | * Restores the default value for the given settings paths. 38 | */ 39 | export function restoreSettings(settings: Settings, ...paths: SettingsPath[]) { 40 | for (const path of paths) { 41 | const value = getSetting(DEFAULT_SETTINGS, path); 42 | setSetting(settings, path, value); 43 | } 44 | } 45 | 46 | /** 47 | * Returns true if _all_ the given settings paths are set to their default values. 48 | */ 49 | export function isDefaultSettings(settings: Settings, ...paths: SettingsPath[]): boolean { 50 | for (const path of paths) { 51 | const defaultValue = getSetting(DEFAULT_SETTINGS, path); 52 | const currentValue = getSetting(settings, path); 53 | if (defaultValue !== currentValue) { 54 | return false; 55 | } 56 | } 57 | return true; 58 | } 59 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | .code { 2 | color: var(--code-normal); 3 | font-size: var(--code-size); 4 | font-family: var(--font-monospace); 5 | vertical-align: baseline; 6 | } 7 | 8 | .comment { 9 | color: var(--code-comment); 10 | } 11 | 12 | @mixin disabled { 13 | pointer-events: none; 14 | opacity: 0.35; 15 | filter: brightness(0.85); 16 | } 17 | 18 | .disabled { 19 | @include disabled; 20 | } 21 | -------------------------------------------------------------------------------- /src/toggle/controller.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorChange } from 'obsidian'; 2 | import { 3 | buildCommentString, 4 | escapeRegex, 5 | findCodeLang, 6 | getCommentTokens, 7 | isMathBlock, 8 | shouldDenyComment, 9 | } from '../utility'; 10 | import { Settings } from '../settings'; 11 | import { LineState, RangeState, ToggleResult } from './types'; 12 | 13 | /** 14 | * Controller for toggling line comments. 15 | */ 16 | export class LineCommentController { 17 | /** 18 | * The set of uncommitted changes that have been made by this controller. 19 | */ 20 | private changes: EditorChange[] = []; 21 | 22 | constructor( 23 | private editor: Editor, 24 | private settings: Settings, 25 | ) {} 26 | 27 | /** 28 | * Take the uncommitted changes that have been made by this controller. 29 | * 30 | * This will drain the list, leaving it empty and ready for future use. 31 | */ 32 | public takeChanges(): EditorChange[] { 33 | const changes = this.changes; 34 | this.changes = []; 35 | return changes; 36 | } 37 | 38 | /** 39 | * Toggle the comment state of the given line. 40 | */ 41 | public toggle(line: number, options: ToggleOptions = {}): ToggleResult { 42 | const original = this.editor.getLine(line); 43 | const indent = Math.max(original.search(/\S/), 0); 44 | 45 | const { state, commentStart, commentEnd } = this.lineState(line); 46 | const { isCommented, text } = state; 47 | 48 | if (commentStart.length === 0 && commentEnd.length === 0) { 49 | return { before: state, after: state, commentStart, commentEnd }; 50 | } 51 | 52 | if (isCommented) { 53 | if (options.forceComment) { 54 | // Line is commented -> do nothing 55 | return { before: state, after: state, commentStart, commentEnd }; 56 | } 57 | 58 | // Line is commented -> uncomment 59 | this.addChange(line, text, indent); 60 | return { before: state, after: { isCommented: false, text }, commentStart, commentEnd }; 61 | } else { 62 | // Line is uncommented -> comment 63 | const newText = buildCommentString(text, commentStart, commentEnd); 64 | this.addChange(line, newText, indent); 65 | return { 66 | before: state, 67 | after: { isCommented: true, text: newText }, 68 | commentStart, 69 | commentEnd, 70 | }; 71 | } 72 | } 73 | 74 | /** 75 | * Get the current comment state of the given range. 76 | */ 77 | public rangeState(fromLine: number, toLine: number): RangeState | null { 78 | let rangeState: RangeState | null = null; 79 | for (let line = fromLine; line <= toLine; line++) { 80 | if (shouldDenyComment(this.editor, line)) { 81 | continue; 82 | } 83 | 84 | const { isCommented } = this.lineState(line).state; 85 | 86 | switch (rangeState) { 87 | case null: 88 | rangeState = isCommented ? 'commented' : 'uncommented'; 89 | break; 90 | case 'commented': 91 | rangeState = isCommented ? 'commented' : 'mixed'; 92 | break; 93 | case 'uncommented': 94 | rangeState = isCommented ? 'mixed' : 'uncommented'; 95 | break; 96 | case 'mixed': 97 | // Do nothing 98 | break; 99 | } 100 | } 101 | 102 | return rangeState; 103 | } 104 | 105 | /** 106 | * Get the current comment state of the given line. 107 | */ 108 | private lineState(line: number): LineStateResult { 109 | const [commentStart, commentEnd] = this.getLineCommentTokens(line); 110 | 111 | const regex = this.buildCommentRegex(commentStart, commentEnd); 112 | const text = this.editor.getLine(line); 113 | const matches = regex.exec(text); 114 | 115 | if (matches === null) { 116 | return { state: { isCommented: false, text: text.trim() }, commentStart, commentEnd }; 117 | } 118 | 119 | const innerText = matches[1]; 120 | return { state: { isCommented: true, text: innerText.trim() }, commentStart, commentEnd }; 121 | } 122 | 123 | private getLineCommentTokens(line: number): [string, string] { 124 | if (isMathBlock(this.editor, line)) { 125 | return ['%', '']; 126 | } 127 | 128 | const lang = findCodeLang(this.editor, line); 129 | return getCommentTokens(this.settings, lang); 130 | } 131 | 132 | /** 133 | * Build a regex that matches the comment tokens according to the line's context and 134 | * current {@link Settings}. 135 | * 136 | * Contains one unnamed capture group containing the text between the comment tokens. 137 | */ 138 | private buildCommentRegex(commentStart: string, commentEnd: string): RegExp { 139 | const start = escapeRegex(commentStart); 140 | const end = escapeRegex(commentEnd); 141 | return new RegExp(`${start}\\s*(.*)\\s*${end}`); 142 | } 143 | 144 | /** 145 | * Add a change to the list of changes to be applied to the editor. 146 | * 147 | * @param line The affected line. 148 | * @param text The new text for the line. 149 | * @param indent The indent of the line (i.e. where to insert the text). 150 | */ 151 | private addChange(line: number, text: string, indent = 0) { 152 | return this.changes.push({ 153 | from: { line, ch: indent }, 154 | to: { line, ch: this.editor.getLine(line).length }, 155 | text, 156 | }); 157 | } 158 | } 159 | 160 | interface ToggleOptions { 161 | forceComment?: boolean; 162 | } 163 | 164 | interface LineStateResult { 165 | state: LineState; 166 | commentStart: string; 167 | commentEnd: string; 168 | } 169 | -------------------------------------------------------------------------------- /src/toggle/index.ts: -------------------------------------------------------------------------------- 1 | export { LineCommentController } from './controller'; 2 | export type { ToggleResult } from './types'; 3 | -------------------------------------------------------------------------------- /src/toggle/types.ts: -------------------------------------------------------------------------------- 1 | export interface LineState { 2 | /** 3 | * Whether the line is commented or not. 4 | */ 5 | isCommented: boolean; 6 | /** 7 | * The uncommented portion of the line. 8 | */ 9 | text: string; 10 | } 11 | 12 | /** 13 | * The various comment states that a range of lines can be in. 14 | */ 15 | export type RangeState = 'uncommented' | 'commented' | 'mixed'; 16 | 17 | export interface ToggleResult { 18 | before: LineState; 19 | after: LineState; 20 | /** 21 | * The tokens used to comment the line (start). 22 | */ 23 | commentStart: string; 24 | /** 25 | * The tokens used to comment the line (end). 26 | */ 27 | commentEnd: string; 28 | } 29 | -------------------------------------------------------------------------------- /src/utility/ListDict.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A list that can be accessed by index or by key. 3 | * 4 | * Item indexes are tracked by a {@link Map}, keyed by the result of {@link toKey}. 5 | */ 6 | export class ListDict { 7 | private readonly list: T[]; 8 | private dict: Map; 9 | 10 | /** 11 | * 12 | * @param toKey The function to use to generate the key for an item 13 | * @param entries The initial entries to populate the list with 14 | */ 15 | constructor( 16 | private toKey: (item: T) => K, 17 | entries?: Iterable, 18 | ) { 19 | this.list = entries ? Array.from(entries) : []; 20 | this.dict = new Map(this.list.map((item, i) => [toKey(item), i])); 21 | } 22 | 23 | /** 24 | * Create a {@link ListDict} that uses the item itself as the key. 25 | */ 26 | public static identity(entries?: Iterable): ListDict { 27 | return new ListDict((item) => item, entries); 28 | } 29 | 30 | /** 31 | * The length of the list. 32 | */ 33 | public get length() { 34 | return this.list.length; 35 | } 36 | 37 | public [Symbol.iterator]() { 38 | return this.list[Symbol.iterator](); 39 | } 40 | 41 | /** 42 | * Get the item with the given key. 43 | */ 44 | public get(key: K): T | undefined { 45 | const index = this.dict.get(key); 46 | return index === undefined ? undefined : this.list[index]; 47 | } 48 | 49 | /** 50 | * Get the item at the given index. 51 | */ 52 | public at(index: number): T | undefined { 53 | return this.list[index]; 54 | } 55 | 56 | /** 57 | * Get the index of the given item. 58 | */ 59 | public indexOf(item: K): number | undefined { 60 | return this.dict.get(item); 61 | } 62 | 63 | /** 64 | * Push an item to the end of the list. 65 | */ 66 | public push(item: T): number { 67 | const index = this.list.push(item) - 1; 68 | this.dict.set(this.toKey(item), index); 69 | return index; 70 | } 71 | 72 | /** 73 | * Remove and return the last item in the list. 74 | */ 75 | public pop(): T | undefined { 76 | const item = this.list.pop(); 77 | if (item) { 78 | this.dict.delete(this.toKey(item)); 79 | } 80 | return item; 81 | } 82 | 83 | /** 84 | * Insert an item at the given index. 85 | */ 86 | public insert(index: number, item: T): boolean { 87 | if (index < 0 || index > this.list.length) { 88 | return false; 89 | } 90 | this.list.splice(index, 0, item); 91 | this.dict.set(this.toKey(item), index); 92 | this.updateIndexes(index + 1); 93 | return true; 94 | } 95 | 96 | /** 97 | * Remove the item with the given key. 98 | */ 99 | public remove(item: K): boolean { 100 | const index = this.dict.get(item); 101 | if (index === undefined) { 102 | return false; 103 | } 104 | this.list.splice(index, 1); 105 | this.dict.delete(item); 106 | this.updateIndexes(index); 107 | 108 | return true; 109 | } 110 | 111 | /** 112 | * Remove the item at the given index. 113 | */ 114 | public removeAt(index: number): boolean { 115 | const item = this.list[index]; 116 | if (item === undefined) { 117 | return false; 118 | } 119 | this.list.splice(index, 1); 120 | this.dict.delete(this.toKey(item)); 121 | return true; 122 | } 123 | 124 | /** 125 | * Swap the positions of two items. 126 | * 127 | * @param itemA The key of the first item 128 | * @param itemB The key of the second item 129 | */ 130 | public swap(itemA: K, itemB: K): boolean { 131 | const indexA = this.dict.get(itemA); 132 | const indexB = this.dict.get(itemB); 133 | if (indexA === undefined || indexB === undefined) { 134 | return false; 135 | } 136 | return this.swapAt(indexA, indexB); 137 | } 138 | 139 | /** 140 | * Swap the positions of two items. 141 | * 142 | * @param indexA The index of the first item 143 | * @param indexB The index of the second item 144 | */ 145 | public swapAt(indexA: number, indexB: number): boolean { 146 | const itemA = this.list[indexA]; 147 | const itemB = this.list[indexB]; 148 | if (itemA === undefined || itemB === undefined) { 149 | return false; 150 | } 151 | 152 | this.list[indexA] = itemB; 153 | this.list[indexB] = itemA; 154 | this.dict.set(this.toKey(itemA), indexB); 155 | this.dict.set(this.toKey(itemB), indexA); 156 | return true; 157 | } 158 | 159 | /** 160 | * Remove all items from the list. 161 | */ 162 | public clear(): number { 163 | const length = this.list.length; 164 | this.list.length = 0; 165 | this.dict.clear(); 166 | return length; 167 | } 168 | 169 | /** 170 | * Create a new array populated with the results of calling a provided function 171 | * on every element in the list. 172 | */ 173 | public map(mapper: (value: T, index: number, array: T[]) => U, thisArg?: any): U[] { 174 | return this.list.map(mapper, thisArg); 175 | } 176 | 177 | /** 178 | * Update all the mapped indexes after the given index. 179 | */ 180 | private updateIndexes(startIndex = 0) { 181 | for (let i = startIndex; i < this.length; i++) { 182 | const item = this.at(i); 183 | if (item === undefined) { 184 | continue; 185 | } 186 | this.dict.set(this.toKey(item), i); 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/utility/animation/AnimationGroup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A group of animations to play at the same time. 3 | */ 4 | export class AnimationGroup { 5 | private readonly keyframes: KeyframeEffect[]; 6 | private readonly longestKeyframe: KeyframeEffect | undefined; 7 | 8 | constructor(...effects: KeyframeEffect[]) { 9 | this.keyframes = Array(effects.length); 10 | let maxDuration = -Infinity; 11 | 12 | for (const effect of effects) { 13 | const duration = Number(effect.getTiming().duration) ?? 0; 14 | if (duration > maxDuration) { 15 | this.longestKeyframe = effect; 16 | maxDuration = duration; 17 | } 18 | 19 | this.keyframes.push(effect); 20 | } 21 | } 22 | 23 | /** 24 | * Plays all animations in the group. 25 | * @param callback A callback that is called when the longest animation finishes. 26 | */ 27 | public play(callback: ((this: Animation, ev: AnimationPlaybackEvent) => any) | null = null) { 28 | const isReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; 29 | for (const keyframe of this.keyframes) { 30 | const animation = new Animation(keyframe); 31 | if (this.longestKeyframe === keyframe) { 32 | animation.onfinish = callback; 33 | } 34 | 35 | if (isReducedMotion) { 36 | animation.finish(); 37 | } else { 38 | animation.play(); 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utility/animation/defaults.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Standard options for animations. 3 | */ 4 | export const AnimationOptions = { 5 | get fastEaseOut() { 6 | return { 7 | duration: 150, 8 | easing: 'ease-out', 9 | }; 10 | }, 11 | get fasterEaseOut() { 12 | return { 13 | duration: 50, 14 | easing: 'ease-out', 15 | }; 16 | }, 17 | get debugEaseOut() { 18 | return { 19 | duration: 2500, 20 | easing: 'ease-out', 21 | }; 22 | }, 23 | } as const satisfies Record; 24 | -------------------------------------------------------------------------------- /src/utility/animation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AnimationGroup'; 2 | export * from './defaults'; 3 | -------------------------------------------------------------------------------- /src/utility/appearanceUtils.ts: -------------------------------------------------------------------------------- 1 | import { CommentAppearance } from '../settings'; 2 | 3 | /** 4 | * Build a CSS style string from the given {@link CommentAppearance}. 5 | */ 6 | export function buildStyleString(appearance: CommentAppearance): string { 7 | const { backgroundColor, showBackground, color, fontTheme, customFont, italic, weight, showOutline, outlineColor } = 8 | appearance; 9 | 10 | const props: string[] = []; 11 | 12 | props.push(`color: ${color}`); 13 | props.push(`font-weight: ${weight}`); 14 | italic && props.push('font-style: italic'); 15 | showBackground && props.push(`background-color: ${backgroundColor}`); 16 | showOutline && 17 | props.push( 18 | `text-shadow: ${outlineColor} -1px -1px 1px, ${outlineColor} 1px -1px 1px, ${outlineColor} -1px 1px 1px, ${outlineColor} 1px 1px 1px`, 19 | ); 20 | 21 | switch (fontTheme) { 22 | case 'default': 23 | break; 24 | case 'monospace': 25 | props.push('font-family: monospace'); 26 | break; 27 | case 'custom': 28 | props.push(`font-family: ${customFont}`); 29 | break; 30 | } 31 | 32 | return props.join('; '); 33 | } 34 | -------------------------------------------------------------------------------- /src/utility/commentUtils.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from '../settings'; 2 | import { Languages } from '../settings/language'; 3 | import { Editor } from 'obsidian'; 4 | import { containsMathBlockTokens } from './editorUtils'; 5 | 6 | /** 7 | * Return a tuple of the start and end comment tokens for the given {@link Settings}. 8 | */ 9 | export function getCommentTokens(settings: Settings, lang: string | null): [string, string] { 10 | if (lang) { 11 | for (const { commentEnd, commentStart, regex } of settings.customLanguages) { 12 | if (regex.trim().length === 0) { 13 | continue; 14 | } 15 | 16 | if (new RegExp(regex, 'i').test(lang)) { 17 | return [commentStart, commentEnd]; 18 | } 19 | } 20 | 21 | const standard = Languages.get(lang); 22 | if (standard) { 23 | return [standard.commentStart, standard.commentEnd]; 24 | } 25 | 26 | // By default, don't comment anything 27 | return ['', '']; 28 | } 29 | 30 | switch (settings.commentStyle) { 31 | case 'html': 32 | return ['']; 33 | case 'obsidian': 34 | return ['%%', '%%']; 35 | case 'custom': 36 | return [settings.customCommentStart, settings.customCommentEnd]; 37 | default: 38 | throw new Error(`Unknown comment kind: ${settings.commentStyle}`); 39 | } 40 | } 41 | 42 | /** 43 | * Build a comment string from the given text and {@link Settings}. 44 | */ 45 | export function buildCommentString(text: string, commentStart: string, commentEnd: string): string { 46 | const end = commentEnd ? ` ${commentEnd}` : ''; 47 | 48 | return `${commentStart} ${text}${end}`; 49 | } 50 | 51 | /** 52 | * Returns true if the given line should not have its comment state toggled by this plugin. 53 | */ 54 | export function shouldDenyComment(editor: Editor, line: number): boolean { 55 | const text = editor.getLine(line).trim(); 56 | return ( 57 | text.length === 0 || text.startsWith('```') || text.startsWith('$$') || containsMathBlockTokens(editor, line) 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/utility/editorUtils.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view'; 2 | import { Editor } from 'obsidian'; 3 | import { syntaxTree } from '@codemirror/language'; 4 | 5 | /** 6 | * Extract a CodeMirror {@link EditorView} from an Obsidian {@link Editor}. 7 | */ 8 | export function extractEditorView(editor: Editor): EditorView { 9 | // https://docs.obsidian.md/Plugins/Editor/Communicating+with+editor+extensions 10 | // @ts-expect-error: not typed 11 | return editor.cm as EditorView; 12 | } 13 | 14 | /** 15 | * Regex for extracting the language from the starting line of a code block. 16 | */ 17 | const CODE_LANG_REGEX = /^```(.*)$/; 18 | 19 | /** 20 | * Find the language of the code block at the given line. 21 | * 22 | * If the line is not in a code block, returns `null`. 23 | * If it is in a code block, returns the language name (or any text following the starting "```"). 24 | */ 25 | export function findCodeLang(editor: Editor, line: number): string | null { 26 | const view = extractEditorView(editor); 27 | 28 | // Lines are 1-indexed so we need to add 1 29 | const linePos = view.state.doc.line(line + 1).from; 30 | 31 | // Set `side` to `1` so we only get the node starting at this line (i.e. not the topmost `Document`) 32 | const cursor = syntaxTree(view.state).cursorAt(linePos, 1); 33 | 34 | // First, check if we're in a code block 35 | if (!cursor.type.name.contains('hmd-codeblock')) { 36 | return null; 37 | } 38 | 39 | // Find the start of the code block 40 | let found = false; 41 | let { from, to } = cursor; 42 | while (line > 0) { 43 | line--; 44 | const linePos = view.state.doc.line(line + 1).from; 45 | const cursor = syntaxTree(view.state).cursorAt(linePos, 1); 46 | if (!cursor.type.name.contains('hmd-codeblock')) { 47 | found = true; 48 | break; 49 | } 50 | 51 | from = cursor.from; 52 | to = cursor.to; 53 | } 54 | 55 | if (!found) { 56 | return null; 57 | } 58 | 59 | const text = view.state.sliceDoc(from, to); 60 | const matches = CODE_LANG_REGEX.exec(text); 61 | return matches?.at(1)?.trim() ?? null; 62 | } 63 | 64 | /** 65 | * Checks if the given line is in a math block. 66 | */ 67 | export function isMathBlock(editor: Editor, line: number): boolean { 68 | const view = extractEditorView(editor); 69 | 70 | // Lines are 1-indexed so we need to add 1 71 | const linePos = view.state.doc.line(line + 1).from; 72 | 73 | // Set `side` to `1` so we only get the node starting at this line (i.e. not the topmost `Document`) 74 | const cursor = syntaxTree(view.state).cursorAt(linePos, 1); 75 | 76 | return cursor.type.name.contains('math') || cursor.type.name.contains('comment_math'); 77 | } 78 | 79 | /** 80 | * Returns true if the given line contains math block tokens (i.e. `$$`). 81 | * 82 | * This will check against the syntax tree to prevent false positives, 83 | * such as when the `$$` tokens are in a code block. 84 | * 85 | * This function is needed because math blocks can be defined inline. 86 | */ 87 | export function containsMathBlockTokens(editor: Editor, line: number): boolean { 88 | const view = extractEditorView(editor); 89 | 90 | // Lines are 1-indexed so we need to add 1 91 | const linePos = view.state.doc.line(line + 1); 92 | 93 | for (let pos = linePos.from; pos < linePos.to; pos++) { 94 | // Set `side` to `1` so we only get the node starting at this line (i.e. not the topmost `Document`) 95 | const cursor = syntaxTree(view.state).cursorAt(pos, 1); 96 | 97 | if ( 98 | cursor.type.name.contains('begin_keyword_math_math-block') || 99 | cursor.type.name.contains('end_keyword_math_math-block') 100 | ) { 101 | return true; 102 | } 103 | } 104 | 105 | return false; 106 | } 107 | -------------------------------------------------------------------------------- /src/utility/generalUtils.ts: -------------------------------------------------------------------------------- 1 | import { EditorPosition } from 'obsidian'; 2 | 3 | /** 4 | * Escape the given text for use in a regular expression. 5 | * 6 | * @see https://stackoverflow.com/a/6969486/11571888 7 | */ 8 | export function escapeRegex(text: string): string { 9 | return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 10 | } 11 | 12 | export enum Ordering { 13 | Less = -1, 14 | Equal = 0, 15 | Greater = 1, 16 | } 17 | 18 | /** 19 | * Compare two {@link EditorPosition}s. 20 | * 21 | * First compares the line numbers, then the character positions. 22 | * 23 | * The resulting {@link Ordering} always follows `a` ORDERING `b`: 24 | * - If `a` is less than `b`, the result is {@link Ordering.Less} 25 | * - If `a` is equal to `b`, the result is {@link Ordering.Equal} 26 | * - If `a` is greater than `b`, the result is {@link Ordering.Greater} 27 | */ 28 | export function comparePos(a: EditorPosition, b: EditorPosition): Ordering { 29 | if (a.line < b.line) { 30 | return Ordering.Less; 31 | } 32 | 33 | if (a.line > b.line) { 34 | return Ordering.Greater; 35 | } 36 | 37 | if (a.ch < b.ch) { 38 | return Ordering.Less; 39 | } 40 | 41 | if (a.ch > b.ch) { 42 | return Ordering.Greater; 43 | } 44 | 45 | return Ordering.Equal; 46 | } 47 | -------------------------------------------------------------------------------- /src/utility/index.ts: -------------------------------------------------------------------------------- 1 | export * from './appearanceUtils'; 2 | export * from './commentUtils'; 3 | export * from './editorUtils'; 4 | export * from './generalUtils'; 5 | export * from './typeUtils'; 6 | export * from './ListDict'; 7 | export * from './animation'; 8 | -------------------------------------------------------------------------------- /src/utility/typeUtils.ts: -------------------------------------------------------------------------------- 1 | type IsArray = T extends Array ? (Array extends T ? true : false) : false; 2 | 3 | type IsObject = T extends object ? (IsArray extends true ? false : true) : false; 4 | 5 | /** 6 | * Converts a union type to an intersection type. 7 | * 8 | * @see https://stackoverflow.com/a/50375286/11621047 9 | */ 10 | type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; 11 | /** 12 | * Converts a union type to a combined object type. 13 | * 14 | * @see https://stackoverflow.com/a/50375286/11621047 15 | */ 16 | type Combine = UnionToIntersection extends infer O ? { [K in keyof O]: O[K] } : never; 17 | 18 | /** 19 | * Converts a type to a flattened representation of its leaf nodes. 20 | * 21 | * The flattened representation is a union of all fields in the object and in any nested objects, 22 | * with the keys being dot-separated paths to the leaf node type. 23 | * 24 | * @example 25 | * interface Foo { 26 | * a: string; 27 | * b: { 28 | * c: number; 29 | * }; 30 | * } 31 | * 32 | * type Result = Leaves<'', Foo>; 33 | * // `Result` has the following type: 34 | * // {a: string} | {'b.c': number} 35 | */ 36 | type Leaves = [keyof T] extends [infer K] 37 | ? K extends keyof T 38 | ? IsObject extends true 39 | ? Leaves<`${A}${Exclude}.`, T[K]> 40 | : { [L in `${A}${Exclude}`]: T[K] } 41 | : never 42 | : never; 43 | 44 | /** 45 | * Flattens an object and all its nested objects into a single one, 46 | * where each key is a dot-separated path to a leaf node. 47 | * 48 | * @example 49 | * interface Foo { 50 | * a: string; 51 | * b: { 52 | * c: number; 53 | * }; 54 | * } 55 | * 56 | * type Result = Flatten; 57 | * // `Result` has the following type: 58 | * // {a: string; 'b.c': number} 59 | */ 60 | export type Flatten = Combine>; 61 | 62 | /** 63 | * Makes all properties of an object readonly, recursively. 64 | */ 65 | export type DeepReadonly = { readonly [K in keyof T]: DeepReadonly }; 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7"] 15 | }, 16 | "include": ["**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | --------------------------------------------------------------------------------