Skip to content

Commit ba9be65

Browse files
chasel34claude
andcommitted
fix(keyboard): forward iframe keydown events to parent window
When a slide or preview iframe receives focus, keydown events fire in the iframe's own browsing context and never reach the parent window's listener. This broke arrow-key navigation and the fullscreen shortcut (f/F) in DeckViewer, and the fullscreen shortcut in PreviewPane. Fix: on each iframe's onLoad, attach a keydown listener to its contentWindow that re-dispatches the event on the parent window. A shared isEditableTarget() helper (src/lib/iframe-key.ts) guards against forwarding keys typed into INPUT/TEXTAREA/contentEditable controls. The naive `instanceof HTMLElement` check is intentionally avoided — it fails across window realms because the element's prototype chain belongs to the iframe's HTMLElement, not the parent's. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5055fd1 commit ba9be65

3 files changed

Lines changed: 32 additions & 0 deletions

File tree

src/components/deck-viewer.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import { parseDeck, type DeckSlide } from "@/lib/deck";
55
import { useT } from "@/lib/i18n";
6+
import { isEditableTarget } from "@/lib/iframe-key";
67

78
type Props = {
89
html: string;
@@ -80,6 +81,15 @@ export function DeckViewer({ html, active, onMainIframe, onSlides }: Props) {
8081
}
8182
}, []);
8283

84+
const handleIframeLoad = useCallback((e: React.SyntheticEvent<HTMLIFrameElement>) => {
85+
const iframeWin = e.currentTarget.contentWindow;
86+
if (!iframeWin) return;
87+
iframeWin.addEventListener("keydown", (ev) => {
88+
if (isEditableTarget(ev.target)) return;
89+
window.dispatchEvent(new KeyboardEvent("keydown", { key: ev.key, bubbles: true, cancelable: true }));
90+
});
91+
}, []);
92+
8393
if (slides.length === 0) {
8494
return (
8595
<div className="grid h-full place-items-center text-[13px] text-[var(--ink-mute)]">
@@ -106,6 +116,7 @@ export function DeckViewer({ html, active, onMainIframe, onSlides }: Props) {
106116
sandbox="allow-scripts allow-same-origin"
107117
className="h-full w-full"
108118
style={{ background: current.bg ?? "#fff", border: "0" }}
119+
onLoad={handleIframeLoad}
109120
/>
110121

111122
{/* floating prev/next arrows */}

src/components/preview-pane.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useStore, selectActiveTask, type LogEntry, type RunStats } from "@/lib/
55
import { useT, type DictKey } from "@/lib/i18n";
66
import { previewHtml, extractHtml } from "@/lib/extract-html";
77
import { isDeck } from "@/lib/deck";
8+
import { isEditableTarget } from "@/lib/iframe-key";
89
import { DeckViewer } from "./deck-viewer";
910

1011
type PreviewTab = "preview" | "deck" | "code" | "log";
@@ -109,6 +110,15 @@ export function PreviewPane({
109110
}
110111
}, []);
111112

113+
const handleIframeLoad = useCallback((e: React.SyntheticEvent<HTMLIFrameElement>) => {
114+
const iframeWin = e.currentTarget.contentWindow;
115+
if (!iframeWin) return;
116+
iframeWin.addEventListener("keydown", (ev) => {
117+
if (isEditableTarget(ev.target)) return;
118+
window.dispatchEvent(new KeyboardEvent("keydown", { key: ev.key, bubbles: true, cancelable: true }));
119+
});
120+
}, []);
121+
112122
// F to toggle fullscreen (only when not on Deck tab — DeckViewer handles its own).
113123
useEffect(() => {
114124
if (tab === "deck") return;
@@ -297,6 +307,7 @@ export function PreviewPane({
297307
sandbox="allow-scripts allow-same-origin"
298308
className="h-full w-full"
299309
style={{ background: "#fff" }}
310+
onLoad={handleIframeLoad}
300311
/>
301312
{isPreviewingTemplate && (
302313
<div

src/lib/iframe-key.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Realm-safe editable-target check. `instanceof HTMLElement` fails when the
3+
* event target comes from an iframe's own window realm, so we check tagName
4+
* (a plain string) and isContentEditable (a plain boolean) directly.
5+
*/
6+
export function isEditableTarget(target: EventTarget | null): boolean {
7+
if (!target || typeof (target as Element).tagName !== "string") return false;
8+
const tag = (target as Element).tagName;
9+
return tag === "INPUT" || tag === "TEXTAREA" || !!(target as HTMLElement).isContentEditable;
10+
}

0 commit comments

Comments
 (0)