| 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,18 +1,35 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 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 | htmlRenderThemeFragment, | 12 | htmlRenderThemeFragment, |
| 4 | htmlRenderThemeMessage, | 13 | htmlRenderThemeMessage, |
| 5 | htmlRenderResult, | 14 | htmlRenderResult, |
| 6 | readHtmlRenderContentHeight, | 15 | readHtmlRenderContentHeight, |
| 16 | readHtmlRenderKeyDown, | ||
| 7 | readHtmlRenderLinkRequest, | 17 | readHtmlRenderLinkRequest, |
| 8 | } from "@t3tools/shared/htmlRender"; | 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 | import { useOpenLink } from "~/browser/useOpenLink"; | 26 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 12 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; | 27 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; |
| 28 | import { shortcutConflictKey } from "~/keybindings"; | ||
| 13 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; | 29 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; |
| 14 | import { readLocalApi } from "~/localApi"; | 30 | import { readLocalApi } from "~/localApi"; |
| 15 | import { cn } from "~/lib/utils"; | 31 | import { cn } from "~/lib/utils"; |
| 32 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; | ||
| 16 | 33 | ||
| 17 | /** | 34 | /** |
| 18 | * Chromium's viewer opens with its own toolbar, a thumbnail rail and a small | 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,6 +39,32 @@ import { cn } from "~/lib/utils"; |
| 22 | */ | 39 | */ |
| 23 | const PDF_VIEWER_FRAGMENT = "#toolbar=0&view=FitH"; | 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 | export const isPdfPreviewFile = (path: string): boolean => | 68 | export const isPdfPreviewFile = (path: string): boolean => |
| 26 | /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | 69 | /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 27 | 70 | ||
| ... | @@ -90,6 +133,63 @@ export function HtmlRenderDocument(props: { | ... | @@ -90,6 +133,63 @@ export function HtmlRenderDocument(props: { |
| 90 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); | 133 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); |
| 91 | }; | 134 | }; |
| 92 | useEffect(postTheme, [theme]); | 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 | const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef); | 193 | const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef); |
| 94 | const openLink = useOpenLink(props.threadRef); | 194 | const openLink = useOpenLink(props.threadRef); |
| 95 | // Same rules as a chat link: a pull request opens in the right panel, any | 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,6 +240,31 @@ export function HtmlRenderDocument(props: { |
| 140 | window.addEventListener("message", openLink); | 240 | window.addEventListener("message", openLink); |
| 141 | return () => window.removeEventListener("message", openLink); | 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 | const { onContentHeight } = props; | 268 | const { onContentHeight } = props; |
| 144 | // A page posts its height once per change, so listen from the commit that | 269 | // A page posts its height once per change, so listen from the commit that |
| 145 | // inserts the frame; a passive effect could run after a fast page's first post. | 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,6 +291,7 @@ export function HtmlRenderDocument(props: { |
| 166 | setLoaded(true); | 291 | setLoaded(true); |
| 167 | // Covers a theme change that landed while the page was loading. | 292 | // Covers a theme change that landed while the page was loading. |
| 168 | postTheme(); | 293 | postTheme(); |
| 294 | postShortcuts(); | ||
| 169 | }} | 295 | }} |
| 170 | // A frame whose color scheme differs from its document's paints an opaque | 296 | // A frame whose color scheme differs from its document's paints an opaque |
| 171 | // canvas, so the blank document a frame starts with would flash white in | 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,6 +9,7 @@ import { |
| 9 | injectHtmlRenderBootstrap, | 9 | injectHtmlRenderBootstrap, |
| 10 | htmlRenderThemeMessage, | 10 | htmlRenderThemeMessage, |
| 11 | readHtmlRenderContentHeight, | 11 | readHtmlRenderContentHeight, |
| 12 | readHtmlRenderKeyDown, | ||
| 12 | readHtmlRenderLinkRequest, | 13 | readHtmlRenderLinkRequest, |
| 13 | readHtmlRenderReference, | 14 | readHtmlRenderReference, |
| 14 | } from "./htmlRender.ts"; | 15 | } from "./htmlRender.ts"; |
| ... | @@ -106,6 +107,29 @@ describe("readHtmlRenderLinkRequest", () => { | ... | @@ -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 | describe("readHtmlRenderContentHeight", () => { | 133 | describe("readHtmlRenderContentHeight", () => { |
| 110 | it("reads only the height of an MCP Apps size-changed notification", () => { | 134 | it("reads only the height of an MCP Apps size-changed notification", () => { |
| 111 | const notification = (params: unknown) => ({ | 135 | const notification = (params: unknown) => ({ |
packages/shared/src/htmlRender.ts+66-2| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | type ThemeAppearance, | 4 | type ThemeAppearance, |
| 5 | type ThemeColors, | 5 | type ThemeColors, |
| 6 | } from "./themePalettes.ts"; | 6 | } from "./themePalettes.ts"; |
| 7 | import { EVENT_CODE_SHORTCUT_KEYS } from "./keybindings.ts"; | ||
| 7 | 8 | ||
| 8 | /** | 9 | /** |
| 9 | * Agent-authored HTML pages ("HTML renders") are self-contained documents an | 10 | * Agent-authored HTML pages ("HTML renders") are self-contained documents an |
| ... | @@ -274,6 +275,11 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ | ... | @@ -274,6 +275,11 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ |
| 274 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; | 275 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; |
| 275 | const OPEN_LINK_METHOD = "ui/open-link"; | 276 | const OPEN_LINK_METHOD = "ui/open-link"; |
| 276 | const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; | 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 | /** The content height in a framed render's `ui/notifications/size-changed` notification. */ | 284 | /** The content height in a framed render's `ui/notifications/size-changed` notification. */ |
| 279 | export function readHtmlRenderContentHeight(data: unknown): number | undefined { | 285 | export function readHtmlRenderContentHeight(data: unknown): number | undefined { |
| ... | @@ -311,6 +317,60 @@ export function readHtmlRenderLinkRequest(data: unknown): | ... | @@ -311,6 +317,60 @@ export function readHtmlRenderLinkRequest(data: unknown): |
| 311 | : undefined; | 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 | /** The empty result a client sends back for a render's request. */ | 374 | /** The empty result a client sends back for a render's request. */ |
| 315 | export function htmlRenderResult(id: string | number) { | 375 | export function htmlRenderResult(id: string | number) { |
| 316 | return { jsonrpc: "2.0", id, result: {} } as const; | 376 | return { jsonrpc: "2.0", id, result: {} } as const; |
| ... | @@ -355,8 +415,12 @@ function rootRule(theme: HtmlRenderTheme): string { | ... | @@ -355,8 +415,12 @@ function rootRule(theme: HtmlRenderTheme): string { |
| 355 | // a framed page asks its client to open it, and a top-level page (mobile) | 415 | // a framed page asks its client to open it, and a top-level page (mobile) |
| 356 | // opens it as a new window, which the client sends to the browser. A framed | 416 | // opens it as a new window, which the client sends to the browser. A framed |
| 357 | // page also reports its content height, measured as the server measures it, | 417 | // page also reports its content height, measured as the server measures it, |
| 358 | // so its client can fit the frame to the page. | 418 | // so its client can fit the frame to the page. A chord the client named as an |
| 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);}})();`; | 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 | function bootstrapMarkup(markup: string): string { | 425 | function bootstrapMarkup(markup: string): string { |
| 362 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | 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,7 +29,7 @@ export interface ShortcutModifierStateLike { |
| 29 | altKey: boolean; | 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 | Backquote: "`", | 33 | Backquote: "`", |
| 34 | Backslash: "\\", | 34 | Backslash: "\\", |
| 35 | BracketLeft: "[", | 35 | BracketLeft: "[", |