From a3f53899af9efcc275ac8ad97215f6af8b63d65d Mon Sep 17 00:00:00 2001 From: SudNitro23 Date: Tue, 16 Jun 2026 10:36:51 +0100 Subject: [PATCH 1/3] PWC-8: Inline Nitro PDF viewer in Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render PDFs inside the Claude conversation using Nitro's nitro-pdf-reader web component, folded into one self-contained MCP-App HTML — no browser popup, no network, no second login. - view_pdf tool mounts the viewer as an MCP-App resource; PDF bytes stream over the MCP transport via get_pdf_for_viewer (the sandbox CSP blocks all outbound network). - scripts/inline-viewer.mjs bundles the multi-file public-reader build into one HTML: app chunks + worker inlined, Pdfium WASM fed via emscripten wasmBinary, Kendo CSS + IBM Plex fonts inlined as data: URLs. - scripts/viewer-bridge.js connects via @modelcontextprotocol/ext-apps, loads the file, reroutes Download through the host, and adds a widescreen toggle. - save_pdf_edits is a stub pending a component save event. - Remove the legacy popup/companion editor and its express/open deps. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + CLAUDE.md | 12 + docs/claude-mcp-app-build-guide.md | 121 + node-version/.prettierignore | 4 + node-version/build.mjs | 18 + node-version/eslint.config.ts | 2 +- node-version/package-lock.json | 33 +- node-version/package.json | 3 +- node-version/scripts/inline-viewer.mjs | 279 + node-version/scripts/viewer-bridge.js | 240 + node-version/src/assets/mcp-app.html | 119148 ++++++++++++++++++++++ node-version/src/tools/index.ts | 2 + node-version/src/tools/viewer.ts | 174 + 13 files changed, 120036 insertions(+), 4 deletions(-) create mode 100644 docs/claude-mcp-app-build-guide.md create mode 100644 node-version/.prettierignore create mode 100644 node-version/scripts/inline-viewer.mjs create mode 100644 node-version/scripts/viewer-bridge.js create mode 100644 node-version/src/assets/mcp-app.html create mode 100644 node-version/src/tools/viewer.ts diff --git a/.gitignore b/.gitignore index 0e7722f..4a58a83 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ tmp*.py node-version/dist/ node-version/node_modules/ node-version/coverage/ + + +# Local test documents +samples/ diff --git a/CLAUDE.md b/CLAUDE.md index 7bacaca..50a507b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,20 @@ MCP server connecting Claude Desktop to Nitro's Document Intelligence Platform A - `src/handlers/` — `PlatformHandler` (operations) and `FilesHandler` (local I/O) - `src/tools/` — MCP tool implementations - `src/auth/` — PKCE auth flow and token management +- `src/assets/mcp-app.html` — prebuilt single-file Nitro PDF viewer (generated by `scripts/inline-viewer.mjs`, copied to `dist/assets/` by `build.mjs`) +- `scripts/` — viewer build pipeline (`inline-viewer.mjs`, `viewer-bridge.js`) - `tests/` — Vitest test suite +## Inline PDF Viewer + +`view_pdf` renders a PDF in Nitro's reader directly inside the Claude conversation (MCP-App iframe — no browser popup, no network, no second login). Implementation: `src/tools/viewer.ts`. + +- The viewer is the `nitro-pdf-reader` Angular web component from the frontend repo (`apps/public-reader`, `nx build public-reader`). +- The MCP-App sandbox has no origin and blocks all network, so `scripts/inline-viewer.mjs` folds the multi-file build into ONE self-contained HTML (`src/assets/mcp-app.html`, ~26 MB): app chunks collapsed via esbuild, worker as Blob URL, Pdfium WASM injected via emscripten `wasmBinary` (sandbox CSP blocks `fetch()` of even `data:` URLs), Kendo CSS + IBM Plex fonts inlined as `data:` URLs. +- `scripts/viewer-bridge.js` (bundled into the HTML) connects via `@modelcontextprotocol/ext-apps`, receives `{filePath}` from the tool result, calls `get_pdf_for_viewer` over the MCP transport, and assigns the bytes to the component's `file` property. +- To rebuild the viewer after a frontend change: `node scripts/inline-viewer.mjs /dist/apps/public-reader/app/en` +- `save_pdf_edits` is a stub — round-trip (viewer edits back to Claude) is pending the component exposing a save event. + ## Commands Run from the repo root using the `n:` namespace (aliased from `node-version/Taskfile.yml`): diff --git a/docs/claude-mcp-app-build-guide.md b/docs/claude-mcp-app-build-guide.md new file mode 100644 index 0000000..4fb3dd6 --- /dev/null +++ b/docs/claude-mcp-app-build-guide.md @@ -0,0 +1,121 @@ +# Building for Claude's Inline MCP-App Sandbox + +A guide for frontend developers integrating a viewer/editor component into Claude Desktop via the MCP-App extension API. + +--- + +## What is the MCP-App Sandbox? + +Claude Desktop supports a feature called **MCP Apps** (`@modelcontextprotocol/ext-apps`). An MCP tool can register a UI resource (`text/html;profile=mcp-app`) and Claude will render it inline in the conversation — no browser popup, no second login. + +The renderer is a sandboxed iframe. The sandbox has one hard constraint: + +> **All outbound network is blocked by CSP.** No CDN fetches, no API calls, no localhost, no external fonts or stylesheets. + +Everything the viewer needs must be **self-contained inside the HTML string** that the MCP server returns. + +--- + +## The Single-File Requirement + +The MCP resource API returns a single string of HTML. There is no concept of sibling files — you cannot reference `main.js`, `styles.css`, or a `.wasm` file sitting next to the HTML. If your build output is a folder of files, none of those files are reachable from the iframe. + +**This means:** +- All JavaScript must be inlined into `', + ); + // Drop the dev-harness script (it sets `showOpenButton = true` for local + // file picking). In Claude the file arrives over MCP — no Open button. + html = html.replace(/` : '') + + `` + + ``; + html = html.replace('', () => `${scripts}`); + + // Inline the IBM Plex woff2 fonts as data: URLs (with gstatic fallback) so the + // reader's typography survives the sandbox CSP instead of degrading to system fonts. + const fontResult = await _inlineFonts(html); + html = fontResult.html; + console.log(`fonts: inlined ${fontResult.count}/${fontResult.total} woff2 as data: URLs`); + + fs.writeFileSync(OUT, html); + console.log(`\n✅ wrote ${OUT}`); + console.log(` final size: ${_mb(html.length)}`); + + // ── Structural validation (no Claude reload needed) ── + const leftovers = []; + if (/ + + Public PDF Reader (dev) + + + + + + + + + + + + diff --git a/node-version/src/tools/index.ts b/node-version/src/tools/index.ts index aba3063..af6d0a8 100644 --- a/node-version/src/tools/index.ts +++ b/node-version/src/tools/index.ts @@ -6,6 +6,7 @@ import { register as registerFileManagement } from './fileManagement.js'; import { register as registerGenerations } from './generations.js'; import { register as registerPii } from './pii.js'; import { register as registerTransformations } from './transformations.js'; +import { register as registerViewer } from './viewer.js'; export function registerAll(server: McpServer, context: AppContext): void { registerFileManagement(server, context); @@ -14,4 +15,5 @@ export function registerAll(server: McpServer, context: AppContext): void { registerGenerations(server, context); registerPii(server, context); registerTransformations(server, context); + registerViewer(server, context); } diff --git a/node-version/src/tools/viewer.ts b/node-version/src/tools/viewer.ts new file mode 100644 index 0000000..860adb3 --- /dev/null +++ b/node-version/src/tools/viewer.ts @@ -0,0 +1,174 @@ +/* + viewer.ts — Render a PDF in the Nitro reader, inline inside Claude. + + The viewer is Nitro's `nitro-pdf-reader` web component (frontend repo, + public-reader app), folded into ONE self-contained HTML by + `scripts/inline-viewer.mjs` and served as an MCP-App resource. The sandbox + has no origin and blocks all network, so everything (Pdfium WASM, worker, + Kendo theme, fonts) is inlined and the PDF bytes travel over the MCP + transport, never HTTP: + 1. `view_pdf` is an MCP-App tool. Its result carries `_meta.ui.resourceUri`, + so the host mounts the viewer, and `content[0].text` is JSON + `{ filePath, filename }` which the viewer reads on launch. + 2. The viewer calls `get_pdf_for_viewer` back through the MCP bridge + (see `scripts/viewer-bridge.js`) to receive the PDF bytes as base64. + 3. `save_pdf_edits` is a stub awaiting round-trip support (the component + does not yet expose a save event). +*/ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + registerAppTool, + registerAppResource, + RESOURCE_MIME_TYPE, +} from '@modelcontextprotocol/ext-apps/server'; +import type { AppContext } from '../context.js'; +import { NON_DESTRUCTIVE } from './annotations.js'; + +const RESOURCE_URI = 'ui://nitro-pdf-viewer/app'; + +// The viewer's @font-face rules reference IBM Plex (Sans/Mono) woff2 files on +// fonts.gstatic.com. The inliner embeds those as data: URLs (offline-first) but +// keeps the gstatic URL as a fallback src. Allowlisting the origin here maps to +// the sandbox CSP font-src directive, so the fallback can still load if the host +// blocks data: fonts. resourceDomains → img-/script-/style-/font-/media-src. +const _VIEWER_CSP = { + resourceDomains: ['https://fonts.gstatic.com', 'https://fonts.googleapis.com'], +} as const; + +// Minimum inline height (px). The viewer auto-reports its content height to the +// host via `sendSizeChanged`; giving the flex shell a min-height floors that +// reported value so the PDF page area gets a usable amount of vertical space. +const MIN_VIEWER_HEIGHT_PX = 720; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Injected to floor the inline viewport height. The nitro-pdf-reader element +// uses height:100vh; without a floor, the bridge's auto-resize can settle on a +// tiny height before content lays out. Target the element + document. +const _HEIGHT_OVERRIDE_STYLE = ``; + +// Load the prebuilt single-file viewer (built by scripts/inline-viewer.mjs). +// Copied from src/assets/ to dist/assets/ by build.mjs, so resolve against both. +function _loadViewerHtml(): string { + const candidates = [ + path.resolve(__dirname, 'assets', 'mcp-app.html'), + path.resolve(__dirname, '..', 'assets', 'mcp-app.html'), + path.resolve(__dirname, '..', 'src', 'assets', 'mcp-app.html'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + const html = fs.readFileSync(candidate, 'utf-8'); + // Inject the height override last in so it wins over bundle CSS. + return html.includes('') + ? html.replace('', `${_HEIGHT_OVERRIDE_STYLE}`) + : _HEIGHT_OVERRIDE_STYLE + html; + } + } + return '

