Skip to content
Merged
24 changes: 13 additions & 11 deletions src/lib/components/markdown-workspace.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@
</div>
{/if}

<section bind:this={shell} class="editor-shell" class:source-hidden={!sourcePaneVisible} class:rendered-hidden={!renderedPaneVisible} class:panes-stacked={stacked} class:panes-swapped={swapped} class:first-hidden={!firstPaneVisible} class:second-hidden={!secondPaneVisible} class:resizing class:pane-moving={drag?.moving} style={`--split: ${splitRatio}%; --content-width: ${contentWidth}px`}>
<section bind:this={shell} class="editor-shell" class:source-hidden={!sourcePaneVisible} class:rendered-hidden={!renderedPaneVisible} class:panes-stacked={stacked} class:panes-swapped={swapped} class:first-hidden={!firstPaneVisible} class:second-hidden={!secondPaneVisible} class:resizing class:pane-moving={drag?.moving} class:hide-leading-scrollbar={scrollSync && bothPanesVisible && !stacked} style={`--split: ${splitRatio}%; --content-width: ${contentWidth}px`}>
<div bind:this={sourcePaneElement} class="source-pane" class:dragged={drag?.moving && drag.pane === 'source'} data-source-theme={resolvedTheme} data-color-theme={colorTheme} style={drag?.moving && drag.pane === 'source' ? `translate: ${drag.dx}px ${drag.dy}px; transform-origin: ${drag.originX}px ${drag.originY}px; --lift-scale: ${drag.scale}` : undefined}>
<div class="source-body" bind:this={sourceBody} onscrollcapture={(event) => handlePaneScroll(event, 'source')} onloadcapture={() => queueScrollSync(leadingPane())}>
<textarea bind:this={editor} class:find-highlights-active={sourceFindActive} value={markdown} onbeforeinput={onEditorBeforeInput} oncopy={onEditorCopy} oncut={onEditorCut} onpaste={onEditorPaste} ondragover={onEditorDragOver} ondrop={onEditorDrop} oninput={handleMarkdownInput} onscroll={syncSourceFindLayer} aria-label="Markdown editor" placeholder={'# Start with a title\n\nThen write. Onyx saves to this device as you go.'} spellcheck="true" disabled={saveState === 'loading' || transferState === 'working'}></textarea>
Expand All @@ -452,16 +452,18 @@
{#if bothPanesVisible}
<button type="button" class="pane-grip" class:grip-start={!swapped} aria-label="Move the source pane" title="Drag to move this pane, or use the arrow keys" onpointerdown={(event) => startPaneDrag(event, 'source')} onpointermove={trackPaneDrag} onpointerup={endPaneDrag} onpointercancel={endPaneDrag} onkeydown={(event) => nudgePane(event, 'source')}></button>
<button type="button" class="pane-grip" class:grip-start={swapped} aria-label="Move the rendered pane" title="Drag to move this pane, or use the arrow keys" onpointerdown={(event) => startPaneDrag(event, 'rendered')} onpointermove={trackPaneDrag} onpointerup={endPaneDrag} onpointercancel={endPaneDrag} onkeydown={(event) => nudgePane(event, 'rendered')}></button>
{/if}
{#if secondPaneVisible}
{@const label = `${firstPaneVisible ? 'Hide' : 'Show'} the ${firstPane} pane`}
{@const Icon = firstPaneVisible ? towardsStart : towardsEnd}
<button class="pane-handle pane-handle-start" title={label} aria-label={label} aria-expanded={firstPaneVisible} onclick={toggleFirstPane}><Icon size={15} /></button>
{/if}
{#if firstPaneVisible}
{@const label = `${secondPaneVisible ? 'Hide' : 'Show'} the ${secondPane} pane`}
{@const Icon = secondPaneVisible ? towardsEnd : towardsStart}
<button class="pane-handle pane-handle-end" title={label} aria-label={label} aria-expanded={secondPaneVisible} onclick={toggleSecondPane}><Icon size={15} /></button>
{@const FirstIcon = towardsStart}
{@const SecondIcon = towardsEnd}
<div class="pane-handle-pair" role="group" aria-label="Pane visibility">
<button class="pane-handle-direction" title={`Hide the ${firstPane} pane`} aria-label={`Hide the ${firstPane} pane`} aria-expanded="true" onclick={toggleFirstPane}><FirstIcon size={15} /></button>
<button class="pane-handle-direction" title={`Hide the ${secondPane} pane`} aria-label={`Hide the ${secondPane} pane`} aria-expanded="true" onclick={toggleSecondPane}><SecondIcon size={15} /></button>
</div>
{:else if secondPaneVisible}
{@const RestoreIcon = towardsEnd}
<button class="pane-handle" title={`Show the ${firstPane} pane`} aria-label={`Show the ${firstPane} pane`} aria-expanded="false" onclick={toggleFirstPane}><RestoreIcon size={15} /></button>
{:else if firstPaneVisible}
{@const RestoreIcon = towardsStart}
<button class="pane-handle" title={`Show the ${secondPane} pane`} aria-label={`Show the ${secondPane} pane`} aria-expanded="false" onclick={toggleSecondPane}><RestoreIcon size={15} /></button>
{/if}
</div>
<div bind:this={renderedPaneElement} class="rendered-pane" class:dragged={drag?.moving && drag.pane === 'rendered'} style={drag?.moving && drag.pane === 'rendered' ? `translate: ${drag.dx}px ${drag.dy}px; transform-origin: ${drag.originX}px ${drag.originY}px; --lift-scale: ${drag.scale}` : undefined} onmousemove={handleRenderedMouseMove} onmousedown={handleRenderedMouseDown} onclick={handleRenderedClick} onscrollcapture={(event) => handlePaneScroll(event, 'rendered')} onloadcapture={() => queueScrollSync(leadingPane())}>
Expand Down
71 changes: 71 additions & 0 deletions src/routes/page.svelte.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,19 +1202,59 @@ 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 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();

await markdown.fill("# Written on the left");
await expect(markdown).toHaveValue("# Written on the left");

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");
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();

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,
);
await page.getByRole("button", { name: "Hide the source pane" }).click();
await expect(page.locator(".pane-divider")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
});

test("coalesces rapid input without postponing the rendered text", async ({ page }) => {
Expand Down Expand Up @@ -1342,6 +1382,37 @@ 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("keeps a native scrollbar only on the right pane during synced scrolling", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled();

const scrollbarWidths = () =>
page.evaluate(() => ({
source: getComputedStyle(
document.querySelector<HTMLTextAreaElement>(".source-body > textarea")!,
).scrollbarWidth,
rendered: getComputedStyle(document.querySelector<HTMLElement>(".rendered-pane")!)
.scrollbarWidth,
}));

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:pane-order", "rendered-first"));
await page.reload();
await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled();
await expect(page.locator(".editor-shell")).toHaveClass(/panes-swapped/);
await expect.poll(scrollbarWidths).toEqual({ source: "auto", rendered: "none" });

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 ({
page,
}) => {
Expand Down
107 changes: 90 additions & 17 deletions src/routes/styles/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,11 +91,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;
Expand All @@ -112,23 +118,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%);
}
Expand Down Expand Up @@ -165,6 +158,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.
Expand Down Expand Up @@ -256,6 +320,7 @@
transition: opacity 0.16s ease;
}
.pane-moving .pane-handle,
.pane-moving .pane-handle-pair,
.pane-moving .pane-grip {
visibility: hidden;
}
Expand Down Expand Up @@ -326,7 +391,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;
Expand All @@ -336,8 +403,14 @@
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 {
scrollbar-width: none;
}
.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 {
color: var(--faint);
Expand Down
Loading