├── .gitattributes ├── .gitignore ├── .hintrc ├── .pylintrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── Markdown input.code-workspace ├── README.md ├── Screenshots ├── first-use.gif ├── screen.png └── screenshots.png ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── config │ ├── cm.css │ ├── config.json │ └── mdi.css ├── gfx │ └── markdown.png ├── py │ ├── __init__.py │ ├── browser_sort_field.py │ ├── constants.py │ ├── field_input.py │ ├── utils.py │ ├── version.py │ ├── window.ui │ ├── window_input.py │ ├── window_qt5.py │ └── window_qt6.py └── ts │ ├── CodeMirror.extensions │ ├── ankiImagePaste.ts │ ├── cloze_decorator.ts │ ├── markdown_extensions.ts │ └── mdi_commands.ts │ ├── commands.ts │ ├── constants.ts │ ├── custom_input.ts │ ├── editor.ts │ ├── field_input.ts │ ├── field_input_2.1.55.ts │ ├── utils.ts │ └── window_input.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js linguist-detectable=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | dist 3 | Screenshots/*.xcf -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "typescript-config/strict": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | persistent = no 3 | extension-pkg-whitelist=PyQt6 4 | ignore = forms,hooks_gen.py 5 | init-hook = 'import sys; sys.path.append("$env:CODEPATH/Anki/anki/qt/", "$env:CODEPATH/Anki/anki/pylib/")' 6 | 7 | [TYPECHECK] 8 | ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio 9 | ignored-classes= 10 | BrowserColumns, 11 | BrowserRow, 12 | SearchNode, 13 | ConfigKey, 14 | OpChanges, 15 | UnburyDeckRequest, 16 | CardAnswer, 17 | QueuedCards, 18 | ChangeNotetypeRequest, 19 | CustomStudyRequest, 20 | Cram, 21 | ScheduleCardsAsNewRequest, 22 | CsvColumn, 23 | CsvMetadata, 24 | 25 | [REPORTS] 26 | output-format=colorized 27 | 28 | [MESSAGES CONTROL] 29 | disable=C,R, 30 | fixme, 31 | unused-wildcard-import, 32 | attribute-defined-outside-init, 33 | redefined-builtin, 34 | wildcard-import, 35 | broad-except, 36 | bare-except, 37 | unused-argument, 38 | unused-variable, 39 | redefined-outer-name, 40 | global-statement, 41 | protected-access, 42 | arguments-differ, 43 | arguments-renamed, 44 | missing-function-docstring, 45 | missing-module-docstring, 46 | trailing-whitespace, 47 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Detailed docs: 3 | // https://code.visualstudio.com/docs/nodejs/nodejs-debugging 4 | "version": "2.0.0", 5 | "configurations": [ 6 | { 7 | "name": "Debug typescript", 8 | "type": "node", 9 | "request": "launch", 10 | "smartStep": true, 11 | "sourceMaps": true, 12 | "args": [ 13 | "${workspaceRoot}/src/ts/.test/.test-converter.ts" 14 | ], 15 | "runtimeArgs": [ 16 | // "--files" -- Don't use => Use environment variable TS_NODE_FILES instead 17 | //"--conditions", "development", // micromark debug 18 | "-r", 19 | "ts-node/register/transpile-only", 20 | "--loader", 21 | "ts-node/esm", 22 | "--experimental-specifier-resolution=node" 23 | ], 24 | "cwd": "${workspaceRoot}", 25 | "internalConsoleOptions": "openOnSessionStart", 26 | "env": { 27 | "TS_NODE_FILES": "true", // Respect include/exclude in tsconfig.json => will read declaration files (ts-node --files src\index.ts) 28 | "DEBUG": "*" // micromark debug log 29 | }, 30 | "skipFiles": [ 31 | "/**", 32 | //"${workspaceRoot}/node_modules/**", 33 | ], 34 | "outputCapture": "std", 35 | "stopOnEntry": false 36 | } 37 | ], 38 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 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 | -------------------------------------------------------------------------------- /Markdown input.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | //"typescript.tsdk": "${workspaceFolder}/src/ts/node_modules/typescript/lib" 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown input 2 | 3 | Anki ([GitHub](https://github.com/ankitects/anki)) addon ([GitHub](https://github.com/TRIAEIOU/Markdown-input)) that allows adding and editing notes in extended [CommonMark](https://spec.commonmark.org/) [Markdown](https://daringfireball.net/projects/markdown/), either directly in the editor fields ("field input mode", similar to the core rich and plain text edit interface) or by opening a separate window to edit a specific field or the entire note ("window input mode"). 4 | 5 | ![Markdown input](https://github.com/TRIAEIOU/Markdown-input/raw/main/Screenshots/screenshots.png?raw=true) 6 | 7 | ![First use](https://github.com/TRIAEIOU/Markdown-input/raw/main/Screenshots/first-use.gif?raw=true) 8 | 9 | ## Anki version note (2.1.56+) 10 | 11 | The editor DOM and internal functioning which `Markdown input` depends on changed in Anki version 2.1.56. The current version of `Markdown input` ships with both 2.1.56+ compatible code as well as the last [release](https://github.com/TRIAEIOU/Markdown-input/releases/tag/v1.2.5) targeted at 2.1.55. Going forward no updates/fixes will be made to the legacy code, any development/bug fixes will be in the 2.1.56+ code. 12 | 13 | ## Warning 14 | 15 | - Markdown is not "another way to write HTML", it is a plain text format that has a determined translation to HTML (the format the Anki editor uses). The HTML generated is a subset of all HTML and, amongst other things, makes heavy use of `

` tags (which are not used by the Anki editor). Furthermore there is no spec on the conversion from HTML *to* Markdown. This makes conversion tricky and there is risk for loss of information when cycling HTML → Markdown → HTML. 16 | - Editing a field in Markdown will result in the original field HTML being converted to Markdown and then back to HTML - the end result may differ from the original (especially in case of complex HTML). For instance, the representation of tables does not allow for nested tables in Markdown. So if the original HTML has nested tables these will be lost on cycling. If you are not familiar with Markdown consider duplicating your deck and play around with a copy so that you are sure you know what you are doing. 17 | - Note however, that if you do not make any changes in the "field input mode" or cancel the "window input mode" the orginal Anki HTML will remain untouched when you toggle back. Also note that in field input mode making a change and then undoing will still count as "making a change" (changes update the HTML continuously). 18 | - If you are not familiar with Markdown look it up where it [began](https://daringfireball.net/projects/markdown/basics) or [here](https://commonmark.org/help/tutorial/) or [here](https://commonmark.org/help/) for instance, to determine if it is for you. 19 | 20 | ## HTML ↔ Markdown 21 | 22 | Conversion to/from HTML is done through [unified](https://unifiedjs.com/) Markdown functions `hast-util-from/to-html` `hast-util-to-mdast`/`mdast-util-to-hast` and `mdast-util-to/from-markdown` which are [CommonMark](https://spec.commonmark.org/) compliant. The following changes/extensions has been made in the addon: 23 | 24 | - Markdown uses `

` tags to mark paragraphs, these are replaced with `
` tags instead to match the Anki editor. 25 | - Markdown has a concept of lists being ["tight" or "loose"](https://spec.commonmark.org/0.30/#loose) which results in list items being wrapped in `

` tags or not. This has been replaced with HTML `.markdown-tight` or `.markdown-loose` classes to allow styling, example: 26 | 27 | ``` CSS 28 | ul.markdown-loose > li, ol.markdown-loose > li {padding-top: 10px; padding-bottom: 10px;} 29 | ul.markdown-tight > li, ol.markdown-tight > li {padding: 0px} 30 | ``` 31 | 32 | - Spec `sample` (`*sample*`) and `sample` (`**sample**`) are swapped to `` and `` to match the Anki editor. 33 | - Markdown syntax extensions (set to `true` or `"some vale"` to enable, `false` to disable): 34 | - `Subscript`: `true` for `~sample~` subscript 35 | - `Superscript`: `true`for `^sample^` superscript 36 | - `Underline`: `true` for `_sample_` underline (spec Markdown is ``, disable to revert to spec). 37 | - `Strikethrough`: `"double"` for `~~sample~~` strikthrough (`"single"` supports single tilde, incompatible with subscript above). 38 | - `Inline media`: `true`for [Inline Media](https://ankiweb.net/shared/info/683715045) directive, e.g. `:audio(im-xyz.ogg){loop auto_front}`/`:video(im-xyz.ogg){loop auto_front height=200}`. 39 | - `Definition lists`: `true` for [Defintion lists](https://github.com/wataru-chocola/mdast-util-definition-list) (not available in the core Anki editor). 40 | - `Table stle`: Table syntax, `"gfm"`/`"extended"`: 41 | - GFM table syntax 42 | 43 | ``` Markdown 44 | | A | GFM | 45 | | :---- | ----: | 46 | | table | ! | 47 | ``` 48 | 49 | - Extended table syntax - GFM style extended to allow headerless tables (no `` generated): 50 | 51 | ``` Markdown 52 | | :--: | ----: | 53 | | A | table | 54 | | with | rows | 55 | ``` 56 | 57 | - `Table newline`: Symbol to replace hard line break (`
`) inside table cells (newline characters are normally not allowed inside Markdown table cells) 58 | - `fences`: Optional [fencing pipes](https://github.github.com/gfm/#tables-extension-) (i.e. at start and end of each row). 59 | - Align none, left, right and center as per [GFM format](https://github.github.com/gfm/#tables-extension-). 60 | 61 | ## Editor 62 | 63 | The editor used is [CodeMirror 6](https://codemirror.net/) with the following configurations: 64 | 65 | - Markdown syntax highlighting and auto fill (continue lists, autoindent etc.). 66 | - Undo history. 67 | - Multiple drawable selections. 68 | - Search and replace, `Ctrl+F`, note: the Anki editor eats `Ctrl+F`, set to other shortcut in config or remap the Anki editor shortcuts with [Customize Keyboard Shortcuts](https://ankiweb.net/shared/info/24411424) or similar. 69 | - Insert cloze deletions 70 | - Cloze without increment: `Ctrl+Shift+Z` - For some reason I am unable to get `Ctrl+Alt+Shift+C` to work (the default Anki keyboard shortcut) 71 | - Cloze with increment: `Ctrl+Shift+c` (with multiple selections this will cloze each incrementally) 72 | - If you feel the cloze deletion tags end up in the wrong place please make sure you understand how Markdown is converted to HTML (notably line breaks and empty lines). 73 | - Allows image pasting in the same way the "rich text input" does. 74 | - Customize the editor styling by copying `cm.css` into `user_files` subdirectory and customize. Consider using `--var(xyz)` to use the Anki colors from the current theme (i.e. follows light/dark mode). 75 | - Customize Markdown input editor shortcuts (i.e. *inside* the field/window, not the core Anki editor) in `config.json`, see `config.json` and [CodeMirror documentation](https://codemirror.net/docs/ref/#view.KeyBinding) for further information. Available functions to map are all in [@codemirror/commands](https://github.com/codemirror/commands/blob/main/src/commands.ts), [@codemirror/search](https://github.com/codemirror/search/blob/main/src/search.ts) and custom commands `clozeNext`, `clozeCurrent`, `joinLines`. 76 | - Configurable search/replace default options (`caseSensitive`, `regexp`, `wholeWord`). 77 | 78 | ### Field input mode 79 | 80 | - Configurable default state for editor fields (`rich text`, `markdown` or `rich text/markdown`, default `rich text`). 81 | - Configurable behaviour of "field input mode" shortcut, cycle between rich text and Markdown (`true`) or just toggle Markdown input (`false`). (default `true`) 82 | - Configurable shortcut to toggle rich text input. (default `Ctrl+Alt+X`) 83 | - Configurable shortcuts to move to next/previous input (since tab is a valid Markdown character it will not work to "tab out" of a markdown input). (default `Ctrl+PgDown` and `Ctrl+PgUp`) 84 | - Configurable option to autohide editor toolbar when focusing Markdown input (to increase available space). (default `true`) 85 | 86 | ### Window input mode 87 | 88 | - Configurable window size (`parent`, `last` or `WidthxHeight`, default `parent`) 89 | - Configurable note editing mode, either the entire note (i.e. all fields) or current field. In `note` editing mode the fields are separated by HTML comments (e.g. ``) and content will be inserted into the correspondingly named field. (`note` or `field`, default `note`) 90 | 91 | ## Configuration 92 | 93 | - "Field input mode" can be configured under `Field input`, note that the default shortcut, `Ctrl+M` conflicts with Mathjax shortcuts, remap one of them. 94 | - "Window input mode" can be configured under `Window input`. 95 | - HTML ↔ Markdown conversion configurable under `Converter`. See [mdastToMarkdown](https://github.com/syntax-tree/mdast-util-to-markdown#tomarkdowntree-options) for `Markdown format` options. 96 | - Editor configurable under `CodeMirror`. See [CodeMirror documentation](https://codemirror.net/docs/) how to configure. 97 | - Default Anki behaviour is to use the entire sort field text in the Browser table, `Browser sort field` option (default `true`) instead uses content of first level 1 heading (if existing). 98 | - Note that Anki shortcuts grab key sequences before they reach the CodeMirror editor, use [Customize Keyboard Shortcut](https://ankiweb.net/shared/info/24411424) or other addon to change the Anki shortcuts as needed. At the time of writing [Customize Keyboard Shortcut](https://ankiweb.net/shared/info/24411424) hooks into the Qt/Python for the cloze shortcuts. This means they never reach CodeMirror so unmap (`) them in [Customize Keyboard Shortcut](https://ankiweb.net/shared/info/24411424) (the new Anki editor grabs the shortcuts on the JavaScript side). 99 | - To customize the look of the finished HTML apply custom [CSS](https://www.w3schools.com/Css/) styling (for instance of `

`, `` `
    ` etc.) to the note template. 100 | - To customize the look and feel of the editor, make a copy of `cm.css` (styling of CodeMirror and syntax highlighting) into a `user_files` subdirectory in the addon directory and modify that file (it will be given precedence and not be overwritten on addon update). 101 | 102 | ## Suggested companion addons 103 | 104 | To achieve a note editing friendly interface consider using: 105 | 106 | - [CSS Injector - Change default editor styles](https://ankiweb.net/shared/info/181103283) to modify the editor interface for smaller margins, for instance something like this will give a slicker, VS Code inspired, editor interface: 107 | 108 | ```css 109 | /* editor.css */ 110 | div > div.editor-field { 111 | border-radius: unset; 112 | border: none !important; 113 | box-shadow: none !important; 114 | padding-left: 10px; 115 | padding-right: 10px; 116 | padding-bottom: 5px; 117 | } 118 | div:not(:nth-child(1)) > .field-container { 119 | border-top: 1px solid var(--border); 120 | } 121 | 122 | .editor-toolbar .button-toolbar { 123 | border: none; 124 | padding: 7px 7px 0px 7px; 125 | margin: 0px; 126 | } 127 | 128 | .editor-field { 129 | --fg: #3b3b3b; 130 | --selected-bg: #ADD6FF80; 131 | } 132 | .night_mode .editor-field { 133 | --fg: #d4d4d4; 134 | --selected-bg: #ADD6FF26; 135 | } 136 | 137 | body { 138 | --fg: #3b3b3b; 139 | --canvas: #ffffff; 140 | --canvas-elevated: #ffffff; 141 | --border: #CECECE; 142 | --border-focus: 1px solid #3794ff; 143 | } 144 | body.night_mode { 145 | --fg: #858585; 146 | --canvas: #1e1e1e; 147 | --canvas-elevated: #1e1e1e; 148 | --border: #474747; 149 | --border-focus: 1px solid #3794ff; 150 | } 151 | ``` 152 | 153 | and 154 | 155 | ```css 156 | /* field.css */ 157 | anki-editable.night_mode { 158 | color: var(--fg); 159 | font-size: 18px; 160 | } 161 | ``` 162 | 163 | - [Sidebar table](https://ankiweb.net/shared/info/1753198255) to move the note/card table to the side for increased editing space, esp. with `Markdown input``Autohide toolbar` set to `true` 164 | - [Browser Maximize/Hide Table/Editor/Sidebar](https://ankiweb.net/shared/info/1819291495) to toggle hiding the sidebar with a keyboard shortcut (e.g. `Ctrl+Shift+B`) 165 | 166 | ## Developers 167 | 168 | Functionality split into different classes to facilitate reuse: 169 | 170 | - [anki-md-html](https://github.com/TRIAEIOU/anki-md-html): library which converts Anki style HTML ↔ Markdown. 171 | - [CustomInputClass](https://github.com/TRIAEIOU/Markdown-input/blob/main/src/ts/custom_input.ts) encapsulates adding an editor to the `Note editor` fields. 172 | - [Editor](https://github.com/TRIAEIOU/Markdown-input/blob/main/src/ts/editor.ts) encapsulates the CodeMirror editor. 173 | 174 | ## Changelog 175 | 176 | - 2022-08-27: Add image paste support, prevent focus from being stolen on focus in/out, bug fixes. 177 | - 2022-10-16: Make window mode non-modal (and allow multiple windows open), add `Ctrl-Shift-j` to join lines, make inline Markdown syntax configurable, make several options configurable, bug fixes. 178 | - 2022-11-20: Make rich and plain text input editable while Markdown input is visible and adjust `config.json` appropriately, add buttons/badges, restructure configuration. 179 | - 2022-12-13: Correct update `json.config` bug. 180 | - 2022-12-16: Fix multiple badges bug. 181 | - 2022-12-20: Fix field input mode bug for *nix (tested in VM `Ubuntu 22.04 LTS`) and macOS (tested in a really slow VM `High Sierra`). 182 | - 2022-12-22: Badge rework and bug fix 183 | - 2023-01-09: Move to 2.1.56 platform (last 2.1.55 shipped until further notice), fix syntax highlighting. 184 | - 2023-03-11: Restructuring of code to allow modularity with other projects and easier maintenance. 185 | - 2023-03-12: Change "dialog mode" to "window mode" and inherit QMainWindow rather than QDialog. Add option to edit complete note in window mode, improve CSS styling of editor, now done from separate CSS file. Modify default styling (based on VS Code styles) to suit both light and dark mode. Fix anyoing eating of trailing spaces when focusing to another window. Add option to hide editor toolbar when focusing a Markdown field. 186 | - 2023-04-26: Improve logic to fix anoying eating of trailing spaces, change search/replace dialog placement so that it is visible without having to scroll. 187 | - 2023-05-04: Add `rich text/markdown` field default configuration option, add list continuation thanks to [David C.](https://github.com/d-k-bo). 188 | - 2023-06-01: Table bug fix (tables now non-optional), rename config option from `Table style` to `Tables`, add code block syntax highlighting, add `Browser sort field` option, add styling of cloze tags in the Markdown editor. -------------------------------------------------------------------------------- /Screenshots/first-use.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TRIAEIOU/markdown-input/58d1bb8de0672d9869ee013024d869640d115aa0/Screenshots/first-use.gif -------------------------------------------------------------------------------- /Screenshots/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TRIAEIOU/markdown-input/58d1bb8de0672d9869ee013024d869640d115aa0/Screenshots/screen.png -------------------------------------------------------------------------------- /Screenshots/screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TRIAEIOU/markdown-input/58d1bb8de0672d9869ee013024d869640d115aa0/Screenshots/screenshots.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "devDependencies": { 4 | "@codemirror/autocomplete": "^6.4.2", 5 | "@codemirror/commands": "^6.2.1", 6 | "@codemirror/lang-markdown": "^6.1.0", 7 | "@codemirror/language": "^6.6.0", 8 | "@codemirror/language-data": "^6.3.1", 9 | "@codemirror/state": "^6.2.0", 10 | "@codemirror/view": "^6.9.2", 11 | "@lezer/highlight": "^1.1.3", 12 | "@lezer/markdown": "^1.0.2", 13 | "@rollup/plugin-commonjs": "^24.0.1", 14 | "@rollup/plugin-node-resolve": "^15.0.1", 15 | "@rollup/plugin-terser": "^0.4.0", 16 | "@rollup/plugin-typescript": "^11.0.0", 17 | "anki-md-html": "github:TRIAEIOU/anki-md-html", 18 | "cross-env": "^7.0.3", 19 | "rollup": "^3.18.0", 20 | "rollup-plugin-svelte": "^7.1.4", 21 | "search": "github:TRIAEIOU/search#input-box", 22 | "shx": "^0.3.4", 23 | "ts-node": "^10.9.1", 24 | "tslib": "^2.5.0", 25 | "typescript": "^5.0.0", 26 | "zip-build": "^1.8.0" 27 | }, 28 | "scripts": { 29 | "build-clean": "shx rm -rf ./dist/* ./lib/* ./bin/*", 30 | "build-ts": "rollup -c", 31 | "build-py": "shx cp ./src/py/*.py ./bin", 32 | "build-gfx": "shx cp ./src/gfx/* ./bin", 33 | "build-cfg": "shx cp ./src/config/* ./bin", 34 | "build-doc": "shx cp LICENSE README.md ./bin", 35 | "build": "npm run build-clean && npm run build-ts && npm run build-py && npm run build-gfx && npm run build-cfg && npm run build-doc", 36 | "prepack": "shx rm -rf ./dist/* && shx cp -r ./bin ./dist/ && shx rm -rf ./dist/bin/meta.json ./dist/bin/__pycache__", 37 | "pack": "npm run prepack && zip-build \"./dist/bin\" \"./dist\" -o -t \"update.zip\" && cross-env-shell pandoc -t $CODEPATH/Anki/addons/pandoc-anki-addon-writer/anki-addon.lua ./README.md -o ./dist/update.txt" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from "rollup-plugin-svelte" 2 | import {nodeResolve} from "@rollup/plugin-node-resolve" 3 | import typescript from "@rollup/plugin-typescript" 4 | import commonjs from "@rollup/plugin-commonjs" 5 | import terser from "@rollup/plugin-terser" 6 | 7 | const plugins = [ 8 | typescript(), 9 | commonjs(), 10 | nodeResolve({ preferBuiltins: false, browser: true }), 11 | svelte({ include: 'src/**/*.svelte' }), 12 | //terser({format: {comments: false}}) 13 | ] 14 | const output = { 15 | dir: "bin", 16 | format: "iife", 17 | name: "MarkdownInput", 18 | globals: {}, 19 | manualChunks: () => 'true' 20 | } 21 | 22 | export default [ 23 | { 24 | input: "src/ts/field_input.ts", 25 | plugins: plugins, 26 | output: output 27 | }, 28 | { 29 | input: "src/ts/window_input.ts", 30 | plugins: plugins, 31 | output: output 32 | }, 33 | { 34 | input: "src/ts/field_input_2.1.55.ts", 35 | plugins: plugins, 36 | output: output 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /src/config/cm.css: -------------------------------------------------------------------------------- 1 | /* General editor styling */ 2 | .cm-editor .cm-content { 3 | padding: 5px; 4 | } 5 | 6 | /* Light mode VS Code style colors */ 7 | .cm-editor { 8 | --fg: #3b3b3b; 9 | --fg-subtle: #5d5d5d; 10 | --canvas: #ffffff; 11 | --canvas-elevated: #ffffff; 12 | --border: #CECECE; 13 | --border-subtle: #CECECE; 14 | --selected-bg: #ADD6FF80; 15 | --comment: #008000; 16 | --keyword: #00f; 17 | --type: #007acc; 18 | --heading: #a31515; 19 | --url: #00b0e8; 20 | --search-match: #bb800966; 21 | --invalid: #f44747; 22 | --selection-match: #6199ff2f; 23 | } 24 | 25 | /* Dark mode VS Code style colors */ 26 | .night_mode .cm-editor { 27 | --fg: #d4d4d4; 28 | --fg-subtle: #858585; 29 | --canvas: #1e1e1e; 30 | --canvas-elevated: #1e1e1e; 31 | --border: #474747; 32 | --border-subtle: #282828; 33 | --selected-bg: #2489db82; 34 | --caret: #c6c6c6; 35 | --heading: #569CD6; 36 | --url: #569CD6; 37 | --type: #4EC9B0; 38 | --class-name: #B8D7A3; 39 | --string: #D69D85; 40 | --comment: #57A64A; 41 | --inserted: rgb(155, 185, 85); 42 | --inserted-bg: rgba(155, 185, 85, 0.2); 43 | --deleted: rgb(255, 0, 0); 44 | --deleted-bg: rgba(255, 0, 0, 0.2); 45 | } 46 | 47 | /* Window styling based on Anki colors */ 48 | .cm-editor { 49 | background-color: var(--canvas-elevated); 50 | } 51 | 52 | .cm-editor .cm-panels { 53 | background-color: var(--canvas); 54 | color: var(--fg-subtle); 55 | border-color: var(--border); 56 | } 57 | 58 | .cm-editor .cm-panel input { 59 | background-color: var(--canvas-elevated); 60 | color: var(--fg); 61 | border-color: var(--border); 62 | font-size: 16px; 63 | outline: none; 64 | } 65 | 66 | .cm-editor .cm-panel .cm-button { 67 | background-color: var(--canvas-elevated); 68 | border-color: var(--border); 69 | background-image: none; 70 | } 71 | 72 | .cm-editor .cm-panel input:focus, 73 | .cm-editor .cm-panel .cm-button:focus { 74 | outline: 1px solid var(--selected-bg); 75 | } 76 | 77 | .cm-editor .cm-panel label { 78 | background-color: transparent; 79 | } 80 | 81 | .cm-editor .cm-content { 82 | background-color: var(--canvas-elevated); 83 | color: var(--fg); 84 | } 85 | 86 | .cm-editor .cm-content { 87 | font-family: Menlo, Monaco, Consolas, "Andale Mono", "Ubuntu Mono", "Courier New", monospace; 88 | font-size: 18px; 89 | } 90 | 91 | .cm-cloze { 92 | font-size: 10px; 93 | color: var(--fg-subtle); 94 | } 95 | 96 | /* 97 | .cm-editor .cm-cursor, 98 | .cm-editor .cm-dropCursor 99 | { 100 | border-left: 2px solid var(--fg); 101 | } 102 | */ 103 | 104 | .cm-editor .cm-line { 105 | padding: 1px 0 1px 1px; /* top right bottom left */ 106 | } 107 | 108 | .cm-editor .cm-activeLine { 109 | background-color: transparent; 110 | border: none; 111 | padding: 1px 0 1px 1px; /* top right bottom left */ 112 | } 113 | 114 | .cm-editor.cm-focused .cm-activeLine { 115 | background-color: transparent; 116 | border: 1px solid var(--border-subtle); 117 | border-right: none; 118 | padding: 0; 119 | } 120 | 121 | .cm-editor ::selection, 122 | .cm-editor .cm-selectionBackground { 123 | background-color: var(--selected-bg); 124 | } 125 | 126 | .cm-editor .cm-selectionMatch 127 | { 128 | background-color: var(--selection-match); 129 | } 130 | 131 | /* 132 | .cm-editor .cm-selectionMatch.cm-searchMatch-selected { 133 | background-color: var(#72a1ff59); 134 | } 135 | */ 136 | 137 | .cm-editor .cm-searchMatch { 138 | background-color: var(--search-match); 139 | } 140 | 141 | .cm-editor.cm-focused .cm-line .cm-matchingBracket, 142 | .cm-editor.cm-focused .cm-line .cm-matchingBracket .tok-meta { 143 | outline: 1px solid var(--border); 144 | } 145 | 146 | .cm-editor.cm-focused .cm-line .cm-nonmatchingBracket, 147 | .cm-editor .tok-invalid 148 | { 149 | color: var(--invalid); 150 | } 151 | 152 | 153 | /* Syntax highlighting based on VS Code */ 154 | 155 | /* 156 | https://raw.githubusercontent.com/isagalaev/highlight.js/master/src/styles/vs2015.css 157 | */ 158 | /* 159 | * Visual Studio 2015 dark style 160 | * Author: Nicolas LLOBERA 161 | */ 162 | 163 | .cm-editor.night_mode .cm-content { 164 | caret-color: var(--caret); 165 | } 166 | 167 | 168 | /*.hljs-keyword, 169 | .hljs-literal, 170 | .hljs-symbol, 171 | .hljs-name */ 172 | .night_mode .cm-editor .tok-meta, 173 | .night_mode .cm-editor .tok-keyword .tok-meta, 174 | .night_mode .cm-editor .tok-keyword, 175 | .night_mode .cm-editor .tok-literal, 176 | .night_mode .cm-editor .tok-heading, 177 | .night_mode .cm-editor .tok-keyword, 178 | .night_mode .cm-editor .tok-atom, 179 | .night_mode .cm-editor .tok-bool, 180 | .night_mode .cm-editor .tok-labelName, 181 | .night_mode .cm-editor .tok-namespace { 182 | color: var(--heading) 183 | } 184 | 185 | .night_mode .cm-editor .tok-meta, 186 | .night_mode .cm-editor .tok-heading { 187 | font-weight: bold; 188 | } 189 | 190 | 191 | /* .hljs-link */ 192 | .night_mode .cm-editor .tok-url, 193 | .night_mode .cm-editor .tok-link { 194 | color: var(--url); 195 | text-decoration: underline; 196 | } 197 | 198 | /* .hljs-built_in, 199 | .hljs-type */ 200 | .night_mode .cm-editor .tok-bool, 201 | .night_mode .cm-editor .tok-typeName { 202 | color: var(--type); 203 | } 204 | 205 | /* .hljs-number, 206 | .hljs-class */ 207 | .night_mode .cm-editor .tok-className { 208 | color: var(--class-name); 209 | } 210 | 211 | /* .hljs-string, 212 | .hljs-meta-string */ 213 | .night_mode .cm-editor .tok-string { 214 | color: var(--string); 215 | } 216 | 217 | /* 218 | .hljs-regexp, 219 | .hljs-template-tag { 220 | color: #9A5334; 221 | } 222 | */ 223 | 224 | /* 225 | .hljs-subst, 226 | .hljs-function, 227 | .hljs-title, 228 | .hljs-params, 229 | .hljs-formula { 230 | color: #DCDCDC; 231 | } 232 | */ 233 | 234 | /* .hljs-comment, 235 | .hljs-quote */ 236 | .night_mode .cm-editor .tok-comment { 237 | color: var(--comment); 238 | font-style: italic; 239 | } 240 | 241 | /* 242 | .hljs-doctag { 243 | color: #608B4E; 244 | } 245 | */ 246 | 247 | /* .hljs-meta, 248 | .hljs-meta-keyword, 249 | .hljs-tag { 250 | color: #9B9B9B; 251 | } 252 | */ 253 | 254 | /* 255 | .hljs-variable, 256 | .hljs-template-variable { 257 | color: #BD63C5; 258 | } 259 | */ 260 | 261 | /* 262 | .hljs-attr, 263 | .hljs-attribute, 264 | .hljs-builtin-name { 265 | color: #9CDCFE; 266 | } 267 | */ 268 | 269 | /* 270 | .hljs-section { 271 | color: gold; 272 | } 273 | */ 274 | 275 | /* .hljs-emphasis */ 276 | .night_mode .cm-editor .tok-emphasis { 277 | font-style: italic; 278 | } 279 | 280 | /* .hljs-strong */ 281 | .night_mode .cm-editor .tok-strong { 282 | font-weight: bold; 283 | } 284 | 285 | /*.hljs-code { 286 | font-family:'Monospace'; 287 | }*/ 288 | 289 | /* 290 | .hljs-bullet, 291 | .hljs-selector-tag, 292 | .hljs-selector-id, 293 | .hljs-selector-class, 294 | .hljs-selector-attr, 295 | .hljs-selector-pseudo { 296 | color: #D7BA7D; 297 | } 298 | */ 299 | 300 | /* .hljs-addition */ 301 | .night_mode .cm-editor .tok-inserted { 302 | background-color: var(--inserted-bg); 303 | color: var(--inserted); 304 | display: inline-block; 305 | width: 100%; 306 | } 307 | 308 | /* .hljs-deletion */ 309 | .night_mode .cm-editor .tok-deleted { 310 | background: var(--deleted-bg); 311 | color: var(--deleted); 312 | display: inline-block; 313 | width: 100%; 314 | } 315 | 316 | /* 317 | From https://raw.githubusercontent.com/isagalaev/highlight.js/master/src/styles/vs.css 318 | */ 319 | /* 320 | 321 | Visual Studio-like style based on original C# coloring by Jason Diamond 322 | 323 | */ 324 | 325 | .cm-editor .cm-content { 326 | caret-color: var(--fg); 327 | } 328 | 329 | 330 | /* 331 | .vscode-light .hljs-function, 332 | .vscode-light .hljs-params, 333 | .vscode-light .hljs-number, 334 | .vscode-light .hljs-class { 335 | color: inherit; 336 | } 337 | */ 338 | 339 | /* .vscode-light .hljs-comment, 340 | .vscode-light .hljs-quote, 341 | .vscode-light .hljs-number, 342 | .vscode-light .hljs-class, 343 | .vscode-light .hljs-variable */ 344 | .cm-editor .tok-comment, 345 | .cm-editor .tok-className { 346 | color: var(--comment); 347 | } 348 | 349 | /* .vscode-light .hljs-keyword, 350 | .vscode-light .hljs-selector-tag, 351 | .vscode-light .hljs-name, 352 | .vscode-light .hljs-tag */ 353 | .cm-editor .tok-keyword { 354 | color: var(--keyword); 355 | } 356 | 357 | /* .vscode-light .hljs-built_in, 358 | .vscode-light .hljs-builtin-name */ 359 | .cm-editor .tok-bool { 360 | color: var(--type); 361 | } 362 | 363 | /* .vscode-light .hljs-string, 364 | .vscode-light .hljs-section, 365 | .vscode-light .hljs-attribute, 366 | .vscode-light .hljs-literal, 367 | .vscode-light .hljs-template-tag, 368 | .vscode-light .hljs-template-variable, 369 | .vscode-light .hljs-type */ 370 | .cm-editor .tok-heading, 371 | .cm-editor .tok-meta, 372 | .cm-editor .tok-keyword .tok-meta, 373 | .cm-editor .tok-string, 374 | .cm-editor .tok-literal, 375 | .cm-editor .tok-typeName { 376 | color: var(--heading); 377 | } 378 | 379 | .cm-editor .tok-heading, 380 | .cm-editor .tok-heading.tok-meta { 381 | font-weight: bold; 382 | } 383 | 384 | /* 385 | .vscode-light .hljs-selector-attr, 386 | .vscode-light .hljs-selector-pseudo, 387 | .vscode-light .hljs-meta, 388 | .vscode-light .hljs-meta-keyword { 389 | color: #2b91af; 390 | } 391 | */ 392 | 393 | /* 394 | .vscode-light .hljs-title, 395 | .vscode-light .hljs-doctag { 396 | color: #808080; 397 | } 398 | */ 399 | 400 | /* 401 | .vscode-light .hljs-attr { 402 | color: #f00; 403 | } 404 | */ 405 | 406 | /* .vscode-light .hljs-symbol, 407 | .vscode-light .hljs-bullet, 408 | .vscode-light .hljs-link */ 409 | .cm-editor .tok-url, 410 | .cm-editor .tok-link { 411 | color: var(--url); 412 | text-decoration: underline; 413 | } 414 | 415 | 416 | /* .vscode-light .hljs-emphasis */ 417 | .cm-editor .tok-emphasis { 418 | font-style: italic; 419 | } 420 | 421 | /* .vscode-light .hljs-strong */ 422 | .cm-editor .tok-strong { 423 | font-weight: bold; 424 | } 425 | -------------------------------------------------------------------------------- /src/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "Field input": { 3 | "Shortcut": "Ctrl+M", 4 | "Rich text shortcut": "Ctrl+Alt+X", 5 | "Next field": "Ctrl+PgDown", 6 | "Previous field": "Ctrl+PgUp", 7 | "Default field state": "rich text", 8 | "Cycle rich text/Markdown": true, 9 | "Autohide toolbar": true 10 | }, 11 | "Window input": { 12 | "Mode": "note", 13 | "Shortcut": "Ctrl+Alt+M", 14 | "Shortcut accept": "Ctrl+Return", 15 | "Shortcut reject": "Shift+Escape", 16 | "Size mode": "parent", 17 | "Last geometry": "" 18 | }, 19 | "Converter": { 20 | "Markdown format": { 21 | "bullet": "-", 22 | "listItemIndent": "one", 23 | "ruleRepetition": 10, 24 | "tightDefinitions": true, 25 | "fences": true, 26 | "hardBreak": "spaces" 27 | }, 28 | "Markdown extensions": { 29 | "Definition lists": true, 30 | "Inline media": true, 31 | "Tables": "extended", 32 | "Table newline": "¨", 33 | "Underline": true, 34 | "Superscript": true, 35 | "Subscript": true, 36 | "Strikethrough": "double" 37 | } 38 | }, 39 | "CodeMirror": { 40 | "keymap": [{ 41 | "key": "Mod-f", 42 | "run": "openSearchPanel", 43 | "scope": "editor search-panel", 44 | "preventDefault": true 45 | },{ 46 | "key": "Mod-n", 47 | "run": "findNext", 48 | "shift": "findPrevious", 49 | "scope": "editor search-panel", 50 | "preventDefault": true 51 | },{ 52 | "key": "Mod-d", 53 | "run": "selectNextOccurrence", 54 | "preventDefault": true 55 | },{ 56 | "key": "Mod-Shift-l", 57 | "run": "selectSelectionMatches", 58 | "preventDefault": true 59 | },{ 60 | "key": "Mod-g", 61 | "run": "gotoLine", 62 | "preventDefault": true 63 | },{ 64 | "key": "Escape", 65 | "run": "closeSearchPanel", 66 | "scope": "editor search-panel", 67 | "preventDefault": true 68 | },{ 69 | "key": "Mod-Shift-z", 70 | "run": "clozeCurrent", 71 | "preventDefault": true 72 | },{ 73 | "key": "Mod-Shift-c", 74 | "run": "clozeNext", 75 | "preventDefault": true 76 | }, 77 | { 78 | "key": "Mod-Shift-j", 79 | "run": "joinLines", 80 | "preventDefault": true 81 | } 82 | ], 83 | "search": { 84 | "caseSensitive": false, 85 | "regexp": true, 86 | "wholeWord": false 87 | } 88 | }, 89 | "General": { 90 | "Browser sort field": true 91 | } 92 | } -------------------------------------------------------------------------------- /src/config/mdi.css: -------------------------------------------------------------------------------- 1 | /* Styling of interaction with elements surrounding codemirror */ 2 | .cm-scroller { 3 | overflow: auto; 4 | } 5 | 6 | .cm-editor .cm-content { 7 | padding: 5px; 8 | } 9 | 10 | div.markdown-input:not([hidden]) ~ div.rich-text-input { 11 | padding: 5px 0 0 0; /* top right bottom left */ 12 | border-top: 1px double var(--border); 13 | } 14 | 15 | .cm-editor .cm-panels-top { 16 | border: 1px solid var(--border); 17 | position: fixed; 18 | z-index: 100; 19 | top: 30px !important; 20 | right: 20px; 21 | left: unset; 22 | } 23 | 24 | /* Only relevant for window input */ 25 | body.mdi-window { 26 | margin: 0; 27 | } 28 | 29 | #cm-container { 30 | height: 100%; 31 | } 32 | 33 | .cm-editor { 34 | margin: 0px; 35 | height: 100%; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/gfx/markdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TRIAEIOU/markdown-input/58d1bb8de0672d9869ee013024d869640d115aa0/src/gfx/markdown.png -------------------------------------------------------------------------------- /src/py/__init__.py: -------------------------------------------------------------------------------- 1 | from aqt import mw, QPushButton, QMessageBox, gui_hooks 2 | 3 | from aqt.utils import * 4 | from . import field_input, window_input, browser_sort_field 5 | from .constants import * 6 | from .version import * 7 | 8 | mw.addonManager.setWebExports(__name__, r"(.*(css|js|map))") 9 | config = mw.addonManager.getConfig(__name__) 10 | 11 | CVER = get_version() 12 | NVER = "2.2.3" 13 | 14 | msgs = [] 15 | 16 | if strvercmp(CVER, '0.0.0') > 0: 17 | if strvercmp(CVER, '2.2.3') < 0: 18 | msgs.append('Option "Browser sort field" has been added. When enabled (default `true`) it will use first `<h1>` tag in note (if it exists) in the "Sort field" column in the browser table (default Anki is unaware of heading tags).') 19 | if ts := config[CONVERTER].get('Table style'): 20 | config[CONVERTER].pop('Table style', None) 21 | config[CONVERTER][TABLES] = ts 22 | 23 | if strvercmp(CVER, '2.2.0') < 0: 24 | config['Window input'].pop('Selection only', None) 25 | 26 | if strvercmp(CVER, '1.2.0') < 0: 27 | msgs.append('The configurations "Restore state on toggle", "Hide plain text on toggle" and "Hide rich text on toggle" for "Field input" have been replaced with "Cycle rich text/Markdown" (making the Markdown shortcut either cycle between rich text and Markdown or simply show/hide the Markdown input).') 28 | msgs.append('"Cloze lists" for "Converter" has been removed.') 29 | config['Field input'].pop('Restore state on toggle', None) 30 | config['Field input'].pop('Hide plain text on toggle', None) 31 | config['Field input'].pop('Hide rich text on toggle', None) 32 | config['Converter'].pop('Cloze lists', None) 33 | 34 | if strvercmp(CVER, '1.2.2') < 0: 35 | config.pop('Rich text shortcut', None) 36 | config.pop('Next field', None) 37 | config.pop('Previous field', None) 38 | config.pop('Default field state', None) 39 | config.pop('Cycle rich text/Markdown', None) 40 | if config['Field input'].get('Rich text shortcut', None) == None: 41 | config['Field input']['Rich text shortcut'] = 'Ctrl+Alt+X' 42 | if config['Field input'].get('Next field', None) == None: 43 | config['Field input']['Next field'] = 'Ctrl+PgDown' 44 | if config['Field input'].get('Previous field', None) == None: 45 | config['Field input']['Previous field'] = 'Ctrl+PgUp' 46 | if config['Field input'].get('Default field state', None) == None: 47 | config['Field input']['Default field state'] = 'rich text' 48 | if config['Field input'].get('Cycle rich text/Markdown', None) == None: 49 | config['Field input']['Cycle rich text/Markdown'] = True 50 | 51 | if strvercmp(CVER, '2.0.0') < 0: 52 | msgs.append('The editor DOM and internal functioning which Markdown input depends on changed in Anki version 2.1.56. The current version of Markdown input ships with both 2.1.56+ compatible code as well as the last release targeted at 2.1.55. Going forward no updates/fixes will be made to the legacy code, any development/bug fixes will be in the 2.1.56+ code.') 53 | msgs.append('Due to the changes mentioned above you will likely need to update the CSS for the field input (Field input/CSS in the configuration).') 54 | 55 | if strvercmp(CVER, NVER) < 0: 56 | set_version(NVER) 57 | 58 | if len(msgs) > 0: 59 | msg_box = QMessageBox(mw) 60 | msg_box.setWindowTitle('Addon "Markdown input" updated') 61 | msg_box.setText("""
    "Markdown input" addon has been updated:
    • """ + '
    • '.join(msgs) + """
    Please see the addon page (https://ankiweb.net/shared/info/904999275) for more details.
    """) 62 | msg_box.addButton(QPushButton('Ok'), QMessageBox.YesRole) 63 | msg_box.exec() 64 | 65 | if config.get(FIELD_INPUT) or config.get(CONVERTER): 66 | field_input.init(config) 67 | 68 | if config.get(WINDOW_INPUT) or config.get(CONVERTER): 69 | window_input.init(config) 70 | 71 | if config.get(GENERAL) and config[GENERAL].get(SORT_FIELD): 72 | gui_hooks.browser_did_change_row.append(browser_sort_field.clear_cache) 73 | gui_hooks.browser_did_fetch_row.append(browser_sort_field.truncate) 74 | -------------------------------------------------------------------------------- /src/py/browser_sort_field.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Sequence 3 | import aqt 4 | from aqt import mw 5 | 6 | _cache = {} 7 | 8 | def clear_cache(*_): 9 | _cache = {} 10 | 11 | def truncate(ncid: aqt.browser.ItemId, is_note: bool, row: aqt.browser.CellRow, cols: Sequence[str]): 12 | """If `

    ` tag exists, use first occurance as sort field with caching""" 13 | try: 14 | sorti = cols.index("noteFld") 15 | nid = ncid if is_note else mw.col.get_card(ncid).nid 16 | if sorts_ := _cache.get(nid): 17 | sorts = sorts_ 18 | else: 19 | note = mw.col.get_note(nid) 20 | sorts = note.fields[mw.col.models.sort_idx(note.note_type())] 21 | if h1 := re.search(r"

    (.*?)

    ", sorts): 22 | sorts = h1.group(1).replace(" ", " ") 23 | else: 24 | sorts = -1 25 | _cache[nid] = sorts 26 | 27 | if sorts != -1: 28 | row.cells[sorti].text = sorts 29 | except: 30 | pass 31 | -------------------------------------------------------------------------------- /src/py/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Sync with constants.ts 4 | 5 | # Field input 6 | FIELD_INPUT = 'Field input' 7 | SC_TOGGLE = 'Shortcut' 8 | SC_RICH = "Rich text shortcut" 9 | SC_NEXT = "Next field" 10 | SC_PREV = "Previous field" 11 | FIELD_DEFAULT = 'Default field state' 12 | CYCLE_RICH_MD = "Cycle rich text/Markdown" 13 | 14 | # Window input 15 | WINDOW_INPUT = "Window input" 16 | WINDOW_MODE = "Mode" # "field", "note" 17 | SC_OPEN = 'Shortcut' 18 | SC_ACCEPT = "Shortcut accept" 19 | SC_REJECT = "Shortcut reject" 20 | SIZE_MODE = "Size mode" # "parent", "last", WIDTHxHEIGHT (e.g "1280x1024") 21 | CSS = "CSS" 22 | LAST_GEOM = "Last geometry" 23 | 24 | # Converter 25 | CONVERTER = "Converter" 26 | MD_FORMAT = "Markdown format" 27 | MD_EXTENSIONS = "Markdown extensions" 28 | TABLES = "Tables" 29 | 30 | # Editor 31 | EDITOR = "CodeMirror" 32 | KEYMAP = "keymap" 33 | THEME = "Theme" 34 | SYNTAX = "Syntax highlighting" 35 | 36 | # General 37 | GENERAL = "General" 38 | SORT_FIELD = "Browser sort field" 39 | ADDON_PATH = os.path.dirname(__file__) 40 | ADDON_RELURL = "/".join(('_addons', os.path.split(ADDON_PATH)[1])) 41 | MDI = "MDI" 42 | -------------------------------------------------------------------------------- /src/py/field_input.py: -------------------------------------------------------------------------------- 1 | import json, tempfile 2 | from aqt import mw, gui_hooks, QKeySequence 3 | from aqt.utils import * 4 | from .constants import * 5 | from .version import strvercmp 6 | from .utils import clip_img_to_md, get_path 7 | 8 | if tmp := re.match(r"^\s*((\d+\.)+\d+)", version_with_build()): 9 | ANKI_VER = tmp.group(1) 10 | else: 11 | ANKI_VER = "2.1.0" 12 | _config = {} 13 | tmp_dir = tempfile.TemporaryDirectory() 14 | 15 | ########################################################################### 16 | def toggle_field(editor: aqt.editor.Editor, name: str, fld: int = None): 17 | """Toggle current field state""" 18 | if fld == None: 19 | fld = editor.currentField if editor.currentField != None else editor.last_field_index 20 | if fld != None: 21 | if name == 'markdown': 22 | editor.web.eval(f'MarkdownInput.toggle({fld});') 23 | else: 24 | editor.web.eval(f'MarkdownInput.toggle_rich({fld});') 25 | 26 | 27 | ########################################################################### 28 | def add_srcs(web_content: aqt.webview.WebContent, context: object): 29 | """Include needed scripts and styles in editor webview header. Called once for the editor""" 30 | global _config 31 | if not isinstance(context, aqt.editor.Editor): 32 | return 33 | addon = mw.addonManager.addonFromModule(__name__) 34 | # Defer script to allow bridgeCommand to be defined 35 | if strvercmp(ANKI_VER, "2.1.55") > 0: # Current version 36 | web_content.head += f""" 37 | 38 | 39 | 40 | """ 41 | else: # Legacy, not updated 42 | web_content.head += f""" 43 | 44 | 45 | 46 | """ 47 | 48 | # Configure Unified and CodeMirror - after script load but before cm instantiation 49 | web_content.body += f''' 50 | 53 | ''' 54 | 55 | ########################################################################### 56 | def bridge(handled: tuple[bool, Any], message: str, context: Any) -> tuple[bool, Any]: 57 | """Bridge to handle image pasting - inserts in media library and returns the MD string""" 58 | if message == 'clipboard_image_to_markdown': 59 | file = clip_img_to_md() 60 | return (True, file) 61 | 62 | return handled 63 | 64 | ########################################################################### 65 | def init(cfg): 66 | """Set configuration and hooks""" 67 | def append_shortcuts(shortcuts, ed): 68 | shortcuts.append( 69 | [QKeySequence(cfg[FIELD_INPUT][SC_TOGGLE]), 70 | lambda _ed=ed: toggle_field(_ed, 'markdown')] 71 | ) 72 | shortcuts.append( 73 | [QKeySequence(cfg[FIELD_INPUT][SC_RICH]), 74 | lambda _ed=ed: toggle_field(_ed, 'rich')] 75 | ) 76 | shortcuts.append( 77 | [QKeySequence(cfg[FIELD_INPUT][SC_NEXT]), 78 | lambda _ed=ed: ed.web.eval('MarkdownInput.cycle_next()')] 79 | ) 80 | shortcuts.append( 81 | [QKeySequence(cfg[FIELD_INPUT][SC_PREV]), 82 | lambda _ed=ed: ed.web.eval('MarkdownInput.cycle_prev()')] 83 | ) 84 | 85 | global _config 86 | _config = cfg 87 | gui_hooks.webview_will_set_content.append(lambda wc, ctx: add_srcs(wc, ctx)) 88 | gui_hooks.editor_will_load_note.append(lambda _js, _note, _ed: _js + r"MarkdownInput.update_all()") 89 | gui_hooks.editor_did_init_shortcuts.append(append_shortcuts) 90 | gui_hooks.webview_did_receive_js_message.append(bridge) 91 | -------------------------------------------------------------------------------- /src/py/utils.py: -------------------------------------------------------------------------------- 1 | import os, datetime 2 | from aqt import QImage, mw, QApplication, QClipboard, colors 3 | from anki.utils import namedtmp 4 | from anki.collection import Config 5 | from .constants import ADDON_PATH 6 | 7 | ########################################################################### 8 | def clip_img_to_md() -> str: 9 | """Check if clipboard contains an image and if so add to media library 10 | and return file name, otherwise return None 11 | """ 12 | mime = QApplication.clipboard().mimeData(QClipboard.Mode.Clipboard) 13 | 14 | if mime.hasImage(): 15 | im = QImage(mime.imageData()) 16 | uname = namedtmp(datetime.datetime.now().strftime('paste-%y.%m.%d-%H.%M.%S')) 17 | if mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG): 18 | ext = ".png" 19 | im.save(uname + ext, None, 50) 20 | else: 21 | ext = ".jpg" 22 | im.save(uname + ext, None, 80) 23 | 24 | path = uname + ext 25 | if os.path.exists(path): 26 | return f"![]({mw.col.media.add_file(path)})" 27 | 28 | return None 29 | 30 | ########################################################################### 31 | def get_path(file_name: str): 32 | """Get path to user defined file or default, note: does not include addon directory.""" 33 | if os.path.exists(os.path.join(ADDON_PATH, f'user_files/{file_name}')): 34 | return f'user_files/{file_name}' 35 | return file_name 36 | 37 | ########################################################################### 38 | def get_colors(dark): 39 | """Get CSS string with Anki colors""" 40 | css = ":root {\n" 41 | for key, val in colors.__dict__.items(): 42 | if type(val) is dict and (val_ := val.get('dark') if dark else val.get('light')): 43 | css += f''' --{key.lower().replace('_', '-')}: {val_};\n''' 44 | css += '}' 45 | return css 46 | 47 | ########################################################################### 48 | def tracefunc(frame, event, arg, indent=[0]): 49 | """Print function entry and exit (own code) use with the following in __init__.py: 50 | 51 | from .utils import tracefunc 52 | import sys 53 | sys.setprofile(tracefunc) 54 | """ 55 | # Check that it is our file 56 | if os.path.dirname(__file__) == os.path.dirname(frame.f_code.co_filename): 57 | if event == "call" or event == "return": 58 | funcname = frame.f_code.co_name 59 | filename = os.path.basename(frame.f_code.co_filename) 60 | if event == "call": 61 | indent[0] += 2 62 | print("-" * indent[0] + "> enter ", funcname, " (", filename, ")") 63 | elif event == "return": 64 | print("<" + "-" * indent[0], "exit ", funcname, " (", filename, ")") 65 | indent[0] -= 2 66 | return tracefunc 67 | -------------------------------------------------------------------------------- /src/py/version.py: -------------------------------------------------------------------------------- 1 | import os, re 2 | from aqt import mw 3 | 4 | def strvercmp(left: str, right: str) -> int: 5 | """Compares semantic version strings.\n 6 | Returns: left version is larger: > 0 7 | right version is larger: < 0 8 | versions are equal: 0""" 9 | 10 | pat = re.compile('^([0-9]+)\.?([0-9]+)?\.?([0-9]+)?([a-z]+)?([0-9]+)?$') 11 | l = pat.match(left).groups() 12 | r = pat.match(right).groups() 13 | for i in range(5): 14 | if l[i] != r[i]: 15 | if i == 3: 16 | return 1 if l[3] == None or (r[3] != None and l > r) else -1 17 | else: 18 | return 1 if r[i] == None or (l[i] != None and int(l[i]) > int(r[i])) else -1 19 | return 0 20 | 21 | def get_version() -> str: 22 | """Get current version string (from meta.json).""" 23 | meta = mw.addonManager.addon_meta(os.path.dirname(__file__)) 24 | return meta.human_version if meta.human_version else '0.0.0' 25 | 26 | def set_version(version: str): 27 | """Set version (to meta.json).""" 28 | meta = mw.addonManager.addon_meta(os.path.dirname(__file__)) 29 | meta.human_version = version 30 | mw.addonManager.write_addon_meta(meta) 31 | -------------------------------------------------------------------------------- /src/py/window.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | window 4 | 5 | 6 | 7 | 0 8 | 0 9 | 600 10 | 800 11 | 12 | 13 | 14 | Markdown 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | about:blank 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | QWebEngineView 40 | QWidget 41 |
    QtWebEngineWidgets/QWebEngineView
    42 |
    43 |
    44 | 45 | 46 |
    47 | -------------------------------------------------------------------------------- /src/py/window_input.py: -------------------------------------------------------------------------------- 1 | import anki 2 | import aqt, os, json, base64, re 3 | from aqt.utils import * 4 | from aqt.qt import QObject, QShortcut, QRect, QMainWindow 5 | from .constants import * 6 | from .utils import clip_img_to_md, get_path 7 | 8 | _config = {} 9 | _dlgs = {} 10 | 11 | ########################################################################### 12 | class Bridge(QObject): 13 | """Class to handle js bridge""" 14 | @pyqtSlot(str, result=str) 15 | def cmd(self, cmd): 16 | if cmd == "clipboard_image_to_markdown": 17 | return json.dumps(clip_img_to_md()) 18 | 19 | ########################################################################### 20 | class IM_window(QMainWindow): 21 | """Main window to edit markdown in external window""" 22 | 23 | ########################################################################### 24 | def __init__(self, editor: aqt.editor.Editor, note: anki.notes.Note, fid: int): 25 | """Constructor: Populates and shows window""" 26 | global _config, _dlgs 27 | super().__init__(None, Qt.WindowType.Window) 28 | _dlgs[hex(id(self))] = self # Save ref to prevent garbage collection 29 | 30 | # Note and field to edit 31 | self.editor = editor 32 | self.nid = note.id 33 | self.fid = fid 34 | 35 | # Create UI 36 | cwidget = QWidget(self) 37 | self.setCentralWidget(cwidget) 38 | vlayout = QVBoxLayout(cwidget) 39 | vlayout.setContentsMargins(0, 0, 0, 0) 40 | 41 | self.web = QWebEngineView(self) 42 | self.web.settings().setAttribute(QWebEngineSettings.WebAttribute.FocusOnNavigationEnabled, True) 43 | self.web.page().setBackgroundColor(theme_manager.qcolor(aqt.colors.CANVAS)) 44 | channel = QWebChannel(self.web) 45 | py = Bridge(self) 46 | channel.registerObject("py", py) 47 | self.web.page().setWebChannel(channel) 48 | 49 | vlayout.addWidget(self.web) 50 | btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok, self) 51 | vlayout.addWidget(btns) 52 | btns.accepted.connect(self.accept) 53 | btns.rejected.connect(self.reject) 54 | btns.setContentsMargins(5, 0, 5, 5) 55 | self.resize(600, 800) 56 | html = f''' 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 73 | 74 | 75 |
    76 | 84 | 85 | 86 | ''' 87 | self.web.setHtml(html, QUrl(aqt.mw.serverURL())) 88 | 89 | name = note.items()[0][1] or "[new]" 90 | if len(name) > 15: 91 | name = name[:15] + "..." 92 | 93 | def __del__(self): 94 | global _dlgs 95 | _dlgs.pop(hex(id(self)), None) 96 | 97 | ########################################################################### 98 | def accept(self) -> None: 99 | """Main window accept""" 100 | global _config 101 | def save_field(res): 102 | if not res: 103 | return 104 | if self.editor.addMode or self.editor.note.id == self.nid: 105 | note = self.editor.note 106 | focus = True 107 | else: 108 | note = aqt.mw.col.get_note(self.nid) 109 | focus = False 110 | 111 | if type(res) == str: # Partial note 112 | note.fields[self.fid] = res 113 | else: # Complete note 114 | for (title, content) in res: note[title] = content 115 | 116 | if focus: self.editor.loadNoteKeepingFocus() 117 | else: aqt.mw.col.update_note(note) 118 | 119 | self.web.page().runJavaScript(f'''(function () {{ 120 | return mdi_editor.get_html(); 121 | }})();''', save_field) 122 | _config[WINDOW_INPUT][LAST_GEOM] = base64.b64encode(self.saveGeometry()).decode('utf-8') 123 | aqt.mw.addonManager.writeConfig(__name__, _config) 124 | super().close() 125 | 126 | ########################################################################### 127 | def reject(self): 128 | """Main window reject""" 129 | global _config 130 | _config[WINDOW_INPUT][LAST_GEOM] = base64.b64encode(self.saveGeometry()).decode('utf-8') 131 | aqt.mw.addonManager.writeConfig(__name__, _config) 132 | super().close() 133 | 134 | ########################################################################### 135 | def edit_field(editor: aqt.editor.Editor): 136 | """Open the markdown window for selected field""" 137 | global _config 138 | 139 | if editor.currentField == None: 140 | return 141 | dlg = IM_window(editor, editor.note, editor.currentField) 142 | if _config[WINDOW_INPUT][SC_ACCEPT]: 143 | QShortcut(_config[WINDOW_INPUT][SC_ACCEPT], dlg).activated.connect(dlg.accept) 144 | if _config[WINDOW_INPUT][SC_REJECT]: 145 | QShortcut(_config[WINDOW_INPUT][SC_REJECT], dlg).activated.connect(dlg.reject) 146 | 147 | if _config[WINDOW_INPUT][SIZE_MODE].lower() == 'last': 148 | dlg.restoreGeometry(base64.b64decode(_config[WINDOW_INPUT][LAST_GEOM])) 149 | elif match:= re.match(r'^(\d+)x(\d+)', _config[WINDOW_INPUT][SIZE_MODE]): 150 | par_geom = editor.parentWindow.geometry() 151 | geom = QRect(par_geom) 152 | scr_geom = aqt.mw.app.primaryScreen().geometry() 153 | 154 | geom.setWidth(int(match.group(1))) 155 | geom.setHeight(int(match.group(2))) 156 | if geom.width() > scr_geom.width(): 157 | geom.setWidth(scr_geom.width()) 158 | if geom.height() > scr_geom.height(): 159 | geom.setHeight(scr_geom.height()) 160 | geom.moveCenter(par_geom.center()) 161 | if geom.x() < 0: 162 | geom.setX(0) 163 | if geom.y() < 0: 164 | geom.setY(0) 165 | 166 | dlg.setGeometry(geom) 167 | else: 168 | dlg.setGeometry(editor.parentWindow.geometry()) 169 | 170 | dlg.show() 171 | 172 | ########################################################################### 173 | def init(cfg: object): 174 | """Configure and activate markdown input window""" 175 | global _config 176 | def editor_btn(buttons, editor): 177 | btn = editor.addButton( 178 | os.path.join(ADDON_PATH, "markdown.png"), 179 | "md_dlg_btn", 180 | edit_field, 181 | tip=f"Markdown Input ({_config[WINDOW_INPUT][SC_OPEN]})", 182 | keys=_config[WINDOW_INPUT][SC_OPEN] 183 | ) 184 | buttons.append(btn) 185 | return buttons 186 | 187 | _config = cfg 188 | aqt.gui_hooks.editor_did_init_buttons.append(editor_btn) 189 | 190 | -------------------------------------------------------------------------------- /src/py/window_qt5.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'window.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.15.4 6 | # 7 | # WARNING: Any manual changes made to this file will be lost when pyuic5 is 8 | # run again. Do not edit this file unless you know what you are doing. 9 | 10 | 11 | from PyQt5 import QtCore, QtGui, QtWidgets 12 | 13 | 14 | class Ui_window(object): 15 | def setupUi(self, window): 16 | window.setObjectName("window") 17 | window.resize(600, 800) 18 | self.centralwidget = QtWidgets.QWidget(window) 19 | self.centralwidget.setObjectName("centralwidget") 20 | self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) 21 | self.verticalLayout.setObjectName("verticalLayout") 22 | self.web = QtWebEngineWidgets.QWebEngineView(self.centralwidget) 23 | self.web.setUrl(QtCore.QUrl("about:blank")) 24 | self.web.setObjectName("web") 25 | self.verticalLayout.addWidget(self.web) 26 | self.btns = QtWidgets.QDialogButtonBox(self.centralwidget) 27 | self.btns.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) 28 | self.btns.setObjectName("btns") 29 | self.verticalLayout.addWidget(self.btns) 30 | window.setCentralWidget(self.centralwidget) 31 | 32 | self.retranslateUi(window) 33 | QtCore.QMetaObject.connectSlotsByName(window) 34 | 35 | def retranslateUi(self, window): 36 | _translate = QtCore.QCoreApplication.translate 37 | window.setWindowTitle(_translate("window", "Markdown")) 38 | from PyQt5 import QtWebEngineWidgets 39 | 40 | 41 | if __name__ == "__main__": 42 | import sys 43 | app = QtWidgets.QApplication(sys.argv) 44 | window = QtWidgets.QMainWindow() 45 | ui = Ui_window() 46 | ui.setupUi(window) 47 | window.show() 48 | sys.exit(app.exec_()) 49 | -------------------------------------------------------------------------------- /src/py/window_qt6.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'window.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.2.2 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from PyQt6 import QtCore, QtGui, QtWidgets 10 | 11 | 12 | class Ui_window(object): 13 | def setupUi(self, window): 14 | window.setObjectName("window") 15 | window.resize(600, 800) 16 | self.centralwidget = QtWidgets.QWidget(window) 17 | self.centralwidget.setObjectName("centralwidget") 18 | self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) 19 | self.verticalLayout.setObjectName("verticalLayout") 20 | self.web = QtWebEngineWidgets.QWebEngineView(self.centralwidget) 21 | self.web.setUrl(QtCore.QUrl("about:blank")) 22 | self.web.setObjectName("web") 23 | self.verticalLayout.addWidget(self.web) 24 | self.btns = QtWidgets.QDialogButtonBox(self.centralwidget) 25 | self.btns.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok) 26 | self.btns.setObjectName("btns") 27 | self.verticalLayout.addWidget(self.btns) 28 | window.setCentralWidget(self.centralwidget) 29 | 30 | self.retranslateUi(window) 31 | QtCore.QMetaObject.connectSlotsByName(window) 32 | 33 | def retranslateUi(self, window): 34 | _translate = QtCore.QCoreApplication.translate 35 | window.setWindowTitle(_translate("window", "Markdown")) 36 | from PyQt6 import QtWebEngineWidgets 37 | 38 | 39 | if __name__ == "__main__": 40 | import sys 41 | app = QtWidgets.QApplication(sys.argv) 42 | window = QtWidgets.QMainWindow() 43 | ui = Ui_window() 44 | ui.setupUi(window) 45 | window.show() 46 | sys.exit(app.exec()) 47 | -------------------------------------------------------------------------------- /src/ts/CodeMirror.extensions/ankiImagePaste.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view" 2 | 3 | declare function bridgeCommand(msg: string): void 4 | declare function bridgeCommand(msg: string, cb: (txt: string) => void): string 5 | 6 | /** 7 | * Extension to handle pasting images into editor, adding it to Anki and 8 | * inserting markdown link 9 | */ 10 | export function ankiImagePaste(options = {}): any { 11 | return EditorView.domEventHandlers({ 12 | paste(event, view) { 13 | bridgeCommand("clipboard_image_to_markdown", (txt) => { 14 | if (txt) { 15 | const selection = view.state.selection 16 | const trs: {}[] = [] 17 | selection.ranges.forEach((rng, n) => { 18 | trs.push({ 19 | changes: { 20 | from: rng.from, to: rng.to, 21 | insert: txt 22 | } 23 | }) 24 | }) 25 | view.dispatch(...trs) 26 | view.dispatch({ 27 | selection: { 28 | anchor: view.state.selection.main.from + 2 29 | } 30 | }) 31 | return true 32 | } 33 | }) 34 | return false 35 | } 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/ts/CodeMirror.extensions/cloze_decorator.ts: -------------------------------------------------------------------------------- 1 | import { EditorView, ViewUpdate, Decoration, DecorationSet, ViewPlugin } from "@codemirror/view" 2 | import { EditorState, RangeSetBuilder } from "@codemirror/state" 3 | 4 | const CLOZE = Decoration.mark({ 5 | attributes: {class: "cm-cloze"} 6 | }) 7 | const CLOZE_RE = new RegExp(String.raw`(?:{{c\d+::|}})`, 'g') 8 | 9 | function _deco(view: EditorView) { 10 | let builder = new RangeSetBuilder() 11 | for (let {from, to} of view.visibleRanges) { 12 | for (let pos = from; pos <= to;) { 13 | let line = view.state.doc.lineAt(pos) 14 | for (const match of line.text.matchAll(CLOZE_RE)) { 15 | builder.add(line.from + match.index, line.from + match.index + match[0].length, CLOZE) 16 | } 17 | pos = line.to + 1 18 | } 19 | } 20 | return builder.finish() 21 | } 22 | 23 | const cloze_decorator = ViewPlugin.fromClass(class { 24 | decorations: DecorationSet 25 | 26 | constructor(view: EditorView) { 27 | this.decorations = _deco(view) 28 | } 29 | 30 | update(update: ViewUpdate) { 31 | if (update.docChanged || update.viewportChanged) 32 | this.decorations = _deco(update.view) 33 | } 34 | }, { 35 | decorations: v => v.decorations 36 | }) 37 | 38 | export {cloze_decorator} 39 | 40 | -------------------------------------------------------------------------------- /src/ts/CodeMirror.extensions/markdown_extensions.ts: -------------------------------------------------------------------------------- 1 | import {MarkdownConfig, Line, LeafBlockParser, BlockContext, InlineContext, LeafBlock} from "@lezer/markdown" 2 | import {tags as t} from "@lezer/highlight" 3 | 4 | // Copied without change from lezer/markdown 5 | //https://github.com/lezer-parser/markdown/blob/bd2b2dd03eb04cc64da4dc634e3462380dd03e05/src/markdown.ts#L232 6 | function space(ch: number) { return ch == 32 || ch == 9 || ch == 10 || ch == 13 } 7 | 8 | // Copied without change from lezer/subscript 9 | // https://github.com/lezer-parser/markdown/blob/bd2b2dd03eb04cc64da4dc634e3462380dd03e05/src/extension.ts#L169 10 | function parseUnderline(ch: number, node: string, mark: string) { 11 | return (cx: InlineContext, next: number, pos: number) => { 12 | if (next != ch || cx.char(pos + 1) == ch) return -1 13 | let elts = [cx.elt(mark, pos, pos + 1)] 14 | for (let i = pos + 1; i < cx.end; i++) { 15 | let next = cx.char(i) 16 | if (next == ch) 17 | return cx.addElement(cx.elt(node, pos, i + 1, elts.concat(cx.elt(mark, i, i + 1)))) 18 | if (next == 92 /* '\\' */) 19 | elts.push(cx.elt("Escape", i, i++ + 2)) 20 | if (space(next)) break 21 | } 22 | return -1 23 | } 24 | } 25 | 26 | // Copied from lezer/subscript - only replaced "Superscript" and "^" 27 | // https://github.com/lezer-parser/markdown/blob/bd2b2dd03eb04cc64da4dc634e3462380dd03e05/src/extension.ts 28 | /// Extension providing _underline_ using `_` markers 29 | export const Underline: MarkdownConfig = { 30 | defineNodes: [ 31 | {name: "Underline", style: t.special(t.content)}, 32 | {name: "UnderlineMark", style: t.processingInstruction} 33 | ], 34 | parseInline: [{ 35 | name: "Underline", 36 | parse: parseUnderline(95 /* '_' */, "Underline", "UnderlineMark") 37 | }] 38 | } -------------------------------------------------------------------------------- /src/ts/CodeMirror.extensions/mdi_commands.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom editor commands for mapping to keymap 3 | */ 4 | import { EditorView } from "@codemirror/view" 5 | 6 | const CLOZE_ORD_RE = new RegExp(String.raw`{{c(\d+)::`, 'g') 7 | /** 8 | * Wrap selection(s) in cloze tags 9 | * @param inc increase ordinal or not 10 | * @returns 11 | */ 12 | const clozeSelections = (inc: boolean) => (view: EditorView) => { 13 | const selection = view.state.selection 14 | let i = 0 15 | let itr = view.state.doc.iter() 16 | while (!itr.done) { 17 | if (!itr.lineBreak) { 18 | let match: RegExpExecArray 19 | while ((match = CLOZE_ORD_RE.exec(itr.value)) !== null) { 20 | const n = parseInt(match[1]) 21 | if (n > i) i = n 22 | } 23 | } 24 | itr.next() 25 | } 26 | 27 | const trs = [] 28 | selection.ranges.forEach((rng, n) => { 29 | if (inc) i++ 30 | if (rng.empty) { 31 | trs.push({ 32 | changes: { 33 | from: rng.from, to: rng.from, 34 | insert: `{{c${i || 1}::}}` 35 | } 36 | }) 37 | } else { 38 | trs.push( 39 | { 40 | changes: { 41 | from: rng.from, to: rng.from, 42 | insert: `{{c${i || 1}::` 43 | } 44 | }, 45 | { 46 | changes: { 47 | from: rng.to, to: rng.to, 48 | insert: '}}' 49 | } 50 | } 51 | ) 52 | 53 | } 54 | }) 55 | view.dispatch(...trs) 56 | const mrng = view.state.selection.main 57 | const startl = `\{\{c${i}::`.length 58 | view.dispatch({ 59 | selection: { 60 | anchor: mrng.empty 61 | ? mrng.from + startl 62 | : mrng.head > mrng.anchor 63 | ? mrng.head + 2 64 | : mrng.head - startl 65 | } 66 | }) 67 | return true 68 | } 69 | 70 | // Public cloze commands 71 | /** Encloze selection(s) incrementing cloze ordinal */ 72 | export const clozeNext = clozeSelections(true) 73 | /** Encloze selection(s) without incrementing cloze ordinal */ 74 | export const clozeCurrent = clozeSelections(false) 75 | 76 | /** Joint lines in selection(s) (or next line if no selection) */ 77 | export const joinLines = (view: EditorView) => { 78 | const selection = view.state.selection 79 | const text = view.state.doc.toString() 80 | let dispatched = false 81 | selection.ranges.forEach((rng, n) => { 82 | const to = rng.empty ? text.length : rng.to 83 | const cursor = rng.empty ? rng.from : -1 84 | const from = rng.empty 85 | ? text.slice(0, rng.from).lastIndexOf('\n') + 1 86 | : rng.from 87 | const tin = text.slice(from, to) 88 | const tout = rng.empty 89 | ? tin.replace(/\s*\n[\n\s]*/, ' ') 90 | : tin.replace(/\s*\n[\n\s]*/g, ' ') 91 | if (tout !== tin) { 92 | dispatched = true 93 | view.dispatch({ 94 | changes: { 95 | from: from, to: to, 96 | insert: tout 97 | } 98 | }) 99 | if (cursor !== -1) 100 | view.dispatch({ selection: { anchor: cursor } }) 101 | } 102 | }) 103 | 104 | return dispatched 105 | } 106 | -------------------------------------------------------------------------------- /src/ts/commands.ts: -------------------------------------------------------------------------------- 1 | import * as commands from "@codemirror/commands" 2 | import * as search from "search" 3 | import * as mdi_commands from "./CodeMirror.extensions/mdi_commands" 4 | 5 | const _fns: any[] = [] 6 | 7 | export function to_function(name: string) { 8 | if (!_fns.length) _init([commands, search, mdi_commands]) 9 | return _fns[name] 10 | 11 | function _init(namespaces: any[]) { 12 | for (const namespace of namespaces) 13 | for (const [k, v] of Object.entries(namespace)) 14 | _fns[k] = v 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/ts/constants.ts: -------------------------------------------------------------------------------- 1 | // Sync with constants.py 2 | 3 | // Field input 4 | export const FIELD_INPUT = 'Field input' 5 | export const SC_TOGGLE = 'Shortcut' 6 | export const SC_RICH = "Rich text shortcut" 7 | export const SC_NEXT = "Next field" 8 | export const SC_PREV = "Previous field" 9 | export const FIELD_DEFAULT = 'Default field state' 10 | export const CYCLE_RICH_MD = "Cycle rich text/Markdown" 11 | export const HIDE_TOOL = "Autohide toolbar" 12 | 13 | // Window input 14 | export const WINDOW_INPUT = "Window input" 15 | export const WINDOW_MODE = "Mode" 16 | export const SC_OPEN = 'Shortcut' 17 | export const SC_ACCEPT = "Shortcut accept" 18 | export const SC_REJECT = "Shortcut reject" 19 | export const SIZE_MODE = "Size mode" // "parent", "last", WIDTHxHEIGHT (e.g "1280x1024") 20 | export const CSS = "CSS" 21 | export const LAST_GEOM = "Last geometry" 22 | 23 | // Converter 24 | export const CONVERTER = "Converter" 25 | export const MD_FORMAT = "Markdown format" 26 | export const MD_EXTENSIONS = "Markdown extensions" 27 | 28 | // Editor 29 | export const EDITOR = "CodeMirror" 30 | export const KEYMAP = "keymap" 31 | export const THEME = "Theme" 32 | export const SYNTAX = "Syntax highlighting" 33 | 34 | // General 35 | export const MDI = "MDI" 36 | 37 | import { Options as AnkiMdHtmlOptions } from "anki-md-html" 38 | import { Configuration as CMConfiguration } from "./editor" 39 | export interface Configuration { 40 | 'Field input': {}, 41 | 'Converter': AnkiMdHtmlOptions, 42 | 'CodeMirror': CMConfiguration 43 | } -------------------------------------------------------------------------------- /src/ts/custom_input.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store" 2 | // @ts-ignore FIXME: how to import correctly? 3 | import type { EditorFieldAPI } from "anki/ts/editor/EditorField.svelte" 4 | import { ancestor } from "./utils" 5 | 6 | 7 | /** 8 | * CONCEPT 9 | * CustomInputClass manages interaction with the note editor, CustomInputAPI is the 10 | * input instance of a specific field. 11 | * "CustomInputClass is the NoteEditor array of CustomInputAPI elements" 12 | */ 13 | 14 | 15 | /** 16 | * Defines the CustomInputAPI's. 17 | */ 18 | class CustomInputConfig { 19 | /** CSS class name (must be valid & unique) */ 20 | readonly class_name: string 21 | /** badge tooltip string */ 22 | readonly tooltip: string 23 | /** badge svg HTML tag to be used as badge */ 24 | readonly badge: string 25 | /** non-arrow function callback to set the content of an editor instance (i.e. on update from another input). Will be called with the CustomInputAPI as `this` and the new content HTML string as parameter. */ 26 | readonly set_content: (html: string) => void 27 | /** non-arrow function callback to focus custom editor instance. Will be called with the CustomInputAPI as `this`. */ 28 | readonly focus: () => void 29 | /** non-arrow function callback to instantiate custom input editor. Will be called with the HTML div that should be set as input parent and a callback function to be called to update the other inputs. Will be called with the CustomInputAPI as `this` and the new content HTML string as parameter and must return the instance. */ 30 | readonly create_editor: (container: HTMLElement, onchange: (html: string) => void) => any 31 | /** non-arrow function callback called after a custom input has been added to a field, use for example to set field input default states. Will be called with the CustomInputAPI as `this`. */ 32 | readonly onadd?: () => void 33 | } 34 | 35 | /** 36 | * Instantiate to create a custom input class. The class handles all 37 | * interaction with the note editor, however the custom_input.py must be 38 | * included on the python side for media paste functionality. 39 | * @member constructor create a new custom input class 40 | * @member get_api get custom input api for specified field 41 | * @member update_all update all custom input editor contents, e.g. on note load, 42 | * will also add CustomInputAPI to any field missing (e.g. on note type switch) 43 | * @member cycle_next Move focus to the next input/down 44 | * @member cycle_prev Move focus to the preceeding input/up 45 | */ 46 | class CustomInputClass extends CustomInputConfig { 47 | readonly name: string 48 | /** 49 | * Class managing the addition and setup/configuration of CustomInputAPI's for 50 | * fields as necessary as well as note level functionality 51 | * @param config configuration for the CustomInputAPI's 52 | */ 53 | constructor(config: CustomInputConfig) { 54 | super() 55 | Object.assign(this, config) 56 | this.name = this.class_name.replace('-', '_') 57 | } 58 | 59 | /** 60 | * Get CustomInputAPI for field by index orEditorFieldAPI instance 61 | * @param field field to get EditorFieldAPI for 62 | * @returns CustomInputAPI | undefined 63 | */ 64 | async get_api(field: number | EditorFieldAPI): Promise { 65 | const el = await (typeof (field) === 'number' 66 | ? require('anki/NoteEditor').instances[0].fields[field] 67 | : field 68 | ).element 69 | return el[this.name] 70 | } 71 | 72 | /** 73 | * Update custom input content in all visible custom inputs, e.g. on note load 74 | * Add custom inputs, inc. badges, to fields PRN 75 | */ 76 | async update_all() { 77 | for (const fld of await require('anki/NoteEditor').instances[0].fields) { 78 | const editor_field = await fld.element as HTMLElement // await necessary 79 | if (!editor_field[this.name]) { 80 | editor_field[this.name] = new CustomInputAPI(this, await fld, await fld.element) 81 | if (this.onadd) this.onadd.call(editor_field[this.name]) 82 | } else if (editor_field.contains(document.activeElement)) 83 | this.focus.call(editor_field[this.name]) 84 | 85 | // Update custom input contents PRN 86 | if (editor_field[this.name].editor_container.classList.contains('expanded')) 87 | this.set_content.call(editor_field[this.name], get(fld.editingArea.content) as string) 88 | } 89 | } 90 | 91 | /** 92 | * Cycle focus to next field/down or first if none active 93 | */ 94 | async cycle_next() { 95 | const active = ancestor(document.activeElement as HTMLElement, '.editing-area > div') 96 | // Check for inputs in current field 97 | let nxt = next_sibling(active) 98 | // No inputs in current field, find first visible in next field 99 | if (!nxt) { 100 | let fld_root = ancestor(active, '.fields > div') as HTMLElement 101 | while (fld_root && !nxt) { 102 | fld_root = fld_root.nextElementSibling as HTMLElement 103 | nxt = fld_root?.querySelector('.editing-area > div:not(.hidden)') as HTMLElement 104 | } 105 | } 106 | if (nxt) this._focus_input(nxt) 107 | 108 | function next_sibling(fld: HTMLElement) { 109 | let nxt = fld?.nextElementSibling as HTMLElement 110 | while (nxt?.classList.contains('hidden')) 111 | nxt = nxt.nextElementSibling as HTMLElement 112 | return nxt 113 | } 114 | } 115 | 116 | /** 117 | * Cycle focus to preceeding field/up or first if none active 118 | */ 119 | async cycle_prev() { 120 | const active = ancestor(document.activeElement as HTMLElement, '.editing-area > div') 121 | // Check for inputs in current field 122 | let prev = prev_sibling(active) 123 | // No inputs in current field, find first visible in next field 124 | if (!prev) { 125 | let fld_root = ancestor(active, '.fields > div') as HTMLElement 126 | while (fld_root && !prev) { 127 | fld_root = fld_root.previousElementSibling as HTMLElement 128 | const prevs = fld_root?.querySelectorAll('.editing-area > div:not(.hidden)') 129 | if (prevs?.length) prev = prevs[prevs.length - 1] as HTMLElement 130 | } 131 | } 132 | if (prev) this._focus_input(prev) 133 | 134 | function prev_sibling(fld: HTMLElement) { 135 | let prev = fld?.previousElementSibling as HTMLElement 136 | while (prev?.classList.contains('hidden')) 137 | prev = prev.previousElementSibling as HTMLElement 138 | return prev 139 | } 140 | } 141 | 142 | 143 | /** 144 | * Focus the editor (custom, plain or rich text) of a an input element 145 | * @param input container element of editor to focus 146 | */ 147 | _focus_input(input: HTMLElement) { 148 | const el = input.querySelector(`.${this.class_name}`) 149 | // Custom input 150 | ? ancestor(input, '.editor-field')[this.name] 151 | // Anki plain text input 152 | : input.querySelector('.CodeMirror > div > textarea') || 153 | // Anki rich text input 154 | input.querySelector('.rich-text-editable')?.shadowRoot.querySelector('anki-editable') 155 | 156 | if (el) el.focus() 157 | } 158 | } 159 | 160 | ///////////////////////////////////////////////////////////////////////////////////////////// 161 | ///////////////////////////////////////////////////////////////////////////////////////////// 162 | ///////////////////////////////////////////////////////////////////////////////////////////// 163 | 164 | 165 | /** 166 | * Custom Input API for a single field - instantiated by CustomInputClass PRN 167 | * @readonly 168 | * @property custom_input_class CustomInputClass to which the API instance belongs 169 | * @property anki_editor_field_api core Anki/Svelte editor field api to which the CustomInputAPI belongs 170 | * @property editor_container "root" HTML element in which the custom editor is placed (inside a div.custom-class-name) 171 | * @property badge_container the HTML element that contains the badge 172 | * @property editor the field custom input editor instance (created on demand) 173 | * @member set_content set content of custom input editor 174 | * @member focus focus the custom input 175 | * @member visible is custom input editor visible 176 | * @member rich_visible is rich text input visible 177 | * @member plain_visible is plain text input visible 178 | * @member toggle toggle custom input show/hide state 179 | * @member show show custom input editor 180 | * @member hide hide custom input editor 181 | * @member toggle_rich toggle rich text input show/hide state 182 | * @member toggle_plain toggle plain text show/hide state 183 | * @member update call to update editor field content (and set rich and plain text PRN) 184 | */ 185 | class CustomInputAPI { 186 | // DOM input: .fields → div (field root) → .field-container → .collapsible → .editor-field → .editing-area → .collapsible (root of input) → .rich/.plain/.custom-text-input 187 | // DOM badge: .fields → div (field root) → .field-container → div → .label-container → .field-state → .plain-text/.custom-badge 188 | 189 | /////////////////////////////////////////////////////////////////// 190 | // Public properties 191 | /** CustomInputClass to which the API instance belongs */ 192 | readonly custom_input_class: CustomInputClass 193 | /** core Anki/Svelte editor field api to which the CustomInputAPI belongs */ 194 | readonly anki_editor_field_api: EditorFieldAPI 195 | /** .collapsible "root" custom input container element */ 196 | readonly editor_container: HTMLElement 197 | /** .custom-badge */ 198 | readonly badge_container: HTMLSpanElement 199 | editor_: any 200 | /** custom input editor (created on read) */ 201 | get editor() { 202 | if (!this.editor_) this.editor_ = this.custom_input_class.create_editor.call(this, 203 | this.editor_container.firstElementChild as HTMLElement, 204 | (html:string) => { 205 | this.anki_editor_field_api.editingArea.content.set(html) 206 | // Store resulting HTML to avoid uneccessary updates 207 | this._html = get(this.anki_editor_field_api.editingArea.content) 208 | } 209 | ) 210 | return this.editor_ 211 | } 212 | 213 | // Temporary store for resulting Anki HTML after each update 214 | _html: string 215 | 216 | 217 | /////////////////////////////////////////////////////////////////// 218 | // Public methods 219 | 220 | /** Set custom input editor content */ 221 | set_content(html: string) { 222 | // Avoid uneccessary updates (as it will mess with whitespace etc) 223 | if (html === this._html) return 224 | this._html = html 225 | this.custom_input_class.set_content.call(this, html) 226 | } 227 | 228 | /** User supplied callback to focus custom input */ 229 | focus() {return this.custom_input_class.focus.call(this)} 230 | 231 | /** Check if custom input editor is visible */ 232 | visible() { return this._visible(this.custom_input_class.class_name) } 233 | 234 | /** Check if rich text input is visible */ 235 | rich_visible() { return this._visible('rich-text-input') } 236 | 237 | /** Check if plain text input is visible */ 238 | plain_visible() { return this._visible('plain-text-input') } 239 | 240 | /** Show custom input */ 241 | show() { 242 | this.editor // ensure instantiated 243 | this._unsubscribe() 244 | const html = get(this.anki_editor_field_api.editingArea.content) as string 245 | this.set_content(html) 246 | this.editor_container.classList.replace('hidden', 'expanded') 247 | this.badge_container.parentElement.parentElement.classList.add('highlighted') 248 | this.focus() 249 | } 250 | 251 | /** Hide custom input unless no visible sibling inputs (all inputs hidden = no way to focus) */ 252 | hide(force?: boolean) { 253 | // When all fields are hidden there is no way to focus the field ⇒ 254 | // prevent hiding of last visible input 255 | if (!force && !this._visible_siblings(this.custom_input_class.class_name)) return 256 | this._unsubscribe() 257 | this.editor_container.classList.replace('expanded', 'hidden') 258 | this.badge_container.parentElement.parentElement.classList.remove('highlighted') 259 | this.anki_editor_field_api.editingArea.refocus() 260 | } 261 | 262 | /** Toggle custom input visibility */ 263 | toggle(force?: boolean) { 264 | if (!this.editor_ || this.editor_container.classList.contains('hidden')) this.show() 265 | else this.hide(force) 266 | } 267 | 268 | /** Toggle rich text input visibility */ 269 | toggle_rich(force?: boolean) { 270 | if (!force && !this._visible_siblings('rich-text-input')) return 271 | this._toggle_builtin('rich-text-input') 272 | } 273 | 274 | /** Toggle plain text input visibility */ 275 | toggle_plain(force?: boolean) { 276 | if (!force && !this._visible_siblings('plain-text-input')) return 277 | this._toggle_builtin('plain-text-input') 278 | } 279 | 280 | /** 281 | * @param input_class the CustomInputClass instance to which the instance belongs 282 | * @param anki_editor_field_api field EditorFieldAPI (promise must be resolved) 283 | * @param editor_field_el EditorFieldAPI.element (promise must be resolved) 284 | */ 285 | constructor(input_class: CustomInputClass, anki_editor_field_api: EditorFieldAPI, editor_field_el: HTMLElement) { 286 | this.custom_input_class = input_class 287 | this.anki_editor_field_api = anki_editor_field_api 288 | // Store current core Anki HTML so we can avoid updating custom input when no change is made 289 | this._html = '' 290 | 291 | const class_name = input_class.class_name 292 | const editing_area = editor_field_el.querySelector('.editing-area') as HTMLElement 293 | const field_container = ancestor(editor_field_el, '.field-container') 294 | 295 | // Set up editor container (editor not instansiated until use) 296 | const wrapper = editing_area.querySelector('.plain-text-input').parentElement 297 | .cloneNode(false) as HTMLDivElement 298 | wrapper.classList.replace('expanded', 'hidden') 299 | const inner = document.createElement('div') 300 | inner.classList.add(class_name) 301 | wrapper.appendChild(inner) 302 | this.editor_container = editing_area.insertBefore(wrapper, editing_area.firstElementChild) 303 | 304 | // Set up badge 305 | const badge_container = field_container.querySelector('.field-state') 306 | const plain_badge = badge_container.querySelector('.plain-text-badge') 307 | this.badge_container = plain_badge.cloneNode(true) as HTMLElement 308 | this.badge_container.classList.replace('plain-text-badge', `${class_name}-badge`) 309 | this.badge_container.onclick = () => this.toggle() 310 | // Copy `visible` class (hover⇒visible functionality) 311 | this.badge_container['observer'] = 312 | new MutationObserver((muts: MutationRecord[], obs: MutationObserver) => { 313 | muts.forEach(mut => { 314 | if ((mut.target as HTMLElement).classList.contains('visible')) 315 | this.badge_container.classList.add('visible') 316 | else 317 | this.badge_container.classList.remove('visible') 318 | }) 319 | }) 320 | this.badge_container['observer'].observe(plain_badge, { attributeFilter: ['class'] }) 321 | const gfx = this.badge_container.querySelector('.badge') as HTMLElement 322 | gfx.title = input_class.tooltip 323 | gfx.querySelector('span').innerHTML = input_class.badge 324 | badge_container.insertBefore(this.badge_container, badge_container.firstElementChild) 325 | 326 | // Handle focus events for subscribing/unsubscribing 327 | field_container.addEventListener('focusin', (evt: Event) => { 328 | // We focus this custom input, unsubscribe 329 | if (ancestor(evt.target as HTMLElement, `.${class_name}`)) this._unsubscribe() 330 | // We focus something else, subscribe 331 | else this._subscribe() 332 | }) 333 | field_container.addEventListener('focusout', evt => { 334 | // New focus outside custom input 335 | if (!ancestor((evt.relatedTarget as HTMLElement), `.${class_name}`)) 336 | this._subscribe() 337 | }) 338 | } 339 | 340 | /////////////////////////////////////////////////////////////////// 341 | // Private 342 | 343 | /** Unsubscribe function if custom input is currently subscribed */ 344 | _do_unsubscribe: () => void 345 | 346 | /** 347 | * Unsubscribe custom input (i.e. now inputing into custom editor) 348 | */ 349 | _unsubscribe() { 350 | if (this._do_unsubscribe) { 351 | this._do_unsubscribe() 352 | this._do_unsubscribe = null 353 | } 354 | } 355 | 356 | /** 357 | * Subscribe custom input to field content updates (i.e. now not inputing into custom editor) 358 | */ 359 | _subscribe() { 360 | if (this._do_unsubscribe) return 361 | this._do_unsubscribe = this.anki_editor_field_api.editingArea.content.subscribe((html: string) => { 362 | this.set_content(html) 363 | }) 364 | } 365 | 366 | /** 367 | * Toggle rich/plain-text-inputs based on class name 368 | * @param class_name `rich-text-input` or `plain-text-input` 369 | */ 370 | _toggle_builtin(class_name: string) { 371 | const el = this.editor_container.parentElement 372 | const input = el.querySelector(`.${class_name}`).parentElement 373 | if (input.classList.contains('hidden')) { 374 | input.classList.replace('hidden', 'expanded') || // <2.1.63 375 | input.classList.replace('hidden', 'measuring') // 2.1.63+ 376 | } 377 | else if (this._visible_siblings(class_name)) { 378 | input.classList.replace('expanded', 'hidden') || 379 | input.classList.replace('measuring', 'hidden') 380 | } 381 | } 382 | 383 | /** 384 | * Check if input is visible (not hidden) 385 | * @param class_name class (rich-text/plain-text/custom - input) of input to check 386 | * @returns visible 387 | */ 388 | _visible(class_name: string) { 389 | return Boolean( 390 | this.editor_container.parentElement 391 | .querySelector(`div:not(.hidden) > div.${class_name}`) 392 | ) 393 | } 394 | 395 | /** 396 | * Check if there are visible input siblings 397 | * @param class_name CSS of class to check for siblings for 398 | */ 399 | _visible_siblings(class_name: string) { 400 | return Boolean( 401 | this.editor_container.parentElement 402 | .querySelector(`:scope > div:not(.hidden) > div:not(.${class_name})`) 403 | ) 404 | } 405 | } 406 | 407 | export { CustomInputClass, CustomInputAPI } 408 | -------------------------------------------------------------------------------- /src/ts/editor.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, keymap, highlightSpecialChars, drawSelection, rectangularSelection, crosshairCursor, highlightActiveLine, dropCursor} from "@codemirror/view" 2 | import {EditorState, Transaction, EditorSelection, SelectionRange, Prec} from "@codemirror/state" 3 | import {indentOnInput, bracketMatching, indentUnit, syntaxHighlighting} from "@codemirror/language" 4 | import {languages} from "@codemirror/language-data" 5 | import {defaultKeymap, historyKeymap, indentWithTab, history} from "@codemirror/commands" 6 | import {closeBrackets, closeBracketsKeymap} from "@codemirror/autocomplete" 7 | import {highlightSelectionMatches, search} from "search" 8 | import {autocompletion, completionKeymap} from "@codemirror/autocomplete" 9 | import {markdown, markdownKeymap, markdownLanguage} from "@codemirror/lang-markdown" 10 | import {ankiImagePaste} from "./CodeMirror.extensions/ankiImagePaste" 11 | import {classHighlighter} from '@lezer/highlight' 12 | import {Subscript, Superscript, Strikethrough, Table} from "@lezer/markdown" 13 | 14 | import {to_function} from "./commands" 15 | import {Underline} from "./CodeMirror.extensions/markdown_extensions" 16 | import { cloze_decorator } from "./CodeMirror.extensions/cloze_decorator" 17 | 18 | /** CodeMirror instance configuration */ 19 | interface Shortcut { 20 | key: string, 21 | shift?: string, 22 | run?: string, 23 | scope?: string, 24 | preventDefault?: string 25 | } 26 | interface Configuration { 27 | parent: Element 28 | keymap?: [Shortcut] 29 | oninput?: (doc: string) => void 30 | events?: {} 31 | "search"?: { 32 | caseSensitive?: boolean 33 | regexp?: boolean 34 | wholeWord?: boolean 35 | } 36 | } 37 | 38 | const lezer_exts = [ 39 | Subscript, 40 | Superscript, 41 | Strikethrough, 42 | Table, 43 | Underline 44 | ] 45 | 46 | class Editor { 47 | cm: EditorView 48 | extensions: any[] 49 | 50 | constructor(cfg: Configuration) { 51 | const km = [] 52 | for (const sc of cfg.keymap) { 53 | const tmp: Shortcut = {key: sc.key} 54 | if('shift' in sc) tmp.shift = to_function(sc.shift) //cm_functions[sc.shift] 55 | if('run' in sc) tmp.run = to_function(sc.run) //cm_functions[sc.run] 56 | if('scope' in sc) tmp.scope = sc.scope 57 | if('preventDefault' in sc) tmp.preventDefault = sc.preventDefault 58 | km.push(tmp) 59 | } 60 | this.extensions = [ 61 | /*highlightSpecialChars(),*/ 62 | history(), 63 | /*drawSelection(),*/ 64 | dropCursor(), 65 | EditorState.allowMultipleSelections.of(true), 66 | indentOnInput(), 67 | bracketMatching(), 68 | closeBrackets(), 69 | autocompletion(), 70 | rectangularSelection(), 71 | search({top: true, ...cfg["search"]}), 72 | crosshairCursor(), 73 | highlightActiveLine(), 74 | highlightSelectionMatches(), 75 | syntaxHighlighting(classHighlighter, {fallback: false}), 76 | cloze_decorator, 77 | indentUnit.of(" "), 78 | // @ts-ignore FIXME: what is correct TS for below? 79 | Prec.highest( 80 | keymap.of([ 81 | ...km, 82 | ...closeBracketsKeymap, 83 | ...markdownKeymap, 84 | ...defaultKeymap, 85 | indentWithTab, 86 | ...historyKeymap, 87 | ...completionKeymap 88 | ]) 89 | ), 90 | EditorView.lineWrapping, 91 | markdown({ base: markdownLanguage, extensions: lezer_exts, codeLanguages: languages }), 92 | ankiImagePaste() 93 | ] 94 | if (cfg.events) this.extensions.push(EditorView.domEventHandlers(cfg.events)) 95 | 96 | let view = new EditorView({ 97 | state: EditorState.create({ 98 | doc: '', 99 | extensions: [ 100 | ] 101 | }) 102 | }); 103 | 104 | const editor_view = { 105 | state: EditorState.create({extensions: this.extensions}), 106 | parent: cfg.parent 107 | } 108 | 109 | if (cfg.oninput) { 110 | editor_view['dispatch'] = function(tr: Transaction) { 111 | const res = this.update([tr]) 112 | if (!tr.changes.empty) cfg.oninput(this.state.doc.toString()) 113 | return res 114 | } 115 | } 116 | 117 | this.cm = new EditorView(editor_view) 118 | } 119 | 120 | set_doc(doc: string, ord: number, pos: 'start'|'end') { 121 | this.cm.setState(EditorState.create({ 122 | doc: doc, 123 | extensions: this.extensions, 124 | selection: {anchor: pos === 'start' ? 0 : doc.length} 125 | })) 126 | } 127 | 128 | get_selections() { 129 | return this.cm.state.selection.ranges 130 | } 131 | 132 | set_selections(ranges: readonly SelectionRange[]) { 133 | this.cm.dispatch({selection: EditorSelection.create(ranges)}) 134 | } 135 | } 136 | 137 | function highlighter(cfg: {}) { 138 | 139 | } 140 | 141 | function theme(cfg: {}) { 142 | 143 | } 144 | 145 | export type {Shortcut, Configuration} 146 | export {Editor, highlighter, theme} -------------------------------------------------------------------------------- /src/ts/field_input.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore FIXME: how to import correctly? 2 | import type { EditorFieldAPI } from "anki/ts/editor/EditorField.svelte" 3 | import { Editor } from "./editor" 4 | import { Converter } from "anki-md-html" 5 | import { ancestor } from "./utils" 6 | import { CustomInputClass } from "./custom_input" 7 | import { CONVERTER, CYCLE_RICH_MD, EDITOR, FIELD_DEFAULT, FIELD_INPUT, 8 | SC_TOGGLE, HIDE_TOOL, MDI, Configuration } from "./constants" 9 | 10 | const MD = '' 11 | 12 | let _config 13 | let _converter 14 | let _editor_count 15 | 16 | ///////////////////////////////////////////////////////////////////////////// 17 | // Non-arrow function (for `this` use) to instantiate an editor instance 18 | function create_editor(parent: HTMLDivElement, onchange: (html: string) => void) { 19 | const events = { 20 | wheel(evt: WheelEvent) { 21 | const fields = ancestor(parent, '.fields') 22 | switch (evt.deltaMode) { 23 | case 0: //DOM_DELTA_PIXEL 24 | fields.scrollTop += evt.deltaY 25 | fields.scrollLeft += evt.deltaX 26 | break 27 | case 1: //DOM_DELTA_LINE 28 | fields.scrollTop += 15 * evt.deltaY 29 | fields.scrollLeft += 15 * evt.deltaX 30 | break 31 | case 2: //DOM_DELTA_PAGE 32 | fields.scrollTop += 0.03 * evt.deltaY 33 | fields.scrollLeft += 0.03 * evt.deltaX 34 | break 35 | } 36 | } 37 | } 38 | if (_config[FIELD_INPUT]?.[HIDE_TOOL]) { 39 | events['focusout'] = function (evt) { 40 | if ( 41 | ancestor((evt.relatedTarget as HTMLElement), `.note-editor`) && 42 | !ancestor((evt.relatedTarget as HTMLElement), `.${parent.className}`) 43 | ) { 44 | (document.querySelector('.editor-toolbar') as HTMLElement).hidden = false 45 | } 46 | } 47 | events['focusin'] = function (evt) { 48 | if (ancestor((evt.target as HTMLElement), `.${parent.className}`)) 49 | (document.querySelector('.editor-toolbar') as HTMLElement).hidden = true 50 | } 51 | } 52 | 53 | return new Editor({ 54 | parent: parent, 55 | oninput: (md: string) => onchange(_converter.markdown_to_html(md)), 56 | events: events, 57 | highlight: {}, 58 | theme: {}, 59 | ..._config[EDITOR] 60 | }) 61 | } 62 | 63 | 64 | ///////////////////////////////////////////////////////////////////////////// 65 | // Non-arrow function (for `this` use) to focus custom input editor 66 | function focus() { 67 | this.editor.cm.focus() 68 | } 69 | 70 | ///////////////////////////////////////////////////////////////////////////// 71 | // Non-arrow function (for `this` use) to set content of custom editor 72 | function set_content(html: string) { 73 | const [md, ord] = _converter.html_to_markdown(html) 74 | this.editor.set_doc(md, ord, 'end') 75 | } 76 | 77 | ///////////////////////////////////////////////////////////////////////////// 78 | // Non-arrow function (for `this` use) to set field input default state 79 | function onadd() { 80 | if (_config[FIELD_INPUT]?.[FIELD_DEFAULT]?.toLowerCase() === 'markdown') { 81 | this.toggle() 82 | this.toggle_rich() 83 | } else if (_config[FIELD_INPUT]?.[FIELD_DEFAULT]?.toLowerCase() === 'rich text/markdown') { 84 | this.toggle() 85 | // Ugly temporary hack to focus first Markdown input initially 86 | if(!_editor_count) { 87 | window.requestAnimationFrame(() => 88 | window.requestAnimationFrame(() => 89 | window.requestAnimationFrame(() => 90 | this.editor.cm.focus() 91 | ) 92 | ) 93 | ) 94 | } 95 | } 96 | _editor_count++ 97 | } 98 | 99 | ///////////////////////////////////////////////////////////////////////////// 100 | // Setup event listeners and configuration - create CM instances only on demand 101 | function init(cfg: Configuration) { 102 | _config = cfg 103 | _converter = new Converter(_config[CONVERTER]) 104 | let tip = "Toggle Markdown input" 105 | if (_config[FIELD_INPUT]?.[SC_TOGGLE]) tip += ` (${_config[FIELD_INPUT][SC_TOGGLE]})` 106 | _config[MDI] = new CustomInputClass({ 107 | class_name: "markdown-input", 108 | tooltip: tip, 109 | create_editor: create_editor, 110 | focus: focus, 111 | set_content: set_content, 112 | onadd: onadd, 113 | badge: MD 114 | }) 115 | _editor_count = 0 116 | } 117 | 118 | ///////////////////////////////////////////////////////////////////////////// 119 | // Toggle md input 120 | function toggle(field: number | EditorFieldAPI) { 121 | if (typeof field === 'number') 122 | _config[MDI].get_api(field).then(api => {do_toggle(api)}) 123 | else do_toggle(field) 124 | 125 | function do_toggle(api) { 126 | if (_config[FIELD_INPUT]?.[CYCLE_RICH_MD] && api.visible() !== api.rich_visible()) { 127 | api.toggle(true) 128 | api.toggle_rich(true) 129 | } else api.toggle() 130 | } 131 | } 132 | 133 | ///////////////////////////////////////////////////////////////////////////// 134 | // Toggle rich text input 135 | function toggle_rich(field: number | EditorFieldAPI) { 136 | if (typeof field === 'number') 137 | _config[MDI].get_api(field).then(api => api.toggle_rich()) 138 | else 139 | field.toggle_rich() 140 | } 141 | 142 | ///////////////////////////////////////////////////////////////////////////// 143 | // Update MD content in all visible MD input on note load 144 | function update_all() { 145 | _config[MDI].update_all() 146 | } 147 | 148 | ///////////////////////////////////////////////////////////////////////////// 149 | // Cycle to next input, changing field PRN 150 | function cycle_next() { 151 | _config[MDI].cycle_next() 152 | } 153 | 154 | ///////////////////////////////////////////////////////////////////////////// 155 | // Cycle to previous input, changing field PRN 156 | function cycle_prev() { 157 | _config[MDI].cycle_prev() 158 | } 159 | 160 | 161 | export type { CustomInputClass } from "./custom_input" 162 | export { init, toggle, toggle_rich, update_all, cycle_next, cycle_prev } 163 | -------------------------------------------------------------------------------- /src/ts/field_input_2.1.55.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store" 2 | // @ts-ignore FIXME: how to import correctly? 3 | import type { NoteEditorAPI } from "anki/ts/editor/NoteEditor.svelte" 4 | // @ts-ignore FIXME: how to import correctly? 5 | import type { EditorFieldAPI, EditingInputAPI } from "anki/ts/editor/EditorField.svelte" 6 | // @ts-ignore FIXME: how to import correctly? 7 | import type { RichTextInputAPI } from "anki/ts/editor/rich-text-input" 8 | // @ts-ignore FIXME: how to import correctly? 9 | import type { PlainTextInputAPI } from "anki/ts/editor/plain-text-input" 10 | import { Editor, Shortcut, Configuration as CMConfiguration } from "./editor" 11 | import { Converter, Options as AnkiMdHtmlOptions } from "anki-md-html" 12 | import { SelectionRange } from "@codemirror/state" 13 | import { CONVERTER, CYCLE_RICH_MD, EDITOR, FIELD_INPUT, SC_TOGGLE } from "./constants" 14 | 15 | ///////////////////////////////////////////////////////////////////////////// 16 | interface Configuration { 17 | 'Field input': {}, 18 | 'Converter': AnkiMdHtmlOptions, 19 | 'CodeMirror': CMConfiguration 20 | } 21 | interface MDInputAPI { 22 | container: HTMLElement, 23 | editor: Editor, 24 | badge: HTMLSpanElement, 25 | refocus: readonly SelectionRange[], 26 | toggle(): void 27 | } 28 | /** Add Markdown Input data to editor field */ 29 | interface MDInputElement extends HTMLElement { 30 | markdown_input: MDInputAPI 31 | } 32 | 33 | const FIELD_DEFAULT = 'Default field state' 34 | const MD = '' 35 | const MD_SOLID = '' 36 | 37 | let _config 38 | let _converter 39 | 40 | 41 | ///////////////////////////////////////////////////////////////////////////// 42 | /** Get NoteEditor */ 43 | const editor = (): { 44 | context: any, 45 | lifecycle: any, 46 | instances: NoteEditorAPI[] 47 | } => { 48 | return require('anki/NoteEditor') 49 | } 50 | 51 | ///////////////////////////////////////////////////////////////////////////// 52 | /** Get the rich text input of a field */ 53 | function rich_edit(field: EditorFieldAPI): RichTextInputAPI | undefined { 54 | return (get(field.editingArea.editingInputs) as EditingInputAPI[]) 55 | .find(input => input?.name === "rich-text") 56 | } 57 | 58 | ///////////////////////////////////////////////////////////////////////////// 59 | /** Get the plain text input of a field */ 60 | function plain_edit(field: EditorFieldAPI): PlainTextInputAPI | undefined { 61 | return (get(field.editingArea.editingInputs) as EditingInputAPI[]) 62 | .find(input => input?.name === "plain-text") 63 | } 64 | 65 | ///////////////////////////////////////////////////////////////////////////// 66 | /** Return wether an input element is hidden by attribute or class */ 67 | function hidden(el: HTMLElement) { 68 | if (!el) return undefined 69 | return Boolean(el.hidden || el.classList.contains('hidden')) 70 | } 71 | 72 | ///////////////////////////////////////////////////////////////////////////// 73 | /** Get ancestor matching a selector */ 74 | function ancestor(descendant: HTMLElement, selector: string) { 75 | while (descendant && !descendant.matches(selector)) 76 | descendant = descendant.parentElement 77 | return descendant 78 | } 79 | 80 | ///////////////////////////////////////////////////////////////////////////// 81 | /** Focus the editor of a an input element */ 82 | function focus(input: HTMLElement) { 83 | if (!input || input.hidden) return false 84 | let editor 85 | if (input.querySelector('.markdown-input > .cm-editor')) 86 | editor = (ancestor(input, '.editor-field') as MDInputElement) 87 | ?.markdown_input?.editor 88 | else editor = input.querySelector('.CodeMirror > div > textarea') as HTMLElement 89 | || input.querySelector('.rich-text-editable')?.shadowRoot.querySelector('anki-editable') 90 | 91 | editor?.focus() 92 | return Boolean(editor) 93 | } 94 | 95 | ///////////////////////////////////////////////////////////////////////////// 96 | /** Cycle to next field or first if none active */ 97 | async function cycle_next() { 98 | const active = ancestor(document.activeElement as HTMLElement, '.editing-area > div') 99 | // Check for inputs in current field 100 | let input = older(active) 101 | // No inputs in current field, find prev field 102 | if (!input) { 103 | let fld_root = ancestor(active, '.editor-field') 104 | .parentElement as HTMLElement 105 | while (fld_root && !input) { 106 | fld_root = fld_root.nextElementSibling as HTMLElement 107 | input = fld_root?.querySelector('.editing-area')?.firstElementChild as HTMLElement 108 | if (hidden(input)) input = older(input) 109 | } 110 | } 111 | focus(input) 112 | 113 | function older(fld: HTMLElement) { 114 | let nxt = fld?.nextElementSibling as HTMLElement 115 | while (hidden(nxt)) nxt = nxt.nextElementSibling as HTMLElement 116 | return nxt 117 | } 118 | } 119 | 120 | ///////////////////////////////////////////////////////////////////////////// 121 | /** Cycle to prev field or first if none active */ 122 | async function cycle_prev() { 123 | const active = ancestor(document.activeElement as HTMLElement, '.editing-area > div') 124 | // Check for inputs in current field 125 | let input = younger(active) 126 | // No inputs in current field, find prev field 127 | if (!input) { 128 | let fld_root = ancestor(active, '.editor-field') 129 | .parentElement as HTMLElement 130 | while (fld_root && !input) { 131 | fld_root = fld_root.previousElementSibling as HTMLElement 132 | input = fld_root?.querySelector('.editing-area')?.lastElementChild as HTMLElement 133 | if (hidden(input)) input = younger(input) 134 | } 135 | } 136 | focus(input) 137 | 138 | function younger(fld: HTMLElement) { 139 | let prv = fld?.previousElementSibling as HTMLElement 140 | while (hidden(prv)) prv = prv.previousElementSibling as HTMLElement 141 | return prv 142 | } 143 | } 144 | 145 | ///////////////////////////////////////////////////////////////////////////// 146 | /** Add editor to field */ 147 | async function add_editor(field: EditorFieldAPI, hidden: boolean): Promise { 148 | const field_el = await field.element as MDInputElement 149 | const ed_area_el = field_el.querySelector('div.editing-area') 150 | if (field_el.markdown_input) return field_el.markdown_input 151 | const container = document.createElement('div') 152 | container.classList.add('markdown-input') 153 | container.hidden = hidden 154 | const tmp = { 155 | parent: container, 156 | oninput: (md: string) => { 157 | field.editingArea.content.set(_converter.markdown_to_html(md)) 158 | }, 159 | events: { 160 | wheel(evt: WheelEvent) { 161 | const fields = field_el.parentElement.parentElement 162 | switch (evt.deltaMode) { 163 | case 0: //DOM_DELTA_PIXEL 164 | fields.scrollTop += evt.deltaY 165 | fields.scrollLeft += evt.deltaX 166 | break 167 | case 1: //DOM_DELTA_LINE 168 | fields.scrollTop += 15 * evt.deltaY 169 | fields.scrollLeft += 15 * evt.deltaX 170 | break 171 | case 2: //DOM_DELTA_PAGE 172 | fields.scrollTop += 0.03 * evt.deltaY 173 | fields.scrollLeft += 0.03 * evt.deltaX 174 | break 175 | } 176 | } 177 | } 178 | } 179 | Object.assign(tmp, _config[EDITOR]) 180 | const markdown_input = { 181 | container: ed_area_el.insertBefore(container, ed_area_el.firstElementChild), 182 | badge: ed_area_el.parentElement.querySelector('.markdown-input-badge span span') as HTMLSpanElement, 183 | editor: new Editor(tmp), 184 | refocus: undefined, 185 | toggle: () => { toggle(field) } 186 | } 187 | field_el.markdown_input = markdown_input 188 | 189 | return markdown_input 190 | } 191 | 192 | ///////////////////////////////////////////////////////////////////////////// 193 | /** Toggle md input */ 194 | async function toggle(field: number | EditorFieldAPI) { 195 | field = typeof (field) === 'number' 196 | ? await editor().instances[0].fields[field] 197 | : field 198 | const el = await field.element 199 | const mi = (el.markdown_input || await add_editor(field, true)) as MDInputAPI 200 | const rbadge = el.querySelector('span.rich-text-badge') as HTMLElement 201 | if (_config[FIELD_INPUT][CYCLE_RICH_MD] 202 | && mi.container.hidden === rich_edit(field).focusable) 203 | rbadge.click() 204 | mi.container.hidden ? show(mi) : hide(mi) 205 | 206 | async function show(mi: MDInputAPI) { 207 | const [md, ord] = _converter.html_to_markdown(get(field.editingArea.content) as string) 208 | mi.editor.set_doc(md, ord, 'end') 209 | mi.container.hidden = false 210 | mi.badge.innerHTML = MD_SOLID 211 | mi.editor.cm.focus() 212 | } 213 | 214 | async function hide(mi: MDInputAPI) { 215 | mi.container.hidden = true 216 | mi.badge.innerHTML = MD 217 | field.editingArea.refocus() 218 | } 219 | } 220 | 221 | ///////////////////////////////////////////////////////////////////////////// 222 | /** Toggle rich text input */ 223 | async function toggle_rich(field: number | EditorFieldAPI) { 224 | field = typeof (field) === 'number' ? await editor().instances[0].fields[field] : field 225 | const el = await field.element as MDInputElement 226 | const rich = el.querySelector('span.rich-text-badge') as HTMLElement 227 | rich.click() 228 | if (rich_edit(field).focusable) el.markdown_input.editor.cm.focus() 229 | } 230 | 231 | 232 | ///////////////////////////////////////////////////////////////////////////// 233 | /** 234 | * Update MD content in all visible MD input on note load 235 | * Add MD icons to all field */ 236 | async function update_all() { 237 | const ed = await editor().instances[0] 238 | const flds = await ed.fields 239 | let index = -1 240 | let focused = false 241 | for (const field of flds) { 242 | index++ 243 | const el = await field.element as MDInputElement 244 | // Add icon if non-existent 245 | if (!el.querySelector('span.markdown-input-badge')) { 246 | const root = el.querySelector('.rich-text-badge').cloneNode(true) as HTMLElement 247 | root.classList.replace('rich-text-badge', 'markdown-input-badge') 248 | root.onclick = () => toggle(field) 249 | const badge = root.querySelector('.badge') as HTMLElement 250 | badge.title = `Toggle Markdown Editor (${_config[FIELD_INPUT][SC_TOGGLE]})` 251 | badge.querySelector('span').innerHTML = MD 252 | const fsel = el.querySelector('span.field-state') 253 | fsel.insertBefore(root, fsel.firstElementChild) 254 | } 255 | 256 | // "New" field and markdown as default 257 | if (!el.markdown_input && _config[FIELD_INPUT][FIELD_DEFAULT] === 'markdown') { 258 | el.markdown_input = await add_editor(field, false) 259 | // Hide rich text if visible 260 | if (rich_edit(field)?.focusable) 261 | (el.querySelector('span.rich-text-badge') as HTMLElement).click() 262 | 263 | // Focus first new field if not already focused 264 | if (!focused) { 265 | el.markdown_input.editor.cm.focus() 266 | focused = true 267 | } 268 | 269 | // "Old field" with focus, refocus (i.e. keep state) 270 | } else if (el.contains(document.activeElement)) { 271 | el.markdown_input?.editor.cm.focus() 272 | focused = true 273 | } 274 | 275 | if (el?.markdown_input?.container.hidden === false) { 276 | const [md, ord] = _converter.html_to_markdown(get(field.editingArea.content) as string) 277 | el.markdown_input.editor.set_doc(md, ord, 'end'); 278 | } 279 | } 280 | } 281 | 282 | ///////////////////////////////////////////////////////////////////////////// 283 | /** Handle focus events for subscribing/unsubscribing and overriding focus-trap */ 284 | async function focusin(evt: FocusEvent) { 285 | const tgt = evt.target as HTMLElement 286 | const el = ancestor(tgt, '.editor-field') as MDInputElement 287 | if (!el.markdown_input) return 288 | 289 | // We focus MD CM, unsubscribe 290 | if (tgt.classList.contains('cm-content')) { 291 | if (el.markdown_input.editor['unsubscribe']) { 292 | el.markdown_input.editor['unsubscribe']() 293 | el.markdown_input.editor['unsubscribe'] = null 294 | } 295 | // We should take back focus when focusing back into document 296 | } else if (el.markdown_input?.refocus !== undefined 297 | && el.markdown_input?.container.hidden === false 298 | ) { 299 | el.markdown_input.editor.set_selections(el.markdown_input.refocus) 300 | el.markdown_input.refocus = undefined 301 | el.markdown_input.editor.cm.focus() // Event recursion 302 | // Focus is somewhere else, subscribe 303 | } else { 304 | if (!el.markdown_input.editor['unsubscribe'] 305 | && !el.markdown_input.editor.cm.dom.parentElement.hidden 306 | ) { 307 | for (const fld of await editor().instances[0].fields) { 308 | if ((await fld.element) === el) { 309 | el.markdown_input.editor['unsubscribe'] = fld.editingArea.content.subscribe(html => { 310 | const [md, ord] = _converter.html_to_markdown(html) 311 | el.markdown_input.editor.set_doc(md, ord, "end") 312 | }) 313 | break 314 | } 315 | } 316 | } 317 | } 318 | } 319 | 320 | ///////////////////////////////////////////////////////////////////////////// 321 | /** Store selection when focusing out from markdown-input */ 322 | async function focusout(evt: FocusEvent) { 323 | const tgt = evt.target as HTMLElement 324 | const tgt_el = ancestor(tgt, '.editor-field') 325 | if (tgt.classList?.contains('cm-content') 326 | && tgt_el !== ancestor(evt.relatedTarget as HTMLElement, '.editor-field')) 327 | (tgt_el as MDInputElement).markdown_input.refocus = 328 | (tgt_el as MDInputElement).markdown_input.editor.get_selections() 329 | } 330 | 331 | ///////////////////////////////////////////////////////////////////////////// 332 | /** Setup event listeners and configuration - create CM instances only on demand */ 333 | function init(cfg: Configuration) { 334 | _config = cfg 335 | _converter = new Converter(_config[CONVERTER]) 336 | if (!document['mdi_focus_added']) { 337 | document.addEventListener('focusin', focusin) 338 | document.addEventListener('focusout', focusout) 339 | document['mdi_focus_added'] = true 340 | } 341 | } 342 | 343 | export { Converter } from "anki-md-html" 344 | export type { Options as AnkiMdHtmlOptions } from "anki-md-html" 345 | export { toggle, toggle_rich, cycle_next, cycle_prev, update_all, init } 346 | -------------------------------------------------------------------------------- /src/ts/utils.ts: -------------------------------------------------------------------------------- 1 | /** Get closest ancestor matching supplied selector */ 2 | function ancestor(descendant: HTMLElement, selector: string) { 3 | while (descendant && !descendant.matches(selector)) 4 | descendant = descendant.parentElement 5 | return descendant 6 | } 7 | 8 | export { ancestor } -------------------------------------------------------------------------------- /src/ts/window_input.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor" 2 | import { Converter } from "anki-md-html" 3 | import { Configuration, CONVERTER, WINDOW_INPUT, WINDOW_MODE, EDITOR } from "./constants" 4 | 5 | class WindowEditor { 6 | editor: Editor 7 | converter: Converter 8 | 9 | constructor(parent: HTMLElement, cfg: Configuration) { 10 | Object.assign(this, cfg) 11 | this.editor = new Editor({ 12 | parent: parent, 13 | ...this[EDITOR] 14 | }) 15 | this.converter = new Converter(this[CONVERTER]) 16 | } 17 | 18 | /** 19 | * Sets content to the complete doc or just the indexed field depending on config 20 | * @param fields Array of tuples of field titles & values 21 | * @param i 22 | */ 23 | set_html(fields: [[title: string, content: string]], i: number) { 24 | if (this[WINDOW_INPUT]?.[WINDOW_MODE] === 'note') { 25 | let html = '' 26 | for (const [title, content] of fields) 27 | html += `

    ${content}

    ` 28 | const [md, ord] = this.converter.html_to_markdown(html) 29 | this.editor.set_doc(md, ord, 'start') 30 | } else { 31 | const [md, ord] = this.converter.html_to_markdown(fields[i][1]) 32 | this.editor.set_doc(md, ord, 'end') 33 | } 34 | this.editor.cm.focus() 35 | } 36 | 37 | /** 38 | * Returns array pair for field title and field content for "note" mode, 39 | * string for field/selection only mode 40 | */ 41 | get_html() { 42 | if (this[WINDOW_INPUT]?.[WINDOW_MODE] === 'note') { 43 | const fields: [title: string, content: string][] = [] 44 | const md = this.editor.cm.state.doc.toString() 45 | for (const match of md.matchAll(/(.*?)^[ \t]*[ \t]*$/gms)) { 46 | if (fields.length) 47 | fields[fields.length - 1][1] = this.converter.markdown_to_html(match[1].trim()) 48 | fields.push([match[2], '']) 49 | } 50 | return fields 51 | } 52 | // else 53 | return this.converter.markdown_to_html(this.editor.cm.state.doc.toString()) 54 | } 55 | } 56 | 57 | export { WindowEditor } 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ES2018", 5 | "moduleResolution": "node", 6 | "isolatedModules": true, 7 | "skipLibCheck": true, 8 | "typeRoots": ["node_modules/@types", "anki/ts/typings"], 9 | "baseUrl": ".", 10 | "allowSyntheticDefaultImports": true, 11 | "allowJs": true, 12 | "checkJs": false, 13 | "strict": false, 14 | "noImplicitAny": false, 15 | "strictNullChecks": false, 16 | "forceConsistentCasingInFileNames": true, 17 | "esModuleInterop": true, 18 | "outDir": "./bin", 19 | "types": ["node"], 20 | // "types": ["svelte", "node"], 21 | "paths": { 22 | "anki/*": ["${env:CODEPATH}/Anki/anki/*"], // Only for typing 23 | }, 24 | "plugins": [], 25 | //"rootDir": "src", 26 | "removeComments": true 27 | }, 28 | "include": [ 29 | "**/*.ts" 30 | ], 31 | "exclude": [ 32 | //"node_modules" 33 | ], 34 | "ts-node": { 35 | "transpileOnly": true, 36 | "files": true, 37 | "esm": true, 38 | "experimentalSpecifierResolution": "node" 39 | } 40 | } --------------------------------------------------------------------------------