Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions e2e/ui/iframe-keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { expect, test, type Page } from "@playwright/test";

const STORE_KEY = "html-everything-store";

const fixtureHtml = `<!doctype html>
<html>
<body>
<p id="plain">Hello</p>
<input id="text-input" type="text" placeholder="type here" />
<div id="editable" contenteditable="true">Editable</div>
</body>
</html>`;

const deckWithNotes = `<!doctype html>
<html>
<body class="deck-shell">
<section class="slide">
<h1 id="heading">Slide 1</h1>
<input id="deck-input" type="text" placeholder="Type here" />
<aside class="notes">Speaker notes for slide 1</aside>
</section>
</body>
</html>`;

async function seedStore(page: Page, html: string) {
const now = 1_700_000_000_000;
const task = {
id: "task_keyboard_test",
name: "Keyboard test",
content: "keyboard test",
format: "html",
templateId: "prototype-web",
html,
status: "done",
log: [],
stats: { outputBytes: html.length, deltaCount: 1 },
createdAt: now,
updatedAt: now,
};
await page.addInitScript(
({ key, taskFixture }) => {
window.localStorage.setItem(
key,
JSON.stringify({
state: {
tasks: [taskFixture],
activeTaskId: taskFixture.id,
selectedAgent: "test-agent",
agentModels: {},
welcomeAck: true,
sidebarCollapsed: false,
locale: "en",
layoutMode: "split",
},
version: 5,
}),
);
},
{ key: STORE_KEY, taskFixture: task },
);
}

type W = typeof window & { __arrowCount: number; __fCount: number };

test.describe("iframe keyboard forwarding (PreviewPane)", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
(window as W).__arrowCount = 0;
(window as W).__fCount = 0;
window.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") (window as W).__arrowCount++;
if (e.key === "f" || e.key === "F") (window as W).__fCount++;
});
});
});

test("forwards ArrowRight from plain iframe element to parent window", async ({ page }) => {
await seedStore(page, fixtureHtml);
await page.goto("/");

const frame = page.frameLocator('iframe[title="preview"]');
await frame.locator("#plain").press("ArrowRight");

const count = await page.evaluate(() => (window as W).__arrowCount);
expect(count).toBe(1);
});

test("does not forward ArrowRight from iframe input to parent window", async ({ page }) => {
await seedStore(page, fixtureHtml);
await page.goto("/");

const frame = page.frameLocator('iframe[title="preview"]');
await frame.locator("#text-input").press("ArrowRight");

const count = await page.evaluate(() => (window as W).__arrowCount);
expect(count).toBe(0);
});

test("does not forward ArrowRight from iframe contenteditable to parent window", async ({ page }) => {
await seedStore(page, fixtureHtml);
await page.goto("/");

const frame = page.frameLocator('iframe[title="preview"]');
await frame.locator("#editable").press("ArrowRight");

const count = await page.evaluate(() => (window as W).__arrowCount);
expect(count).toBe(0);
});

test("forwards f key from plain iframe element to parent window", async ({ page }) => {
await seedStore(page, fixtureHtml);
await page.goto("/");

const frame = page.frameLocator('iframe[title="preview"]');
await frame.locator("#plain").press("f");

const count = await page.evaluate(() => (window as W).__fCount);
expect(count).toBe(1);
});

test("does not forward f key from iframe input to parent window", async ({ page }) => {
await seedStore(page, fixtureHtml);
await page.goto("/");

const frame = page.frameLocator('iframe[title="preview"]');
await frame.locator("#text-input").press("f");

const count = await page.evaluate(() => (window as W).__fCount);
expect(count).toBe(0);
});
});

