├── .npmrc ├── .eslintignore ├── versions.json ├── .env.dist ├── .gitignore ├── .editorconfig ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── package.json ├── LICENSE ├── src ├── styles.scss └── main.ts ├── assets └── snippet.css ├── esbuild.config.mjs └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "1.8.9" 3 | } 4 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | VAULT_PLUGIN_PATH=/path/to/your/.obsidian/plugins/your-plugin 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | .DS_Store 4 | .env 5 | *.iml 6 | *.map 7 | node_modules 8 | dist 9 | data.json 10 | -------------------------------------------------------------------------------- /.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 = 2 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "simple-table-math", 3 | "name": "Simple Table Math", 4 | "version": "0.2.4", 5 | "minAppVersion": "1.8.9", 6 | "description": "Do some math (sum, average, etc.) in your markdown tables.", 7 | "author": "Sandro Ducceschi", 8 | "authorUrl": "https://eatcodeplay.dev", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "allowSyntheticDefaultImports": 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 | "**/*.ts" 24 | ], 25 | "exclude": [ 26 | "node_modules" 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /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 | } 23 | } -------------------------------------------------------------------------------- /.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 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "20.x" 20 | 21 | - name: Build plugin 22 | run: | 23 | npm install 24 | npm run build 25 | 26 | - name: Create release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | tag="${GITHUB_REF#refs/tags/}" 31 | 32 | gh release create "$tag" \ 33 | --title="$tag" \ 34 | dist/main.js manifest.json dist/styles.css 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-table-math", 3 | "version": "0.2.4", 4 | "description": "Do some math (sum, average, etc.) in your markdown tables for Obsidian (https://obsidian.md).", 5 | "main": "src/main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "test": "tsc -noEmit -skipLibCheck", 9 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "engines": { 13 | "node": ">=20.x" 14 | }, 15 | "keywords": [], 16 | "author": "Sandro Ducceschi", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/lodash": "^4.17.16", 20 | "@types/node": "^16.11.6", 21 | "@types/numeral": "^2.0.5", 22 | "@typescript-eslint/eslint-plugin": "5.29.0", 23 | "@typescript-eslint/parser": "5.29.0", 24 | "builtin-modules": "3.3.0", 25 | "dotenv": "^16.5.0", 26 | "esbuild": "^0.25.5", 27 | "esbuild-sass-plugin": "^3.3.1", 28 | "obsidian": "latest", 29 | "tslib": "2.4.0", 30 | "typescript": "4.7.4" 31 | }, 32 | "dependencies": { 33 | "numeral": "^2.0.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sandro Ducceschi 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/styles.scss: -------------------------------------------------------------------------------- 1 | div.markdown-rendered table.table-editor { 2 | tr > td.stm-cell { 3 | &.stm-value, & > .stm-value { 4 | padding: var(--size-2-2) var(--size-4-2); // must be the same as the original .table-cell-wrapper 5 | font-weight: calc(var(--font-weight) + var(--bold-modifier)); 6 | 7 | & + .table-cell-wrapper { 8 | display: none; 9 | } 10 | } 11 | &:focus-within { 12 | & > .stm-value { 13 | display: none; 14 | } 15 | } 16 | &:not(:focus-within) { 17 | & > .stm-value ~ .table-cell-wrapper { 18 | padding: 0; 19 | opacity: 0; 20 | max-width: 0; 21 | max-height: 0; 22 | overflow: hidden; 23 | } 24 | } 25 | } 26 | tr.stm-row:last-of-type:not(.off) { 27 | background-color: var(--background-secondary); 28 | } 29 | } 30 | 31 | div.el-table > table { 32 | tr.stm-row > td.stm-cell { 33 | &.stm-value, & > .stm-value { 34 | font-weight: calc(var(--font-weight) + var(--bold-modifier)); 35 | } 36 | } 37 | tr.stm-row:last-of-type:not(.off) { 38 | background-color: var(--background-secondary); 39 | } 40 | } 41 | 42 | .notice > .notice-message > svg + .stm-notice { 43 | position: relative; 44 | top: -3px; 45 | margin-left: 8px; 46 | } 47 | -------------------------------------------------------------------------------- /assets/snippet.css: -------------------------------------------------------------------------------- 1 | /* ------------------------------- 2 | Source / Live View Styles 3 | ------------------------------- */ 4 | 5 | /* Styles the cells of a table with calculated values */ 6 | div.markdown-rendered table.table-editor tr > td.stm-cell.stm-value, 7 | div.markdown-rendered table.table-editor tr > td.stm-cell > .stm-value { 8 | padding: var(--size-2-2) var(--size-4-2); /* must be the same as the original .table-cell-wrapper */ 9 | font-weight: calc(var(--font-weight) + var(--bold-modifier)); 10 | } 11 | 12 | /* Styles the last row of a table with calculated values */ 13 | div.markdown-rendered table.table-editor tr.stm-row:last-of-type:not(.off) { 14 | background-color: var(--background-secondary); 15 | } 16 | 17 | /* ------------------------------- 18 | Reading View Styles 19 | ------------------------------- */ 20 | 21 | /* Styles the cells of a table with calculated values */ 22 | div.el-table > table tr.stm-row > td.stm-cell.stm-value, 23 | div.el-table > table tr.stm-row > td.stm-cell > .stm-value { 24 | font-weight: calc(var(--font-weight) + var(--bold-modifier)); 25 | } 26 | 27 | /* Styles the last row of a table with calculated values */ 28 | div.el-table > table tr.stm-row:last-of-type:not(.off) { 29 | background-color: var(--background-secondary); 30 | } 31 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | import { sassPlugin } from 'esbuild-sass-plugin'; 5 | import { cp, readFile } from 'node:fs/promises'; 6 | import 'dotenv/config'; 7 | 8 | const getBanner = async () => { 9 | const manifest = JSON.parse(await readFile('./manifest.json', 'utf-8')); 10 | return `/** 11 | * Simple Table Math v${manifest.version} 12 | * @author ${manifest.author} 13 | * @url ${manifest.authorUrl} 14 | */`; 15 | }; 16 | const prod = (process.argv[2] === 'production'); 17 | const vaultCopy = { 18 | name: 'vault-copy', 19 | setup(build) { 20 | build.onEnd(async () => { 21 | const vaultPluginPath = process.env.VAULT_PLUGIN_PATH; 22 | if (vaultPluginPath) { 23 | try { 24 | await Promise.all([ 25 | cp('dist/main.js', `${vaultPluginPath}/main.js`, { overwrite: true }), 26 | cp('manifest.json', `${vaultPluginPath}/manifest.json`, { overwrite: true }), 27 | cp('dist/styles.css', `${vaultPluginPath}/styles.css`, { overwrite: true }), 28 | ]); 29 | } catch (copyError) { 30 | console.error('Error copying files:', copyError); 31 | } 32 | } 33 | }); 34 | }, 35 | }; 36 | 37 | const context = await esbuild.context({ 38 | banner: { 39 | js: await getBanner(), 40 | }, 41 | entryPoints: [ 42 | 'src/main.ts', 43 | 'src/styles.scss', 44 | ], 45 | bundle: true, 46 | external: [ 47 | 'obsidian', 48 | 'electron', 49 | '@codemirror/autocomplete', 50 | '@codemirror/collab', 51 | '@codemirror/commands', 52 | '@codemirror/language', 53 | '@codemirror/lint', 54 | '@codemirror/search', 55 | '@codemirror/state', 56 | '@codemirror/view', 57 | '@lezer/common', 58 | '@lezer/highlight', 59 | '@lezer/lr', 60 | ...builtins], 61 | format: 'cjs', 62 | target: 'es2018', 63 | logLevel: 'info', 64 | sourcemap: prod ? false : 'inline', 65 | treeShaking: true, 66 | outdir: 'dist', 67 | plugins: [sassPlugin(), vaultCopy], 68 | minify: prod, 69 | }); 70 | 71 | if (prod) { 72 | await context.rebuild(); 73 | process.exit(0); 74 | } else { 75 | await context.watch(); 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Table Math 2 | 3 | A plugin for Obsidian that performs mathematical operations on Markdown tables. 4 | It dynamically calculates and displays results within your tables as you edit them. 5 | You can also copy the results to your clipboard, either via a keyboard shortcut or via the context menu. 6 | 7 | https://github.com/user-attachments/assets/af3b295f-5bbd-497f-b507-696e9fcbb690 8 | 9 | ## How to Use 10 | 11 | The plugin allows you to perform calculations (sum, average, minimum, maximum, subtraction, multiplication) on columns or rows of numbers within your Markdown tables. 12 | To trigger a calculation, place a special tag within a table cell. 13 | 14 | The tag follows this format: `[operation][direction][start:end][currency]` 15 | 16 | * **`[operation]`**: A three-letter code indicating the operation: 17 | * `SUM`: Calculates the sum of the values. 18 | * `AVG`: Calculates the average of the values. 19 | * `MIN`: Finds the minimum value. 20 | * `MAX`: Finds the maximum value. 21 | * `SUB`: Subtracts the subsequent values from the first value. 22 | * `MUL`: Multiplies all the values together. 23 | * **`[direction]`**: Indicates the direction of the values to operate on: 24 | * `^`: Looks at the cells above the current cell in the same column. 25 | * `<`: Looks at the cells to the left of the current cell in the same row. 26 | * **`[start:end]`** (Optional): Specifies a range of cells to include in the calculation. 27 | * If omitted, it defaults to all applicable cells in the specified direction. 28 | * Use a colon-separated format (e.g., `1:3` for the first three cells). The indices are 1-based. 29 | * You can also just specify a start (e.g., `2`). 30 | * **`[currency]`** (Optional): A 2-4 letter currency code (e.g., `USD`, `EUR`, `GBP`). If provided, the result will be formatted with the specified currency symbol. 31 | * **`[format]`** (Optional): Format options for input and output. 32 | * #e[n] Only accepts inputs written in scientific notations, and outputs results in scientific notation with n fraction digits. 33 | 34 | ## Examples 35 | 36 | **A simple summing example:** 37 | ``` 38 | | Just Numbers | 39 | | -----------: | 40 | | 100.00 | 41 | | 50.00 | 42 | | 25.00 | 43 | | 12.50 | 44 | | SUM^ | 45 | ``` 46 | 47 | **Calculating the average and sum of columns:** 48 | ``` 49 | | Name | Jan | Feb | Mar | Total | 50 | |:----------- |:---- |:---- |:---- |:----- | 51 | | Alice | 10 | 20 | 15 | SUM< | 52 | | Bob | 5 | 12 | 8 | SUM< | 53 | | **Average** | AVG^ | AVG^ | AVG^ | | 54 | ``` 55 | 56 | **Combining operations and displaying currency symbols:** 57 | 58 | ``` 59 | | Item | Price | Quantity | Total | 60 | | :---------- | :----: | :------: | ---------: | 61 | | Apple | $ 1.00 | 5 | MUL `Community plugins`. 123 | 3. Make sure `Safe mode` is off. 124 | 4. Click `Browse` and search for "Simple Table Math". 125 | 5. Click `Install` and then `Enable` the plugin. 126 | 127 | ### Manually: 128 | 129 | You can install it either by using [BRAT](https://obsidian.md/plugins?id=obsidian42-brat) or manually by following the instructions below: 130 | 131 | 1. Download the latest release from the [Releases](https://github.com/eatcodeplay/obsidian-simple-table-math/releases) page. 132 | 2. Extract the downloaded ZIP content into a new folder in your Obsidian vault's plugins folder (e.g., `/.obsidian/plugins/simple-table-math`). 133 | 3. **Note:** On some operating systems, the `.obsidian` folder might be hidden. Make sure to show hidden files in your file explorer. 134 | 4. Open Obsidian. 135 | 5. Go to `Settings` -> `Community plugins`. 136 | 6. Make sure `Safe mode` is off. 137 | 7. Find "Simple Table Math" in the list and enable it. 138 | 139 | ## Contributing 140 | 141 | If you find any issues or have suggestions for improvements, feel free to open an issue or submit a pull request on the [GitHub repository](https://github.com/eatcodeplay/obsidian-simple-table-math/). 142 | 143 | ## License 144 | 145 | [MIT License](LICENSE) 146 | 147 | --- 148 | 149 | **Enjoy doing math in your Obsidian tables!** 150 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | debounce, 4 | getIcon, 5 | getLanguage, 6 | KeymapEventHandler, 7 | MarkdownView, 8 | Menu, 9 | MenuItem, 10 | Notice, Platform, 11 | Plugin, 12 | PluginSettingTab, 13 | sanitizeHTMLToDom, 14 | Setting, 15 | } from 'obsidian'; 16 | import numeral from 'numeral'; 17 | 18 | //---------------------------------- 19 | // Interfaces 20 | //---------------------------------- 21 | interface SimpleTableMathSettings { 22 | fractions: number; 23 | locale: string | null; 24 | styleLastRow: boolean; 25 | } 26 | 27 | const DEFAULT_SETTINGS: SimpleTableMathSettings = { 28 | fractions: 2, 29 | locale: null, 30 | styleLastRow: true, 31 | } 32 | 33 | /** 34 | * A plugin that performs mathematical operations on Markdown tables in Obsidian. 35 | * The plugin actively listens to user interactions and processes table data accordingly. 36 | */ 37 | export default class SimpleTableMath extends Plugin { 38 | //--------------------------------------------------- 39 | // 40 | // Variables 41 | // 42 | //--------------------------------------------------- 43 | settings: SimpleTableMathSettings; 44 | 45 | keymapHandlers: KeymapEventHandler[] = []; 46 | debouncedProcessing: () => void; 47 | preventProcessing: boolean = false; 48 | forceProcessing: boolean = false; 49 | 50 | //--------------------------------------------------- 51 | // 52 | // Plugin Lifecycle 53 | // 54 | //--------------------------------------------------- 55 | async onload() { 56 | const app: App = this.app; 57 | const workspace = app.workspace; 58 | 59 | await this.loadSettings(); 60 | this.addSettingTab(new SettingTab(this.app, this)); 61 | 62 | this.debouncedProcessing = debounce(this.process.bind(this), 250); 63 | 64 | workspace.onLayoutReady(() => { 65 | this.registerEvent(workspace.on('layout-change', this.debouncedProcessing)); 66 | this.registerEvent(workspace.on('editor-change', this.debouncedProcessing)); 67 | this.registerEvent(workspace.on('editor-menu', this.handleEditorMenuEvent.bind(this))); 68 | 69 | this.keymapHandlers = [ 70 | app.scope.register(null, 'Tab', this.debouncedProcessing), 71 | app.scope.register(null, 'ArrowLeft', this.debouncedProcessing), 72 | app.scope.register(null, 'ArrowUp', this.debouncedProcessing), 73 | app.scope.register(null, 'ArrowRight', this.debouncedProcessing), 74 | app.scope.register(null, 'ArrowDown', this.debouncedProcessing), 75 | ] 76 | 77 | this.registerDomEvent(document, 'click', this.debouncedProcessing); 78 | this.registerDomEvent(document, 'keydown', this.handleKeyDownEvent.bind(this)); 79 | }); 80 | 81 | this.registerEvent(workspace.on('file-open', () => { 82 | this.forceProcessing = true; 83 | this.debouncedProcessing(); 84 | })); 85 | } 86 | 87 | onunload() { 88 | this.keymapHandlers.forEach((handler) => { 89 | this.app.scope.unregister(handler); 90 | }); 91 | } 92 | 93 | //--------------------------------------------------- 94 | // 95 | // Methods 96 | // 97 | //--------------------------------------------------- 98 | 99 | //---------------------------------- 100 | // Table Processing 101 | //---------------------------------- 102 | process() { 103 | if (!this.preventProcessing) { 104 | this.preventProcessing = true; 105 | 106 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 107 | const viewMode = activeView?.getMode() || null; 108 | const isReadingMode = viewMode === 'preview'; 109 | 110 | let tables: HTMLTableElement[] = []; 111 | const tableSelector = isReadingMode ? 'div.el-table > table' : 'div.markdown-rendered table.table-editor'; 112 | if (isReadingMode || this.forceProcessing) { 113 | tables = Array.from(document.querySelectorAll(tableSelector)) || []; 114 | this.forceProcessing = false; 115 | } 116 | const table = document.activeElement?.closest(tableSelector); 117 | if (table) { 118 | this.forceProcessing = true; 119 | tables = [table] as HTMLTableElement[]; 120 | } 121 | 122 | const rowClasses = [ 123 | 'stm-row', 124 | ...(this.settings.styleLastRow ? [] : ['off']), 125 | ]; 126 | 127 | const defaultNumRegex = /-?\d+(?:[.,'’`\u202f]\d{3})*(?:[.,]\d+)?/; 128 | const exponentialRegex = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/; 129 | 130 | tables.forEach((table) => { 131 | const rows = Array.from(table.querySelectorAll('tr')); 132 | rows.forEach((row, rowIndex) => { 133 | const cells = Array.from(row.children) as HTMLTableCellElement[]; 134 | cells.forEach((cell, colIndex) => { 135 | const rawText = this.extractCellContent(cell).trim().toLowerCase() || ''; 136 | const match = rawText.match(/^([a-z]{3})([<^])(?:(\d+)(?::(\d+))?)?([a-z]{2,4})?(?:\#e(\d+))?$/i); 137 | const isActiveElement = this.isDocumentActiveElementChildOf(cell) 138 | if (match && !isActiveElement) { 139 | const operation = match[1].toLowerCase(); 140 | const direction = match[2]; 141 | const startStr = match[3]; 142 | const endStr = match[4]; 143 | const currency = match[5]?.toUpperCase() || null; 144 | const exponential = match[6] ? parseInt(match[6], 10) : 0; 145 | const numRegex = exponential ? exponentialRegex : defaultNumRegex; 146 | const values: number[] = []; 147 | 148 | let startIndex = startStr ? parseInt(startStr, 10) - 1 : 0; 149 | if (isNaN(startIndex)) { 150 | startIndex = 0; 151 | } 152 | 153 | let endIndex = endStr ? parseInt(endStr, 10) - 1 : -1; 154 | if (isNaN(endIndex)) { 155 | endIndex = -1; 156 | } 157 | 158 | if (direction === '^') { 159 | const actualStartRow = Math.max(0, startIndex); 160 | const actualEndRow = endIndex !== -1 ? endIndex : rowIndex - 1; 161 | const finalEndRow = Math.min(actualEndRow, rowIndex - 1); 162 | if (actualStartRow <= finalEndRow) { 163 | for (let r = actualStartRow; r <= finalEndRow; r++) { 164 | const aboveCell = rows[r]?.children?.[colIndex] as HTMLTableCellElement | undefined | null; 165 | const textContent = this.extractCellContent(aboveCell, true); 166 | const value = this.extractNumber(textContent, numRegex); 167 | if (value !== null) { 168 | values.push(value); 169 | } 170 | } 171 | } 172 | } else if (direction === '<') { 173 | const actualStartCol = Math.max(0, startIndex); 174 | const actualEndCol = endIndex !== -1 ? endIndex : colIndex - 1; 175 | const finalEndCol = Math.min(actualEndCol, colIndex - 1); 176 | if (actualStartCol <= finalEndCol) { 177 | for (let c = actualStartCol; c <= finalEndCol; c++) { 178 | const leftCell = cells[c] as HTMLTableCellElement | undefined | null; 179 | const textContent = this.extractCellContent(leftCell, true); 180 | const value = this.extractNumber(textContent, numRegex); 181 | if (value !== null) { 182 | values.push(value); 183 | } 184 | } 185 | } 186 | } 187 | 188 | let result: number | null = null; 189 | if (operation === 'sum') { 190 | result = values.reduce((a, b) => a + b, 0); 191 | } else if (operation === 'avg' && values.length > 0) { 192 | result = values.reduce((a, b) => a + b, 0) / values.length; 193 | } else if (operation === 'min') { 194 | result = values.length > 0 ? Math.min(...values) : 0; 195 | } else if (operation === 'max') { 196 | result = values.length > 0 ? Math.max(...values) : 0; 197 | } else if (operation === 'sub') { 198 | result = values.length > 0 ? values.reduce((a, b) => a - b) : 0; 199 | } else if (operation === 'mul') { 200 | result = values.length > 1 ? values.reduce((a, b) => a * b, 1) : 0; 201 | } else if (operation === 'div' && values.length > 0) { 202 | result = values[0]; 203 | if (values.length > 1) { 204 | for (let i = 1; i < values.length; i++) { 205 | result = result / values[i]; 206 | } 207 | } 208 | } 209 | 210 | if (result !== null) { 211 | let vElement = cell.querySelector('div.stm-value') as HTMLElement | null; 212 | if (isReadingMode) { 213 | vElement = cell; 214 | cell.classList.add('stm-value'); 215 | } 216 | if (!vElement) { 217 | vElement = document.createElement('div'); 218 | vElement.classList.add('stm-value'); 219 | cell.prepend(vElement); 220 | } 221 | 222 | if (vElement) { 223 | cell.classList.add('stm-cell'); 224 | cell.tabIndex = -1; 225 | cell.closest('tr')?.classList.add(...rowClasses); 226 | const defaultLocale = getLanguage(); 227 | if (exponential) { 228 | vElement.textContent = result.toExponential(exponential) 229 | } else { 230 | vElement.textContent = result.toLocaleString(this.settings.locale || defaultLocale, { 231 | style: currency ? 'currency' : 'decimal', 232 | currency: currency || undefined, 233 | minimumFractionDigits: this.settings.fractions, 234 | maximumFractionDigits: this.settings.fractions, 235 | }) 236 | }; 237 | } 238 | } 239 | } else if (!isActiveElement && cell.classList.contains('stm-cell')) { 240 | let vElement = cell.querySelector('div.stm-value') as HTMLElement | null; 241 | if (vElement) { 242 | cell.removeChild(vElement); 243 | const row = cell.closest('tr'); 244 | if (row) { 245 | const values = row.querySelectorAll('.stm-value'); 246 | if (values.length === 0) { 247 | row.classList.remove(...rowClasses); 248 | } 249 | } 250 | } 251 | } 252 | }); 253 | }); 254 | }); 255 | } 256 | this.preventProcessing = false; 257 | } 258 | 259 | //---------------------------------- 260 | // Settings Methods 261 | //---------------------------------- 262 | async loadSettings() { 263 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 264 | } 265 | 266 | async saveSettings() { 267 | await this.saveData(this.settings); 268 | const trs = document.querySelectorAll('tr.stm-row'); 269 | trs.forEach((tr) => { 270 | if (this.settings.styleLastRow) { 271 | tr.classList.remove('off'); 272 | } else { 273 | tr.classList.add('off'); 274 | } 275 | }) 276 | } 277 | 278 | //---------------------------------- 279 | // UI Methods 280 | //---------------------------------- 281 | showNotice(message: string, icon: string | null = null, duration: number = 1000) { 282 | const fragment = sanitizeHTMLToDom(`${message}`); 283 | if (icon) { 284 | const svg = getIcon(icon); 285 | if (svg) { 286 | fragment.prepend(svg); 287 | } 288 | } 289 | new Notice(fragment, duration); 290 | } 291 | 292 | //---------------------------------- 293 | // Event Handlers 294 | //---------------------------------- 295 | handleEditorMenuEvent(menu: Menu) { 296 | const cell = document.activeElement?.closest('td.stm-cell'); 297 | const value = cell?.querySelector('.stm-value') as HTMLElement | null; 298 | if (value) { 299 | menu.addItem((item: MenuItem) => { 300 | item 301 | .setTitle('Copy calculated value') 302 | .setIcon('square-equal') 303 | .onClick(async () => { 304 | navigator.clipboard.writeText(value.textContent || ''); 305 | this.showNotice('Copied!', 'copy-check'); 306 | }); 307 | }); 308 | } 309 | } 310 | 311 | handleKeyDownEvent(evt: KeyboardEvent) { 312 | if (this.isCopyShortcut(evt)) { 313 | this.process(); 314 | const cell = document.activeElement?.closest('td.stm-cell, th.stm-cell'); 315 | if (cell) { 316 | const value = cell.querySelector('.stm-value') as HTMLElement | null; 317 | if (value) { 318 | setTimeout(() => { 319 | navigator.clipboard.writeText(value.textContent || ''); 320 | this.showNotice('Copied!', 'copy-check'); 321 | }, 25); 322 | } 323 | } 324 | } 325 | } 326 | 327 | //---------------------------------- 328 | // Helper Methods 329 | //---------------------------------- 330 | extractCellContent(cell: HTMLTableCellElement | null | undefined, treatAsValue: boolean = false): string { 331 | if (!cell) { 332 | return ''; 333 | } 334 | let element = cell.querySelector('.table-cell-wrapper') as HTMLElement | null; 335 | if (treatAsValue) { 336 | let valueElement = cell.querySelector('.stm-value') as HTMLElement | null; 337 | if (valueElement) { 338 | element = valueElement; 339 | } 340 | } 341 | const wrapper = element || cell; 342 | return wrapper?.textContent || ''; 343 | } 344 | 345 | extractNumber(str: string | null, numRegex: RegExp): number | null{ 346 | if (!str) { 347 | return null; 348 | } 349 | const match = str.match(numRegex); 350 | if (!match) { 351 | return null; 352 | } 353 | 354 | let numStr = match[0].replace(/['’`\u202f]/g, ''); 355 | return numeral(numStr).value(); 356 | } 357 | 358 | isDocumentActiveElementChildOf(parentNode: HTMLElement): boolean { 359 | if (!document.activeElement || !parentNode) { 360 | return false; 361 | } 362 | return parentNode.contains(document.activeElement); 363 | } 364 | 365 | isCopyShortcut(evt: KeyboardEvent) { 366 | if (Platform.isMacOS) { 367 | return evt.metaKey && evt.key === 'c' && !evt.altKey && !evt.shiftKey; 368 | } 369 | return evt.ctrlKey && evt.key === 'c' && !evt.altKey && !evt.shiftKey; 370 | } 371 | } 372 | 373 | /** 374 | * Represents the settings tab for configuring the SimpleTableMath plugin. 375 | * 376 | * This class provides a user interface within the plugin settings to allow 377 | * users to customize specific plugin options such as the number of decimal places 378 | * displayed and locale-based number formatting. 379 | */ 380 | class SettingTab extends PluginSettingTab { 381 | plugin: SimpleTableMath; 382 | 383 | constructor(app: App, plugin: SimpleTableMath) { 384 | super(app, plugin); 385 | this.plugin = plugin; 386 | } 387 | 388 | display(): void { 389 | const {containerEl} = this; 390 | 391 | containerEl.empty(); 392 | 393 | new Setting(containerEl) 394 | .setName('Fractions') 395 | .setDesc('Maximum number of decimal places to display.') 396 | .addText(text => text 397 | .setPlaceholder('Enter a number') 398 | .setValue(this.plugin.settings.fractions.toString()) 399 | .onChange(async (value) => { 400 | this.plugin.settings.fractions = parseInt(value, 10) || 0; 401 | await this.plugin.saveSettings(); 402 | })); 403 | 404 | const language = getLanguage(); 405 | new Setting(containerEl) 406 | .setName('Number formatting') 407 | .setDesc(sanitizeHTMLToDom(`Enter a locale code (language tag like en, en-US, or de-CH) to customize how numbers are formatted, (e.g., decimal separators, thousands separators).

If left empty, the current Obsidian app language ("${language}") will be used for number formatting.
Learn more about language codes`)) 408 | .addText(text => text 409 | .setPlaceholder(`e.g.: en, en-US, de-CH`) // Using the clearer example format 410 | .setValue(this.plugin.settings.locale || '') 411 | .onChange(async (value) => { 412 | this.plugin.settings.locale = (value === '') ? null : value; 413 | await this.plugin.saveSettings(); 414 | })); 415 | 416 | new Setting(containerEl) 417 | .setName('Highlight last row calculations') 418 | .setDesc(sanitizeHTMLToDom(`Enable styling for the last row in tables that contain calculations.
Learn more about styling the results`)) 419 | .addToggle(component => component 420 | .setValue(this.plugin.settings.styleLastRow) 421 | .onChange(async (value) => { 422 | this.plugin.settings.styleLastRow = value; 423 | await this.plugin.saveSettings(); 424 | })); 425 | } 426 | } 427 | --------------------------------------------------------------------------------