Repository navigation
feat(web): view visual replies full screen with zoom #17561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Yash-Singh1
merged 4 commits into
pingdotgg:main
from
Bil0000:t3/visualization-fullscreen-zoom
Oct 14, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
595288c
feat(web): view visual replies full screen with zoom
Bil0000 f3f30fa
fix(web): expand visual replies like image previews
Bil0000 795af45
chore: sync visualization preview with main
Bil0000 7a941b8
fix(web): keep only full-screen visual reply action
Bil0000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| // @vitest-environment jsdom | ||
|
|
||
| import { act } from "react"; | ||
| import { createRoot, type Root } from "react-dom/client"; | ||
| import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| import { htmlRenderTheme, HTML_RENDER_DEFAULT_FONTS } from "@t3tools/shared/htmlRender"; | ||
| import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; | ||
| import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
|
|
||
| import { HtmlRenderDialog } from "./HtmlRenderDialog"; | ||
| import { HtmlRenderFrame } from "./HtmlRenderFrame"; | ||
|
|
||
| const { refresh, showError, openLink, openChangeRequestLink } = vi.hoisted(() => ({ | ||
| refresh: vi.fn(), | ||
| showError: vi.fn(), | ||
| openLink: vi.fn(), | ||
| openChangeRequestLink: vi.fn(), | ||
| })); | ||
| vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink })); | ||
| vi.mock("~/lib/openPullRequestLink", () => ({ | ||
| useOpenChangeRequestLink: () => openChangeRequestLink, | ||
| })); | ||
| vi.mock("~/assets/assetUrls", () => ({ | ||
| useAssetUrlState: () => ({ | ||
| _tag: "Success", | ||
| url: "https://environment.test/original.html", | ||
| expiresAt: Date.now() + 60 * 60_000, | ||
| }), | ||
| useAssetUrlRefresh: () => refresh, | ||
| })); | ||
| vi.mock("../ui/toast", () => ({ toastManager: { add: showError } })); | ||
|
|
||
| vi.mock("~/hooks/useHtmlRenderTheme", () => ({ | ||
| useHtmlRenderTheme: () => | ||
| htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS), | ||
| })); | ||
|
|
||
| let root: Root; | ||
| let container: HTMLDivElement; | ||
| const onClose = vi.fn(); | ||
|
|
||
| beforeEach(async () => { | ||
| vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| vi.stubGlobal( | ||
| "ResizeObserver", | ||
| class { | ||
| observe() {} | ||
| disconnect() {} | ||
| unobserve() {} | ||
| }, | ||
| ); | ||
| refresh.mockReset(); | ||
| openLink.mockReset().mockResolvedValue(undefined); | ||
| openChangeRequestLink.mockReset().mockReturnValue(false); | ||
| showError.mockClear(); | ||
| onClose.mockClear(); | ||
| container = document.createElement("div"); | ||
| document.body.append(container); | ||
| root = createRoot(container); | ||
| await act(() => | ||
| root.render( | ||
| <HtmlRenderDialog | ||
| src="https://environment.test/page.html" | ||
| title="Diagram" | ||
| onClose={onClose} | ||
| />, | ||
| ), | ||
| ); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await act(() => root.unmount()); | ||
| container.remove(); | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| async function click(label: string) { | ||
| const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`); | ||
| expect(button).not.toBeNull(); | ||
| await act(() => button!.click()); | ||
| } | ||
|
|
||
| it("zooms the loaded page within bounds and resets without reloading it", async () => { | ||
| const frame = document.querySelector("iframe")!; | ||
| const src = frame.src; | ||
| for (let step = 0; step < 6; step += 1) await click("Zoom in"); | ||
| expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]')!.disabled).toBe(true); | ||
| expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("2"); | ||
| for (let step = 0; step < 8; step += 1) await click("Zoom out"); | ||
| expect(document.querySelector<HTMLButtonElement>('[aria-label="Zoom out"]')!.disabled).toBe(true); | ||
| expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("0.5"); | ||
| await click("Reset zoom"); | ||
| expect(frame.parentElement!.style.getPropertyValue("zoom")).toBe("1"); | ||
| expect(document.querySelector("iframe")).toBe(frame); | ||
| expect(frame.src).toBe(src); | ||
| expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms"); | ||
| }); | ||
|
|
||
| it("closes with the close button or Escape", async () => { | ||
| await click("Close"); | ||
| expect(onClose).toHaveBeenCalledOnce(); | ||
| onClose.mockClear(); | ||
| await act(() => { | ||
| document.activeElement?.dispatchEvent( | ||
| new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), | ||
| ); | ||
| }); | ||
| expect(onClose).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| async function renderInline() { | ||
| await act(() => | ||
| root.render( | ||
| <HtmlRenderFrame | ||
| environmentId={EnvironmentId.make("environment")} | ||
| htmlRender={{ attachmentId: "html", title: "Diagram", height: 400 }} | ||
| threadRef={{ | ||
| environmentId: EnvironmentId.make("environment"), | ||
| threadId: ThreadId.make("thread"), | ||
| }} | ||
| />, | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| it("offers only full screen and opens with a fresh URL while keeping the inline page loaded", async () => { | ||
| await renderInline(); | ||
| expect(document.querySelector('[aria-label="Open in panel"]')).toBeNull(); | ||
| const inlineFrame = document.querySelector("iframe")!; | ||
| const originalSrc = inlineFrame.src; | ||
| const expandButton = document.querySelector<HTMLButtonElement>( | ||
| '[aria-label="Open full screen"]', | ||
| )!; | ||
| expandButton.focus(); | ||
| refresh.mockResolvedValue("https://environment.test/renewed.html"); | ||
| await click("Open full screen"); | ||
| expect(refresh).toHaveBeenCalledOnce(); | ||
| expect(document.querySelector('[role="dialog"] iframe')?.getAttribute("src")).toContain( | ||
| "https://environment.test/renewed.html#", | ||
| ); | ||
| expect(inlineFrame.src).toBe(originalSrc); | ||
| await click("Close"); | ||
| expect(document.querySelector('[role="dialog"]')).toBeNull(); | ||
| expect(document.querySelector("iframe")).toBe(inlineFrame); | ||
| expect(document.activeElement).toBe(expandButton); | ||
| }); | ||
|
|
||
| it("routes links from the expanded page through its thread", async () => { | ||
| await renderInline(); | ||
| refresh.mockResolvedValue("https://environment.test/renewed.html"); | ||
| openChangeRequestLink.mockReturnValue(true); | ||
| await click("Open full screen"); | ||
| const frame = document.querySelector<HTMLIFrameElement>('[role="dialog"] iframe')!; | ||
| frame.focus(); | ||
| const url = "https://github.com/pingdotgg/t3code/pull/17561"; | ||
| await act(() => { | ||
| window.dispatchEvent( | ||
| new MessageEvent("message", { | ||
| source: frame.contentWindow, | ||
| data: { jsonrpc: "2.0", id: "link", method: "ui/open-link", params: { url } }, | ||
| }), | ||
| ); | ||
| }); | ||
| expect(openChangeRequestLink).toHaveBeenCalledWith( | ||
| expect.objectContaining({ metaKey: false, ctrlKey: false }), | ||
| url, | ||
| undefined, | ||
| EnvironmentId.make("environment"), | ||
| ); | ||
| expect(openLink).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it.each([null, new Error("Disconnected")])( | ||
| "reports an unavailable URL instead of opening a blank page: %s", | ||
| async (failure) => { | ||
| await renderInline(); | ||
| if (failure === null) refresh.mockResolvedValue(null); | ||
| else refresh.mockRejectedValue(failure); | ||
| await click("Open full screen"); | ||
| expect(document.querySelector('[role="dialog"]')).toBeNull(); | ||
| expect(showError).toHaveBeenCalledWith( | ||
| expect.objectContaining({ type: "error", title: "Page unavailable" }), | ||
| ); | ||
| }, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| import { MinusIcon, PlusIcon, XIcon } from "lucide-react"; | ||
| import { useRef, useState } from "react"; | ||
|
|
||
| import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; | ||
| import { Button } from "../ui/button"; | ||
| import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; | ||
|
|
||
| export function HtmlRenderDialog(props: { | ||
| readonly src: string; | ||
| readonly title: string; | ||
| readonly threadRef?: ScopedThreadRef | undefined; | ||
| readonly onClose: () => void; | ||
| }) { | ||
| const [zoom, setZoom] = useState(1); | ||
| const closeButtonRef = useRef<HTMLButtonElement>(null); | ||
| return ( | ||
| <Dialog open onOpenChange={(open) => !open && props.onClose()}> | ||
| <DialogPopup | ||
| variant="media" | ||
| showCloseButton={false} | ||
| bottomStickOnMobile={false} | ||
| className="row-start-1" | ||
| initialFocus={closeButtonRef} | ||
| > | ||
| <DialogTitle className="sr-only">Expanded {props.title} preview</DialogTitle> | ||
| <div className="relative w-[92vw]"> | ||
| <Button | ||
| ref={closeButtonRef} | ||
| aria-label="Close" | ||
| size="icon-xs" | ||
| variant="media-close" | ||
| className="absolute right-0 -top-10 z-20" | ||
| onClick={props.onClose} | ||
| > | ||
| <XIcon /> | ||
| </Button> | ||
| <div className="h-[min(86vh,calc(100vh-160px))] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 [container-type:size]"> | ||
| <div className="h-[100cqh] w-[100cqw]" style={{ zoom }}> | ||
| <HtmlRenderDocument | ||
| src={props.src} | ||
| title={props.title} | ||
| threadRef={props.threadRef} | ||
| className="block size-full" | ||
| /> | ||
| </div> | ||
| </div> | ||
| <div className="mt-2 flex min-w-0 items-center justify-center gap-3 text-xs text-white/80"> | ||
| <span className="truncate">{props.title}</span> | ||
| <div className="flex shrink-0 items-center gap-1"> | ||
| <Button | ||
| aria-label="Zoom out" | ||
| size="icon-sm" | ||
| variant="overlay" | ||
| disabled={zoom === 0.5} | ||
| onClick={() => setZoom((value) => value - 0.25)} | ||
| > | ||
| <MinusIcon /> | ||
| </Button> | ||
| <Button | ||
| aria-label="Reset zoom" | ||
| size="sm" | ||
| variant="overlay" | ||
| onClick={() => setZoom(1)} | ||
| > | ||
| {Math.round(zoom * 100)}% | ||
| </Button> | ||
| <Button | ||
| aria-label="Zoom in" | ||
| size="icon-sm" | ||
| variant="overlay" | ||
| disabled={zoom === 2} | ||
| onClick={() => setZoom((value) => value + 0.25)} | ||
| > | ||
| <PlusIcon /> | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </DialogPopup> | ||
| </Dialog> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.