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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/lib/components/markdown-workspace.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
let sourceFindMarkup = $derived(
sourceFindActive ? highlightFindMatches(markdown, findMatches, activeFindMatch) : '',
);
const CENTER_SNAP_DISTANCE = 12;

function syncSourceFindLayer(event?: Event): void {
const target = event?.currentTarget instanceof HTMLTextAreaElement ? event.currentTarget : editor;
Expand All @@ -130,7 +131,7 @@
const span = stacked ? bounds.height : bounds.width;
if (!span) return;
const offset = stacked ? event.clientY - bounds.top : event.clientX - bounds.left;
onResize((offset / span) * 100);
onResize(Math.abs(offset - span / 2) <= CENTER_SNAP_DISTANCE ? 50 : (offset / span) * 100);
}

function startResize(event: PointerEvent): void {
Expand Down Expand Up @@ -448,7 +449,7 @@
</div>
</div>
<div class="pane-divider">
<button type="button" class="pane-resize" class:enabled={bothPanesVisible} aria-label={`Resize the panes, the ${firstPane} pane takes ${Math.round(splitRatio)} percent`} title="Drag to resize, double-click to even out" tabindex={bothPanesVisible ? 0 : -1} onpointerdown={startResize} onpointermove={trackResize} onpointerup={endResize} onpointercancel={endResize} onkeydown={nudgeResize} ondblclick={resetSplit}></button>
<button type="button" class="pane-resize" class:enabled={bothPanesVisible} aria-label={`Resize the panes, the ${firstPane} pane takes ${Math.round(splitRatio)} percent`} title="Drag to resize; snaps to center. Double-click to even out" tabindex={bothPanesVisible ? 0 : -1} onpointerdown={startResize} onpointermove={trackResize} onpointerup={endResize} onpointercancel={endResize} onkeydown={nudgeResize} ondblclick={resetSplit}></button>
{#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>
Expand Down
41 changes: 39 additions & 2 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,53 @@
import NotesSidebar from '$lib/components/notes-sidebar.svelte';
import StatusNotices from '$lib/components/status-notices.svelte';
import ImagePreview from '$lib/components/image-preview.svelte';
import { createPageController } from './page-controller.svelte.js';
import { createPageController, DEFAULT_SIDEBAR_WIDTH } from './page-controller.svelte.js';

const page = createPageController();
const SIDEBAR_SNAP_DISTANCE = 12;
let sidebarResizePointerId: number | undefined;
let sidebarResizing = $state(false);

function startSidebarResize(event: PointerEvent): void {
if (event.button !== 0 || !event.isPrimary || sidebarResizePointerId !== undefined) return;
event.preventDefault();
sidebarResizePointerId = event.pointerId;
sidebarResizing = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}

function trackSidebarResize(event: PointerEvent): void {
if (event.pointerId !== sidebarResizePointerId) return;
const bounds = (event.currentTarget as HTMLElement).parentElement?.getBoundingClientRect();
if (!bounds) return;
const width = page.sidebarSide === 'right' ? bounds.right - event.clientX : event.clientX - bounds.left;
page.setSidebarWidth(Math.abs(width - DEFAULT_SIDEBAR_WIDTH) <= SIDEBAR_SNAP_DISTANCE ? DEFAULT_SIDEBAR_WIDTH : width);
}

function endSidebarResize(event: PointerEvent): void {
if (event.pointerId !== sidebarResizePointerId) return;
sidebarResizePointerId = undefined;
sidebarResizing = false;
const handle = event.currentTarget as HTMLElement;
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
page.saveSidebarWidth();
}

function nudgeSidebarResize(event: KeyboardEvent): void {
const direction = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (!direction) return;
event.preventDefault();
page.setSidebarWidth(page.sidebarWidth + direction * (page.sidebarSide === 'right' ? -10 : 10));
page.saveSidebarWidth();
}
</script>

<svelte:head>
<title>onyx - a quiet place to think in markdown</title>
<meta name="description" content="A fast, local-first Markdown editor with full-text search that works offline." />
</svelte:head>

<div class="app" class:sidebar-open={page.sidebarOpen} class:sidebar-collapsed={page.sidebarCollapsed} class:sidebar-right={page.sidebarSide === 'right'} class:sidebar-dragging={page.sidebarDropSide !== undefined} class:palette-open={page.paletteOpen} inert={page.settingsOpen || page.restoreModalOpen || page.imagePreview !== undefined}>
<div class="app" class:sidebar-open={page.sidebarOpen} class:sidebar-collapsed={page.sidebarCollapsed} class:sidebar-right={page.sidebarSide === 'right'} class:sidebar-dragging={page.sidebarDropSide !== undefined} class:sidebar-resizing={sidebarResizing} class:palette-open={page.paletteOpen} style={`--sidebar-width: ${page.sidebarWidth}px`} inert={page.settingsOpen || page.restoreModalOpen || page.imagePreview !== undefined}>
<NotesSidebar
vaults={page.vaults}
activeVaultId={page.activeVaultId}
Expand Down Expand Up @@ -106,6 +142,7 @@
onChangePage={page.changeNotePage}
onContentWidthChange={page.setContentWidth}
/>
<button type="button" class="sidebar-resize" aria-label={`Resize notes sidebar, ${page.sidebarWidth} pixels wide`} title="Drag to resize; snaps to the default width" onpointerdown={startSidebarResize} onpointermove={trackSidebarResize} onpointerup={endSidebarResize} onpointercancel={endSidebarResize} onkeydown={nudgeSidebarResize}></button>

<MarkdownWorkspace
storageNotice={page.storageNotice}
Expand Down
23 changes: 23 additions & 0 deletions src/routes/page-controller.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ type SidebarSide = "left" | "right";

const SIDEBAR_DRAG_HOLD_MS = 300;
const SIDEBAR_DRAG_SLOP_PX = 6;
export const DEFAULT_SIDEBAR_WIDTH = 258;
export const MIN_SIDEBAR_WIDTH = 200;
export const MAX_SIDEBAR_WIDTH = 480;

interface EditorSelection {
start: number;
Expand Down Expand Up @@ -440,6 +443,7 @@ Press \`${commandPaletteShortcut}\` for the command palette, \`${saveShortcut}\`
let recentNoteIds = $state<string[]>([]);
let sidebarCollapsed = $state(false);
let sidebarSide = $state<SidebarSide>("left");
let sidebarWidth = $state(DEFAULT_SIDEBAR_WIDTH);
let sidebarDropSide = $state<SidebarSide | undefined>();
let shortcuts = $state<KeyboardShortcuts>(structuredClone(defaultKeyboardShortcuts));
let primaryModifier = $state<PrimaryModifier>("meta");
Expand Down Expand Up @@ -1202,6 +1206,8 @@ Press \`${commandPaletteShortcut}\` for the command palette, \`${saveShortcut}\`
};
narrowQuery?.addEventListener("change", onViewportChange);
sidebarSide = readLocalStorage("onyx:sidebar-side") === "right" ? "right" : "left";
const storedSidebarWidth = Number(readLocalStorage("onyx:sidebar-width"));
if (Number.isFinite(storedSidebarWidth)) sidebarWidth = clampSidebarWidth(storedSidebarWidth);
const storedSplit = Number(readLocalStorage("onyx:split-ratio"));
if (Number.isFinite(storedSplit)) splitRatio = clampSplitRatio(storedSplit);
const storedContentWidth = Number(readLocalStorage("onyx:content-width"));
Expand Down Expand Up @@ -3843,6 +3849,18 @@ Press \`${commandPaletteShortcut}\` for the command palette, \`${saveShortcut}\`
writeLocalStorage("onyx:sidebar-side", side);
}

function clampSidebarWidth(value: number): number {
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, value));
}

function setSidebarWidth(value: number): void {
sidebarWidth = clampSidebarWidth(value);
}

function saveSidebarWidth(): void {
writeLocalStorage("onyx:sidebar-width", String(sidebarWidth));
}

/** Holding a sidebar toggle picks it up; releasing it over either half of the window docks the sidebar there. */
function startSidebarDrag(event: PointerEvent): void {
if (event.button !== 0 || !event.isPrimary) return;
Expand Down Expand Up @@ -4248,6 +4266,9 @@ Press \`${commandPaletteShortcut}\` for the command palette, \`${saveShortcut}\`
get sidebarSide() {
return sidebarSide;
},
get sidebarWidth() {
return sidebarWidth;
},
get sidebarDropSide() {
return sidebarDropSide;
},
Expand Down Expand Up @@ -4370,6 +4391,8 @@ Press \`${commandPaletteShortcut}\` for the command palette, \`${saveShortcut}\`
dismissStorageNotice,
toggleSidebar,
setSidebarSide,
setSidebarWidth,
saveSidebarWidth,
startSidebarDrag,
insertSyntax,
prefixLine,
Expand Down
120 changes: 120 additions & 0 deletions src/routes/page.svelte.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,82 @@ test("moves the sidebar to the right by holding and dragging its toggle", async
await expect(app).toHaveClass(/sidebar-right/);
});

test("resizes the sidebar on either side and remembers its width", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled();
const sidebar = page.locator(".sidebar");
const separator = page.getByRole("button", { name: /Resize notes sidebar/ });
await expect(sidebar).toHaveCSS("width", "258px");

const dragTo = async (width: number) => {
const bounds = await separator.boundingBox();
const viewport = page.viewportSize();
if (!bounds || !viewport) throw new Error("The sidebar resize handle is not laid out");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 4);
await page.mouse.down();
await page.mouse.move(
(await page.locator(".app").getAttribute("class"))?.includes("sidebar-right")
? viewport.width - width
: width,
bounds.y + bounds.height / 4,
);
await page.mouse.up();
await expect(sidebar).toHaveCSS("width", `${width}px`);
};

await dragTo(330);
await page.reload();
await expect(sidebar).toHaveCSS("width", "330px");
await page.evaluate(() => localStorage.setItem("onyx:sidebar-side", "right"));
await page.reload();
await dragTo(380);
await page.reload();
await expect(sidebar).toHaveCSS("width", "380px");

await separator.focus();
await page.keyboard.press("ArrowLeft");
await expect(sidebar).toHaveCSS("width", "390px");
await page.getByRole("button", { name: "Hide notes sidebar" }).click();
await expect(separator).toBeHidden();
await page.getByRole("button", { name: "Show notes sidebar" }).click();
await expect(sidebar).toHaveCSS("width", "390px");
});

for (const side of ["left", "right"] as const) {
test(`snaps the ${side} sidebar to its default width while dragging`, async ({ page }) => {
await page.goto("/");
if (side === "right") {
await page.evaluate(() => localStorage.setItem("onyx:sidebar-side", "right"));
await page.reload();
}
await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled();

const sidebar = page.locator(".sidebar");
const handle = page.getByRole("button", { name: /Resize notes sidebar/ });
const bounds = await handle.boundingBox();
const viewport = page.viewportSize();
if (!bounds || !viewport) throw new Error("The sidebar resize handle is not laid out");
const y = bounds.y + bounds.height / 4;
const moveToWidth = (width: number) =>
page.mouse.move(side === "right" ? viewport.width - width : width, y);

await page.mouse.move(bounds.x + bounds.width / 2, y);
await page.mouse.down();
await moveToWidth(330);
await expect(sidebar).toHaveCSS("width", "330px");
await moveToWidth(266);
await expect(sidebar).toHaveCSS("width", "258px");
await moveToWidth(278);
await expect(sidebar).toHaveCSS("width", "278px");
await moveToWidth(250);
await expect(sidebar).toHaveCSS("width", "258px");
await page.mouse.up();

await page.reload();
await expect(sidebar).toHaveCSS("width", "258px");
});
}

test("chooses the sidebar position from its context menu", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled();
Expand Down Expand Up @@ -1575,6 +1651,50 @@ test("moves a pane by dragging its grip beside the divider or with the arrow key
expect(await gripOffset("Move the source pane")).toBeGreaterThan(14);
});

for (const layout of ["columns", "rows"] as const) {
test(`snaps the ${layout} divider to an exact 50/50 split while dragging`, async ({ page }) => {
await page.goto("/");
if (layout === "rows") {
await page.evaluate(() => localStorage.setItem("onyx:pane-layout", "rows"));
await page.reload();
}
await expect(page.getByRole("textbox", { name: "Markdown editor" })).toBeEnabled();

const shell = page.locator(".editor-shell");
const stacked = layout === "rows";
if (stacked) await expect(shell).toHaveClass(/panes-stacked/);
const bounds = await shell.boundingBox();
const resizeBounds = await page.locator(".pane-resize").boundingBox();
if (!bounds || !resizeBounds) throw new Error("The pane divider is not laid out");
const start = stacked
? { x: bounds.x + bounds.width / 4, y: resizeBounds.y + resizeBounds.height / 2 }
: { x: resizeBounds.x + resizeBounds.width / 2, y: bounds.y + bounds.height / 4 };
const moveTo = async (offset: number) => {
await page.mouse.move(
stacked ? start.x : bounds.x + offset,
stacked ? bounds.y + offset : start.y,
);
};
const split = () =>
shell.evaluate((element) => Number.parseFloat(element.style.getPropertyValue("--split")));
const span = stacked ? bounds.height : bounds.width;

await page.mouse.move(start.x, start.y);
await page.mouse.down();
await moveTo(span * 0.3);
await expect.poll(split).toBeLessThan(50);
await moveTo(span / 2 + 8);
await expect.poll(split).toBe(50);
await moveTo(span / 2 + 20);
await expect.poll(split).toBeGreaterThan(50);
await moveTo(span / 2 - 8);
await expect.poll(split).toBe(50);
await page.mouse.up();
await page.reload();
await expect.poll(split).toBe(50);
});
}

test("copies and downloads the Markdown source from the file menu", async ({ page }) => {
await page.goto("/");
const markdown = page.getByRole("textbox", { name: "Markdown editor" });
Expand Down
46 changes: 43 additions & 3 deletions src/routes/styles/app-shell.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.app {
display: grid;
grid-template-columns: 258px minmax(0, 1fr);
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
position: relative;
height: 100dvh;
background: var(--paper);
}
Expand Down Expand Up @@ -159,6 +160,41 @@
.sidebar-collapsed .sidebar {
display: none;
}
.sidebar-resize {
position: absolute;
z-index: 10;
top: 0;
bottom: 0;
left: calc(var(--sidebar-width) - 10px);
width: 10px;
padding: 0;
border: 0;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.sidebar-resize::after {
content: "";
position: absolute;
inset: 0 auto 0 4px;
width: 2px;
background: var(--accent-quiet);
opacity: 0;
transition: opacity 0.15s ease;
}
.sidebar-resize:hover::after,
.sidebar-resize:focus-visible::after,
.sidebar-resizing .sidebar-resize::after {
opacity: 1;
}
.sidebar-collapsed .sidebar-resize {
display: none;
}
.sidebar-resizing,
.sidebar-resizing * {
cursor: col-resize !important;
user-select: none;
}
.notes-heading,
.notes-title,
.notes-actions {
Expand Down Expand Up @@ -1259,7 +1295,7 @@
white-space: nowrap;
}
.app.sidebar-right {
grid-template-columns: minmax(0, 1fr) 258px;
grid-template-columns: minmax(0, 1fr) var(--sidebar-width);
}
.app.sidebar-right.sidebar-collapsed {
grid-template-columns: minmax(0, 1fr) 0;
Expand All @@ -1272,6 +1308,10 @@
.sidebar-right .workspace {
grid-column: 1;
}
.sidebar-right .sidebar-resize {
right: calc(var(--sidebar-width) - 10px);
left: auto;
}
.sidebar-toggle,
.collapsed-sidebar-toggle {
touch-action: none;
Expand All @@ -1286,7 +1326,7 @@
position: fixed;
inset: 8px auto 8px 8px;
z-index: 40;
width: 258px;
width: var(--sidebar-width);
border: 2px dashed var(--accent-quiet);
border-radius: 10px;
background: color-mix(in srgb, var(--accent-bg-strong) 55%, transparent);
Expand Down
6 changes: 6 additions & 0 deletions src/routes/styles/responsive.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
.sidebar-open.palette-open::after {
display: none;
}
.sidebar-resize {
display: none;
}
.sidebar-drop-target {
width: 258px;
}
.workspace {
grid-column: 1;
}
Expand Down
Loading