test.describe("DeckViewer N key — notes panel", () => {
test("pressing N in iframe body shows notes; pressing N in iframe input leaves them visible", async ({
page,
}) => {
await seedStore(page, deckWithNotes);
await page.goto("/");

const frame = page.frameLocator('iframe[title^="slide-"]');

await expect(page.getByText("Speaker notes for slide 1")).not.toBeVisible();

// N in body → notes appear (forwarding works)
await frame.locator("#heading").press("n");
await expect(page.getByText("Speaker notes for slide 1")).toBeVisible();

// N in input → notes stay visible (guard blocks the toggle-back)
await frame.locator("#deck-input").press("n");
await expect(page.getByText("Speaker notes for slide 1")).toBeVisible();
});
});
11 changes: 11 additions & 0 deletions next/src/components/deck-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseDeck, type DeckSlide } from "@/lib/deck";
import { useT } from "@/lib/i18n";
import { isEditableTarget } from "@/lib/iframe-key";

type Props = {
html: string;
Expand Down Expand Up @@ -80,6 +81,15 @@ export function DeckViewer({ html, active, onMainIframe, onSlides }: Props) {
}
}, []);

const handleIframeLoad = useCallback((e: React.SyntheticEvent<HTMLIFrameElement>) => {
const iframeWin = e.currentTarget.contentWindow;
if (!iframeWin) return;
iframeWin.addEventListener("keydown", (ev) => {
if (isEditableTarget(ev.target)) return;
window.dispatchEvent(new KeyboardEvent("keydown", { key: ev.key, bubbles: true, cancelable: true }));
});
}, []);

if (slides.length === 0) {
return (
<div className="grid h-full place-items-center text-[13px] text-[var(--ink-mute)]">
Expand All @@ -106,6 +116,7 @@ export function DeckViewer({ html, active, onMainIframe, onSlides }: Props) {
sandbox="allow-scripts allow-same-origin"
className="h-full w-full"
style={{ background: current.bg ?? "#fff", border: "0" }}
onLoad={handleIframeLoad}
/>

{/* floating prev/next arrows */}
Expand Down
11 changes: 11 additions & 0 deletions next/src/components/preview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useStore, selectActiveTask, type LogEntry, type RunStats } from "@/lib/
import { useT, type DictKey } from "@/lib/i18n";
import { previewHtml, extractHtml } from "@/lib/extract-html";
import { isDeck } from "@/lib/deck";
import { isEditableTarget } from "@/lib/iframe-key";
import { DeckViewer } from "./deck-viewer";

type PreviewTab = "preview" | "deck" | "code" | "log";
Expand Down Expand Up @@ -109,6 +110,15 @@ export function PreviewPane({
}
}, []);

const handleIframeLoad = useCallback((e: React.SyntheticEvent<HTMLIFrameElement>) => {
const iframeWin = e.currentTarget.contentWindow;
if (!iframeWin) return;
iframeWin.addEventListener("keydown", (ev) => {
if (isEditableTarget(ev.target)) return;
window.dispatchEvent(new KeyboardEvent("keydown", { key: ev.key, bubbles: true, cancelable: true }));
});
}, []);

// F to toggle fullscreen (only when not on Deck tab — DeckViewer handles its own).
useEffect(() => {
if (tab === "deck") return;
Expand Down Expand Up @@ -297,6 +307,7 @@ export function PreviewPane({
sandbox="allow-scripts allow-same-origin"
className="h-full w-full"
style={{ background: "#fff" }}
onLoad={handleIframeLoad}
/>
{isPreviewingTemplate && (
<div
Expand Down
10 changes: 10 additions & 0 deletions next/src/lib/iframe-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Realm-safe editable-target check. `instanceof HTMLElement` fails when the
* event target comes from an iframe's own window realm, so we check tagName
* (a plain string) and isContentEditable (a plain boolean) directly.
*/
export function isEditableTarget(target: EventTarget | null): boolean {
if (!target || typeof (target as Element).tagName !== "string") return false;
const tag = (target as Element).tagName;
return tag === "INPUT" || tag === "TEXTAREA" || !!(target as HTMLElement).isContentEditable;
}
Loading