diff --git a/index.html b/index.html
index 60dd87d..e19b8f0 100644
--- a/index.html
+++ b/index.html
@@ -270,6 +270,55 @@
overflow: hidden;
}
+ /* Drag handle that resizes the left sidebar. It sits between the sidebar's
+ right border and the content area as a narrow invisible strip that lights
+ up on hover/while dragging. */
+ #sidebar-resizer {
+ flex: 0 0 6px;
+ cursor: col-resize;
+ background: transparent;
+ border-left: 1px solid transparent;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+ }
+
+ #sidebar-resizer:hover,
+ body.resizing-sidebar #sidebar-resizer {
+ background: var(--accent-light);
+ border-left-color: var(--accent);
+ }
+
+ body.resizing-sidebar {
+ cursor: col-resize;
+ user-select: none;
+ }
+
+ /* Drag handle that resizes the right outline panel. Mirrors the sidebar
+ resizer: it sits just left of the outline's border and is hidden whenever
+ the outline itself is hidden. */
+ #outline-resizer {
+ flex: 0 0 6px;
+ cursor: col-resize;
+ background: transparent;
+ border-right: 1px solid transparent;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+ display: none;
+ }
+
+ #outline-resizer:hover,
+ body.resizing-outline #outline-resizer {
+ background: var(--accent-light);
+ border-right-color: var(--accent);
+ }
+
+ body.resizing-outline {
+ cursor: col-resize;
+ user-select: none;
+ }
+
#sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
@@ -1139,7 +1188,8 @@
);
}
- html[data-outline="hidden"] #outline {
+ html[data-outline="hidden"] #outline,
+ html[data-outline="hidden"] #outline-resizer {
display: none !important;
}
@@ -1780,6 +1830,8 @@
#sidebar,
#outline,
+ #sidebar-resizer,
+ #outline-resizer,
#empty-state,
.copy-btn,
.mermaid-fullscreen-btn,
@@ -1845,6 +1897,8 @@
/* PDF export mode — applied via JS before createPDF capture */
body.print-mode #sidebar,
body.print-mode #outline,
+ body.print-mode #sidebar-resizer,
+ body.print-mode #outline-resizer,
body.print-mode #empty-state,
body.print-mode .copy-btn,
body.print-mode .mermaid-fullscreen-btn {
@@ -2052,6 +2106,8 @@
Markdown Viewer
+
+
Markdown Viewer
@@ -2083,6 +2139,8 @@ Markdown Viewer
+
+
On this page
diff --git a/src/main.ts b/src/main.ts
index 424bbcd..06eb29f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -27,24 +27,36 @@ import {
BODY_FONT_KEY,
CODE_FONT_KEY,
CUSTOM_FONT_SIZE_KEY,
+ DEFAULT_OUTLINE_WIDTH,
+ DEFAULT_SIDEBAR_WIDTH,
FONTSIZE_KEY,
FONT_WEIGHT_KEY,
MAX_CUSTOM_FONT_SIZE,
+ MAX_OUTLINE_WIDTH,
+ MAX_SIDEBAR_WIDTH,
MIN_CUSTOM_FONT_SIZE,
+ MIN_OUTLINE_WIDTH,
+ MIN_SIDEBAR_WIDTH,
FINDER_OPEN_KEY,
OUTLINE_PREF_KEY,
+ OUTLINE_WIDTH_KEY,
+ SIDEBAR_WIDTH_KEY,
applyBodyFontToDOM,
applyCodeFontToDOM,
applyCustomFontSizeToDOM,
applyFontSizeToDOM,
applyFontWeightToDOM,
applyOutlinePrefToDOM,
+ applyOutlineWidthToDOM,
+ applySidebarWidthToDOM,
resolveInitialCustomFontSize,
resolveInitialFontFamily,
resolveInitialFontSize,
resolveInitialFinderOpenPref,
resolveInitialFontWeight,
resolveInitialOutlinePref,
+ resolveInitialOutlineWidth,
+ resolveInitialSidebarWidth,
setSegmentActive,
type FinderOpenPref,
type FontSize,
@@ -158,6 +170,12 @@ async function initTheme(): Promise
{
const savedOutline = resolveInitialOutlinePref(
await store.get(OUTLINE_PREF_KEY)
);
+ const savedSidebarWidth = resolveInitialSidebarWidth(
+ await store.get(SIDEBAR_WIDTH_KEY)
+ );
+ const savedOutlineWidth = resolveInitialOutlineWidth(
+ await store.get(OUTLINE_WIDTH_KEY)
+ );
const storeCustoms = sanitizeCustomThemes(
await store.get(CUSTOM_THEMES_KEY)
@@ -192,6 +210,8 @@ async function initTheme(): Promise {
applyActiveTheme();
applyFontSize(savedFontSize);
applyOutlinePref(savedOutline);
+ applySidebarWidthToDOM(document.documentElement, savedSidebarWidth);
+ applyOutlineWidthToDOM(document.documentElement, savedOutlineWidth);
applyBodyFontToDOM(document.documentElement, currentBodyFont);
applyCodeFontToDOM(document.documentElement, currentCodeFont);
applyFontWeightToDOM(document.documentElement, currentFontWeight);
@@ -432,6 +452,9 @@ const examplesBtn = document.getElementById(
) as HTMLButtonElement;
const outlineEl = document.getElementById("outline") as HTMLDivElement;
const outlineNav = document.getElementById("outline-nav") as HTMLElement;
+const outlineResizer = document.getElementById(
+ "outline-resizer"
+) as HTMLDivElement;
const titlebarFilename = document.getElementById(
"titlebar-filename"
) as HTMLSpanElement;
@@ -457,6 +480,11 @@ const searchOptWholeWord = document.getElementById(
"search-opt-wholeword"
) as HTMLInputElement;
+const sidebarEl = document.getElementById("sidebar") as HTMLDivElement;
+const sidebarResizer = document.getElementById(
+ "sidebar-resizer"
+) as HTMLDivElement;
+
const appWindow = getCurrentWindow();
// Keeps the native window title in sync with what the window shows. The title is
@@ -666,6 +694,121 @@ function initSearch(): void {
document.addEventListener("keydown", handleGlobalSearchShortcut);
}
+// --- Sidebar resizing ---
+
+let sidebarDragging = false;
+let sidebarDragStartX = 0;
+let sidebarDragStartWidth = 0;
+
+function currentSidebarWidth(): number {
+ return sidebarEl.getBoundingClientRect().width;
+}
+
+function setSidebarWidth(px: number): void {
+ const clamped = Math.min(
+ MAX_SIDEBAR_WIDTH,
+ Math.max(MIN_SIDEBAR_WIDTH, px)
+ );
+ applySidebarWidthToDOM(document.documentElement, clamped);
+}
+
+function initSidebarResizer(): void {
+ sidebarResizer.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ // Capture so moves/up keep firing even if the cursor leaves the window.
+ sidebarResizer.setPointerCapture(e.pointerId);
+ sidebarDragging = true;
+ sidebarDragStartX = e.clientX;
+ sidebarDragStartWidth = currentSidebarWidth();
+ document.body.classList.add("resizing-sidebar");
+ });
+
+ sidebarResizer.addEventListener("pointermove", (e) => {
+ if (!sidebarDragging) return;
+ setSidebarWidth(sidebarDragStartWidth + (e.clientX - sidebarDragStartX));
+ });
+
+ function endSidebarDrag(): void {
+ if (!sidebarDragging) return;
+ sidebarDragging = false;
+ document.body.classList.remove("resizing-sidebar");
+ }
+
+ sidebarResizer.addEventListener("pointerup", async (e) => {
+ if (!sidebarDragging) return;
+ sidebarResizer.releasePointerCapture(e.pointerId);
+ endSidebarDrag();
+ await savePref(SIDEBAR_WIDTH_KEY, Math.round(currentSidebarWidth()));
+ });
+
+ sidebarResizer.addEventListener("pointercancel", () => {
+ endSidebarDrag();
+ });
+
+ sidebarResizer.addEventListener("dblclick", async () => {
+ setSidebarWidth(DEFAULT_SIDEBAR_WIDTH);
+ await savePref(SIDEBAR_WIDTH_KEY, DEFAULT_SIDEBAR_WIDTH);
+ });
+}
+
+// --- Outline resizing ---
+
+let outlineDragging = false;
+let outlineDragStartX = 0;
+let outlineDragStartWidth = 0;
+
+function currentOutlineWidth(): number {
+ return outlineEl.getBoundingClientRect().width;
+}
+
+function setOutlineWidth(px: number): void {
+ const clamped = Math.min(
+ MAX_OUTLINE_WIDTH,
+ Math.max(MIN_OUTLINE_WIDTH, px)
+ );
+ applyOutlineWidthToDOM(document.documentElement, clamped);
+}
+
+function initOutlineResizer(): void {
+ outlineResizer.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ // Capture so moves/up keep firing even if the cursor leaves the window.
+ outlineResizer.setPointerCapture(e.pointerId);
+ outlineDragging = true;
+ outlineDragStartX = e.clientX;
+ outlineDragStartWidth = currentOutlineWidth();
+ document.body.classList.add("resizing-outline");
+ });
+
+ outlineResizer.addEventListener("pointermove", (e) => {
+ if (!outlineDragging) return;
+ // The handle sits on the outline's left edge: dragging right shrinks it.
+ setOutlineWidth(outlineDragStartWidth - (e.clientX - outlineDragStartX));
+ });
+
+ function endOutlineDrag(): void {
+ if (!outlineDragging) return;
+ outlineDragging = false;
+ document.body.classList.remove("resizing-outline");
+ }
+
+ outlineResizer.addEventListener("pointerup", async (e) => {
+ if (!outlineDragging) return;
+ outlineResizer.releasePointerCapture(e.pointerId);
+ endOutlineDrag();
+ await savePref(OUTLINE_WIDTH_KEY, Math.round(currentOutlineWidth()));
+ });
+
+ outlineResizer.addEventListener("pointercancel", () => {
+ endOutlineDrag();
+ });
+
+ outlineResizer.addEventListener("dblclick", async () => {
+ setOutlineWidth(DEFAULT_OUTLINE_WIDTH);
+ await savePref(OUTLINE_WIDTH_KEY, DEFAULT_OUTLINE_WIDTH);
+ });
+}
+
// --- Preferences modal ---
const prefsBackdrop = document.getElementById(
@@ -1519,6 +1662,8 @@ async function init(): Promise {
await initTheme();
initSearch();
initPreferences();
+ initSidebarResizer();
+ initOutlineResizer();
// Runtime opens (hot-start file association, "Open With", CLI events)
appWindow.listen("open-folder", (event) => {
@@ -1922,10 +2067,12 @@ function buildOutline(): void {
if (headings.length === 0) {
outlineEl.style.display = "none";
+ outlineResizer.style.display = "none";
return;
}
outlineEl.style.display = "block";
+ outlineResizer.style.display = "block";
const items: { el: HTMLAnchorElement; heading: Element }[] = [];
for (const h of headings) {
diff --git a/src/preferences.test.ts b/src/preferences.test.ts
index 73d37da..c7b7d8e 100644
--- a/src/preferences.test.ts
+++ b/src/preferences.test.ts
@@ -23,10 +23,22 @@ import {
applyCodeFontToDOM,
applyFontWeightToDOM,
applyCustomFontSizeToDOM,
+ applySidebarWidthToDOM,
+ applyOutlineWidthToDOM,
+ resolveInitialSidebarWidth,
+ isValidSidebarWidth,
+ resolveInitialOutlineWidth,
+ isValidOutlineWidth,
effectiveFontSizePx,
PRESET_FONT_SIZE_PX,
MIN_CUSTOM_FONT_SIZE,
MAX_CUSTOM_FONT_SIZE,
+ MIN_SIDEBAR_WIDTH,
+ MAX_SIDEBAR_WIDTH,
+ DEFAULT_SIDEBAR_WIDTH,
+ MIN_OUTLINE_WIDTH,
+ MAX_OUTLINE_WIDTH,
+ DEFAULT_OUTLINE_WIDTH,
} from "./preferences";
describe("resolveDark", () => {
@@ -359,3 +371,79 @@ describe("resolveInitialFinderOpenPref", () => {
expect(isFinderOpenPref("")).toBe(false);
});
});
+
+describe("sidebar width", () => {
+ it("isValidSidebarWidth accepts values inside the range", () => {
+ expect(isValidSidebarWidth(MIN_SIDEBAR_WIDTH)).toBe(true);
+ expect(isValidSidebarWidth(320)).toBe(true);
+ expect(isValidSidebarWidth(MAX_SIDEBAR_WIDTH)).toBe(true);
+ });
+
+ it("isValidSidebarWidth rejects out-of-range or non-numbers", () => {
+ expect(isValidSidebarWidth(MIN_SIDEBAR_WIDTH - 1)).toBe(false);
+ expect(isValidSidebarWidth(MAX_SIDEBAR_WIDTH + 1)).toBe(false);
+ expect(isValidSidebarWidth(NaN)).toBe(false);
+ expect(isValidSidebarWidth(Infinity)).toBe(false);
+ expect(isValidSidebarWidth("320")).toBe(false);
+ expect(isValidSidebarWidth(null)).toBe(false);
+ expect(isValidSidebarWidth(undefined)).toBe(false);
+ });
+
+ it("resolveInitialSidebarWidth keeps a valid value", () => {
+ expect(resolveInitialSidebarWidth(300)).toBe(300);
+ expect(resolveInitialSidebarWidth(MIN_SIDEBAR_WIDTH)).toBe(
+ MIN_SIDEBAR_WIDTH
+ );
+ });
+
+ it("resolveInitialSidebarWidth falls back to the default for bad values", () => {
+ expect(resolveInitialSidebarWidth(null)).toBe(DEFAULT_SIDEBAR_WIDTH);
+ expect(resolveInitialSidebarWidth("300")).toBe(DEFAULT_SIDEBAR_WIDTH);
+ expect(resolveInitialSidebarWidth(10)).toBe(DEFAULT_SIDEBAR_WIDTH);
+ expect(resolveInitialSidebarWidth(10000)).toBe(DEFAULT_SIDEBAR_WIDTH);
+ });
+
+ it("applySidebarWidthToDOM sets --sidebar-width in px", () => {
+ const root = document.createElement("html");
+ applySidebarWidthToDOM(root, 420);
+ expect(root.style.getPropertyValue("--sidebar-width")).toBe("420px");
+ });
+});
+
+describe("outline width", () => {
+ it("isValidOutlineWidth accepts values inside the range", () => {
+ expect(isValidOutlineWidth(MIN_OUTLINE_WIDTH)).toBe(true);
+ expect(isValidOutlineWidth(280)).toBe(true);
+ expect(isValidOutlineWidth(MAX_OUTLINE_WIDTH)).toBe(true);
+ });
+
+ it("isValidOutlineWidth rejects out-of-range or non-numbers", () => {
+ expect(isValidOutlineWidth(MIN_OUTLINE_WIDTH - 1)).toBe(false);
+ expect(isValidOutlineWidth(MAX_OUTLINE_WIDTH + 1)).toBe(false);
+ expect(isValidOutlineWidth(NaN)).toBe(false);
+ expect(isValidOutlineWidth(Infinity)).toBe(false);
+ expect(isValidOutlineWidth("280")).toBe(false);
+ expect(isValidOutlineWidth(null)).toBe(false);
+ expect(isValidOutlineWidth(undefined)).toBe(false);
+ });
+
+ it("resolveInitialOutlineWidth keeps a valid value", () => {
+ expect(resolveInitialOutlineWidth(260)).toBe(260);
+ expect(resolveInitialOutlineWidth(MIN_OUTLINE_WIDTH)).toBe(
+ MIN_OUTLINE_WIDTH
+ );
+ });
+
+ it("resolveInitialOutlineWidth falls back to the default for bad values", () => {
+ expect(resolveInitialOutlineWidth(null)).toBe(DEFAULT_OUTLINE_WIDTH);
+ expect(resolveInitialOutlineWidth("260")).toBe(DEFAULT_OUTLINE_WIDTH);
+ expect(resolveInitialOutlineWidth(10)).toBe(DEFAULT_OUTLINE_WIDTH);
+ expect(resolveInitialOutlineWidth(10000)).toBe(DEFAULT_OUTLINE_WIDTH);
+ });
+
+ it("applyOutlineWidthToDOM sets --outline-width in px", () => {
+ const root = document.createElement("html");
+ applyOutlineWidthToDOM(root, 300);
+ expect(root.style.getPropertyValue("--outline-width")).toBe("300px");
+ });
+});
diff --git a/src/preferences.ts b/src/preferences.ts
index 7f3cf47..b12b588 100644
--- a/src/preferences.ts
+++ b/src/preferences.ts
@@ -35,6 +35,16 @@ export const CODE_FONT_KEY = "codeFontFamily";
export const FONT_WEIGHT_KEY = "fontWeight";
export const CUSTOM_FONT_SIZE_KEY = "customFontSizePx";
+export const SIDEBAR_WIDTH_KEY = "sidebarWidth";
+export const MIN_SIDEBAR_WIDTH = 160;
+export const MAX_SIDEBAR_WIDTH = 640;
+export const DEFAULT_SIDEBAR_WIDTH = 260;
+
+export const OUTLINE_WIDTH_KEY = "outlineWidth";
+export const MIN_OUTLINE_WIDTH = 160;
+export const MAX_OUTLINE_WIDTH = 480;
+export const DEFAULT_OUTLINE_WIDTH = 220;
+
export const FONT_WEIGHT_VALUES = [
"300",
"400",
@@ -172,6 +182,40 @@ export function resolveInitialOutlinePref(value: unknown): OutlinePref {
return isOutlinePref(value) ? value : "auto";
}
+export function isValidSidebarWidth(value: unknown): value is number {
+ return (
+ typeof value === "number" &&
+ Number.isFinite(value) &&
+ value >= MIN_SIDEBAR_WIDTH &&
+ value <= MAX_SIDEBAR_WIDTH
+ );
+}
+
+export function resolveInitialSidebarWidth(value: unknown): number {
+ return isValidSidebarWidth(value) ? value : DEFAULT_SIDEBAR_WIDTH;
+}
+
+export function applySidebarWidthToDOM(root: HTMLElement, px: number): void {
+ root.style.setProperty("--sidebar-width", `${px}px`);
+}
+
+export function isValidOutlineWidth(value: unknown): value is number {
+ return (
+ typeof value === "number" &&
+ Number.isFinite(value) &&
+ value >= MIN_OUTLINE_WIDTH &&
+ value <= MAX_OUTLINE_WIDTH
+ );
+}
+
+export function resolveInitialOutlineWidth(value: unknown): number {
+ return isValidOutlineWidth(value) ? value : DEFAULT_OUTLINE_WIDTH;
+}
+
+export function applyOutlineWidthToDOM(root: HTMLElement, px: number): void {
+ root.style.setProperty("--outline-width", `${px}px`);
+}
+
export function setSegmentActive(group: HTMLElement, value: string): void {
for (const btn of group.querySelectorAll("button")) {
btn.classList.toggle("active", btn.dataset.value === value);