├── .npmrc ├── versions.json ├── .eslintignore ├── img └── demo.gif ├── .editorconfig ├── manifest.json ├── .gitignore ├── styles.css ├── README.md ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── pr.yml ├── src ├── util │ └── Devlogger.ts ├── ui │ ├── ConfirmationModal.ts │ ├── ExpansionEntrySetting.ts │ └── SettingsTab.ts ├── main.ts ├── types.ts └── abbreviation │ └── AbbreviationExtention.ts ├── package.json ├── LICENSE └── esbuild.config.mjs /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WoodenMaiden/obsidian-abbreviations/HEAD/img/demo.gif -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "abbreviations", 3 | "name": "Abbreviations expander", 4 | "version": "1.1.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Easily create abbreviations that will be expanded after hitting `Space`.", 7 | "author": "Yann POMIE (WoodenMaiden)", 8 | "authorUrl": "https://yann-pomie.fr", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /.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 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .center-h { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: space-around; 14 | align-items: center; 15 | } 16 | 17 | .turned-on { 18 | color: var(--interactive-accent); 19 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Abbreviations Plugin 2 | 3 | This plugin allows you to define abbreviations for words that will be expanded when you type them. This is useful for words that you often misspell or for words that you want to expand into longer words or sentences. 4 | 5 | ![demo](./img/demo.gif) 6 | 7 | You can define abbreviations into the dedicated section in the menu, you can create, enable, disable, delete and even set them to be case insensitive meaning that even with all caps, an abbreviation can be expanded. 8 | 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "experimentalDecorators": true, 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "src/**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off", 22 | "no-console": "error" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | labels: 13 | - "dependencies" 14 | reviewers: 15 | - "WoodenMaiden" 16 | - package-ecosystem: github-actions 17 | directory: "/" 18 | schedule: 19 | interval: "daily" 20 | labels: 21 | - "dependencies" 22 | reviewers: 23 | - "WoodenMaiden" 24 | -------------------------------------------------------------------------------- /src/util/Devlogger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable no-console*/ 3 | 4 | import "reflect-metadata"; 5 | 6 | 7 | const dummyConsole: Partial = { 8 | log: (..._: any) => {}, 9 | warn: (..._: any) => {}, 10 | info: (..._: any) => {}, 11 | trace: (..._: any) => {}, 12 | error: console.error, 13 | }; 14 | 15 | 16 | // sets the logger to console in development mode and to a mock console in production mode 17 | export default function Devlogger(): PropertyDecorator { 18 | return (target: any, key: string) => { 19 | const logger = process.env.NODE_ENV === "development" ? console : dummyConsole 20 | 21 | Reflect.deleteProperty(target, key); 22 | Reflect.defineProperty(target, key, { 23 | value: logger, 24 | writable: false, 25 | configurable: false, 26 | enumerable: false 27 | }); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "1.0.0", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 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 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^22.0.2", 16 | "@typescript-eslint/eslint-plugin": "7.9.0", 17 | "@typescript-eslint/parser": "7.14.1", 18 | "builtin-modules": "4.0.0", 19 | "esbuild": "0.23.0", 20 | "obsidian": "latest", 21 | "tslib": "2.8.1", 22 | "typescript": "5.7.3" 23 | }, 24 | "dependencies": { 25 | "@codemirror/view": "^6.26.3", 26 | "@types/codemirror": "^5.60.15", 27 | "codemirror": "^6.0.1", 28 | "reflect-metadata": "^0.2.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/ConfirmationModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, App, Setting } from "obsidian"; 2 | 3 | export class ConfirmationModal extends Modal { 4 | private callback: () => void; 5 | 6 | constructor(app: App, callback: () => void) { 7 | super(app); 8 | this.callback = callback; 9 | } 10 | 11 | onOpen() { 12 | const { contentEl } = this; 13 | contentEl.createEl("h2", { text: "🛑 Are you sure?" }); 14 | contentEl.createEl("p", { text: "This will reset all your settings to default." }); 15 | contentEl.createEl("p", { text: "Every changes you made so far will be undone" }); 16 | 17 | 18 | new Setting(contentEl) 19 | .addButton((button) => 20 | button 21 | .setButtonText("Reset") 22 | .setWarning() 23 | .onClick(() => { 24 | this.callback(); 25 | this.close(); 26 | }) 27 | ) 28 | } 29 | 30 | onClose() { 31 | this.contentEl.empty(); 32 | } 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yann POMIE 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 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Plugin, 3 | } from 'obsidian'; 4 | 5 | import { 6 | AbbreviationPluginSettings, 7 | DEFAULT_SETTINGS, 8 | } from './types'; 9 | 10 | import { AbbreviationExpanderPlugin } from './abbreviation/AbbreviationExtention' 11 | import Devlogger from './util/Devlogger'; 12 | import SettingsTab from './ui/SettingsTab'; 13 | import { ViewPlugin } from '@codemirror/view'; 14 | 15 | 16 | export default class AbbreviationPlugin extends Plugin { 17 | @Devlogger() 18 | private readonly logger: Console; 19 | 20 | settings: AbbreviationPluginSettings; 21 | 22 | async onload() { 23 | await this.loadSettings(); 24 | 25 | this.addSettingTab(new SettingsTab(this.app, this)); 26 | 27 | this.registerEditorExtension([ 28 | ViewPlugin.define( 29 | (view) => new AbbreviationExpanderPlugin(view, this.settings) 30 | ), 31 | ]) 32 | } 33 | 34 | async loadSettings() { 35 | this.settings = { ...structuredClone(DEFAULT_SETTINGS), ...(await this.loadData())}; 36 | } 37 | 38 | async saveSettings() { 39 | await this.saveData(this.settings); 40 | } 41 | 42 | async resetSettings() { 43 | this.settings = { ...structuredClone(DEFAULT_SETTINGS) }; 44 | await this.saveData(this.settings); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | define: { 42 | "process.env.NODE_ENV": `"${prod ? "production" : "development"}"` 43 | } 44 | }); 45 | 46 | if (prod) { 47 | await context.rebuild(); 48 | process.exit(0); 49 | } else { 50 | await context.watch(); 51 | } 52 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Expansion { 2 | value: string; 3 | isEnabled: boolean; 4 | position: number; 5 | isCaseSensitive?: boolean; 6 | } 7 | 8 | export interface AbbreviationPluginSettings { 9 | abbreviations: Record; 10 | } 11 | 12 | export interface AbbreviationLocation { 13 | startingCharacter: number; 14 | abbreviation: Expansion; 15 | } 16 | 17 | export const DEFAULT_SETTINGS: AbbreviationPluginSettings = { 18 | abbreviations: { 19 | 'eg.': { 20 | value: 'for example', 21 | isEnabled: true, 22 | position: 0 23 | }, 24 | 'atm' : { 25 | value: 'at the moment', 26 | isEnabled: true, 27 | position: 1 28 | }, 29 | 'imo' : { 30 | value: 'in my opinion', 31 | isEnabled: true, 32 | position: 2 33 | }, 34 | 'w/' : { 35 | value: 'with', 36 | isEnabled: true, 37 | position: 3 38 | }, 39 | 'w/o' : { 40 | value: 'without', 41 | isEnabled: true, 42 | position: 4 43 | }, 44 | 'ily' : { 45 | value: 'I love you', 46 | isEnabled: true, 47 | position: 5 48 | }, 49 | 'btw' : { 50 | value: 'by the way', 51 | isEnabled: true, 52 | position: 6 53 | }, 54 | 'afaik': { 55 | value: 'as far as I know', 56 | isEnabled: true, 57 | position: 7 58 | }, 59 | 'rn' : { 60 | value: 'right now', 61 | isEnabled: true, 62 | position: 8 63 | } 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /.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@v4 14 | with: 15 | fetch-depth: 0 # So we can fetch all tags 16 | 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: "18.x" 22 | 23 | - name: Build plugin 24 | run: | 25 | npm ci 26 | npm run build 27 | 28 | - name: Get previous tag 29 | id: previousTag 30 | run: | 31 | name="$(git --no-pager tag --sort=creatordate --merged ${{ github.ref_name }} | tail -2 | head -1)" 32 | echo "previousTag: $name" 33 | echo "previousTag=$name" >> $GITHUB_ENV 34 | 35 | - name: Generate changelog 36 | uses: requarks/changelog-action@v1 37 | with: 38 | fromTag: ${{ github.ref_name }} 39 | includeRefIssues: true 40 | reverseOrder: true 41 | token: ${{ github.token }} 42 | toTag: ${{ env.previousTag }} 43 | useGitmojis: true 44 | writeToFile: true 45 | 46 | - name: Create release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | run: | 50 | tag="${GITHUB_REF#refs/tags/}" 51 | 52 | gh release create "$tag" \ 53 | -F CHANGELOG.md \ 54 | --latest \ 55 | --title="$tag" \ 56 | main.js manifest.json styles.css 57 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - main 8 | types: 9 | - edited 10 | - synchronize 11 | - opened 12 | - ready_for_review 13 | - reopened 14 | 15 | permissions: 16 | contents: read 17 | pull-requests: write 18 | 19 | jobs: 20 | greeting: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Add PR Comment 24 | uses: actions/first-interaction@v1.2.0 25 | if: github.actor != 'dependabot[bot]' 26 | with: 27 | repo-token: ${{ secrets.GITHUB_TOKEN }} 28 | pr-message: | 29 | Hello @${{ github.actor }}! Thanks for contributing to the plugin! 30 | 31 | Your PR basically need 3 things to be merged: 32 | - Your PR title need to follow the [conventional commits spec](https://www.conventionalcommits.org/en/v1.0.0/) 33 | - It must transpile without error and be tested 34 | - An approbation 35 | 36 | check_title: 37 | runs-on: ubuntu-latest 38 | if: github.actor != 'dependabot[bot]' 39 | steps: 40 | - name: Check PR title 41 | uses: amannn/action-semantic-pull-request@v5.5.3 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | build: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v4 50 | 51 | - name: Setup Node.js 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: "18.x" 55 | 56 | - name: Install dependencies 57 | run: npm install 58 | 59 | - name: Build 60 | run: npm run build 61 | 62 | -------------------------------------------------------------------------------- /src/ui/ExpansionEntrySetting.ts: -------------------------------------------------------------------------------- 1 | import { Setting, debounce} from "obsidian"; 2 | 3 | import { Expansion } from "../types"; 4 | 5 | export type ExpansionEntrySettingParameters = { 6 | abbreviation: string; 7 | expansion: Expansion; 8 | onDisable?: (value: boolean) => unknown; 9 | onRemove?: () => unknown; 10 | onAbbreviationEdit?: (value: string, oldValue: string) => unknown; 11 | onExpansionEdit?: (value: string, oldValue: string) => unknown; 12 | onCaseSensitiveChange?: () => unknown; 13 | }; 14 | 15 | export class ExpansionEntrySetting extends Setting { 16 | abbreviation: string; 17 | expansion: Expansion; 18 | 19 | constructor(elt: HTMLElement, opt: ExpansionEntrySettingParameters) { 20 | super(elt); 21 | elt.addClass("center-h"); 22 | this.abbreviation = opt.abbreviation; 23 | this.expansion = opt.expansion; 24 | 25 | const emptyFunction = (..._: never) => {}; 26 | this.addToggle((toggle) => 27 | toggle 28 | .setValue(this.expansion.isEnabled) 29 | .onChange(opt.onDisable ?? emptyFunction) 30 | ) 31 | .addText((textAreaAbbrev) => 32 | textAreaAbbrev 33 | .setPlaceholder("Abbreviation") 34 | .setValue(this.abbreviation) 35 | .onChange( 36 | debounce((value: string) => (opt.onAbbreviationEdit)? 37 | opt.onAbbreviationEdit(value, this.abbreviation) : 38 | emptyFunction 39 | , 750, true) 40 | ) 41 | .setDisabled(!this.expansion.isEnabled) 42 | ) 43 | // Expansion field 44 | .addText((textAreaExpansion) => 45 | textAreaExpansion 46 | .setPlaceholder("Meaning") 47 | .setValue(this.expansion.value) 48 | .onChange( 49 | debounce((value: string) => (opt.onExpansionEdit)? 50 | opt.onExpansionEdit(value, this.expansion.value) : 51 | emptyFunction 52 | , 750) 53 | ) 54 | .setDisabled(!this.expansion.isEnabled) 55 | ) 56 | // case sensitive button 57 | .addButton(caseButton => { 58 | caseButton 59 | .setIcon("case-sensitive") 60 | .setTooltip("Case sensitive") 61 | .onClick(opt.onCaseSensitiveChange ?? emptyFunction) 62 | 63 | if (this.expansion.isCaseSensitive) caseButton.setClass("turned-on") 64 | }) 65 | // Remove button 66 | .addExtraButton(removeButton => 67 | removeButton 68 | .setIcon("cross") 69 | .setTooltip("Remove") 70 | .onClick(opt.onRemove ?? emptyFunction) 71 | ) 72 | } 73 | 74 | public get getAbbreviation(): string { 75 | return this.abbreviation; 76 | } 77 | 78 | public get getExpansion(): Expansion { 79 | return this.expansion; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/abbreviation/AbbreviationExtention.ts: -------------------------------------------------------------------------------- 1 | import { SelectionRange, Transaction } from '@codemirror/state'; 2 | import { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'; 3 | 4 | 5 | import { 6 | AbbreviationLocation, 7 | AbbreviationPluginSettings, 8 | Expansion 9 | } from '../types'; 10 | 11 | import Devlogger from '../util/Devlogger'; 12 | 13 | export class AbbreviationExpanderPlugin implements PluginValue { 14 | @Devlogger() 15 | private readonly logger: Console; 16 | 17 | 18 | 19 | constructor( 20 | private view: EditorView, 21 | private settings: AbbreviationPluginSettings 22 | ) {} 23 | 24 | 25 | /** 26 | * Detects an abbreviation at the left of the cursor, returns its meaning and starting position 27 | * returns null if no abbreviation is found 28 | * 29 | * example (lets assume atm is in the list of abbreviations): 30 | * v cursor 31 | * > what are you doing atm| 32 | * ☝️ this will detect atm 33 | * 34 | * but not here: 35 | * v cursor 36 | * > what are you doing atm | 37 | * 38 | * @param document Document 39 | * @param position position of the cursor 40 | * @param abbreviations list of abbreviations 41 | * @returns {AbbreviationLocation | null} 42 | */ 43 | private detectAbbreviation( 44 | editorText: string, 45 | position: number, 46 | abbreviations: Record 47 | ): AbbreviationLocation | null { 48 | let wordStart = position; 49 | 50 | do { 51 | const previous = (wordStart - 1 < 0)? 0: wordStart - 1; 52 | if (/\s/.test(editorText.substring(previous, position))) break; 53 | 54 | --wordStart; 55 | } while (wordStart > 0); 56 | 57 | const word = editorText.substring(wordStart, position); 58 | const wordAbbrv = Object.keys(this.settings.abbreviations) 59 | .find(k => k.toLocaleLowerCase() === word.toLocaleLowerCase()) 60 | 61 | if (!wordAbbrv) return null; 62 | 63 | const wordEntry = abbreviations[wordAbbrv]; 64 | const sameCase = word === wordAbbrv; 65 | 66 | if (word !== "" && wordEntry?.value && (sameCase || !wordEntry.isCaseSensitive)) 67 | return { 68 | startingCharacter: wordStart, 69 | abbreviation: this.settings.abbreviations[wordAbbrv] 70 | }; 71 | 72 | return null; 73 | } 74 | 75 | private isCursor(selection: SelectionRange): boolean { 76 | return selection.from == selection.to; 77 | } 78 | 79 | private inputIsSpace(tr: Transaction): boolean { 80 | let hasSpaceInsert = false; 81 | tr.changes.iterChanges((_fromA, _toA, _fromB, _toB, insert) => { 82 | if (insert.length === 1 && insert.toString() === " ") { 83 | hasSpaceInsert = true; 84 | } 85 | }); 86 | return hasSpaceInsert; 87 | } 88 | 89 | update(update: ViewUpdate): void { 90 | const input = update.transactions.at(-1); 91 | 92 | // check if update is the typing of a single space 93 | if (input && input.isUserEvent("input.type") && this.inputIsSpace(input)) { 94 | const cursorPosition = update.view.state.selection.main.head; 95 | this.logger.log("Doc:", update.view.state.doc.toString()); 96 | this.logger.log("Selection:", update.view.state.wordAt(cursorPosition - 1)); 97 | 98 | 99 | const abbreviation = this.detectAbbreviation( 100 | update.view.state.doc.toString(), 101 | cursorPosition - 1, // we substract one to get the position of the word 102 | this.settings.abbreviations 103 | ); 104 | 105 | if (!abbreviation) return; 106 | 107 | this.logger.log('found abbreviation:', abbreviation.abbreviation.value); 108 | 109 | requestAnimationFrame(() => { // to defer the dispatch to the next frame 110 | update.view.dispatch({ 111 | changes: { 112 | from: abbreviation.startingCharacter, 113 | to: cursorPosition, 114 | insert: abbreviation.abbreviation.value + " ", 115 | }, 116 | }); 117 | }) 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ui/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | PluginSettingTab, 4 | Setting, 5 | Notice 6 | } from 'obsidian'; 7 | 8 | import { ExpansionEntrySetting } from '../ui/ExpansionEntrySetting'; 9 | import { ConfirmationModal } from '../ui/ConfirmationModal'; 10 | 11 | import AbbreviationPlugin from '../main'; 12 | 13 | import { Expansion } from '../types'; 14 | 15 | export default class AbbreviationSettingTab extends PluginSettingTab { 16 | plugin: AbbreviationPlugin; 17 | 18 | constructor(app: App, plugin: AbbreviationPlugin) { 19 | super(app, plugin); 20 | } 21 | 22 | display(): void { 23 | const {containerEl} = this; 24 | 25 | containerEl.empty(); 26 | 27 | new Setting(containerEl) 28 | .setName('Abbreviations') 29 | .setDesc('Add abbreviations to be replaced in your notes') 30 | .addButton(addButton => 31 | addButton 32 | .setIcon('plus') 33 | .onClick(async () => { 34 | if ("" in this.plugin.settings.abbreviations) return; 35 | 36 | this.plugin.settings.abbreviations[''] = { 37 | value: '', 38 | isEnabled: true, 39 | position: 0 40 | }; 41 | 42 | const offsetted: Record = Object.fromEntries( 43 | Object.entries(this.plugin.settings.abbreviations).map( 44 | ([abbreviation, expansion]) => { 45 | return [abbreviation, { 46 | ...expansion, 47 | position: !abbreviation? 48 | expansion.position: 49 | expansion.position + 1 50 | }] 51 | } 52 | )); 53 | 54 | this.plugin.settings.abbreviations = offsetted; 55 | 56 | await this.plugin.saveSettings() 57 | this.display(); 58 | }) 59 | ) 60 | .addExtraButton(resetButton => 61 | resetButton 62 | .setIcon('reset') 63 | .setTooltip('Reset to defaults') 64 | .onClick(async () => { 65 | new ConfirmationModal(this.app, () => { 66 | this.plugin.resetSettings() 67 | this.display(); 68 | }).open(); 69 | }) 70 | ); 71 | 72 | containerEl.createEl('p', { text: 'Here you can define your abbreviations.'}); 73 | const listingFeatEl = containerEl.createEl('ul'); 74 | listingFeatEl.createEl('li', { text: 'The toggle button at the left of each entry allows you to enable/disable the abbreviation.'}); 75 | listingFeatEl.createEl('li', { text: 'The first text field at the center is the abbreviation itself.'}); 76 | listingFeatEl.createEl('li', { text: 'The second text field is its meaning, which will appear on your documents.'}); 77 | listingFeatEl.createEl('li', { text: 'The button at the right of each entry allows you to set if your abbreviation is case sentitive.'}); 78 | listingFeatEl.createEl('li', { text: 'The last button at the far right of an entry allows you to remove it.'}); 79 | 80 | // Here goes all the abbreviations entries 81 | const listEl = containerEl.createEl("ul"); 82 | Object.entries(this.plugin.settings.abbreviations) 83 | .sort((a,b) => a[1].position - b[1].position) 84 | .forEach((entry) => { 85 | const [ abbreviation, expansion ] = entry; 86 | new ExpansionEntrySetting(listEl, { 87 | abbreviation, 88 | expansion, 89 | onRemove: async () => { 90 | delete this.plugin.settings.abbreviations[abbreviation]; 91 | 92 | // update positions 93 | const updated: Record = Object.fromEntries( 94 | Object.entries(this.plugin.settings.abbreviations) 95 | .filter(e => expansion.position < e[1].position) 96 | .map( 97 | ([abbreviation, expansion]) => { 98 | return [abbreviation, { 99 | ...expansion, 100 | position: !abbreviation? 101 | expansion.position: 102 | expansion.position - 1 103 | }] 104 | } 105 | ) 106 | ) 107 | 108 | this.plugin.settings.abbreviations = { 109 | ...this.plugin.settings.abbreviations, 110 | ...updated 111 | }; 112 | 113 | 114 | this.display(); 115 | await this.plugin.saveSettings() 116 | }, 117 | onAbbreviationEdit: async (newAbbreviation: string, oldAbbreviation: string) => { 118 | newAbbreviation = newAbbreviation.trim(); 119 | if (newAbbreviation in this.plugin.settings.abbreviations) { 120 | new Notice(`⚠️ Abbreviation ${newAbbreviation} already exists\nThis change will not be saved`); 121 | return; 122 | } 123 | 124 | 125 | this.plugin.settings.abbreviations[newAbbreviation] = this.plugin.settings.abbreviations[abbreviation]; 126 | delete this.plugin.settings.abbreviations[oldAbbreviation]; 127 | 128 | this.display(); 129 | await this.plugin.saveSettings() 130 | }, 131 | onExpansionEdit: async (newExpansion: string) => { 132 | newExpansion = newExpansion.trim(); 133 | this.plugin.settings.abbreviations[abbreviation].value = newExpansion; 134 | 135 | if (!newExpansion) { 136 | new Notice(`⚠️ Expansion cannot be empty\nThis change will be saved but not applied`); 137 | return; 138 | } 139 | 140 | await this.plugin.saveSettings() 141 | }, 142 | onDisable: async (isEnabled: boolean) => { 143 | this.plugin.settings.abbreviations[abbreviation].isEnabled = isEnabled; 144 | await this.plugin.saveSettings() 145 | this.display(); 146 | }, 147 | onCaseSensitiveChange: async () =>{ 148 | this.plugin.settings.abbreviations[abbreviation].isCaseSensitive = 149 | !this.plugin.settings.abbreviations[abbreviation].isCaseSensitive; 150 | 151 | await this.plugin.saveSettings() 152 | this.display(); 153 | }, 154 | }) 155 | }) 156 | } 157 | } 158 | --------------------------------------------------------------------------------