diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 231a6486059..ae0cfafd6f3 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1342,15 +1342,9 @@ def setupMenus(self) -> None: qconnect(m.actionPreferences.triggered, self.onPrefs) # View - qconnect( - m.actionZoomIn.triggered, - lambda: self.web.setZoomFactor(self.web.zoomFactor() + 0.1), - ) - qconnect( - m.actionZoomOut.triggered, - lambda: self.web.setZoomFactor(self.web.zoomFactor() - 0.1), - ) - qconnect(m.actionResetZoom.triggered, lambda: self.web.setZoomFactor(1)) + qconnect(m.actionZoomIn.triggered, self.zoom_in) + qconnect(m.actionZoomOut.triggered, self.zoom_out) + qconnect(m.actionResetZoom.triggered, self.reset_zoom) # app-wide shortcut qconnect(m.actionFullScreen.triggered, self.on_toggle_full_screen) m.actionFullScreen.setShortcut( @@ -1397,6 +1391,30 @@ def show_menubar(self) -> None: self.form.menubar.setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX) self.form.menubar.setMinimumSize(0, 0) + # Triggering a zoom action with Shift held down changes the zoom level without + # affecting layout sizing, thus allowing users to zoom into images + + def zoom_in(self) -> None: + if self.state == "review" and bool( + self.app.queryKeyboardModifiers() & Qt.KeyboardModifier.ShiftModifier + ): + self.reviewer.zoom_in() + else: + self.web.setZoomFactor(self.web.zoomFactor() + 0.1) + + def zoom_out(self) -> None: + if self.state == "review" and bool( + self.app.queryKeyboardModifiers() & Qt.KeyboardModifier.ShiftModifier + ): + self.reviewer.zoom_out() + else: + self.web.setZoomFactor(self.web.zoomFactor() - 0.1) + + def reset_zoom(self) -> None: + if self.state == "review": + self.reviewer.reset_zoom() + self.web.setZoomFactor(1) + # Auto update ########################################################################## diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 89f1d05de9c..c1da7328276 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -389,6 +389,7 @@ def _showQuestion(self) -> None: self._update_mark_icon() self._showAnswerButton() self.mw.web.setFocus() + self.maybe_restore_scale_factor() # user hook gui_hooks.reviewer_did_show_question(c) @@ -586,6 +587,8 @@ def _linkHandler(self, url: str) -> None: play_clicked_audio(url, self.card) elif url.startswith("updateToolbar"): self.mw.toolbarWeb.update_background_image() + elif url.startswith("zoom"): + self.store_zoom_step(int(url.split(":")[1])) elif url == "statesMutated": self._states_mutated = True else: @@ -1077,6 +1080,35 @@ def onReplayRecorded(self) -> None: return av_player.play_file(self._recordedAudio) + # Zoom handling + + def zoom_in(self): + self.web.eval("anki.triggerZoomStep(1)") + + def zoom_out(self): + self.web.eval("anki.triggerZoomStep(-1)") + + def reset_zoom(self): + self.web.eval("anki.resetZoom()") + + def set_zoom_step(self, step: int, interactive: bool = True): + """Set predefined reviewer zoom step, cf. zoom.ts for indices + + Args: + step: zoom step corresponding to predefined zoom factor index + interactive: controls zoom info box and zoom step persistence + """ + self.web.eval( + f"anki.setZoomStep({json.dumps(step)}, {json.dumps(interactive)})" + ) + + def store_zoom_step(self, step: int): + self.mw.pm.profile["lastReviewerZoomStep"] = step + + def maybe_restore_scale_factor(self): + if scale_factor := self.mw.pm.profile.get("lastReviewerZoomStep", None): + self.set_zoom_step(scale_factor, interactive=False) + # legacy onBuryCard = bury_current_card diff --git a/ts/reviewer/index.ts b/ts/reviewer/index.ts index 7825f8472a5..eb59d2ccf5d 100644 --- a/ts/reviewer/index.ts +++ b/ts/reviewer/index.ts @@ -11,10 +11,14 @@ export { default as $, default as jQuery } from "jquery/dist/jquery"; import { setupImageCloze } from "../image-occlusion/review"; import { mutateNextCardStates } from "./answering"; +import { resetZoom, setupWheelZoom, setZoomStep, triggerZoomStep } from "./zoom"; globalThis.anki = globalThis.anki || {}; globalThis.anki.mutateNextCardStates = mutateNextCardStates; globalThis.anki.setupImageCloze = setupImageCloze; +globalThis.anki.setZoomStep = setZoomStep; +globalThis.anki.triggerZoomStep = triggerZoomStep; +globalThis.anki.resetZoom = resetZoom; import { bridgeCommand } from "@tslib/bridgecommand"; import { registerPackage } from "@tslib/runtime-require"; @@ -263,6 +267,8 @@ document.addEventListener("focusout", (event) => { } }); +setupWheelZoom(); + registerPackage("anki/reviewer", { // If you append a function to this each time the question or answer // is shown, it will be called before MathJax has been rendered. diff --git a/ts/reviewer/reviewer.scss b/ts/reviewer/reviewer.scss index 1c5d6a7d4a0..1103127af55 100644 --- a/ts/reviewer/reviewer.scss +++ b/ts/reviewer/reviewer.scss @@ -11,6 +11,7 @@ hr { body { margin: 20px; overflow-wrap: break-word; + transform-origin: top; // default background setting to fit with toolbar background-size: cover; background-repeat: no-repeat; @@ -40,12 +41,15 @@ pre { #_flag { position: fixed; + [dir="ltr"] & { right: 10px; } + [dir="rtl"] & { left: 10px; } + top: 0; font-size: 30px; -webkit-text-stroke-width: 1px; @@ -54,12 +58,15 @@ pre { #_mark { position: fixed; + [dir="ltr"] & { left: 10px; } + [dir="rtl"] & { right: 10px; } + top: 0; font-size: 30px; color: yellow; @@ -67,6 +74,17 @@ pre { -webkit-text-stroke-color: black; } +#_zoominfo { + position: fixed; + display: none; + + right: 10px; + top: 0; + font-size: 20px; + color: var(--fg-subtle); + background: var(--canvas-overlay); +} + #typeans { width: 100%; // https://anki.tenderapp.com/discussions/beta-testing/1854-using-margin-auto-causes-horizontal-scrollbar-on-typesomething @@ -126,6 +144,7 @@ button { .drawing { zoom: 50%; } + .nightMode img.drawing { filter: unquote("invert(1) hue-rotate(180deg)"); } diff --git a/ts/reviewer/zoom.ts b/ts/reviewer/zoom.ts new file mode 100644 index 00000000000..ecf71255c02 --- /dev/null +++ b/ts/reviewer/zoom.ts @@ -0,0 +1,103 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/* eslint +@typescript-eslint/no-explicit-any: "off", + */ + +import { bridgeCommand } from "@tslib/bridgecommand"; + +// Chromium defaults +const PRESET_ZOOM_FACTORS = [ + 0.25, + 1 / 3.0, + 0.5, + 2 / 3.0, + 0.75, + 0.8, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, + 2.5, + 3.0, + 4.0, + 5.0, +]; +const DEFAULT_ZOOM_STEP = 7; + +let zoomStep = DEFAULT_ZOOM_STEP; +let zoomSaveTimer: number | null = null; + +export function triggerZoomStep(sign: number): void { + const step = zoomStep + sign; + if (step < 0 || step > (PRESET_ZOOM_FACTORS.length - 1)) { + return; + } + + setZoomStep(step); +} + +export function setZoomStep(step: number, interactive = true): void { + const zoomedContainer = document.body; + const zoomFactor = PRESET_ZOOM_FACTORS[step]; + if (zoomFactor === undefined) { + return; + } + zoomedContainer.style.transform = `scale(${zoomFactor})`; + zoomStep = step; + if (zoomSaveTimer) { + clearTimeout(zoomSaveTimer); + } + if (interactive) { + displayZoomInfo(zoomFactor); + zoomSaveTimer = setTimeout(() => { + storeZoomStep(step); + }, 100); + } +} + +export function resetZoom(): void { + setZoomStep(DEFAULT_ZOOM_STEP); +} + +function storeZoomStep(step: number) { + bridgeCommand(`zoom:${step}`); +} + +const zoomInfoId = "_zoominfo"; +let zoomInfoTimer: number | null = null; + +function displayZoomInfo(zoomFactor: number) { + let zoomInfoBox = document.getElementById(zoomInfoId); + if (!zoomInfoBox) { + zoomInfoBox = document.createElement("div"); + document.documentElement.appendChild(zoomInfoBox); + zoomInfoBox.id = zoomInfoId; + } + if (zoomInfoTimer) { + clearTimeout(zoomInfoTimer); + } + zoomInfoBox.innerHTML = `${Math.round(zoomFactor * 100)}%`; + zoomInfoBox.style.display = "block"; + zoomInfoTimer = setTimeout(() => { + zoomInfoBox!.style.display = "none"; + }, 1000); +} + +export function setupWheelZoom(): void { + document.addEventListener( + "wheel", + (event) => { + if (!(event.ctrlKey && event.shiftKey)) { + return; + } + event.preventDefault(); + triggerZoomStep(-Math.sign(event.deltaY)); + }, + { passive: false }, + ); +}