├── .gitignore ├── .vscodeignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── CHANGELOG.md ├── src ├── extension.ts ├── currency.ts ├── document.ts ├── math.ts └── decorator.ts ├── .github └── workflows │ ├── publish.yml │ └── build.yml ├── .eslintrc.json ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | .gitignore 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/.eslintrc.json 9 | **/*.map 10 | **/*.ts 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "mathpad" extension will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | ## [Unreleased] 8 | 9 | - Initial release -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import EditorDecorator from './decorator'; 3 | 4 | export function activate(context: vscode.ExtensionContext) { 5 | let decorator = new EditorDecorator(context); 6 | context.subscriptions.push(decorator); 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - run: npm install 13 | 14 | - name: Publish to Marketplace 15 | run: | 16 | sudo npm install -g vsce 17 | vsce publish -p ${{ secrets.AZURE_DEVOPS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/class-name-casing": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - run: npm install 14 | 15 | - name: Build VSIX package 16 | run: | 17 | sudo npm install -g vsce 18 | vsce package -o mathpad.vsix 19 | 20 | - name: Upload VSIX package 21 | uses: actions/upload-artifact@v2 22 | with: 23 | name: mathpad-${{ github.sha }}.vsix 24 | path: mathpad.vsix 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "${defaultBuildTask}" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/currency.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { ExtensionContext } from 'vscode'; 3 | 4 | // 7 days 5 | const dataTtl = 7 * 24 * 60 * 60 * 1000; 6 | 7 | export interface ExchangeData { 8 | readonly base: string, 9 | readonly date?: string, 10 | readonly rates: { 11 | [currency: string]: number, 12 | }, 13 | } 14 | 15 | export async function getExchangeRates(ctx: ExtensionContext): Promise { 16 | let data = ctx.globalState.get("exchangeRates"); 17 | 18 | if (!data || (data.date && Date.now() - new Date(data.date).getTime() > dataTtl)) { 19 | console.log("Fetching latest currency exchange rates..."); 20 | 21 | try { 22 | let response = await axios.get("https://api.exchangeratesapi.io/latest"); 23 | data = response.data; 24 | await ctx.globalState.update("exchangeRates", data); 25 | } catch (error) { 26 | console.log("Error fetching currency exchange info.", error); 27 | } 28 | } 29 | 30 | return data ?? { 31 | base: "EUR", 32 | rates: {}, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stephen M. Coakley 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mathpad", 3 | "displayName": "Mathpad", 4 | "description": "Interactive scratchpad calculator.", 5 | "publisher": "sagebind", 6 | "version": "0.0.2", 7 | "license": "MIT", 8 | "engines": { 9 | "vscode": "^1.44.0" 10 | }, 11 | "keywords": [ 12 | "calc", 13 | "calculator", 14 | "math" 15 | ], 16 | "categories": [ 17 | "Other" 18 | ], 19 | "homepage": "https://github.com/sagebind/mathpad", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/sagebind/mathpad.git" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/sagebind/mathpad/issues" 26 | }, 27 | "extensionKind": [ 28 | "ui", 29 | "workspace" 30 | ], 31 | "activationEvents": [ 32 | "onLanguage:markdown" 33 | ], 34 | "main": "./out/extension.js", 35 | "scripts": { 36 | "vscode:prepublish": "npm run compile", 37 | "compile": "tsc -p ./", 38 | "lint": "eslint src --ext ts", 39 | "watch": "tsc -watch -p ./", 40 | "pretest": "npm run compile && npm run lint" 41 | }, 42 | "dependencies": { 43 | "@types/mathjs": "^6.0.5", 44 | "axios": "^0.21.1", 45 | "mathjs": "^6.6.4" 46 | }, 47 | "devDependencies": { 48 | "@types/vscode": "^1.44.0", 49 | "@types/glob": "^7.1.1", 50 | "@types/mocha": "^7.0.2", 51 | "@types/node": "^13.11.0", 52 | "eslint": "^6.8.0", 53 | "@typescript-eslint/parser": "^2.30.0", 54 | "@typescript-eslint/eslint-plugin": "^2.30.0", 55 | "glob": "^7.1.6", 56 | "mocha": "^7.1.2", 57 | "typescript": "^3.8.3", 58 | "vscode-test": "^1.3.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/document.ts: -------------------------------------------------------------------------------- 1 | import { MathJsStatic } from 'mathjs'; 2 | import { TextDocument } from 'vscode'; 3 | import { defaultScope } from './math'; 4 | 5 | /** 6 | * A math-enabled text document. 7 | */ 8 | export default class MathDocument { 9 | document: TextDocument; 10 | results = new Map(); 11 | 12 | // Expression compiler cache. 13 | private compileCache = new Map(); 14 | 15 | constructor(document: TextDocument, private math: MathJsStatic) { 16 | this.document = document; 17 | } 18 | 19 | /** 20 | * Re-evaluate any math expressions in the document. 21 | */ 22 | evaluate() { 23 | this.results.clear(); 24 | let scope = defaultScope(); 25 | 26 | for (let lineNumber = 0; lineNumber < this.document.lineCount; lineNumber++) { 27 | const line = this.document.lineAt(lineNumber); 28 | 29 | if (!line.isEmptyOrWhitespace) { 30 | const trimmed = line.text.trim(); 31 | const compiled = this.compile(trimmed); 32 | 33 | if (compiled) { 34 | try { 35 | const result = compiled.evaluate(scope); 36 | scope["last"] = result; 37 | 38 | // Only display value results. 39 | if (typeof result !== "function" && typeof result !== "undefined") { 40 | this.results.set(lineNumber, result); 41 | } 42 | } catch (error) { 43 | // console.log(error); 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * Attempt to compile the given string as a math expression. 52 | * 53 | * @param text The math expression to compile. 54 | */ 55 | private compile(text: string): math.EvalFunction | null { 56 | let compiled = this.compileCache.get(text); 57 | 58 | if (!compiled) { 59 | try { 60 | compiled = this.math.compile(text); 61 | this.compileCache.set(text, compiled); 62 | } catch (error) { 63 | // console.log(error); 64 | return null; 65 | } 66 | } 67 | 68 | return compiled; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/math.ts: -------------------------------------------------------------------------------- 1 | import * as mathjs from 'mathjs'; 2 | import { getExchangeRates } from './currency'; 3 | import { ExtensionContext } from 'vscode'; 4 | 5 | /** 6 | * Create a customized math.js instance. 7 | */ 8 | export function create(ctx: ExtensionContext): mathjs.MathJsStatic { 9 | const math = mathjs.create(mathjs.all, {}) as mathjs.MathJsStatic; 10 | 11 | // Addition for dates. 12 | const addDate = math.factory('add', ['typed'], ({ typed }) => { 13 | // @ts-ignore 14 | return typed('add', { 15 | 'Date, Unit': function (a: Date, b: mathjs.Unit) { 16 | return new Date(a.getTime() + b.toNumber("ms")); 17 | }, 18 | 19 | 'Unit, Date': function (a: mathjs.Unit, b: Date) { 20 | return new Date(a.toNumber("ms") + b.getTime()); 21 | }, 22 | }); 23 | }); 24 | 25 | // Subtraction for dates. 26 | const subtractDate = math.factory('subtract', ['typed'], ({ typed }) => { 27 | // @ts-ignore 28 | return typed('subtract', { 29 | 'Date, Unit': function (a: Date, b: mathjs.Unit) { 30 | return new Date(a.getTime() - b.toNumber("ms")); 31 | }, 32 | 33 | 'Date, Date': function (a: Date, b: Date) { 34 | return math.unit(a.getTime() - b.getTime(), "ms").to("s"); 35 | }, 36 | }); 37 | }); 38 | 39 | math.import([ 40 | addDate, 41 | subtractDate, 42 | ], {}); 43 | 44 | getExchangeRates(ctx).then(data => { 45 | math.createUnit(data.base); 46 | let loaded = 1; 47 | 48 | Object.keys(data.rates) 49 | .filter(currency => currency !== data.base) 50 | .forEach(currency => { 51 | math.createUnit(currency, `${1 / data.rates[currency]} ${data.base}`); 52 | loaded += 1; 53 | }); 54 | 55 | console.log("Loaded definitions for %d currencies.", loaded); 56 | }); 57 | 58 | return math; 59 | } 60 | 61 | export function defaultScope(): any { 62 | return { 63 | today: today(), 64 | now: new Date(), 65 | }; 66 | } 67 | 68 | function today(): Date { 69 | let today = new Date(); 70 | today.setHours(0); 71 | today.setMinutes(0); 72 | today.setSeconds(0); 73 | today.setMilliseconds(0); 74 | 75 | return today; 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mathpad 2 | 3 | Mathpad is a small extension for [Visual Studio Code] that turns a Markdown editor into an interactive scratchpad calculator. 4 | 5 | [![License](https://img.shields.io/github/license/sagebind/mathpad)](LICENSE) 6 | [![Visual Studio Marketplace Version](https://img.shields.io/visual-studio-marketplace/v/sagebind.mathpad)](https://marketplace.visualstudio.com/items?itemName=sagebind.mathpad) 7 | ![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/sagebind.mathpad) 8 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/sagebind/mathpad/build)](https://github.com/sagebind/mathpad/actions) 9 | 10 | I created this because I couldn't find a good notebook calculator that I liked that 11 | 12 | - is free and open source, 13 | - supports all the platforms I care about, 14 | - works completely offline, 15 | - supports variables and functions, 16 | - allows you to edit anywhere (as opposed to REPL style), 17 | - and works in and saves as plain text. 18 | 19 | I already have [Visual Studio Code] installed on all my computers and use it for lots of text editing purposes, so I decided to make it also my calculator using this extension. 20 | 21 | ## Features 22 | 23 | - Math expressions on their own line inside a Markdown editor are evaluated automatically and their results are displayed in grey at the end of the line. 24 | - Variables can be defined on their own line like `x = 42` and referenced on any line below it. 25 | - Functions can be defined on their own line like `f(x) = 42 / x` and referenced on any line below it. 26 | - Changing a variable or function definition will automatically re-evaluate everywhere it is used in the rest of the document. 27 | 28 | Much more is available as math expressions, which are powered by [Math.js]. Check out the Math.js documentation for even more examples of what sorts of expressions can be evaluated. 29 | 30 | ## Future ideas 31 | 32 | There's a lot more neat ideas that _could_ be implemented, including: 33 | 34 | - Defining our own language/editor type with dedicated math syntax highlighting. 35 | - Evaluating expressions embedded in normal text. 36 | - Replacing an expression with its result. 37 | 38 | ## License 39 | 40 | This project's source code and documentation is licensed under the MIT license. See the [LICENSE](LICENSE) file for details. 41 | 42 | 43 | [Math.js]: https://mathjs.org 44 | [Visual Studio Code]: https://code.visualstudio.com 45 | -------------------------------------------------------------------------------- /src/decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DecorationOptions, 3 | DecorationRangeBehavior, 4 | Disposable, 5 | DocumentSelector, 6 | languages, 7 | TextDocument, 8 | TextEditor, 9 | ThemeColor, 10 | Uri, 11 | window, 12 | workspace, 13 | ExtensionContext, 14 | } from "vscode"; 15 | import MathDocument from "./document"; 16 | import { MathJsStatic } from 'mathjs'; 17 | import { create } from "./math"; 18 | 19 | const decorationType = window.createTextEditorDecorationType({ 20 | after: { 21 | color: new ThemeColor("editorCodeLens.foreground"), 22 | fontStyle: "normal", 23 | margin: "0 0 0 24em", 24 | }, 25 | isWholeLine: true, 26 | rangeBehavior: DecorationRangeBehavior.ClosedClosed, 27 | }); 28 | 29 | export default class EditorDecorator implements Disposable { 30 | documentSelector: DocumentSelector = [ 31 | "markdown" 32 | ]; 33 | private documents = new Map(); 34 | private disposables: Disposable[] = []; 35 | private math: MathJsStatic; 36 | 37 | constructor(private ctx: ExtensionContext) { 38 | this.math = create(ctx); 39 | 40 | // Handle editors being created and disposed, which we might be 41 | // interested in. 42 | this.disposables.push(window.onDidChangeVisibleTextEditors(() => { 43 | this.renderAll(); 44 | })); 45 | 46 | // An editor's language could change, so re-evaluate an editor when that 47 | // happens. 48 | this.disposables.push(window.onDidChangeTextEditorOptions(event => { 49 | this.renderEditor(event.textEditor); 50 | })); 51 | 52 | // Re-render when a math document is edited. 53 | this.disposables.push(workspace.onDidChangeTextDocument(event => { 54 | // If this document isn't math-enabled, then do nothing. 55 | if (!this.isMathEnabled(event.document)) { 56 | return; 57 | } 58 | 59 | // Find all editors for this document and re-render their math. 60 | window.visibleTextEditors.forEach(editor => { 61 | if (editor.document.uri === event.document.uri) { 62 | this.renderEditor(editor); 63 | } 64 | }); 65 | })); 66 | 67 | // Cleanup our math document data when a text document is closed. 68 | this.disposables.push(workspace.onDidCloseTextDocument(document => { 69 | this.documents.delete(document.uri); 70 | })); 71 | 72 | // Do a first-pass on initial load. 73 | this.renderAll(); 74 | } 75 | 76 | /** 77 | * Re-render all math decorations on all math-enabled editors. 78 | */ 79 | renderAll() { 80 | window.visibleTextEditors.forEach(editor => this.renderEditor(editor)); 81 | } 82 | 83 | /** 84 | * Re-render all math decorations on the given editor. 85 | */ 86 | renderEditor(editor: TextEditor) { 87 | let decorationsArray: DecorationOptions[] = []; 88 | 89 | if (this.isMathEnabled(editor.document)) { 90 | let document = this.getMathDocument(editor.document); 91 | 92 | document.evaluate(); 93 | 94 | document.results.forEach((value, lineNumber) => { 95 | decorationsArray.push({ 96 | range: document.document.lineAt(lineNumber).range, 97 | renderOptions: { 98 | after: { 99 | contentText: ` = ${this.format(value)}`, 100 | margin: "0 0 0 24em" 101 | } 102 | } 103 | }); 104 | }); 105 | } 106 | 107 | editor.setDecorations(decorationType, decorationsArray); 108 | } 109 | 110 | dispose() { 111 | this.disposables.forEach(d => d.dispose()); 112 | } 113 | 114 | private getMathDocument(document: TextDocument): MathDocument { 115 | let mathDocument = this.documents.get(document.uri); 116 | 117 | if (!mathDocument) { 118 | mathDocument = new MathDocument(document, this.math); 119 | this.documents.set(document.uri, mathDocument); 120 | } 121 | 122 | return mathDocument; 123 | } 124 | 125 | private isMathEnabled(document: TextDocument): boolean { 126 | return languages.match(this.documentSelector, document) > 0; 127 | } 128 | 129 | /** 130 | * Format a numeric result as a string for display. 131 | * 132 | * @param value Number to format 133 | */ 134 | private format(value: any): string { 135 | if (value instanceof Date) { 136 | if (value.getHours() || value.getMinutes() || value.getSeconds() || value.getMilliseconds()) { 137 | return value.toLocaleString(); 138 | } 139 | return value.toLocaleDateString(); 140 | } 141 | 142 | return this.math.format(value, number => { 143 | let s = this.math.format(number, { 144 | lowerExp: -9, 145 | upperExp: 15, 146 | }); 147 | 148 | // Add thousands separators if number is formatted as fixed. 149 | if (/^\d+(\.\d+)?$/.test(s)) { 150 | s = s.replace(/\B(?