| author | |
| committer | |
| log | 573cec01664d8bfef9da53dd31d68398e5814170 |
| tree | 1a93861ae09f440a71f4194ad868da7bd22c741e |
| parent | dff750b1b8818eb004b8c3d060edcdc09f88a07f |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 219 insertions(+), 5 deletions(-)
apps/web/src/components/files/BrowserDocumentFrame.tsx+128-2| ... | ... | @@ -1,18 +1,35 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | 2 | import { |
| 3 | type KeybindingCommand, | |
| 4 | type KeybindingShortcut, | |
| 5 | MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | |
| 6 | type ScopedThreadRef, | |
| 7 | THREAD_JUMP_KEYBINDING_COMMANDS, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | type HtmlRenderShortcut, | |
| 11 | htmlRenderShortcutsMessage, | |
| 3 | 12 | htmlRenderThemeFragment, |
| 4 | 13 | htmlRenderThemeMessage, |
| 5 | 14 | htmlRenderResult, |
| 6 | 15 | readHtmlRenderContentHeight, |
| 16 | readHtmlRenderKeyDown, | |
| 7 | 17 | readHtmlRenderLinkRequest, |
| 8 | 18 | } from "@t3tools/shared/htmlRender"; |
| 9 | import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; | |
| 19 | import { | |
| 20 | isMacPlatform, | |
| 21 | matchesKeybindingShortcut, | |
| 22 | normalizeEventKey, | |
| 23 | } from "@t3tools/shared/keybindings"; | |
| 24 | import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 10 | 25 | |
| 11 | 26 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 12 | 27 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; |
| 28 | import { shortcutConflictKey } from "~/keybindings"; | |
| 13 | 29 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; |
| 14 | 30 | import { readLocalApi } from "~/localApi"; |
| 15 | 31 | import { cn } from "~/lib/utils"; |
| 32 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; | |
| 16 | 33 | |
| 17 | 34 | /** |
| 18 | 35 | * Chromium's viewer opens with its own toolbar, a thumbnail rail and a small |
| ... | ... | @@ -22,6 +39,32 @@ import { cn } from "~/lib/utils"; |
| 22 | 39 | */ |
| 23 | 40 | const PDF_VIEWER_FRAGMENT = "#toolbar=0&view=FitH"; |
| 24 | 41 | |
| 42 | /** | |
| 43 | * Shortcuts that still work while a focused HTML render holds the keyboard. | |
| 44 | * The page reports the keys, and a page script can report any key it likes, | |
| 45 | * so only commands that change what the reader sees are here, never one that | |
| 46 | * changes a thread. | |
| 47 | */ | |
| 48 | const HTML_RENDER_SHORTCUT_COMMANDS: ReadonlySet<KeybindingCommand> = new Set([ | |
| 49 | "commandPalette.toggle", | |
| 50 | "filePicker.toggle", | |
| 51 | "projectSearch.toggle", | |
| 52 | "chat.find", | |
| 53 | "sidebar.toggle", | |
| 54 | "rightPanel.toggle", | |
| 55 | "rightPanel.toggleMaximized", | |
| 56 | "threadPanel.toggle", | |
| 57 | "terminal.toggle", | |
| 58 | "preview.toggle", | |
| 59 | "navigation.back", | |
| 60 | "navigation.forward", | |
| 61 | "thread.previous", | |
| 62 | "thread.next", | |
| 63 | ...THREAD_JUMP_KEYBINDING_COMMANDS, | |
| 64 | // Share Cmd/Ctrl+digit with thread jumps, and act only while the picker is open. | |
| 65 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | |
| 66 | ]); | |
| 67 | ||
| 25 | 68 | export const isPdfPreviewFile = (path: string): boolean => |
| 26 | 69 | /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 27 | 70 | |
| ... | ... | @@ -90,6 +133,63 @@ export function HtmlRenderDocument(props: { |
| 90 | 133 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); |
| 91 | 134 | }; |
| 92 | 135 | useEffect(postTheme, [theme]); |
| 136 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | |
| 137 | // Chat also resolves project scripts from the thread's own environment. | |
| 138 | const environmentKeybindings = useAtomValue( | |
| 139 | serverEnvironment.configValueAtom(props.threadRef?.environmentId ?? null), | |
| 140 | )?.keybindings; | |
| 141 | // The app resolves a replayed key in its live context, so every allowed | |
| 142 | // binding counts whatever its `when`, and a key that any binding gives to | |
| 143 | // another command stays with the page. Only Cmd/Ctrl chords: a printable key | |
| 144 | // would type into the composer instead. | |
| 145 | const { shortcuts, blocked } = useMemo(() => { | |
| 146 | const mac = isMacPlatform(navigator.platform); | |
| 147 | const resolve = (shortcut: KeybindingShortcut): HtmlRenderShortcut => ({ | |
| 148 | key: shortcut.key, | |
| 149 | metaKey: shortcut.metaKey || (shortcut.modKey && mac), | |
| 150 | ctrlKey: shortcut.ctrlKey || (shortcut.modKey && !mac), | |
| 151 | shiftKey: shortcut.shiftKey, | |
| 152 | altKey: shortcut.altKey, | |
| 153 | }); | |
| 154 | const blocked = [...keybindings, ...(environmentKeybindings ?? [])] | |
| 155 | .filter((binding) => !HTML_RENDER_SHORTCUT_COMMANDS.has(binding.command)) | |
| 156 | .map((binding) => resolve(binding.shortcut)); | |
| 157 | const shortcuts = new Map<string, HtmlRenderShortcut>(); | |
| 158 | for (const { command, shortcut } of keybindings) { | |
| 159 | if ( | |
| 160 | HTML_RENDER_SHORTCUT_COMMANDS.has(command) && | |
| 161 | (shortcut.metaKey || shortcut.ctrlKey || shortcut.modKey) | |
| 162 | ) { | |
| 163 | shortcuts.set(shortcutConflictKey(shortcut), resolve(shortcut)); | |
| 164 | } | |
| 165 | } | |
| 166 | return { shortcuts: [...shortcuts.values()], blocked }; | |
| 167 | }, [environmentKeybindings, keybindings]); | |
| 168 | const postShortcuts = () => { | |
| 169 | frameRef.current?.contentWindow?.postMessage( | |
| 170 | htmlRenderShortcutsMessage(shortcuts, blocked), | |
| 171 | "*", | |
| 172 | ); | |
| 173 | }; | |
| 174 | useEffect(postShortcuts, [blocked, shortcuts]); | |
| 175 | // The page reports the key and code, and a page script can report any pair, | |
| 176 | // so a key that could match another command's binding is never replayed. A | |
| 177 | // named key (Backspace, Enter) must be the shortcut's own, since some app | |
| 178 | // handlers read `event.key` directly. A dead key matches by its code. | |
| 179 | const isShortcut = useEffectEvent( | |
| 180 | (key: NonNullable<ReturnType<typeof readHtmlRenderKeyDown>>) => { | |
| 181 | const matches = (shortcut: HtmlRenderShortcut) => | |
| 182 | matchesKeybindingShortcut(key, { ...shortcut, modKey: false }, navigator.platform); | |
| 183 | const allowed = shortcuts.filter(matches); | |
| 184 | return ( | |
| 185 | allowed.length > 0 && | |
| 186 | !blocked.some(matches) && | |
| 187 | (key.key.length === 1 || | |
| 188 | key.key === "Dead" || | |
| 189 | allowed.some((shortcut) => shortcut.key === normalizeEventKey(key.key))) | |
| 190 | ); | |
| 191 | }, | |
| 192 | ); | |
| 93 | 193 | const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef); |
| 94 | 194 | const openLink = useOpenLink(props.threadRef); |
| 95 | 195 | // Same rules as a chat link: a pull request opens in the right panel, any |
| ... | ... | @@ -140,6 +240,31 @@ export function HtmlRenderDocument(props: { |
| 140 | 240 | window.addEventListener("message", openLink); |
| 141 | 241 | return () => window.removeEventListener("message", openLink); |
| 142 | 242 | }, []); |
| 243 | // A focused frame takes every key, so app shortcuts would stop until the | |
| 244 | // reader clicked out. The page passes on the chords posted to it, which replay | |
| 245 | // on the frame element and bubble to the app's window listeners. A report is | |
| 246 | // gated like a link and must be one of those chords. | |
| 247 | useEffect(() => { | |
| 248 | const replayKey = (event: MessageEvent) => { | |
| 249 | const frame = frameRef.current; | |
| 250 | const key = readHtmlRenderKeyDown(event.data); | |
| 251 | if ( | |
| 252 | key === undefined || | |
| 253 | frame === null || | |
| 254 | event.source !== frame.contentWindow || | |
| 255 | document.activeElement !== frame || | |
| 256 | navigator.userActivation?.isActive === false | |
| 257 | ) { | |
| 258 | return; | |
| 259 | } | |
| 260 | if (!isShortcut(key)) return; | |
| 261 | frame.dispatchEvent( | |
| 262 | new KeyboardEvent("keydown", { ...key, bubbles: true, cancelable: true }), | |
| 263 | ); | |
| 264 | }; | |
| 265 | window.addEventListener("message", replayKey); | |
| 266 | return () => window.removeEventListener("message", replayKey); | |
| 267 | }, []); | |
| 143 | 268 | const { onContentHeight } = props; |
| 144 | 269 | // A page posts its height once per change, so listen from the commit that |
| 145 | 270 | // inserts the frame; a passive effect could run after a fast page's first post. |
| ... | ... | @@ -166,6 +291,7 @@ export function HtmlRenderDocument(props: { |
| 166 | 291 | setLoaded(true); |
| 167 | 292 | // Covers a theme change that landed while the page was loading. |
| 168 | 293 | postTheme(); |
| 294 | postShortcuts(); | |
| 169 | 295 | }} |
| 170 | 296 | // A frame whose color scheme differs from its document's paints an opaque |
| 171 | 297 | // canvas, so the blank document a frame starts with would flash white in |
packages/shared/src/htmlRender.test.ts+24| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | injectHtmlRenderBootstrap, |
| 10 | 10 | htmlRenderThemeMessage, |
| 11 | 11 | readHtmlRenderContentHeight, |
| 12 | readHtmlRenderKeyDown, | |
| 12 | 13 | readHtmlRenderLinkRequest, |
| 13 | 14 | readHtmlRenderReference, |
| 14 | 15 | } from "./htmlRender.ts"; |
| ... | ... | @@ -106,6 +107,29 @@ describe("readHtmlRenderLinkRequest", () => { |
| 106 | 107 | }); |
| 107 | 108 | }); |
| 108 | 109 | |
| 110 | describe("readHtmlRenderKeyDown", () => { | |
| 111 | it("reads the key of a keydown notification and nothing else", () => { | |
| 112 | const notification = (params: unknown) => ({ | |
| 113 | jsonrpc: "2.0", | |
| 114 | method: "t3/notifications/keydown", | |
| 115 | params, | |
| 116 | }); | |
| 117 | expect(readHtmlRenderKeyDown(notification({ key: "k", code: "KeyK", metaKey: true }))).toEqual({ | |
| 118 | key: "k", | |
| 119 | code: "KeyK", | |
| 120 | metaKey: true, | |
| 121 | ctrlKey: false, | |
| 122 | shiftKey: false, | |
| 123 | altKey: false, | |
| 124 | repeat: false, | |
| 125 | }); | |
| 126 | expect(readHtmlRenderKeyDown(notification({ key: 1, code: "KeyK" }))).toBeUndefined(); | |
| 127 | expect( | |
| 128 | readHtmlRenderKeyDown({ jsonrpc: "2.0", method: "ui/open-link", params: { key: "k" } }), | |
| 129 | ).toBeUndefined(); | |
| 130 | }); | |
| 131 | }); | |
| 132 | ||
| 109 | 133 | describe("readHtmlRenderContentHeight", () => { |
| 110 | 134 | it("reads only the height of an MCP Apps size-changed notification", () => { |
| 111 | 135 | const notification = (params: unknown) => ({ |
packages/shared/src/htmlRender.ts+66-2| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | type ThemeAppearance, |
| 5 | 5 | type ThemeColors, |
| 6 | 6 | } from "./themePalettes.ts"; |
| 7 | import { EVENT_CODE_SHORTCUT_KEYS } from "./keybindings.ts"; | |
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * Agent-authored HTML pages ("HTML renders") are self-contained documents an |
| ... | ... | @@ -274,6 +275,11 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ |
| 274 | 275 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; |
| 275 | 276 | const OPEN_LINK_METHOD = "ui/open-link"; |
| 276 | 277 | const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; |
| 278 | // Not part of MCP Apps: a focused frame keeps its keys from the app, so the | |
| 279 | // client tells the page which chords are app shortcuts and the page passes | |
| 280 | // those on. | |
| 281 | const SHORTCUTS_METHOD = "t3/notifications/shortcuts"; | |
| 282 | const KEY_DOWN_METHOD = "t3/notifications/keydown"; | |
| 277 | 283 | |
| 278 | 284 | /** The content height in a framed render's `ui/notifications/size-changed` notification. */ |
| 279 | 285 | export function readHtmlRenderContentHeight(data: unknown): number | undefined { |
| ... | ... | @@ -311,6 +317,60 @@ export function readHtmlRenderLinkRequest(data: unknown): |
| 311 | 317 | : undefined; |
| 312 | 318 | } |
| 313 | 319 | |
| 320 | /** The key a framed render passed on in a `t3/notifications/keydown` notification. */ | |
| 321 | export function readHtmlRenderKeyDown(data: unknown): | |
| 322 | | { | |
| 323 | readonly key: string; | |
| 324 | readonly code: string; | |
| 325 | readonly metaKey: boolean; | |
| 326 | readonly ctrlKey: boolean; | |
| 327 | readonly shiftKey: boolean; | |
| 328 | readonly altKey: boolean; | |
| 329 | readonly repeat: boolean; | |
| 330 | } | |
| 331 | | undefined { | |
| 332 | if (typeof data !== "object" || data === null) return undefined; | |
| 333 | const { jsonrpc, method, params } = data as Record<string, unknown>; | |
| 334 | if (jsonrpc !== "2.0" || method !== KEY_DOWN_METHOD) return undefined; | |
| 335 | if (typeof params !== "object" || params === null) return undefined; | |
| 336 | const { key, code, metaKey, ctrlKey, shiftKey, altKey, repeat } = params as Record< | |
| 337 | string, | |
| 338 | unknown | |
| 339 | >; | |
| 340 | if (typeof key !== "string" || typeof code !== "string") return undefined; | |
| 341 | return { | |
| 342 | key, | |
| 343 | code, | |
| 344 | metaKey: metaKey === true, | |
| 345 | ctrlKey: ctrlKey === true, | |
| 346 | shiftKey: shiftKey === true, | |
| 347 | altKey: altKey === true, | |
| 348 | repeat: repeat === true, | |
| 349 | }; | |
| 350 | } | |
| 351 | ||
| 352 | /** A chord as the page sees it: `mod` already resolved to Meta or Control. */ | |
| 353 | export interface HtmlRenderShortcut { | |
| 354 | /** Lowercase key name, as in a keybinding: `k`, `[`, `enter`. */ | |
| 355 | readonly key: string; | |
| 356 | readonly metaKey: boolean; | |
| 357 | readonly ctrlKey: boolean; | |
| 358 | readonly shiftKey: boolean; | |
| 359 | readonly altKey: boolean; | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * The `t3/notifications/shortcuts` notification a client posts into a render: | |
| 364 | * the chords it may pass on, and chords of other commands that the page keeps | |
| 365 | * even when one also matches an allowed chord. | |
| 366 | */ | |
| 367 | export function htmlRenderShortcutsMessage( | |
| 368 | shortcuts: ReadonlyArray<HtmlRenderShortcut>, | |
| 369 | blocked: ReadonlyArray<HtmlRenderShortcut>, | |
| 370 | ) { | |
| 371 | return { jsonrpc: "2.0", method: SHORTCUTS_METHOD, params: { shortcuts, blocked } } as const; | |
| 372 | } | |
| 373 | ||
| 314 | 374 | /** The empty result a client sends back for a render's request. */ |
| 315 | 375 | export function htmlRenderResult(id: string | number) { |
| 316 | 376 | return { jsonrpc: "2.0", id, result: {} } as const; |
| ... | ... | @@ -355,8 +415,12 @@ function rootRule(theme: HtmlRenderTheme): string { |
| 355 | 415 | // a framed page asks its client to open it, and a top-level page (mobile) |
| 356 | 416 | // opens it as a new window, which the client sends to the browser. A framed |
| 357 | 417 | // page also reports its content height, measured as the server measures it, |
| 358 | // so its client can fit the frame to the page. | |
| 359 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);}})();`; | |
| 418 | // so its client can fit the frame to the page. A chord the client named as an | |
| 419 | // app shortcut is passed on rather than left to the browser, unless a text | |
| 420 | // field has focus or the page handled it: the deciding listener is added while | |
| 421 | // the key dispatches, so it runs after every listener the page has. Keys | |
| 422 | // match as resolveEventKeys reads them, so the client accepts what it claims. | |
| 423 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`; | |
| 360 | 424 | |
| 361 | 425 | function bootstrapMarkup(markup: string): string { |
| 362 | 426 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); |
packages/shared/src/keybindings.ts+1-1| ... | ... | @@ -29,7 +29,7 @@ export interface ShortcutModifierStateLike { |
| 29 | 29 | altKey: boolean; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = { | |
| 32 | export const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = { | |
| 33 | 33 | Backquote: "`", |
| 34 | 34 | Backslash: "\\", |
| 35 | 35 | BracketLeft: "[", |