├── 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 | 
12 | 
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 |
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 |
--------------------------------------------------------------------------------