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