├── assets ├── edit.png └── addcard.png ├── src ├── config.json ├── config.md ├── icons │ └── file.svg └── __init__.py ├── README.md ├── .drone.yml └── .gitignore /assets/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rad4day/anki-editor-preview-plugin/HEAD/assets/edit.png -------------------------------------------------------------------------------- /assets/addcard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rad4day/anki-editor-preview-plugin/HEAD/assets/addcard.png -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "showPreviewAutomatically": true, 3 | "splitRatio": "1:1", 4 | "location": "below" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anki Live Editor Preview 2 | 3 | This plugin adds a live preview to all your card editors. 4 | It was written out of pure annoyance of having to open the "Cards..." screen every time. 5 | 6 | [On AnkiWeb](https://ankiweb.net/shared/info/1960039667) 7 | 8 | --- 9 | ## Images 10 | 11 | ![AddCard Screen](assets/addcard.png) 12 | ![Browser Cards](assets/edit.png) 13 | -------------------------------------------------------------------------------- /src/config.md: -------------------------------------------------------------------------------- 1 | ### Config 2 | \- `showPreviewAutomatically` [boolean (true | false)]:
3 |    Defines if the preview window should show up automatically as you enter the Editor (default: true)

4 | \- `splitRatio` [int:int]:
5 |    Defines the default split ratio of the main view and preview view (default: 1:1)
6 |
7 | \- `location` [string (above | below | left | right)]:
8 |    Defines where to render the preview (default: below) 9 |
10 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: docker 4 | name: Build Anki Plugin 5 | 6 | trigger: 7 | event: 8 | include: 9 | - tag 10 | 11 | steps: 12 | - name: Build Archive 13 | image: debian:bookworm 14 | pull: always 15 | commands: 16 | - apt-get update && apt-get install -y zip 17 | - ./build.sh 18 | 19 | - name: Upload Artifact to Gitea 20 | depends_on: 21 | - Build Archive 22 | image: plugins/gitea-release 23 | settings: 24 | api_key: 25 | from_secret: gitea_api_token 26 | checksum: sha256 27 | base_url: https://git.tobiasmanske.de 28 | files: editor-preview.ankiaddon 29 | 30 | image_pull_secrets: 31 | - registry 32 | -------------------------------------------------------------------------------- /src/icons/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 12 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/vim,python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim,python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | .idea 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | # pytype static type analyzer 141 | .pytype/ 142 | 143 | # Cython debug symbols 144 | cython_debug/ 145 | 146 | ### Vim ### 147 | # Swap 148 | [._]*.s[a-v][a-z] 149 | !*.svg # comment out if you don't need vector files 150 | [._]*.sw[a-p] 151 | [._]s[a-rt-v][a-z] 152 | [._]ss[a-gi-z] 153 | [._]sw[a-p] 154 | 155 | # Session 156 | Session.vim 157 | Sessionx.vim 158 | 159 | # Temporary 160 | .netrwhist 161 | *~ 162 | # Auto-generated tag files 163 | tags 164 | # Persistent undo 165 | [._]*.un~ 166 | 167 | # End of https://www.toptal.com/developers/gitignore/api/vim,python 168 | *.ankiaddon 169 | .DS_Store 170 | 171 | # Stores actual addon config if the src directory is symlinked into an anki installation during development 172 | src/meta.json 173 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Set 3 | 4 | from anki import hooks, buildinfo 5 | from aqt import editor, gui_hooks, mw 6 | from aqt.utils import * 7 | from aqt.theme import theme_manager 8 | from aqt.webview import AnkiWebView 9 | from aqt.editor import Editor 10 | 11 | config = mw.addonManager.getConfig(__name__) 12 | 13 | 14 | class EditorPreview(object): 15 | editors: Set[editor.Editor] = set() 16 | js = [ 17 | "js/mathjax.js", 18 | "js/vendor/mathjax/tex-chtml.js", 19 | "js/reviewer.js", 20 | ] 21 | 22 | def __init__(self): 23 | gui_hooks.editor_did_init.append(self.editor_init_hook) 24 | gui_hooks.editor_did_init_buttons.append(self.editor_init_button_hook) 25 | gui_hooks.editor_did_load_note.append(self.editor_note_hook) 26 | gui_hooks.editor_did_fire_typing_timer.append(self.onedit_hook) 27 | buildversion = buildinfo.version.split(".") 28 | 29 | # Anki changed their versioning scheme in 2023 to year.month(.patch), causing things to explode here. 30 | if int(buildversion[0]) >= 24 or (int(buildversion[0]) == 23 and int(buildversion[1]) == 12 and 2 < len(buildversion) and int(buildversion[2])) >= 1: # >= 23.12.1 31 | self.js = [ 32 | "js/mathjax.js", 33 | "js/vendor/mathjax/tex-chtml-full.js", 34 | "js/reviewer.js", 35 | ] 36 | elif int(buildversion[0]) < 23 and int(buildversion[2]) < 45: # < 2.1.45 37 | self.js = [ 38 | "js/vendor/jquery.min.js", 39 | "js/vendor/css_browser_selector.min.js", 40 | "js/mathjax.js", 41 | "js/vendor/mathjax/tex-chtml.js", 42 | "js/reviewer.js", 43 | ] 44 | 45 | def cleanup_editor_preview(editor): 46 | self.editors.discard(editor) 47 | if hasattr(editor, "editor_preview"): 48 | editor.editor_preview.cleanup() 49 | editor.editor_preview.close() 50 | Editor.cleanup = hooks.wrap(Editor.cleanup, cleanup_editor_preview) 51 | 52 | def editor_init_hook(self, ed: editor.Editor): 53 | ed.editor_preview = AnkiWebView(parent=ed.web, title="editor_preview") 54 | # This is taken out of clayout.py 55 | ed.editor_preview.stdHtml( 56 | ed.mw.reviewer.revHtml(), 57 | css=["css/reviewer.css"], 58 | js=self.js, 59 | context=ed, 60 | ) 61 | 62 | if not config["showPreviewAutomatically"]: 63 | ed.editor_preview.hide() 64 | 65 | self._inject_splitter(ed) 66 | 67 | def _get_splitter(self, editor): 68 | mainR, editorR = [int(r) * 10000 for r in config["splitRatio"].split(":")] 69 | location = config["location"] 70 | split = QSplitter() 71 | if location == "above": 72 | split.setOrientation(Qt.Orientation.Vertical) 73 | split.addWidget(editor.editor_preview) 74 | split.addWidget(editor.wrapped_web) 75 | sizes = [editorR, mainR] 76 | elif location == "below": 77 | split.setOrientation(Qt.Orientation.Vertical) 78 | split.addWidget(editor.wrapped_web) 79 | split.addWidget(editor.editor_preview) 80 | sizes = [mainR, editorR] 81 | elif location == "left": 82 | split.setOrientation(Qt.Orientation.Horizontal) 83 | split.addWidget(editor.editor_preview) 84 | split.addWidget(editor.wrapped_web) 85 | sizes = [editorR, mainR] 86 | elif location == "right": 87 | split.setOrientation(Qt.Orientation.Horizontal) 88 | split.addWidget(editor.wrapped_web) 89 | split.addWidget(editor.editor_preview) 90 | sizes = [mainR, editorR] 91 | else: 92 | raise ValueError("Invalid value for config key location") 93 | 94 | split.setSizes(sizes) 95 | return split 96 | 97 | def _inject_splitter(self, editor: editor.Editor): 98 | layout = editor.web.parentWidget().layout() 99 | if layout is None: 100 | layout = QVBoxLayout() 101 | editor.web.parentWidget().setLayout(layout) 102 | web_index = layout.indexOf(editor.web) 103 | layout.removeWidget(editor.web) 104 | 105 | # Wrap a widget on the outer layer of the webview 106 | # So that other plugins can continue to modify the layout 107 | editor.wrapped_web = QWidget() 108 | wrapLayout = QHBoxLayout() 109 | editor.wrapped_web.setLayout(wrapLayout) 110 | wrapLayout.addWidget(editor.web) 111 | 112 | split = self._get_splitter(editor) 113 | layout.insertWidget(web_index, split) 114 | 115 | def editor_note_hook(self, editor): 116 | self.editors = set(filter(lambda it: it.note is not None, self.editors)) 117 | self.editors.add(editor) 118 | # The initial loading of notes will also trigger an editing event 119 | # which will cause a second refresh 120 | # Caching the content of notes here will be used to determine if the content has changed 121 | editor.cached_fields = list(editor.note.fields) 122 | self.refresh(editor) 123 | 124 | def editor_init_button_hook(self, buttons, editor): 125 | addon_path = os.path.dirname(__file__) 126 | icons_dir = os.path.join(addon_path, "icons") 127 | b = editor.addButton( 128 | icon=os.path.join(icons_dir, "file.svg"), 129 | cmd="_editor_toggle_preview", 130 | tip="Toggle Live Preview", 131 | func=lambda o=editor: self.onEditorPreviewButton(o), 132 | disables=False, 133 | ) 134 | buttons.append(b) 135 | 136 | def onEditorPreviewButton(self, origin: editor.Editor): 137 | if origin.editor_preview.isHidden(): 138 | origin.editor_preview.show() 139 | else: 140 | origin.editor_preview.hide() 141 | 142 | def _obtainCardText(self, note): 143 | c = note.ephemeral_card() 144 | a = mw.prepare_card_text_for_display(c.answer()) 145 | a = gui_hooks.card_will_show(a, c, "clayoutAnswer") 146 | bodyclass = theme_manager.body_classes_for_card_ord(c.ord, theme_manager.night_mode) 147 | bodyclass += " editor-preview" 148 | 149 | return f"_showAnswer({json.dumps(a)},'{bodyclass}');" 150 | 151 | def onedit_hook(self, note): 152 | for editor in self.editors: 153 | if editor.note == note and editor.cached_fields != note.fields: 154 | editor.cached_fields = list(note.fields) 155 | self.refresh(editor) 156 | 157 | def refresh(self, editor): 158 | editor.editor_preview.eval(self._obtainCardText(editor.note)) 159 | 160 | eprev = EditorPreview() 161 | --------------------------------------------------------------------------------