From 7ead6bd6b85e5ebfb0999a3821f1b01fdcd17ace Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 21:48:28 +0530 Subject: [PATCH 1/8] feat(editor): hide synced pane scrollbars --- src/lib/components/markdown-workspace.svelte | 2 +- src/routes/page.svelte.e2e.ts | 24 ++++++++++++++++++++ src/routes/styles/editor.css | 8 +++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/components/markdown-workspace.svelte b/src/lib/components/markdown-workspace.svelte index 0f13364..0586077 100644 --- a/src/lib/components/markdown-workspace.svelte +++ b/src/lib/components/markdown-workspace.svelte @@ -432,7 +432,7 @@ {/if} -
+
handlePaneScroll(event, 'source')} onloadcapture={() => queueScrollSync(leadingPane())}> diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index 1eac75e..01e017f 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1307,6 +1307,30 @@ test("scrolls each pane to the part of the note shown in the other one", async ( await expect(page.locator(".rendered-pane").getByText("The last word.")).toBeInViewport(); }); +test("hides both native scrollbars only while synced scrolling is active", async ({ page }) => { + await page.goto("/"); + const markdown = page.getByRole("textbox", { name: "Markdown editor" }); + await expect(markdown).toBeEnabled(); + + const scrollbarWidths = () => + page.evaluate(() => ({ + source: getComputedStyle( + document.querySelector(".source-body > textarea")!, + ).scrollbarWidth, + rendered: getComputedStyle(document.querySelector(".rendered-pane")!) + .scrollbarWidth, + })); + + await expect(page.locator(".editor-shell")).toHaveClass(/sync-scrollbars/); + await expect.poll(scrollbarWidths).toEqual({ source: "none", rendered: "none" }); + + await page.evaluate(() => localStorage.setItem("onyx:scroll-sync", "false")); + await page.reload(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled(); + await expect(page.locator(".editor-shell")).not.toHaveClass(/sync-scrollbars/); + await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "auto" }); +}); + test("moves a pane by dragging its grip beside the divider or with the arrow keys", async ({ page, }) => { diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 3e9f53b..55cdac6 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -339,6 +339,14 @@ max-width: var(--content-width); margin: 0 auto; } +.sync-scrollbars .source-body > textarea, +.sync-scrollbars .rendered-pane { + scrollbar-width: none; +} +.sync-scrollbars .source-body > textarea::-webkit-scrollbar, +.sync-scrollbars .rendered-pane::-webkit-scrollbar { + display: none; +} .source-body > textarea::placeholder { color: var(--faint); } From a68af8da65398bc0c44529d971188e37676dbc34 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 21:55:38 +0530 Subject: [PATCH 2/8] feat(editor): add animated divider scroll control --- src/lib/components/markdown-workspace.svelte | 121 ++++++++++++++++++- src/routes/styles/editor.css | 96 ++++++++++++++- 2 files changed, 213 insertions(+), 4 deletions(-) diff --git a/src/lib/components/markdown-workspace.svelte b/src/lib/components/markdown-workspace.svelte index 0586077..f431e9b 100644 --- a/src/lib/components/markdown-workspace.svelte +++ b/src/lib/components/markdown-workspace.svelte @@ -173,6 +173,7 @@ let sourcePaneElement = $state(); let renderedPaneElement = $state(); + let paneDivider = $state(); let drag = $state<{ pane: Pane; pointerId: number; startX: number; startY: number; dx: number; dy: number; originX: number; originY: number; scale: number; edge: PaneEdge; moving: boolean }>(); function paneEdge(pane: Pane): PaneEdge { @@ -269,12 +270,117 @@ let scrollDriver: Pane = 'rendered'; let syncFrame = 0; const syncedTops = new WeakMap(); + const SCROLLBAR_IDLE_DELAY = 650; + const SCROLLBAR_LENGTH = 46; + let syncScrollProgress = $state(0); + let syncScrollbarActive = $state(false); + let syncScrollbarElement = $state(); + let scrollIdleTimer: ReturnType | undefined; + let scrollbarDrag: { pointerId: number; pane: Pane; grabOffset: number } | undefined; + let syncScrollbarEnabled = $derived(scrollSync && bothPanesVisible); function scrollerOf(pane: Pane): HTMLElement | undefined { if (pane === 'rendered') return renderedPaneElement; return (sourceBody?.firstElementChild as HTMLElement | null) ?? undefined; } + function clearScrollIdleTimer(): void { + if (scrollIdleTimer) clearTimeout(scrollIdleTimer); + scrollIdleTimer = undefined; + } + + function hideSyncScrollbar(): void { + clearScrollIdleTimer(); + syncScrollbarActive = false; + } + + function scheduleScrollIdle(): void { + clearScrollIdleTimer(); + scrollIdleTimer = setTimeout(() => { + if (scrollbarDrag || document.activeElement === syncScrollbarElement) return; + syncScrollbarActive = false; + }, SCROLLBAR_IDLE_DELAY); + } + + function updateSyncScrollProgress(pane: Pane): void { + const scroller = scrollerOf(pane); + if (!scroller) return; + const range = scroller.scrollHeight - scroller.clientHeight; + syncScrollProgress = range > 0 ? Math.min(Math.max(scroller.scrollTop / range, 0), 1) : 0; + } + + function showSyncScrollbar(pane: Pane): void { + if (!syncScrollbarEnabled) return; + updateSyncScrollProgress(pane); + syncScrollbarActive = true; + if (!scrollbarDrag) scheduleScrollIdle(); + } + + function scrollbarPane(): Pane { + const driver = scrollerOf(scrollDriver); + if (driver && driver.scrollHeight > driver.clientHeight) return scrollDriver; + return scrollDriver === 'source' ? 'rendered' : 'source'; + } + + function scrollbarAxis(event: PointerEvent): number { + return stacked ? event.clientX : event.clientY; + } + + function moveSyncScrollbar(event: PointerEvent): void { + if (!scrollbarDrag || event.pointerId !== scrollbarDrag.pointerId) return; + const bounds = paneDivider?.getBoundingClientRect(); + const scroller = scrollerOf(scrollbarDrag.pane); + if (!bounds || !scroller) return; + const start = stacked ? bounds.left : bounds.top; + const span = stacked ? bounds.width : bounds.height; + const travel = Math.max(span - SCROLLBAR_LENGTH, 1); + const progress = Math.min(Math.max((scrollbarAxis(event) - start - scrollbarDrag.grabOffset) / travel, 0), 1); + const scrollRange = Math.max(scroller.scrollHeight - scroller.clientHeight, 0); + syncScrollProgress = progress; + scroller.scrollTo({ top: progress * scrollRange, behavior: 'instant' }); + queueScrollSync(scrollbarDrag.pane); + } + + function startSyncScrollbarDrag(event: PointerEvent): void { + if (event.button !== 0 || !syncScrollbarActive) return; + const bounds = paneDivider?.getBoundingClientRect(); + if (!bounds) return; + event.preventDefault(); + clearScrollIdleTimer(); + const span = stacked ? bounds.width : bounds.height; + const start = stacked ? bounds.left : bounds.top; + const travel = Math.max(span - SCROLLBAR_LENGTH, 1); + scrollbarDrag = { + pointerId: event.pointerId, + pane: scrollbarPane(), + grabOffset: scrollbarAxis(event) - start - syncScrollProgress * travel, + }; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + } + + function endSyncScrollbarDrag(event: PointerEvent): void { + if (!scrollbarDrag || event.pointerId !== scrollbarDrag.pointerId) return; + scrollbarDrag = undefined; + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); + scheduleScrollIdle(); + } + + function nudgeSyncScrollbar(event: KeyboardEvent): void { + let progress: number; + if (event.key === 'Home' || event.key === 'End') progress = event.key === 'Home' ? 0 : 1; + else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft' || event.key === 'PageUp') progress = syncScrollProgress - (event.key === 'PageUp' ? 0.15 : 0.03); + else if (event.key === 'ArrowDown' || event.key === 'ArrowRight' || event.key === 'PageDown') progress = syncScrollProgress + (event.key === 'PageDown' ? 0.15 : 0.03); + else return; + event.preventDefault(); + const pane = scrollbarPane(); + const scroller = scrollerOf(pane); + if (!scroller) return; + syncScrollProgress = Math.min(Math.max(progress, 0), 1); + scroller.scrollTo({ top: syncScrollProgress * Math.max(scroller.scrollHeight - scroller.clientHeight, 0), behavior: 'instant' }); + queueScrollSync(pane); + scheduleScrollIdle(); + } + function proseAnchors(scroller: HTMLElement): ScrollAnchor[] { const article = scroller.querySelector('article.prose'); return article ? elementAnchors(scroller, [...article.children].map((element, index) => [element, renderedBlockLines[index]])) : []; @@ -375,7 +481,8 @@ syncedTops.delete(scroller); if (synced !== undefined && Math.abs(scroller.scrollTop - synced) <= 1) return; if (glide?.target === scroller) stopGlide(); - queueScrollSync(pane, true); + showSyncScrollbar(pane); + queueScrollSync(pane, !scrollbarDrag); } $effect(() => { @@ -383,6 +490,10 @@ else tick().then(() => queueScrollSync(leadingPane())); }); + $effect(() => { + if (!syncScrollbarEnabled) hideSyncScrollbar(); + }); + $effect(() => { void [markdown, renderedMarkdown, bothPanesVisible, stacked, contentWidth]; tick().then(() => queueScrollSync(leadingPane())); @@ -401,6 +512,7 @@ observer.disconnect(); cancelAnimationFrame(syncFrame); syncFrame = 0; + hideSyncScrollbar(); stopGlide(); }; }); @@ -432,7 +544,7 @@
{/if} -
+
handlePaneScroll(event, 'source')} onloadcapture={() => queueScrollSync(leadingPane())}> @@ -441,8 +553,11 @@ {/if}
-
+
+ {#if syncScrollbarEnabled} + + {/if} {#if bothPanesVisible} diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 55cdac6..15f8f2b 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -1,5 +1,6 @@ .editor-shell { --split: 50%; + --sync-scroll-length: 46px; position: relative; --content-width: 700px; flex: 1; @@ -106,6 +107,10 @@ cursor: pointer; transform: translate(-50%, -50%); transition: + top 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + left 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + opacity 0.12s ease, + transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), width 0.16s ease, height 0.16s ease, background 0.16s ease, @@ -165,6 +170,94 @@ .pane-handle:hover { color: var(--ink); } +.sync-scrollbar { + position: absolute; + top: calc( + var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 + ); + left: 50%; + z-index: 7; + width: 18px; + height: var(--sync-scroll-length); + padding: 0; + border: 0; + background: transparent; + opacity: 0; + pointer-events: none; + cursor: ns-resize; + touch-action: none; + transform: translate(-50%, -50%) scaleY(0.25); + transition: + top 0.06s linear, + opacity 0.1s ease, + transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1); +} +.sync-scrollbar::after { + content: ""; + position: absolute; + inset: 0 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--line-hover) 82%, var(--ink)); + box-shadow: 0 1px 6px color-mix(in srgb, var(--ink) 14%, transparent); + transition: + inset 0.14s ease, + background 0.14s ease, + box-shadow 0.14s ease; +} +.sync-scrolling .sync-scrollbar { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, -50%) scaleY(1); +} +.sync-scrollbar:hover::after, +.sync-scrollbar:focus-visible::after { + inset-inline: 5px; + background: color-mix(in srgb, var(--accent) 42%, var(--line-hover)); + box-shadow: 0 1px 8px color-mix(in srgb, var(--accent) 24%, transparent); +} +.sync-scrollbar:focus-visible { + outline: none; +} +.sync-scrolling .pane-handle { + top: calc( + var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 + ); + width: 5px; + height: 24px; + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%) scaleY(0.35); +} +.panes-stacked .sync-scrollbar { + top: 50%; + left: calc( + var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 + ); + width: var(--sync-scroll-length); + height: 18px; + cursor: ew-resize; + transform: translate(-50%, -50%) scaleX(0.25); +} +.panes-stacked .sync-scrollbar::after { + inset: 6px 0; +} +.panes-stacked.sync-scrolling .sync-scrollbar { + transform: translate(-50%, -50%) scaleX(1); +} +.panes-stacked .sync-scrollbar:hover::after, +.panes-stacked .sync-scrollbar:focus-visible::after { + inset-block: 5px; + inset-inline: 0; +} +.panes-stacked.sync-scrolling .pane-handle { + top: 50%; + left: calc( + var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 + ); + width: 24px; + height: 5px; + transform: translate(-50%, -50%) scaleX(0.35); +} /* * Each pane's grip is a pill just inside it beside the divider, midway between the pane handles. * It stays clear of the window's top edge, which full-screen macOS gives to the menu bar. @@ -256,7 +349,8 @@ transition: opacity 0.16s ease; } .pane-moving .pane-handle, -.pane-moving .pane-grip { +.pane-moving .pane-grip, +.pane-moving .sync-scrollbar { visibility: hidden; } /* Shows where the dragged pane will land; it glides between edges as the pointer crosses them. */ From 46262db46285ba6dc5965ffc1c8d302c1620021f Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 21:55:42 +0530 Subject: [PATCH 3/8] test(editor): cover divider scrollbar interaction --- src/routes/page.svelte.e2e.ts | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index 01e017f..0ab91f1 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1331,6 +1331,76 @@ test("hides both native scrollbars only while synced scrolling is active", async await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "auto" }); }); +test("morphs the pane handles into a shared draggable divider scrollbar", async ({ page }) => { + await page.goto("/"); + const markdown = page.getByRole("textbox", { name: "Markdown editor" }); + await expect(markdown).toBeEnabled(); + await markdown.fill( + Array.from( + { length: 80 }, + (_, index) => `## Part ${index}\n\nParagraph ${index}.\n\nMore detail for part ${index}.`, + ).join("\n\n"), + ); + + const shell = page.locator(".editor-shell"); + const rendered = page.locator(".rendered-pane"); + const scrollbar = page.getByRole("button", { name: "Scroll both panes" }); + await rendered.evaluate((pane) => { + pane.scrollTop = (pane.scrollHeight - pane.clientHeight) * 0.4; + }); + + await expect(shell).toHaveClass(/sync-scrolling/); + await scrollbar.focus(); + await expect(scrollbar).toHaveCSS("opacity", "1"); + await expect + .poll(() => + shell.evaluate((element) => + Number(getComputedStyle(element).getPropertyValue("--sync-scroll-progress")), + ), + ) + .toBeCloseTo(0.4, 1); + + await expect + .poll(async () => { + const thumb = await scrollbar.boundingBox(); + const handles = await page.locator(".pane-handle").all(); + if (!thumb || handles.length !== 2) return Number.POSITIVE_INFINITY; + const thumbCenter = thumb.y + thumb.height / 2; + const offsets = await Promise.all( + handles.map(async (handle) => { + const box = await handle.boundingBox(); + return box ? Math.abs(box.y + box.height / 2 - thumbCenter) : Number.POSITIVE_INFINITY; + }), + ); + return Math.max(...offsets); + }) + .toBeLessThan(2); + + const before = await page.evaluate(() => ({ + source: document.querySelector(".source-body > textarea")!.scrollTop, + rendered: document.querySelector(".rendered-pane")!.scrollTop, + })); + const thumb = await scrollbar.boundingBox(); + if (!thumb) throw new Error("The shared scrollbar is not laid out"); + await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2); + await page.mouse.down(); + await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2 + 100, { + steps: 8, + }); + await page.mouse.up(); + + await expect + .poll(() => page.locator(".source-body > textarea").evaluate((element) => element.scrollTop)) + .toBeGreaterThan(before.source); + await expect + .poll(() => rendered.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(before.rendered); + + await scrollbar.evaluate((element) => element.blur()); + await expect(shell).not.toHaveClass(/sync-scrolling/, { timeout: 2_000 }); + await expect(page.locator(".pane-handle").first()).toHaveCSS("opacity", "1"); +}); + test("moves a pane by dragging its grip beside the divider or with the arrow keys", async ({ page, }) => { From 06e7c706bcf2700c4c38355e136acebaee45b42e Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 22:05:13 +0530 Subject: [PATCH 4/8] fix(editor): simplify divider scrollbar transition --- src/routes/page.svelte.e2e.ts | 34 +++++++++++++++++----------------- src/routes/styles/editor.css | 31 +++---------------------------- 2 files changed, 20 insertions(+), 45 deletions(-) diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index 0ab91f1..e56029f 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1331,13 +1331,13 @@ test("hides both native scrollbars only while synced scrolling is active", async await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "auto" }); }); -test("morphs the pane handles into a shared draggable divider scrollbar", async ({ page }) => { +test("fades the pane handles behind a shared draggable divider scrollbar", async ({ page }) => { await page.goto("/"); const markdown = page.getByRole("textbox", { name: "Markdown editor" }); await expect(markdown).toBeEnabled(); await markdown.fill( Array.from( - { length: 80 }, + { length: 16 }, (_, index) => `## Part ${index}\n\nParagraph ${index}.\n\nMore detail for part ${index}.`, ).join("\n\n"), ); @@ -1345,6 +1345,13 @@ test("morphs the pane handles into a shared draggable divider scrollbar", async const shell = page.locator(".editor-shell"); const rendered = page.locator(".rendered-pane"); const scrollbar = page.getByRole("button", { name: "Scroll both panes" }); + const handles = page.locator(".pane-handle"); + const idleHandleCenters = await handles.evaluateAll((elements) => + elements.map((element) => { + const bounds = element.getBoundingClientRect(); + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; + }), + ); await rendered.evaluate((pane) => { pane.scrollTop = (pane.scrollHeight - pane.clientHeight) * 0.4; }); @@ -1360,21 +1367,14 @@ test("morphs the pane handles into a shared draggable divider scrollbar", async ) .toBeCloseTo(0.4, 1); - await expect - .poll(async () => { - const thumb = await scrollbar.boundingBox(); - const handles = await page.locator(".pane-handle").all(); - if (!thumb || handles.length !== 2) return Number.POSITIVE_INFINITY; - const thumbCenter = thumb.y + thumb.height / 2; - const offsets = await Promise.all( - handles.map(async (handle) => { - const box = await handle.boundingBox(); - return box ? Math.abs(box.y + box.height / 2 - thumbCenter) : Number.POSITIVE_INFINITY; - }), - ); - return Math.max(...offsets); - }) - .toBeLessThan(2); + await expect(handles.first()).toHaveCSS("opacity", "0"); + const activeHandleCenters = await handles.evaluateAll((elements) => + elements.map((element) => { + const bounds = element.getBoundingClientRect(); + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; + }), + ); + expect(activeHandleCenters).toEqual(idleHandleCenters); const before = await page.evaluate(() => ({ source: document.querySelector(".source-body > textarea")!.scrollTop, diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 15f8f2b..6188f47 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -107,10 +107,7 @@ cursor: pointer; transform: translate(-50%, -50%); transition: - top 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), - left 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.12s ease, - transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), width 0.16s ease, height 0.16s ease, background 0.16s ease, @@ -186,11 +183,8 @@ pointer-events: none; cursor: ns-resize; touch-action: none; - transform: translate(-50%, -50%) scaleY(0.25); - transition: - top 0.06s linear, - opacity 0.1s ease, - transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1); + transform: translate(-50%, -50%); + transition: opacity 0.1s ease; } .sync-scrollbar::after { content: ""; @@ -207,7 +201,6 @@ .sync-scrolling .sync-scrollbar { opacity: 1; pointer-events: auto; - transform: translate(-50%, -50%) scaleY(1); } .sync-scrollbar:hover::after, .sync-scrollbar:focus-visible::after { @@ -219,14 +212,8 @@ outline: none; } .sync-scrolling .pane-handle { - top: calc( - var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 - ); - width: 5px; - height: 24px; opacity: 0; pointer-events: none; - transform: translate(-50%, -50%) scaleY(0.35); } .panes-stacked .sync-scrollbar { top: 50%; @@ -236,28 +223,16 @@ width: var(--sync-scroll-length); height: 18px; cursor: ew-resize; - transform: translate(-50%, -50%) scaleX(0.25); + transform: translate(-50%, -50%); } .panes-stacked .sync-scrollbar::after { inset: 6px 0; } -.panes-stacked.sync-scrolling .sync-scrollbar { - transform: translate(-50%, -50%) scaleX(1); -} .panes-stacked .sync-scrollbar:hover::after, .panes-stacked .sync-scrollbar:focus-visible::after { inset-block: 5px; inset-inline: 0; } -.panes-stacked.sync-scrolling .pane-handle { - top: 50%; - left: calc( - var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 - ); - width: 24px; - height: 5px; - transform: translate(-50%, -50%) scaleX(0.35); -} /* * Each pane's grip is a pill just inside it beside the divider, midway between the pane handles. * It stays clear of the window's top edge, which full-screen macOS gives to the menu bar. From 4980059c9e557f10826d8d4e0f53f4b857f32168 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 22:16:49 +0530 Subject: [PATCH 5/8] fix(editor): keep scrollbar on right pane --- src/lib/components/markdown-workspace.svelte | 121 +------------------ src/routes/page.svelte.e2e.ts | 91 +++----------- src/routes/styles/editor.css | 79 +----------- 3 files changed, 22 insertions(+), 269 deletions(-) diff --git a/src/lib/components/markdown-workspace.svelte b/src/lib/components/markdown-workspace.svelte index f431e9b..d657341 100644 --- a/src/lib/components/markdown-workspace.svelte +++ b/src/lib/components/markdown-workspace.svelte @@ -173,7 +173,6 @@ let sourcePaneElement = $state(); let renderedPaneElement = $state(); - let paneDivider = $state(); let drag = $state<{ pane: Pane; pointerId: number; startX: number; startY: number; dx: number; dy: number; originX: number; originY: number; scale: number; edge: PaneEdge; moving: boolean }>(); function paneEdge(pane: Pane): PaneEdge { @@ -270,117 +269,12 @@ let scrollDriver: Pane = 'rendered'; let syncFrame = 0; const syncedTops = new WeakMap(); - const SCROLLBAR_IDLE_DELAY = 650; - const SCROLLBAR_LENGTH = 46; - let syncScrollProgress = $state(0); - let syncScrollbarActive = $state(false); - let syncScrollbarElement = $state(); - let scrollIdleTimer: ReturnType | undefined; - let scrollbarDrag: { pointerId: number; pane: Pane; grabOffset: number } | undefined; - let syncScrollbarEnabled = $derived(scrollSync && bothPanesVisible); function scrollerOf(pane: Pane): HTMLElement | undefined { if (pane === 'rendered') return renderedPaneElement; return (sourceBody?.firstElementChild as HTMLElement | null) ?? undefined; } - function clearScrollIdleTimer(): void { - if (scrollIdleTimer) clearTimeout(scrollIdleTimer); - scrollIdleTimer = undefined; - } - - function hideSyncScrollbar(): void { - clearScrollIdleTimer(); - syncScrollbarActive = false; - } - - function scheduleScrollIdle(): void { - clearScrollIdleTimer(); - scrollIdleTimer = setTimeout(() => { - if (scrollbarDrag || document.activeElement === syncScrollbarElement) return; - syncScrollbarActive = false; - }, SCROLLBAR_IDLE_DELAY); - } - - function updateSyncScrollProgress(pane: Pane): void { - const scroller = scrollerOf(pane); - if (!scroller) return; - const range = scroller.scrollHeight - scroller.clientHeight; - syncScrollProgress = range > 0 ? Math.min(Math.max(scroller.scrollTop / range, 0), 1) : 0; - } - - function showSyncScrollbar(pane: Pane): void { - if (!syncScrollbarEnabled) return; - updateSyncScrollProgress(pane); - syncScrollbarActive = true; - if (!scrollbarDrag) scheduleScrollIdle(); - } - - function scrollbarPane(): Pane { - const driver = scrollerOf(scrollDriver); - if (driver && driver.scrollHeight > driver.clientHeight) return scrollDriver; - return scrollDriver === 'source' ? 'rendered' : 'source'; - } - - function scrollbarAxis(event: PointerEvent): number { - return stacked ? event.clientX : event.clientY; - } - - function moveSyncScrollbar(event: PointerEvent): void { - if (!scrollbarDrag || event.pointerId !== scrollbarDrag.pointerId) return; - const bounds = paneDivider?.getBoundingClientRect(); - const scroller = scrollerOf(scrollbarDrag.pane); - if (!bounds || !scroller) return; - const start = stacked ? bounds.left : bounds.top; - const span = stacked ? bounds.width : bounds.height; - const travel = Math.max(span - SCROLLBAR_LENGTH, 1); - const progress = Math.min(Math.max((scrollbarAxis(event) - start - scrollbarDrag.grabOffset) / travel, 0), 1); - const scrollRange = Math.max(scroller.scrollHeight - scroller.clientHeight, 0); - syncScrollProgress = progress; - scroller.scrollTo({ top: progress * scrollRange, behavior: 'instant' }); - queueScrollSync(scrollbarDrag.pane); - } - - function startSyncScrollbarDrag(event: PointerEvent): void { - if (event.button !== 0 || !syncScrollbarActive) return; - const bounds = paneDivider?.getBoundingClientRect(); - if (!bounds) return; - event.preventDefault(); - clearScrollIdleTimer(); - const span = stacked ? bounds.width : bounds.height; - const start = stacked ? bounds.left : bounds.top; - const travel = Math.max(span - SCROLLBAR_LENGTH, 1); - scrollbarDrag = { - pointerId: event.pointerId, - pane: scrollbarPane(), - grabOffset: scrollbarAxis(event) - start - syncScrollProgress * travel, - }; - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - } - - function endSyncScrollbarDrag(event: PointerEvent): void { - if (!scrollbarDrag || event.pointerId !== scrollbarDrag.pointerId) return; - scrollbarDrag = undefined; - (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); - scheduleScrollIdle(); - } - - function nudgeSyncScrollbar(event: KeyboardEvent): void { - let progress: number; - if (event.key === 'Home' || event.key === 'End') progress = event.key === 'Home' ? 0 : 1; - else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft' || event.key === 'PageUp') progress = syncScrollProgress - (event.key === 'PageUp' ? 0.15 : 0.03); - else if (event.key === 'ArrowDown' || event.key === 'ArrowRight' || event.key === 'PageDown') progress = syncScrollProgress + (event.key === 'PageDown' ? 0.15 : 0.03); - else return; - event.preventDefault(); - const pane = scrollbarPane(); - const scroller = scrollerOf(pane); - if (!scroller) return; - syncScrollProgress = Math.min(Math.max(progress, 0), 1); - scroller.scrollTo({ top: syncScrollProgress * Math.max(scroller.scrollHeight - scroller.clientHeight, 0), behavior: 'instant' }); - queueScrollSync(pane); - scheduleScrollIdle(); - } - function proseAnchors(scroller: HTMLElement): ScrollAnchor[] { const article = scroller.querySelector('article.prose'); return article ? elementAnchors(scroller, [...article.children].map((element, index) => [element, renderedBlockLines[index]])) : []; @@ -481,8 +375,7 @@ syncedTops.delete(scroller); if (synced !== undefined && Math.abs(scroller.scrollTop - synced) <= 1) return; if (glide?.target === scroller) stopGlide(); - showSyncScrollbar(pane); - queueScrollSync(pane, !scrollbarDrag); + queueScrollSync(pane, true); } $effect(() => { @@ -490,10 +383,6 @@ else tick().then(() => queueScrollSync(leadingPane())); }); - $effect(() => { - if (!syncScrollbarEnabled) hideSyncScrollbar(); - }); - $effect(() => { void [markdown, renderedMarkdown, bothPanesVisible, stacked, contentWidth]; tick().then(() => queueScrollSync(leadingPane())); @@ -512,7 +401,6 @@ observer.disconnect(); cancelAnimationFrame(syncFrame); syncFrame = 0; - hideSyncScrollbar(); stopGlide(); }; }); @@ -544,7 +432,7 @@
{/if} -
+
handlePaneScroll(event, 'source')} onloadcapture={() => queueScrollSync(leadingPane())}> @@ -553,11 +441,8 @@ {/if}
-
+
- {#if syncScrollbarEnabled} - - {/if} {#if bothPanesVisible} diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index e56029f..080bc77 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1307,10 +1307,11 @@ test("scrolls each pane to the part of the note shown in the other one", async ( await expect(page.locator(".rendered-pane").getByText("The last word.")).toBeInViewport(); }); -test("hides both native scrollbars only while synced scrolling is active", async ({ page }) => { +test("keeps a native scrollbar only on the right pane during synced scrolling", async ({ + page, +}) => { await page.goto("/"); - const markdown = page.getByRole("textbox", { name: "Markdown editor" }); - await expect(markdown).toBeEnabled(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled(); const scrollbarWidths = () => page.evaluate(() => ({ @@ -1321,84 +1322,20 @@ test("hides both native scrollbars only while synced scrolling is active", async .scrollbarWidth, })); - await expect(page.locator(".editor-shell")).toHaveClass(/sync-scrollbars/); - await expect.poll(scrollbarWidths).toEqual({ source: "none", rendered: "none" }); + await expect(page.locator(".editor-shell")).toHaveClass(/hide-leading-scrollbar/); + await expect.poll(scrollbarWidths).toEqual({ source: "none", rendered: "auto" }); - await page.evaluate(() => localStorage.setItem("onyx:scroll-sync", "false")); + await page.evaluate(() => localStorage.setItem("onyx:pane-order", "rendered-first")); await page.reload(); await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled(); - await expect(page.locator(".editor-shell")).not.toHaveClass(/sync-scrollbars/); - await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "auto" }); -}); - -test("fades the pane handles behind a shared draggable divider scrollbar", async ({ page }) => { - await page.goto("/"); - const markdown = page.getByRole("textbox", { name: "Markdown editor" }); - await expect(markdown).toBeEnabled(); - await markdown.fill( - Array.from( - { length: 16 }, - (_, index) => `## Part ${index}\n\nParagraph ${index}.\n\nMore detail for part ${index}.`, - ).join("\n\n"), - ); - - const shell = page.locator(".editor-shell"); - const rendered = page.locator(".rendered-pane"); - const scrollbar = page.getByRole("button", { name: "Scroll both panes" }); - const handles = page.locator(".pane-handle"); - const idleHandleCenters = await handles.evaluateAll((elements) => - elements.map((element) => { - const bounds = element.getBoundingClientRect(); - return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; - }), - ); - await rendered.evaluate((pane) => { - pane.scrollTop = (pane.scrollHeight - pane.clientHeight) * 0.4; - }); + await expect(page.locator(".editor-shell")).toHaveClass(/panes-swapped/); + await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "none" }); - await expect(shell).toHaveClass(/sync-scrolling/); - await scrollbar.focus(); - await expect(scrollbar).toHaveCSS("opacity", "1"); - await expect - .poll(() => - shell.evaluate((element) => - Number(getComputedStyle(element).getPropertyValue("--sync-scroll-progress")), - ), - ) - .toBeCloseTo(0.4, 1); - - await expect(handles.first()).toHaveCSS("opacity", "0"); - const activeHandleCenters = await handles.evaluateAll((elements) => - elements.map((element) => { - const bounds = element.getBoundingClientRect(); - return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; - }), - ); - expect(activeHandleCenters).toEqual(idleHandleCenters); - - const before = await page.evaluate(() => ({ - source: document.querySelector(".source-body > textarea")!.scrollTop, - rendered: document.querySelector(".rendered-pane")!.scrollTop, - })); - const thumb = await scrollbar.boundingBox(); - if (!thumb) throw new Error("The shared scrollbar is not laid out"); - await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2); - await page.mouse.down(); - await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2 + 100, { - steps: 8, - }); - await page.mouse.up(); - - await expect - .poll(() => page.locator(".source-body > textarea").evaluate((element) => element.scrollTop)) - .toBeGreaterThan(before.source); - await expect - .poll(() => rendered.evaluate((element) => element.scrollTop)) - .toBeGreaterThan(before.rendered); - - await scrollbar.evaluate((element) => element.blur()); - await expect(shell).not.toHaveClass(/sync-scrolling/, { timeout: 2_000 }); - await expect(page.locator(".pane-handle").first()).toHaveCSS("opacity", "1"); + await page.evaluate(() => localStorage.setItem("onyx:pane-layout", "rows")); + await page.reload(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled(); + await expect(page.locator(".editor-shell")).not.toHaveClass(/hide-leading-scrollbar/); + await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "auto" }); }); test("moves a pane by dragging its grip beside the divider or with the arrow keys", async ({ diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 6188f47..9a1512c 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -1,6 +1,5 @@ .editor-shell { --split: 50%; - --sync-scroll-length: 46px; position: relative; --content-width: 700px; flex: 1; @@ -107,7 +106,6 @@ cursor: pointer; transform: translate(-50%, -50%); transition: - opacity 0.12s ease, width 0.16s ease, height 0.16s ease, background 0.16s ease, @@ -167,72 +165,6 @@ .pane-handle:hover { color: var(--ink); } -.sync-scrollbar { - position: absolute; - top: calc( - var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 - ); - left: 50%; - z-index: 7; - width: 18px; - height: var(--sync-scroll-length); - padding: 0; - border: 0; - background: transparent; - opacity: 0; - pointer-events: none; - cursor: ns-resize; - touch-action: none; - transform: translate(-50%, -50%); - transition: opacity 0.1s ease; -} -.sync-scrollbar::after { - content: ""; - position: absolute; - inset: 0 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--line-hover) 82%, var(--ink)); - box-shadow: 0 1px 6px color-mix(in srgb, var(--ink) 14%, transparent); - transition: - inset 0.14s ease, - background 0.14s ease, - box-shadow 0.14s ease; -} -.sync-scrolling .sync-scrollbar { - opacity: 1; - pointer-events: auto; -} -.sync-scrollbar:hover::after, -.sync-scrollbar:focus-visible::after { - inset-inline: 5px; - background: color-mix(in srgb, var(--accent) 42%, var(--line-hover)); - box-shadow: 0 1px 8px color-mix(in srgb, var(--accent) 24%, transparent); -} -.sync-scrollbar:focus-visible { - outline: none; -} -.sync-scrolling .pane-handle { - opacity: 0; - pointer-events: none; -} -.panes-stacked .sync-scrollbar { - top: 50%; - left: calc( - var(--sync-scroll-progress) * (100% - var(--sync-scroll-length)) + var(--sync-scroll-length) / 2 - ); - width: var(--sync-scroll-length); - height: 18px; - cursor: ew-resize; - transform: translate(-50%, -50%); -} -.panes-stacked .sync-scrollbar::after { - inset: 6px 0; -} -.panes-stacked .sync-scrollbar:hover::after, -.panes-stacked .sync-scrollbar:focus-visible::after { - inset-block: 5px; - inset-inline: 0; -} /* * Each pane's grip is a pill just inside it beside the divider, midway between the pane handles. * It stays clear of the window's top edge, which full-screen macOS gives to the menu bar. @@ -324,8 +256,7 @@ transition: opacity 0.16s ease; } .pane-moving .pane-handle, -.pane-moving .pane-grip, -.pane-moving .sync-scrollbar { +.pane-moving .pane-grip { visibility: hidden; } /* Shows where the dragged pane will land; it glides between edges as the pointer crosses them. */ @@ -408,12 +339,12 @@ max-width: var(--content-width); margin: 0 auto; } -.sync-scrollbars .source-body > textarea, -.sync-scrollbars .rendered-pane { +.hide-leading-scrollbar:not(.panes-swapped) .source-body > textarea, +.hide-leading-scrollbar.panes-swapped .rendered-pane { scrollbar-width: none; } -.sync-scrollbars .source-body > textarea::-webkit-scrollbar, -.sync-scrollbars .rendered-pane::-webkit-scrollbar { +.hide-leading-scrollbar:not(.panes-swapped) .source-body > textarea::-webkit-scrollbar, +.hide-leading-scrollbar.panes-swapped .rendered-pane::-webkit-scrollbar { display: none; } .source-body > textarea::placeholder { From a4cbe89dfaf4c3761b3c7c1a5e57e21a0d0edc34 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 22:39:04 +0530 Subject: [PATCH 6/8] feat(editor): unify pane visibility handle --- src/lib/components/markdown-workspace.svelte | 22 ++--- src/routes/page.svelte.e2e.ts | 29 +++++++ src/routes/styles/editor.css | 89 +++++++++++++++++--- 3 files changed, 116 insertions(+), 24 deletions(-) diff --git a/src/lib/components/markdown-workspace.svelte b/src/lib/components/markdown-workspace.svelte index d657341..eaf8efd 100644 --- a/src/lib/components/markdown-workspace.svelte +++ b/src/lib/components/markdown-workspace.svelte @@ -446,16 +446,18 @@ {#if bothPanesVisible} - {/if} - {#if secondPaneVisible} - {@const label = `${firstPaneVisible ? 'Hide' : 'Show'} the ${firstPane} pane`} - {@const Icon = firstPaneVisible ? towardsStart : towardsEnd} - - {/if} - {#if firstPaneVisible} - {@const label = `${secondPaneVisible ? 'Hide' : 'Show'} the ${secondPane} pane`} - {@const Icon = secondPaneVisible ? towardsEnd : towardsStart} - + {@const FirstIcon = towardsStart} + {@const SecondIcon = towardsEnd} +
+ + +
+ {:else if secondPaneVisible} + {@const RestoreIcon = towardsEnd} + + {:else if firstPaneVisible} + {@const RestoreIcon = towardsStart} + {/if}
handlePaneScroll(event, 'rendered')} onloadcapture={() => queueScrollSync(leadingPane())}> diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index 080bc77..2862762 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1202,10 +1202,21 @@ test("keeps source and rendered panes synchronized and lets each pane be tucked await markdown.fill("# Written on the right"); await expect(page.locator(".rendered-pane h1")).toHaveText("Written on the right"); + const handle = page.getByRole("group", { name: "Pane visibility" }); + await expect(handle).toBeVisible(); + await expect(handle.getByRole("button")).toHaveCount(2); + const dividerBox = await page.locator(".pane-divider").boundingBox(); + const handleBox = await handle.boundingBox(); + if (!dividerBox || !handleBox) throw new Error("The divider handle is not laid out"); + expect(handleBox.x + handleBox.width / 2).toBeCloseTo(dividerBox.x + dividerBox.width / 2, 0); + expect(handleBox.y + handleBox.height / 2).toBeCloseTo(dividerBox.y + dividerBox.height / 2, 0); + await page.getByRole("button", { name: "Hide the source pane" }).click(); await expect(markdown).toBeHidden(); + await expect(handle).toHaveCount(0); await page.getByRole("button", { name: "Show the source pane" }).click(); await expect(markdown).toBeVisible(); + await expect(handle).toBeVisible(); await markdown.fill("# Written on the left"); await expect(markdown).toHaveValue("# Written on the left"); @@ -1215,6 +1226,24 @@ test("keeps source and rendered panes synchronized and lets each pane be tucked await expect(page.locator(".rendered-pane")).toBeHidden(); await page.getByRole("button", { name: "Show the rendered pane" }).click(); await expect(page.locator(".rendered-pane")).toBeVisible(); + + await page.evaluate(() => localStorage.setItem("onyx:pane-layout", "rows")); + await page.reload(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled(); + await expect(handle).toHaveCSS("flex-direction", "column"); + const stackedDividerBox = await page.locator(".pane-divider").boundingBox(); + const stackedHandleBox = await handle.boundingBox(); + if (!stackedDividerBox || !stackedHandleBox) { + throw new Error("The stacked divider handle is not laid out"); + } + expect(stackedHandleBox.x + stackedHandleBox.width / 2).toBeCloseTo( + stackedDividerBox.x + stackedDividerBox.width / 2, + 0, + ); + expect(stackedHandleBox.y + stackedHandleBox.height / 2).toBeCloseTo( + stackedDividerBox.y + stackedDividerBox.height / 2, + 0, + ); }); type ScrollSide = "source" | "rendered"; diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 9a1512c..1c7a496 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -87,11 +87,13 @@ .editor-shell.panes-stacked.resizing { cursor: row-resize; } -.editor-shell.resizing .pane-handle { +.editor-shell.resizing .pane-handle, +.editor-shell.resizing .pane-handle-pair { transition: none; } .pane-handle { position: absolute; + top: 50%; left: 50%; z-index: 6; display: grid; @@ -112,23 +114,10 @@ border-color 0.16s ease, color 0.16s ease; } -.pane-handle-start { - top: 27%; -} -.pane-handle-end { - top: 73%; -} .panes-stacked .pane-handle { - top: 50%; width: 30px; height: 6px; } -.panes-stacked .pane-handle-start { - left: 27%; -} -.panes-stacked .pane-handle-end { - left: 73%; -} .first-hidden .pane-handle { transform: translate(0, -50%); } @@ -165,6 +154,77 @@ .pane-handle:hover { color: var(--ink); } +.pane-handle-pair { + position: absolute; + top: 50%; + left: 50%; + z-index: 6; + display: flex; + width: 6px; + height: 30px; + overflow: hidden; + border: 1px solid transparent; + border-radius: 999px; + background: var(--line-hover); + transform: translate(-50%, -50%); + transition: + width 0.16s ease, + height 0.16s ease, + background 0.16s ease, + border-color 0.16s ease, + box-shadow 0.16s ease; +} +.pane-handle-direction { + display: grid; + flex: 1 1 50%; + place-items: center; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + opacity: 0; + cursor: pointer; + transition: + color 0.14s ease, + opacity 0.1s ease; +} +.pane-divider:hover:not(:has(.pane-grip:hover)) .pane-handle-pair, +.pane-handle-pair:hover, +.pane-handle-pair:focus-within { + width: 46px; + height: 26px; + border-color: var(--line-strong); + background: var(--surface); + box-shadow: var(--shadow-sm); +} +.pane-divider:hover:not(:has(.pane-grip:hover)) .pane-handle-direction, +.pane-handle-pair:hover .pane-handle-direction, +.pane-handle-pair:focus-within .pane-handle-direction { + opacity: 1; +} +.pane-handle-direction:hover, +.pane-handle-direction:focus-visible { + color: var(--ink); +} +.pane-handle-direction:focus-visible { + outline: none; +} +.panes-stacked .pane-handle-pair { + flex-direction: column; + width: 30px; + height: 6px; +} +.panes-stacked .pane-handle-direction { + width: 100%; + min-height: 0; +} +.panes-stacked .pane-divider:hover:not(:has(.pane-grip:hover)) .pane-handle-pair, +.panes-stacked .pane-handle-pair:hover, +.panes-stacked .pane-handle-pair:focus-within { + width: 26px; + height: 46px; +} /* * Each pane's grip is a pill just inside it beside the divider, midway between the pane handles. * It stays clear of the window's top edge, which full-screen macOS gives to the menu bar. @@ -256,6 +316,7 @@ transition: opacity 0.16s ease; } .pane-moving .pane-handle, +.pane-moving .pane-handle-pair, .pane-moving .pane-grip { visibility: hidden; } From de80418c13fa4235ffad1c3fe17bc274817fe928 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 22:56:57 +0530 Subject: [PATCH 7/8] fix(editor): pin source scrollbar to pane edge --- src/routes/page.svelte.e2e.ts | 7 +++++++ src/routes/styles/editor.css | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index 2862762..4f2f326 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1224,6 +1224,13 @@ test("keeps source and rendered panes synchronized and lets each pane be tucked await expect(page.locator(".rendered-pane h1")).toHaveText("Written on the left"); await page.getByRole("button", { name: "Hide the rendered pane" }).click(); await expect(page.locator(".rendered-pane")).toBeHidden(); + const sourcePaneBox = await page.locator(".source-pane").boundingBox(); + const sourceEditorBox = await markdown.boundingBox(); + if (!sourcePaneBox || !sourceEditorBox) throw new Error("The source pane is not laid out"); + expect(sourceEditorBox.x + sourceEditorBox.width).toBeCloseTo( + sourcePaneBox.x + sourcePaneBox.width, + 0, + ); await page.getByRole("button", { name: "Show the rendered pane" }).click(); await expect(page.locator(".rendered-pane")).toBeVisible(); diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 1c7a496..74b435a 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -387,7 +387,9 @@ width: 100%; min-height: 0; resize: none; - padding: 52px clamp(28px, 5vw, 72px) 60px; + padding: 52px + max(clamp(28px, 5vw, 72px), calc((100% - var(--content-width)) / 2 + clamp(28px, 5vw, 72px))) + 60px; border: 0; outline: 0; background: transparent; @@ -397,8 +399,6 @@ font-variant-ligatures: none; line-height: 1.78; tab-size: 2; - max-width: var(--content-width); - margin: 0 auto; } .hide-leading-scrollbar:not(.panes-swapped) .source-body > textarea, .hide-leading-scrollbar.panes-swapped .rendered-pane { From 2a7fd593bd73821f5880cf330396c209a48ad598 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 22 Sep 2026 23:03:32 +0530 Subject: [PATCH 8/8] fix(editor): hide collapsed pane divider --- src/routes/page.svelte.e2e.ts | 4 ++++ src/routes/styles/editor.css | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/routes/page.svelte.e2e.ts b/src/routes/page.svelte.e2e.ts index 4f2f326..2eb14f2 100644 --- a/src/routes/page.svelte.e2e.ts +++ b/src/routes/page.svelte.e2e.ts @@ -1214,6 +1214,7 @@ test("keeps source and rendered panes synchronized and lets each pane be tucked await page.getByRole("button", { name: "Hide the source pane" }).click(); await expect(markdown).toBeHidden(); await expect(handle).toHaveCount(0); + await expect(page.locator(".pane-divider")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await page.getByRole("button", { name: "Show the source pane" }).click(); await expect(markdown).toBeVisible(); await expect(handle).toBeVisible(); @@ -1224,6 +1225,7 @@ test("keeps source and rendered panes synchronized and lets each pane be tucked await expect(page.locator(".rendered-pane h1")).toHaveText("Written on the left"); await page.getByRole("button", { name: "Hide the rendered pane" }).click(); await expect(page.locator(".rendered-pane")).toBeHidden(); + await expect(page.locator(".pane-divider")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); const sourcePaneBox = await page.locator(".source-pane").boundingBox(); const sourceEditorBox = await markdown.boundingBox(); if (!sourcePaneBox || !sourceEditorBox) throw new Error("The source pane is not laid out"); @@ -1251,6 +1253,8 @@ test("keeps source and rendered panes synchronized and lets each pane be tucked stackedDividerBox.y + stackedDividerBox.height / 2, 0, ); + await page.getByRole("button", { name: "Hide the source pane" }).click(); + await expect(page.locator(".pane-divider")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); }); type ScrollSide = "source" | "rendered"; diff --git a/src/routes/styles/editor.css b/src/routes/styles/editor.css index 74b435a..29f8262 100644 --- a/src/routes/styles/editor.css +++ b/src/routes/styles/editor.css @@ -57,6 +57,10 @@ position: relative; background: var(--line); } +.first-hidden .pane-divider, +.second-hidden .pane-divider { + background: transparent; +} .panes-stacked .pane-divider { grid-row: 2; grid-column: 1;