From 1e4559141d48864fc6758a87643c6941a22fc0f5 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 09:00:25 -0400 Subject: [PATCH 1/2] feat(preview): add pane-local controls and drag proxy --- .../preview-baseline-contract.spec.ts | 104 ++++++++++++++++++ .../session/session-preview-tab.tsx | 78 ++++++++++--- .../session/session-preview-tree.test.ts | 35 +++++- .../session/session-preview-tree.ts | 8 ++ packages/app/src/design-polish.css | 21 +++- .../components/preview-file-routing.test.ts | 28 +++-- 6 files changed, 244 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/regression/preview-baseline-contract.spec.ts b/packages/app/e2e/regression/preview-baseline-contract.spec.ts index f3981dc58..5206a3db3 100644 --- a/packages/app/e2e/regression/preview-baseline-contract.spec.ts +++ b/packages/app/e2e/regression/preview-baseline-contract.spec.ts @@ -236,6 +236,11 @@ test("splits a Preview leaf at its right edge without recreating the moved rende await page.mouse.down() await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 }) await expect(panel.locator('[data-preview-dragging="notes/second.md"]')).toBeVisible() + const dragProxy = panel.locator("[data-preview-tab-drag-proxy]") + await expect(dragProxy).toBeVisible() + await expect(dragProxy).toContainText("second.md") + await expect(dragProxy).toHaveCSS("pointer-events", "none") + expect((await dragProxy.boundingBox())?.x).toBeGreaterThan(leafBox.x) const preview = panel.locator('[data-preview-drop-preview="right"]') await expect(preview).toBeVisible() await expect(preview).toHaveCSS("pointer-events", "none") @@ -245,6 +250,7 @@ test("splits a Preview leaf at its right edge without recreating the moved rende await page.mouse.up() await page.waitForTimeout(250) await expect(preview).toHaveCount(0) + await expect(dragProxy).toHaveCount(0) const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') await expect(panel.locator("[data-preview-leaf]")).toHaveCount(2) @@ -330,6 +336,99 @@ test("retains an unsaved editor draft through a Preview edge split", async ({ pa expect(await host.evaluate((element) => element.isConnected)).toBe(true) }) +test("keeps Preview zoom independent in split panes", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') + const rootZoom = rootLeaf.locator('input[type="text"]:visible') + const destinationZoom = destinationLeaf.locator('input[type="text"]') + await expect(rootZoom).toHaveValue("100%") + await expect(destinationZoom).toHaveValue("100%") + + await rootZoom.fill("130") + await rootZoom.press("Enter") + + await expect(rootZoom).toHaveValue("130%") + await expect(destinationZoom).toHaveValue("100%") +}) + +test("inherits source zoom and focuses the new Preview leaf after a split", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const rootZoom = rootLeaf.locator('[data-preview-host="notes/second.md"] input[type="text"]') + await rootZoom.fill("130") + await rootZoom.press("Enter") + await expect(rootZoom).toHaveValue("130%") + + const source = panel.getByRole("tab", { name: "second.md" }) + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') + await expect(destinationLeaf).toHaveAttribute("data-focused", "true") + await expect(destinationLeaf.locator('input[type="text"]')).toHaveValue("130%") +}) + +test("adopts destination pane zoom when transferring a Preview renderer", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const rootZoom = rootLeaf.locator('input[type="text"]') + await rootZoom.fill("130") + await rootZoom.press("Enter") + await expect(rootZoom).toHaveValue("130%") + + const sourceLeaf = panel.locator('[data-preview-leaf="pane-1"]') + const movedHost = sourceLeaf.locator('[data-preview-host="notes/second.md"]') + const host = await movedHost.elementHandle() + const transferTab = sourceLeaf.getByRole("tab", { name: "second.md" }) + const transferBox = await transferTab.boundingBox() + const transferTarget = await rootLeaf.boundingBox() + if (!host || !transferBox || !transferTarget) throw new Error("Preview leaves must be measurable before transferring") + + await page.mouse.move(transferBox.x + transferBox.width / 2, transferBox.y + transferBox.height / 2) + await page.mouse.down() + await page.mouse.move(transferTarget.x + transferTarget.width / 2, transferTarget.y + transferTarget.height / 2, { steps: 8 }) + await page.mouse.up() + + await expect(rootLeaf.locator('[data-preview-host="notes/second.md"] input[type="text"]')).toHaveValue("130%") + expect(await host.evaluate((element) => element.isConnected)).toBe(true) +}) + test("keeps both Preview leaves at their minimum width while resizing a divider", async ({ page }) => { await openPreview(page) await openPreviewFile(page, secondMarkdownFile) @@ -431,10 +530,15 @@ test("clears the prospective pane preview when a Preview drag is cancelled", asy await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) await page.mouse.down() await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 }) + const dragProxy = panel.locator("[data-preview-tab-drag-proxy]") + await expect(dragProxy).toBeVisible() await expect(panel.locator('[data-preview-drop-preview="right"]')).toBeVisible() await page.mouse.move(leafBox.x - 20, leafBox.y + leafBox.height / 2, { steps: 4 }) await expect(panel.locator("[data-preview-drop-preview]")).toHaveCount(0) await page.mouse.up() + await expect(dragProxy).toHaveAttribute("data-leaving", "true") + await page.waitForTimeout(200) + await expect(dragProxy).toHaveCount(0) await expect(panel.locator("[data-preview-leaf]")).toHaveCount(1) await expect(leaf.getByRole("tab", { name: "second.md" })).toHaveAttribute("aria-selected", "true") diff --git a/packages/app/src/components/session/session-preview-tab.tsx b/packages/app/src/components/session/session-preview-tab.tsx index 98a8d11c0..674227630 100644 --- a/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app/src/components/session/session-preview-tab.tsx @@ -21,6 +21,7 @@ import { removePreviewPath, resizePreviewSplit, selectPreviewPath, + setPreviewLeafZoom, type PreviewDropPosition, type PreviewLeaf, type PreviewPane, @@ -39,21 +40,24 @@ type PreviewDrop = { type PreviewDrag = { path: string sourceLeafID: string + x: number + y: number drop?: PreviewDrop } export function SessionPreviewTab(props: { previewFile: Accessor }) { const [dirtyPaths, setDirtyPaths] = createStore>({}) const [saveRequests, setSaveRequests] = createStore>({}) - const [zoom, setZoom] = createSignal(100) const [workspace, setWorkspace] = createSignal(createPreviewWorkspace()) const [capacityMessage, setCapacityMessage] = createSignal(null) const [closingPath, setClosingPath] = createSignal(null) const [previewDrag, setPreviewDrag] = createSignal(null) + const [dragProxy, setDragProxy] = createSignal<{ path: string; x: number; y: number; leaving: boolean } | null>(null) const leafElements = new Map() const [hostMounts, setHostMounts] = createStore>({}) let stopPreviewDrag: (() => void) | undefined + let dragProxyTimer: ReturnType | undefined const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs) @@ -84,11 +88,16 @@ export function SessionPreviewTab(props: { previewFile: Accessor window.addEventListener("message", handlePreviewFile) onCleanup(() => window.removeEventListener("message", handlePreviewFile)) }) - onCleanup(() => stopPreviewDrag?.()) + onCleanup(() => { + stopPreviewDrag?.() + if (dragProxyTimer) clearTimeout(dragProxyTimer) + }) - const zoomIn = () => setZoom((value) => Math.min(value + 10, 500)) - const zoomOut = () => setZoom((value) => Math.max(value - 10, 50)) - const onZoomChange = (value: number) => setZoom(Math.round(Math.min(Math.max(value, 50), 500))) + const zoomForPath = (path: string) => previewLeafContaining(workspace().tree, path)?.zoom ?? 100 + const setZoomForPath = (path: string, value: number) => { + const leaf = previewLeafContaining(workspace().tree, path) + if (leaf) setWorkspace((current) => setPreviewLeafZoom(current, leaf.id, value)) + } const removePath = (path: string) => { setWorkspace((current) => removePreviewPath(current, path)) @@ -144,13 +153,30 @@ export function SessionPreviewTab(props: { previewFile: Accessor const source = event.currentTarget as HTMLElement source.setPointerCapture?.(event.pointerId) - const stop = () => { + const clearDragProxy = (fade: boolean) => { + const proxy = dragProxy() + if (!proxy) return + if (!fade) { + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = undefined + setDragProxy(null) + return + } + setDragProxy({ ...proxy, leaving: true }) + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = setTimeout(() => { + setDragProxy(null) + dragProxyTimer = undefined + }, 160) + } + const stop = (fadeProxy = false) => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) window.removeEventListener("pointercancel", onCancel) if (source.hasPointerCapture?.(event.pointerId)) source.releasePointerCapture(event.pointerId) stopPreviewDrag = undefined setPreviewDrag(null) + clearDragProxy(fadeProxy) } const onMove = (moveEvent: PointerEvent) => { if (!active) { @@ -158,15 +184,25 @@ export function SessionPreviewTab(props: { previewFile: Accessor active = true } moveEvent.preventDefault() - setPreviewDrag({ path, sourceLeafID, drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID) }) + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = undefined + const next = { + path, + sourceLeafID, + x: moveEvent.clientX, + y: moveEvent.clientY, + drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID), + } + setPreviewDrag(next) + setDragProxy({ path, x: next.x, y: next.y, leaving: false }) } const onUp = (upEvent: PointerEvent) => { const drop = active ? dropTargetAt(upEvent.clientX, upEvent.clientY, path, sourceLeafID) : undefined - stop() + stop(!drop) if (!drop) return setWorkspace((current) => movePreviewTab(current, { path, targetLeafID: drop.leafID, position: drop.position, targetIndex: drop.targetIndex })) } - const onCancel = () => stop() + const onCancel = () => stop(true) stopPreviewDrag?.() stopPreviewDrag = stop @@ -303,6 +339,22 @@ export function SessionPreviewTab(props: { previewFile: Accessor return (
+ + {(proxy) => ( + + )} + diff --git a/packages/app/src/components/session/session-preview-tree.test.ts b/packages/app/src/components/session/session-preview-tree.test.ts index 1154cd7c1..fb20f5b95 100644 --- a/packages/app/src/components/session/session-preview-tree.test.ts +++ b/packages/app/src/components/session/session-preview-tree.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test" -import { createPreviewWorkspace, movePreviewTab, openPreviewPath, previewLeaves, previewMinimumExtent } from "./session-preview-tree" +import { + createPreviewWorkspace, + movePreviewTab, + openPreviewPath, + previewLeafByID, + previewLeaves, + previewMinimumExtent, + setPreviewLeafZoom, +} from "./session-preview-tree" describe("Preview workspace pane tree", () => { test("moves a tab into a focused right sibling leaf", () => { @@ -11,9 +19,9 @@ describe("Preview workspace pane tree", () => { position: "right", }) - expect(previewLeaves(moved.tree).map(({ id, tabs, selectedPath }) => ({ id, tabs, selectedPath }))).toEqual([ - { id: "root", tabs: ["notes/baseline.md"], selectedPath: "notes/baseline.md" }, - { id: "pane-1", tabs: ["notes/second.md"], selectedPath: "notes/second.md" }, + expect(previewLeaves(moved.tree).map(({ id, tabs, selectedPath, zoom }) => ({ id, tabs, selectedPath, zoom }))).toEqual([ + { id: "root", tabs: ["notes/baseline.md"], selectedPath: "notes/baseline.md", zoom: 100 }, + { id: "pane-1", tabs: ["notes/second.md"], selectedPath: "notes/second.md", zoom: 100 }, ]) expect(moved.focusedLeafID).toBe("pane-1") }) @@ -46,4 +54,23 @@ describe("Preview workspace pane tree", () => { expect(previewMinimumExtent(nested.tree, "horizontal")).toBe(450) expect(previewMinimumExtent(nested.tree, "vertical")).toBe(150) }) + + test("inherits source zoom on split and destination zoom on transfer", () => { + const sourceZoomed = setPreviewLeafZoom(createPreviewWorkspace(["notes/one.md", "notes/two.md"]), "root", 130) + const split = movePreviewTab(sourceZoomed, { + path: "notes/two.md", + targetLeafID: "root", + position: "right", + }) + + expect(previewLeafByID(split.tree, "pane-1")?.zoom).toBe(130) + const destinationZoomed = setPreviewLeafZoom(split, "root", 90) + const transferred = movePreviewTab(destinationZoomed, { + path: "notes/two.md", + targetLeafID: "root", + position: "center", + }) + + expect(previewLeafByID(transferred.tree, "root")?.zoom).toBe(90) + }) }) diff --git a/packages/app/src/components/session/session-preview-tree.ts b/packages/app/src/components/session/session-preview-tree.ts index 79c310a15..b94624b56 100644 --- a/packages/app/src/components/session/session-preview-tree.ts +++ b/packages/app/src/components/session/session-preview-tree.ts @@ -3,6 +3,7 @@ export type PreviewLeaf = { id: string tabs: string[] selectedPath: string | null + zoom: number } export type PreviewSplit = { @@ -32,6 +33,7 @@ export const createPreviewWorkspace = (paths: readonly string[] = []): PreviewWo id: "root", tabs: [...paths], selectedPath: paths.at(-1) ?? null, + zoom: 100, }, focusedLeafID: "root", nextPaneID: 1, @@ -139,6 +141,11 @@ export const selectPreviewPath = (workspace: PreviewWorkspace, leafID: string, p } } +export const setPreviewLeafZoom = (workspace: PreviewWorkspace, leafID: string, zoom: number): PreviewWorkspace => ({ + ...workspace, + tree: mapLeaf(workspace.tree, leafID, (leaf) => ({ ...leaf, zoom: Math.round(Math.min(Math.max(zoom, 50), 500)) })), +}) + export const removePreviewPath = (workspace: PreviewWorkspace, path: string): PreviewWorkspace => { const source = previewLeafContaining(workspace.tree, path) if (!source) return workspace @@ -195,6 +202,7 @@ export const movePreviewTab = ( id: `pane-${workspace.nextPaneID}`, tabs: [input.path], selectedPath: input.path, + zoom: source.zoom, } const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical" const newPaneBeforeTarget = input.position === "left" || input.position === "top" diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 012c0ccba..079b69574 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -171,7 +171,7 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da background: var(--v2-background-bg-base); } #review-panel .preview-pane-leaf[data-focused] { - box-shadow: inset 0 0 0 var(--border-width) var(--v2-border-border-strong); + box-shadow: inset 0 0 0 var(--border-width) var(--accent-edge); } #review-panel .preview-pane-leaf > [data-component="tabs"].preview-tab-strip { flex: 0 0 auto; @@ -225,6 +225,25 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="top"] { top: 0; } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="bottom"] { bottom: 0; } +#review-panel .preview-tab-drag-proxy { + position: fixed; + z-index: 60; + top: 0; + left: 0; + display: flex; + align-items: center; + max-width: min-content; + padding: var(--space-1) var(--space-2); + border: var(--border-width) solid var(--v2-border-border-base); + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--elev-float); + opacity: 0.92; + pointer-events: none; + transform: translate3d(calc(var(--preview-drag-x) + var(--space-2)), calc(var(--preview-drag-y) + var(--space-2)), 0); + transition: opacity 0.16s ease; +} +#review-panel .preview-tab-drag-proxy[data-leaving] { opacity: 0; } /* ── session status dots ── Four states: green (done, unread), grey (done, seen), yellow (running), diff --git a/packages/session-ui/src/v2/components/preview-file-routing.test.ts b/packages/session-ui/src/v2/components/preview-file-routing.test.ts index 42eea1ca5..d2ef75087 100644 --- a/packages/session-ui/src/v2/components/preview-file-routing.test.ts +++ b/packages/session-ui/src/v2/components/preview-file-routing.test.ts @@ -226,6 +226,10 @@ describe("toolbar layout (#934)", () => { path.resolve(__dirname, "../../../../app/src/components/session/preview-file-view.tsx"), "utf8", ) + const previewTreeSrc = fs.readFileSync( + path.resolve(__dirname, "../../../../app/src/components/session/session-preview-tree.ts"), + "utf8", + ) const pdfCanvasViewSrc = fs.readFileSync( path.resolve(__dirname, "../../../../app/src/components/session/pdf-canvas-view.tsx"), "utf8", @@ -235,8 +239,8 @@ describe("toolbar layout (#934)", () => { // SessionPreviewTab must NOT render the zoom buttons/input (aria-label) expect(previewTabSrc).not.toContain("Zoom out") expect(previewTabSrc).not.toContain("Zoom in") - // But it must define and pass zoom/zoomIn/zoomOut as props - expect(previewTabSrc).toContain("zoom={zoom}") + // It routes the pane-local zoom accessor to each renderer host. + expect(previewTabSrc).toContain("zoom={() => zoomForPath(path)}") // PreviewFileView MUST render the zoom widget expect(fileViewSrc).toContain("Zoom out") @@ -256,8 +260,8 @@ describe("toolbar layout (#934)", () => { }) test("SessionPreviewTab passes zoomIn and zoomOut to PreviewFileView", () => { - expect(previewTabSrc).toContain("zoomIn={zoomIn}") - expect(previewTabSrc).toContain("zoomOut={zoomOut}") + expect(previewTabSrc).toContain("zoomIn={() => setZoomForPath(path, zoomForPath(path) + 10)}") + expect(previewTabSrc).toContain("zoomOut={() => setZoomForPath(path, zoomForPath(path) - 10)}") }) test("image zoom uses direct CSS sizing, not transform: scale (#934)", () => { @@ -371,8 +375,8 @@ describe("toolbar layout (#934)", () => { }) test("zoom floor is category-aware: 100% for image/pdf, 50% for markdown/text (#934)", () => { - // The parent (SessionPreviewTab) keeps the global floor at 50% - expect(previewTabSrc).toContain("Math.max(z - 10, 50)") + // The parent routes existing renderer controls to the owning leaf pane. + expect(previewTabSrc).toContain("setZoomForPath") // PreviewFileView applies a higher floor for image/pdf in its zoom-out handler expect(fileViewSrc).toContain("zoomFloor") }) @@ -380,8 +384,8 @@ describe("toolbar layout (#934)", () => { test("pinch/wheel zoom: SessionPreviewTab exposes onZoomChange to PreviewFileView (#934)", () => { // Parent must define a handler that accepts an arbitrary zoom value expect(previewTabSrc).toContain("onZoomChange") - // Must clamp to [50, 500] — same bounds as the +/- buttons - expect(previewTabSrc).toContain("500") + // The leaf model clamps to the same [50, 500] range. + expect(previewTreeSrc).toContain("Math.min(Math.max(zoom, 50), 500)") }) test("pinch/wheel zoom: PreviewFileView has a wheel handler for ctrlKey/shiftKey (#934)", () => { @@ -638,9 +642,9 @@ describe("dirty dot indicator (#937)", () => { "utf8", ) - test("shows an unsaved dot based on file state unsavedContent", () => { - // Must reference unsavedContent to determine dot visibility - expect(previewTabSrc).toContain("unsavedContent") + test("shows an unsaved dot based on workspace dirty state", () => { + // The workspace owns only the dirty flag; the renderer owns draft text. + expect(previewTabSrc).toContain("dirtyPaths") }) test("unsaved dot has aria-label for accessibility", () => { @@ -696,7 +700,7 @@ describe("file save uses SDK client (#937)", () => { test("clears unsavedContent after successful save", () => { // After a successful write, the dirty state must be cleared (null) - expect(saveFileBody).toMatch(/onUnsavedContent\(null\)|onClearUnsaved/) + expect(saveFileBody).toContain("setUnsavedContent(null)") }) test("saveFile uses try/catch for error handling", () => { From 7fcafffadc43e5dc18be24bedfed20e9006e027c Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 09:05:56 -0400 Subject: [PATCH 2/2] fix(preview): keep pane controls within leaf bounds --- .../preview-baseline-contract.spec.ts | 26 +++++++++++++++++++ .../components/session/preview-file-view.tsx | 4 +-- packages/app/src/design-polish.css | 13 ++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/preview-baseline-contract.spec.ts b/packages/app/e2e/regression/preview-baseline-contract.spec.ts index 5206a3db3..b160a65ca 100644 --- a/packages/app/e2e/regression/preview-baseline-contract.spec.ts +++ b/packages/app/e2e/regression/preview-baseline-contract.spec.ts @@ -365,6 +365,32 @@ test("keeps Preview zoom independent in split panes", async ({ page }) => { await expect(destinationZoom).toHaveValue("100%") }) +test("keeps each pane's zoom control inside its minimum-width leaf", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') + for (const leaf of [rootLeaf, destinationLeaf]) { + const leafBox = await leaf.boundingBox() + const inputBox = await leaf.locator('input[type="text"]:visible').boundingBox() + if (!leafBox || !inputBox) throw new Error("Preview leaf and zoom input must be measurable") + expect(inputBox.x).toBeGreaterThanOrEqual(leafBox.x) + expect(inputBox.x + inputBox.width).toBeLessThanOrEqual(leafBox.x + leafBox.width) + } +}) + test("inherits source zoom and focuses the new Preview leaf after a split", async ({ page }) => { await openPreview(page) await openPreviewFile(page, secondMarkdownFile) diff --git a/packages/app/src/components/session/preview-file-view.tsx b/packages/app/src/components/session/preview-file-view.tsx index 6f8216edf..3d26bd5ea 100644 --- a/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app/src/components/session/preview-file-view.tsx @@ -360,15 +360,13 @@ export function PreviewFileView(props: { > {/* Floating controls — top-right overlay */}