From fc22550e91abe53cf3c687409b176cecdf2eddeb Mon Sep 17 00:00:00 2001 From: kodi Date: Thu, 12 Mar 2026 15:08:30 +0100 Subject: [PATCH] feat: pdf viewer toegevoegd --- project_docs/PDF_VIEWER_V1_DESIGN.md | 127 ++++++++++++++++++ .../__pycache__/routes_files.cpython-313.pyc | Bin 4468 -> 4866 bytes webui/backend/app/api/routes_files.py | 13 ++ .../file_ops_service.cpython-313.pyc | Bin 20398 -> 21962 bytes .../backend/app/services/file_ops_service.py | 40 ++++++ .../test_api_pdf_golden.cpython-313.pyc | Bin 0 -> 7071 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 13514 -> 14038 bytes .../tests/golden/test_api_pdf_golden.py | 92 +++++++++++++ .../tests/golden/test_ui_smoke_golden.py | 7 + webui/html/app.js | 84 +++++++++++- webui/html/index.html | 11 ++ webui/html/style.css | 9 ++ 12 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 project_docs/PDF_VIEWER_V1_DESIGN.md create mode 100644 webui/backend/tests/golden/__pycache__/test_api_pdf_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_pdf_golden.py diff --git a/project_docs/PDF_VIEWER_V1_DESIGN.md b/project_docs/PDF_VIEWER_V1_DESIGN.md new file mode 100644 index 0000000..89c5b8e --- /dev/null +++ b/project_docs/PDF_VIEWER_V1_DESIGN.md @@ -0,0 +1,127 @@ +# PDF Viewer v1 + +## 1. Doel +PDF-viewing voegt nu directe waarde toe omdat PDF-bestanden in de huidige file manager al zichtbaar en selecteerbaar zijn, maar nog niet in de webui bekeken kunnen worden zonder externe tooling of downloadstap. Dat past logisch naast de bestaande text-viewer en video-viewer: elk veelvoorkomend bestandstype krijgt een passende read-only viewer, terwijl de dual-pane workflow intact blijft. + +## 2. Startgedrag +Aanbevolen v1-gedrag: +- `F3` opent de PDF viewer voor exact 1 geselecteerd PDF-bestand. +- De bestaande `View`-knop hergebruikt dezelfde centrale view-dispatch en opent bij een PDF dezelfde PDF viewer. +- Dit geldt alleen bij exact 1 geselecteerd item met extensie `.pdf`. +- Bij geen selectie, multi-select, of niet-PDF blijft `F3` het bestaande view-gedrag respecteren of niets doen als `View` disabled zou zijn. +- Gewone `Enter`-semantiek blijft intact en wordt niet gewijzigd voor PDF. + +## 3. Scope +In scope voor v1: +- alleen PDF +- read-only +- bekijken in modal +- browser-native scrollen binnen de viewer +- sluiten via `X` en `Escape` + +Niet in scope: +- edit +- annotatie +- OCR +- losse downloadfeature +- page thumbnails +- search in PDF-documenten +- print-specifieke UI + +## 4. Viewer-richting +Aanbevolen v1-richting: browser-native embedded PDF-view in een aparte modal. + +Concreet: +- frontend opent een aparte PDF-modal +- modal bevat een embedded viewer via `iframe` of `embed` naar een read-only backend endpoint +- paginaweergave en scrollen worden aan de browser-PDF-viewer overgelaten +- sluiten werkt via `X`, `Escape`, en eventueel overlay-click als dat consistent is met bestaande modals + +Waarom deze richting: +- laag regressierisico +- geen extra dependencies nodig +- geen custom PDF rendering stack nodig +- sluit aan op het bestaande modalmodel + +Niet aanbevolen voor v1: +- eigen PDF-rendering met `pdf.js` of vergelijkbaar, tenzij later blijkt dat browser-native gedrag onvoldoende is +- dat zou extra assets, integratie en regressierisico introduceren + +## 5. Backend-impact +Aanbevolen: een apart read-only PDF endpoint, analoog aan video/view. + +Voorstel: +- `GET /api/files/pdf?path=...` + +Eisen: +- padvalidatie via bestaande `path_guard` +- alleen files +- directory -> bestaande `type_conflict` +- path not found -> bestaande not-found fout +- traversal / invalid root alias / outside whitelist -> bestaande securityfouten +- `Content-Type: application/pdf` +- streaming/read-only gedrag zonder onnodige buffering + +Waarom een apart endpoint: +- expliciete content-type-afhandeling voor PDF +- scheiding van concerns ten opzichte van tekst-view en video-streaming +- eenvoudige frontend-integratie in een PDF-modal + +## 6. Frontend-impact +Aanbevolen: +- aparte PDF-modal, niet hergebruik van text-view modal +- wel hergebruik van bestaande modalstijl en focusregels + +Gedrag: +- `F3` en `View` dispatchen op bestandstype +- tekstbestanden blijven naar text viewer gaan +- video blijft naar video viewer gaan +- PDF gaat naar PDF viewer +- editorflow blijft ongemoeid + +Dat voorkomt regressie op bestaande viewers en houdt de semantiek per bestandstype helder. + +## 7. Risico's +Belangrijkste risico's: +- browserverschillen in ingebouwde PDF-weergave +- sommige browsers of embedded contexten kunnen PDF anders tonen of beperkter ondersteunen +- grote PDF-bestanden kunnen trager laden, maar browser-native streaming is nog steeds lichter dan custom rendering +- security moet strikt read-only blijven via bestaande whitelist/path-guards +- regressierisico in bestaande `F3`/`View` dispatchlogica als filetype-routing rommelig wordt geïmplementeerd + +Beperking voor v1: +- geen garantie op identieke UX in elke browser +- wel een veilige, kleine baseline voor moderne browsers met ingebouwde PDF-viewer + +## 8. Teststrategie +Backend golden tests: +- PDF endpoint success +- directory -> `type_conflict` +- path not found +- traversal blocked +- invalid root alias +- non-pdf blocked of routed naar duidelijke fout + +UI smoke/regressietests: +- PDF-modal container aanwezig +- `F3` / `View` wiring aanwezig voor PDF-flow +- bestaande text/video modal containers blijven aanwezig +- geen regressie op huidige view-dispatch + +Handmatige validatie: +- PDF openen via `F3` +- PDF openen via `View` +- sluiten via `X` en `Escape` +- gewone `Enter` blijft ongewijzigd +- text/video viewers blijven correct openen +- groot PDF-bestand laadt zonder de UI te blokkeren + +## 9. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: +- gebruik een apart read-only backend endpoint voor PDF +- gebruik browser-native embedded PDF-weergave in een aparte modal +- laat `F3` en `View` dezelfde type-dispatch gebruiken +- houd PDF strikt read-only +- voeg geen externe PDF-library of rendering stack toe in v1 + +Dit levert de kleinste veilige stap op: bruikbare PDF-viewing, minimale architectuurimpact, en geen onnodige verbreding van scope. diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index e98803c4a2ac95d1d2a4815541f7ad07f87bd8eb..e15c344280a4aa49f917242e37bd91de1c6a37ca 100644 GIT binary patch delta 563 zcmaKp%}WA77{+&ISJPc}w~Vyabp1%&4hE5z=##7t(m}mO60}7^A!E?3!b{im{U`F8 zSF2l>AdC(jqHEA8p*jY=V+G-%f&KB!`#kgRe7r}i3D29`EeklT>+_ujSIy(6^I|P| zK|;0XED6H|$r1w&NsdIIjXDZJnjxXp`_Kg;TJ$`h1#7s0pVzSRBjh8pKASahd16uOv@IaYaSAn+CTMv@M{Mhm^8nW3wpXP`NY~Q7|1*p$NlZBfOCLMG@9q$7BhfTw&y= zYwU<`g7qUzJ2SXm<_L0lIl4Z-dDHR74{*!Q(Uat-{IAoG-(9a_cBr?3#v8<%Ew_^> u{E`?yCCk8#N0;*``IA+yny(&8rR96#g%2&|80=6dv3LCNqMb-NOnd?KB5xM} delta 441 zcmZot`=Z46nU|M~0SK1N-<0`*Ya*Wn(WC&p+lfjZPf|KJI<;9hQrOHIV5#xsu17vzP_Q zY*JBUoP3mLpMot&i4>41W&#ol3PrLY0XrBGGz?#J&QMx F4**BLO_~4z diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 20aca79..c25476d 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -66,6 +66,19 @@ async def video( ) +@router.get("/pdf") +async def pdf( + path: str, + service: FileOpsService = Depends(get_file_ops_service), +) -> StreamingResponse: + prepared = service.prepare_pdf_stream(path=path) + return StreamingResponse( + prepared["content"], + headers=prepared["headers"], + media_type=prepared["content_type"], + ) + + @router.get("/thumbnail") async def thumbnail( path: str, diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 4af8890f40a008d55243ac6ff2d467a38db64f35..7e6c6a8acfbfe9b072de47651441403aa8a410a0 100644 GIT binary patch delta 1572 zcmah}ZA@EL7{2HB_EK7)ql~r`=m(#cPnoin1v)+klY%fkh$A2kVHYf6v^njjVlrG7 z6BFZ)<;3`v4K*=lYUbEA*%ForIyIsjbo?k5|CkV?Bu+BsK9a?F-ouBh`{BLGljlC~ z^S$KoAy)h!N31 zNvyF6Bhm;W63)gFaH7RISj(A=bFs`>61HOpPTKV9uoLUJo{U{MnKM1BmWTD54g=1| z2Ck&`}D5^y!^;+5AGU`;XJ{FPCS=#7%k>RvwDCszRcVRoCsMtntDenLL3X0!CG@FMhlxEpj|G$$0%ODELl}_rwA16(X8K_iPI^kZ$puhV24RVT zlU>BlNZb@6N@##6;Pi(hL>V{|cO~wt;$h2VVs~f{oh`Q)SaV{Qs;H&vd}VBxFS^UO zXlZ*bWyxTP8XP~HY)hHNOV<6&m_zT?#L$cy-Fq6LD>PWUVwhpAkgow|X@6Zk`hiZ? zU2#63LI@@(%oY|lS|?NVq_+~yQ`K8eXKIg6q#xYh;&1Bg=xXlh@^|$dY{uk&DqLj6 z;syWNEkB9D;8;W%i})iig%tl#kSxN6oy`7SqmgNRESXnf}?Bhkl}_ zrgBjd)rKaQ22In!mS;tSNH4d%jee!i9C%qw7S-zqmJwQ|=UN?>Rmg?eFh41+#%Ew6D0d@{MbT_b;+@i^4oS_5W>aP>Bw^*KDZ;kg@t zGu{Vh`~Wyx*p5EF%~q+kt+0=WySK%EllcO7S->rBEXG@q$yx9MC!YX(1OVP~W8`RP zROtciQ^pFTik#-feTqQ$R0LTYIH_8mJ2ZJ^+%j1*MFHU#G+E9`RNq zeXspchAhaYbrEUzu4@=u14eJQ|An&YZ%^K8_zDug1egQ(4&W*Pub8+>TvvM}c=+h3 r;w3VhBpBlG8EWl()wQM;1NxuCBL(w1$9> z0AbJg=!|1m&9Es3=7!TJV`4}$!B{gvbH2o<3DIIZ;?(%*?=GM#Qq*7z5ORgI5Dk!M z5`yM~(HMh1X3{Blf+k}u%G_(dXhvV*6pTYfI0MGxHZ-`iewcuMg8O44`U@Apx>+#5 zoiJh&8U+t*3BqIyG#_HE^EWm|G!uhX4B`O~3l_}e#XNc=d~5EFB}gSR_ViQ5}++v1}@LblGdWooLtk z9BuYb8TwV(A3)W|d{qT<^hINlA2jR!5rYP}tY3^fXMj1~Xw8xh>+G=3aY93_X8mCL zj4uT1#_V-Psb=@!yiP-`PW^n|UmsBEpzRFPpSI&LpdT%;0nIP&gPZ!D;#&Y?bpM`@ z#s~a5A9Hy~3rb7iHtjE+w``$&ivLL`Yfda$0{@<(t$TA}f)4G?rcd`RDbF}upf}2j zHtoBz5}2fx@?Y?Rnk#zXxqhi)Hz==oaEUHeCc{hmtukAB1A0nTk`K(%^6CT92cV dict: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_dir(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": resolved_target.relative}, + ) + if not resolved_target.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Unsupported path type for pdf", + status_code=409, + details={"path": resolved_target.relative}, + ) + + content_type = self._pdf_content_type_for(resolved_target.absolute) + if content_type is None: + raise AppError( + code="unsupported_type", + message="File type is not supported for pdf viewing", + status_code=409, + details={"path": resolved_target.relative}, + ) + + return { + "headers": {"Content-Length": str(int(resolved_target.absolute.stat().st_size))}, + "content_type": content_type, + "content": self._filesystem.stream_file(resolved_target.absolute), + } + @staticmethod def _join_relative(base: str, name: str) -> str: return f"{base}/{name}" if base else name @@ -428,6 +464,10 @@ class FileOpsService: def _thumbnail_content_type_for(path: Path) -> str | None: return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower()) + @staticmethod + def _pdf_content_type_for(path: Path) -> str | None: + return PDF_CONTENT_TYPES.get(path.suffix.lower()) + def _record_history( self, *, diff --git a/webui/backend/tests/golden/__pycache__/test_api_pdf_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_pdf_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22c1f6e013c474eb58b2ca453906a0d4b79ded07 GIT binary patch literal 7071 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 SD<|GL@sq(vJP}%%(*F+$a^#o* literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index df05abf95b88de39aa083e5a9920b940a41aeb85..a52b88de716c4c158da9084a7c8ee17a326d7e7c 100644 GIT binary patch delta 548 zcmX?=c`cXkGcPX}0}!k~wkh+Q>PEhQ%#3R`^Ri@cPM*n|GdW9EU~(+0$YfSN6EN+| z2j(-nPWEF}o;;P$aB{bb7)!86DD&h-6$O@HPY6RO*ejITlnG=m1A_vCK0~ngTC9v$ zHop^b-p*_0uwQQj7J0682T{nQ00dnZ-bD!Kpc^U~@Ez!K~C2O-+UB&CXh4ENsQV zATKW49IrQ*X|kpvKby^tv@0C0lWPnmCZ`(kPgc|C-^^`j%fu1Q$ZGz*ipg_xfUy^& cNG=njq1+oRj%jR5q`YKF>axQ^ktWZ1M&bWkz!_X#plJLF8m-m72-^ zsv?YblT%c6IqicTf*mcH@>nMqs4BAB2Rj8jPClS2%ji7$0iS|UC|j_LDHF&O3=9ek z=?t2#n+4P+vThd95oKX3016gcZPquK%e0xp*oTQDf|1qydkK^4<``2iMv*ioM#~u- V*CjMBN@#vz0P#KqP2OcD0{~h_QcwT@ diff --git a/webui/backend/tests/golden/test_api_pdf_golden.py b/webui/backend/tests/golden/test_api_pdf_golden.py new file mode 100644 index 0000000..6934d9c --- /dev/null +++ b/webui/backend/tests/golden/test_api_pdf_golden.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +class PdfApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, path: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get("/api/files/pdf", params={"path": path}) + + return asyncio.run(_run()) + + def test_pdf_endpoint_success(self) -> None: + payload = b"%PDF-1.7\n1 0 obj\n<<>>\nendobj\n" + (self.root / "sample.pdf").write_bytes(payload) + + response = self._request("storage1/sample.pdf") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["content-type"], "application/pdf") + self.assertEqual(response.headers["content-length"], str(len(payload))) + self.assertEqual(response.content, payload) + + def test_pdf_directory_type_conflict(self) -> None: + (self.root / "docs").mkdir() + + response = self._request("storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_pdf_path_not_found(self) -> None: + response = self._request("storage1/missing.pdf") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_pdf_traversal_blocked(self) -> None: + response = self._request("storage1/../etc/passwd") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_pdf_invalid_root_alias(self) -> None: + response = self._request("unknown/sample.pdf") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_pdf_non_pdf_blocked(self) -> None: + (self.root / "notes.txt").write_text("hello", encoding="utf-8") + + response = self._request("storage1/notes.txt") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "unsupported_type") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index cba2575..53c2e24 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -51,6 +51,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("F8", body) self.assertIn('id="viewer-modal"', body) self.assertIn('id="video-modal"', body) + self.assertIn('id="pdf-modal"', body) + self.assertIn('id="pdf-frame"', body) + self.assertIn('id="pdf-close-btn"', body) self.assertIn('id="video-player"', body) self.assertIn('id="video-close-btn"', body) self.assertIn('id="settings-modal"', body) @@ -151,6 +154,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js) self.assertIn('return triggerActionButton("rename-btn");', app_js) self.assertIn('function openVideoViewer()', app_js) + self.assertIn('function openPdfViewer()', app_js) + self.assertIn('document.getElementById("pdf-modal")', app_js) + self.assertIn("`/api/files/pdf?", app_js) + self.assertIn('if (isPdfSelection(selected)) {', app_js) self.assertIn('video.player.src = streamUrl;', app_js) self.assertIn('document.getElementById("video-close-btn")', app_js) self.assertIn('row.ondblclick = (ev) => {', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index a7a5632..65b6d64 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -162,6 +162,18 @@ function videoElements() { }; } +function pdfElements() { + return { + overlay: document.getElementById("pdf-modal"), + title: document.getElementById("pdf-title"), + fileName: document.getElementById("pdf-file-name"), + filePath: document.getElementById("pdf-file-path"), + error: document.getElementById("pdf-error"), + frame: document.getElementById("pdf-frame"), + closeButton: document.getElementById("pdf-close-btn"), + }; +} + function moveElements() { return { overlay: document.getElementById("move-popup"), @@ -468,6 +480,13 @@ function isVideoSelection(item) { return lower.endsWith(".mp4") || lower.endsWith(".mkv"); } +function isPdfSelection(item) { + if (!item || item.kind !== "file") { + return false; + } + return (item.name || "").toLowerCase().endsWith(".pdf"); +} + function currentParentPath(path) { const normalized = (path || "").trim(); if (!normalized) { @@ -774,6 +793,15 @@ function renderPaneItems(pane) { renderPaneItems(pane); openVideoViewer(); }; + } else if (entry.kind === "file" && isPdfSelection({ path: entry.path, name: entry.name, kind: entry.kind })) { + row.ondblclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + model.currentRowIndex = index; + setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); + renderPaneItems(pane); + openPdfViewer(); + }; } items.append(row); }); @@ -1480,6 +1508,17 @@ function closeVideoViewer() { video.player.load(); } +function isPdfOpen() { + return !pdfElements().overlay.classList.contains("hidden"); +} + +function closePdfViewer() { + const pdf = pdfElements(); + pdf.overlay.classList.add("hidden"); + pdf.error.textContent = ""; + pdf.frame.removeAttribute("src"); +} + function isInfoOpen() { return !infoElements().overlay.classList.contains("hidden"); } @@ -1760,7 +1799,7 @@ function closeEditor() { resetEditorState(); } -async function openViewer() { +async function openTextViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { return; @@ -1787,6 +1826,22 @@ async function openViewer() { } } +async function openPdfViewer() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || !isPdfSelection(selectedItems[0])) { + return; + } + const selected = selectedItems[0]; + const pdf = pdfElements(); + const pdfUrl = `/api/files/pdf?${new URLSearchParams({ path: selected.path }).toString()}`; + pdf.overlay.classList.remove("hidden"); + pdf.title.textContent = "PDF"; + pdf.fileName.textContent = selected.name; + pdf.filePath.textContent = selected.path; + pdf.error.textContent = ""; + pdf.frame.src = pdfUrl; +} + function videoPlaybackMessage(item) { const lower = (item.name || "").toLowerCase(); if (lower.endsWith(".mkv")) { @@ -1845,6 +1900,23 @@ async function openEditor() { } } +function openViewer() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { + return; + } + const selected = selectedItems[0]; + if (isVideoSelection(selected)) { + openVideoViewer(); + return; + } + if (isPdfSelection(selected)) { + openPdfViewer(); + return; + } + openTextViewer(); +} + async function saveEditor() { if (!editorState.path) { return; @@ -2031,6 +2103,13 @@ function handleKeyboardShortcuts(event) { } return; } + if (isPdfOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closePdfViewer(); + } + return; + } if (isViewerOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -2210,6 +2289,9 @@ function setupEvents() { } }; + const pdf = pdfElements(); + pdf.closeButton.onclick = closePdfViewer; + const wildcard = wildcardPopupElements(); wildcard.cancelButton.onclick = closeWildcardPopup; wildcard.applyButton.onclick = submitWildcardPopup; diff --git a/webui/html/index.html b/webui/html/index.html index e23e744..2dc32e2 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -214,6 +214,17 @@ + +