Viewer asset (mcp-app.html) not found.

'; +} + +export function register(server: McpServer, context: AppContext): void { + // ── The viewer UI resource ──────────────────────────────────────────────── + registerAppResource( + server, + 'Nitro PDF Viewer', + RESOURCE_URI, + { + mimeType: RESOURCE_MIME_TYPE, + description: 'Self-contained Nitro PDF reader (Pdfium WASM inlined)', + _meta: { ui: { csp: _VIEWER_CSP } }, + }, + () => ({ + contents: [ + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: _loadViewerHtml(), + // Content-item _meta.ui takes precedence over the listing-level value. + _meta: { ui: { csp: _VIEWER_CSP } }, + }, + ], + }), + ); + + // ── 1. Launch tool — mounts the viewer and hands it the file to open ────── + registerAppTool( + server, + 'view_pdf', + { + description: + 'Render a PDF in the Nitro viewer directly inside the conversation (no browser ' + + 'popup, no network, no second login). Provide the absolute filePath (use ' + + 'list_files to resolve it first). This is the preferred way to show a PDF to ' + + 'the user.', + inputSchema: { + filePath: z.string().describe('Absolute path to the PDF file, or a path starting with ~.'), + fileName: z.string().optional().describe('Display name, e.g. "contract.pdf".'), + }, + annotations: NON_DESTRUCTIVE('View PDF'), + _meta: { ui: { resourceUri: RESOURCE_URI } }, + }, + ({ filePath, fileName }) => { + const filename = fileName ?? path.basename(filePath); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ filePath, filename }) }], + }; + }, + ); + + // ── 2. Byte provider — the viewer calls this back over the MCP bridge ───── + server.registerTool( + 'get_pdf_for_viewer', + { + description: + 'Internal: returns the PDF bytes (base64) for the viewer. Called by the ' + + 'viewer itself — not normally invoked directly.', + inputSchema: { + filePath: z.string().describe('Absolute path to the PDF file, or a path starting with ~.'), + }, + annotations: NON_DESTRUCTIVE('Get PDF For Viewer'), + }, + ({ filePath }) => { + const bytes = context.filesHandler.read(filePath); + const payload = { + pdfBytes: bytes.toString('base64'), + filename: path.basename(filePath), + }; + return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }; + }, + ); + + // ── 3. Save stub — round-trip support pending (component save event) ────── + server.registerTool( + 'save_pdf_edits', + { + description: + 'Internal: persists viewer edits (e.g. page rotations). Stub — not yet implemented.', + inputSchema: { + filePath: z.string().describe('Absolute path to the PDF file.'), + rotations: z + .record(z.string(), z.number()) + .optional() + .describe('Map of page index → rotation in degrees.'), + }, + annotations: NON_DESTRUCTIVE('Save PDF Edits'), + }, + ({ filePath }) => ({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + outputFilename: path.basename(filePath), + note: 'Stub — edits are not yet persisted.', + }), + }, + ], + }), + ); +} From 7649045e55c94180087f5df2556b70fefedd0b00 Mon Sep 17 00:00:00 2001 From: SudNitro23 Date: Fri, 19 Jun 2026 11:04:23 +0100 Subject: [PATCH 2/3] Update Nitro MCP Logo --- node-version/icon.png | Bin 16130 -> 20491 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/node-version/icon.png b/node-version/icon.png index f4b6ebf2474008c768c36ea3df44ddf000c9e26e..a9c554311a94234406bdbdf05e49bbae3f5d9e76 100644 GIT binary patch literal 20491 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 WCz&e-A4VbI<6yhP=Kgm7*na@KyKc7t literal 16130 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 Date: Fri, 19 Jun 2026 11:53:22 +0100 Subject: [PATCH 3/3] Add keyboard shortcut controls for Viewer --- node-version/scripts/viewer-bridge.js | 85 ++++++++++++++++++++++++--- node-version/src/assets/mcp-app.html | 81 +++++++++++++++++++++---- 2 files changed, 146 insertions(+), 20 deletions(-) diff --git a/node-version/scripts/viewer-bridge.js b/node-version/scripts/viewer-bridge.js index f174132..906b8da 100644 --- a/node-version/scripts/viewer-bridge.js +++ b/node-version/scripts/viewer-bridge.js @@ -179,18 +179,34 @@ HTMLAnchorElement.prototype.click = function () { // the button state. Placed bottom-right above the reader footer, clear of the // zoom controls. let _displayMode = 'inline'; +let _widescreenBtn = null; -function _setWidescreenIcon(btn) { - btn.textContent = _displayMode === 'fullscreen' ? '🗗' : '⛶'; - btn.title = _displayMode === 'fullscreen' ? 'Exit widescreen' : 'Widescreen'; +// Single source of truth for the button icon. Driven both by our own toggle and +// by host-initiated changes (e.g. the user clicks the host's X to exit +// fullscreen) — without the host sync the button would keep showing the +// "minimize" glyph after the host already returned to inline, then misbehave on +// the next click. +function _applyDisplayMode(mode) { + if (!mode || mode === _displayMode) { + if (_widescreenBtn) _renderWidescreenIcon(); + return; + } + _displayMode = mode; + _renderWidescreenIcon(); +} + +function _renderWidescreenIcon() { + if (!_widescreenBtn) return; + const full = _displayMode === 'fullscreen'; + _widescreenBtn.textContent = full ? '🗗' : '⛶'; + _widescreenBtn.title = full ? 'Exit widescreen' : 'Widescreen'; } -async function _toggleWidescreen(btn) { +async function _toggleWidescreen() { const want = _displayMode === 'fullscreen' ? 'inline' : 'fullscreen'; try { const res = await app.requestDisplayMode({ mode: want }); - _displayMode = res?.mode ?? _displayMode; - _setWidescreenIcon(btn); + _applyDisplayMode(res?.mode ?? _displayMode); if (_displayMode !== want) _status(`host kept display mode "${_displayMode}"`); } catch (err) { _status(`widescreen toggle failed: ${String(err)}`, true); @@ -207,13 +223,65 @@ function _addWidescreenButton() { 'border-radius:50%;border:none;cursor:pointer;background:#090b21;color:#fff;' + 'font-size:18px;line-height:40px;text-align:center;opacity:.85;' + 'box-shadow:0 2px 8px rgba(0,0,0,.35)'; - _setWidescreenIcon(btn); + _widescreenBtn = btn; + _renderWidescreenIcon(); btn.addEventListener('mouseenter', () => (btn.style.opacity = '1')); btn.addEventListener('mouseleave', () => (btn.style.opacity = '.85')); - btn.addEventListener('click', () => void _toggleWidescreen(btn)); + btn.addEventListener('click', () => void _toggleWidescreen()); document.body.appendChild(btn); } +// ── Keyboard shortcuts ────────────────────────────────────────────────────── +// Esc exits widescreen; ⌘/Ctrl+Z = undo, ⌘/Ctrl+Shift+Z (or Ctrl+Y) = redo. +// Undo/redo are driven by clicking the reader's own toolbar buttons, so they +// dispatch the proper events and respect the disabled (canUndo/canRedo) state. +// The reader binds no undo/redo shortcuts itself, so we own them here. +function _isEditable(el) { + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true; +} + +function _clickReaderButton(testid) { + const btn = document.querySelector(`[data-testid="${testid}"]`); + if (btn && !btn.disabled) btn.click(); +} + +function _installKeyboardShortcuts() { + window.addEventListener( + 'keydown', + (e) => { + // Esc leaves widescreen (only when actually in fullscreen). + if (e.key === 'Escape' && _displayMode === 'fullscreen') { + e.preventDefault(); + void _toggleWidescreen(); + return; + } + const mod = e.metaKey || e.ctrlKey; + // Never hijack undo inside a text field — let native editing handle it. + if (!mod || _isEditable(e.target)) return; + const key = e.key.toLowerCase(); + if (key === 'z') { + e.preventDefault(); + _clickReaderButton(e.shiftKey ? 'reader-redo' : 'reader-undo'); + } else if (key === 'y') { + e.preventDefault(); + _clickReaderButton('reader-redo'); + } + }, + true, + ); +} + +// Keep the widescreen button in sync when the HOST changes the display mode +// (e.g. the user clicks the host's X / minimize to leave fullscreen). The host +// sends ui/notifications/host-context-changed with the new displayMode; without +// this the button's state goes stale and the next click misbehaves. +// Registered before connect() like ontoolresult. +app.onhostcontextchanged = (ctx) => { + if (ctx?.displayMode) _applyDisplayMode(ctx.displayMode); +}; + // Must be registered before connect() — the launching tool result arrives as a // notification right after the initialize handshake. app.ontoolresult = async (result) => { @@ -236,5 +304,6 @@ app .then(() => { _status('connected; awaiting tool result…'); _addWidescreenButton(); + _installKeyboardShortcuts(); }) .catch((err) => _status(`connect failed: ${String(err)}`, true)); diff --git a/node-version/src/assets/mcp-app.html b/node-version/src/assets/mcp-app.html index 9c06216..ad30eed 100644 --- a/node-version/src/assets/mcp-app.html +++ b/node-version/src/assets/mcp-app.html @@ -40,8 +40,13 @@ if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x13 + '" is not supported'); }); -var __esm = (fn4, res) => function __init() { - return fn4 && (res = (0, fn4[__getOwnPropNames(fn4)[0]])(fn4 = 0)), res; +var __esm = (fn4, res, err) => function __init() { + if (err) throw err[0]; + try { + return fn4 && (res = (0, fn4[__getOwnPropNames(fn4)[0]])(fn4 = 0)), res; + } catch (e10) { + throw err = [e10], e10; + } }; var __export = (target, all) => { for (var name in all) @@ -104113,8 +104118,13 @@ if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); - var __esm = (fn, res) => function __init() { - return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + var __esm = (fn, res, err) => function __init() { + if (err) throw err[0]; + try { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + } catch (e) { + throw err = [e], e; + } }; var __export = (target, all) => { for (var name in all) @@ -119098,16 +119108,26 @@ _hostDownload(blob, filename).catch((err) => _status(`download failed: ${String(err)}`, true)); }; var _displayMode = "inline"; - function _setWidescreenIcon(btn) { - btn.textContent = _displayMode === "fullscreen" ? "\u{1F5D7}" : "\u26F6"; - btn.title = _displayMode === "fullscreen" ? "Exit widescreen" : "Widescreen"; + var _widescreenBtn = null; + function _applyDisplayMode(mode) { + if (!mode || mode === _displayMode) { + if (_widescreenBtn) _renderWidescreenIcon(); + return; + } + _displayMode = mode; + _renderWidescreenIcon(); } - async function _toggleWidescreen(btn) { + function _renderWidescreenIcon() { + if (!_widescreenBtn) return; + const full = _displayMode === "fullscreen"; + _widescreenBtn.textContent = full ? "\u{1F5D7}" : "\u26F6"; + _widescreenBtn.title = full ? "Exit widescreen" : "Widescreen"; + } + async function _toggleWidescreen() { const want = _displayMode === "fullscreen" ? "inline" : "fullscreen"; try { const res = await app.requestDisplayMode({ mode: want }); - _displayMode = res?.mode ?? _displayMode; - _setWidescreenIcon(btn); + _applyDisplayMode(res?.mode ?? _displayMode); if (_displayMode !== want) _status(`host kept display mode "${_displayMode}"`); } catch (err) { _status(`widescreen toggle failed: ${String(err)}`, true); @@ -119119,12 +119139,48 @@ btn.id = "__nitro_widescreen"; btn.type = "button"; btn.style.cssText = "position:fixed;right:16px;bottom:72px;z-index:2147483646;width:40px;height:40px;border-radius:50%;border:none;cursor:pointer;background:#090b21;color:#fff;font-size:18px;line-height:40px;text-align:center;opacity:.85;box-shadow:0 2px 8px rgba(0,0,0,.35)"; - _setWidescreenIcon(btn); + _widescreenBtn = btn; + _renderWidescreenIcon(); btn.addEventListener("mouseenter", () => btn.style.opacity = "1"); btn.addEventListener("mouseleave", () => btn.style.opacity = ".85"); - btn.addEventListener("click", () => void _toggleWidescreen(btn)); + btn.addEventListener("click", () => void _toggleWidescreen()); document.body.appendChild(btn); } + function _isEditable(el) { + if (!el) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable === true; + } + function _clickReaderButton(testid) { + const btn = document.querySelector(`[data-testid="${testid}"]`); + if (btn && !btn.disabled) btn.click(); + } + function _installKeyboardShortcuts() { + window.addEventListener( + "keydown", + (e) => { + if (e.key === "Escape" && _displayMode === "fullscreen") { + e.preventDefault(); + void _toggleWidescreen(); + return; + } + const mod = e.metaKey || e.ctrlKey; + if (!mod || _isEditable(e.target)) return; + const key = e.key.toLowerCase(); + if (key === "z") { + e.preventDefault(); + _clickReaderButton(e.shiftKey ? "reader-redo" : "reader-undo"); + } else if (key === "y") { + e.preventDefault(); + _clickReaderButton("reader-redo"); + } + }, + true + ); + } + app.onhostcontextchanged = (ctx) => { + if (ctx?.displayMode) _applyDisplayMode(ctx.displayMode); + }; app.ontoolresult = async (result) => { try { const data = _readPayload(result); @@ -119142,6 +119198,7 @@ app.connect().then(() => { _status("connected; awaiting tool result\u2026"); _addWidescreenButton(); + _installKeyboardShortcuts(); }).catch((err) => _status(`connect failed: ${String(err)}`, true)); })();