From 1dcb29ec4e916811bd906c3c1d9c601bed320bdd Mon Sep 17 00:00:00 2001 From: "Refone.Mac" <710196926@qq.com> Date: Fri, 25 Sep 2026 16:15:40 +0800 Subject: [PATCH 1/2] feat(sidebar): drag to resize the left sidebar --- index.html | 26 +++++++++++++++ src/main.ts | 73 +++++++++++++++++++++++++++++++++++++++++ src/preferences.test.ts | 44 +++++++++++++++++++++++++ src/preferences.ts | 22 +++++++++++++ 4 files changed, 165 insertions(+) diff --git a/index.html b/index.html index 60dd87d..25e7d87 100644 --- a/index.html +++ b/index.html @@ -270,6 +270,30 @@ 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; + } + #sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); @@ -2052,6 +2076,8 @@

Markdown Viewer

+ +

Markdown Viewer

diff --git a/src/main.ts b/src/main.ts index 424bbcd..e72adc6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,24 +27,30 @@ import { BODY_FONT_KEY, CODE_FONT_KEY, CUSTOM_FONT_SIZE_KEY, + DEFAULT_SIDEBAR_WIDTH, FONTSIZE_KEY, FONT_WEIGHT_KEY, MAX_CUSTOM_FONT_SIZE, + MAX_SIDEBAR_WIDTH, MIN_CUSTOM_FONT_SIZE, + MIN_SIDEBAR_WIDTH, FINDER_OPEN_KEY, OUTLINE_PREF_KEY, + SIDEBAR_WIDTH_KEY, applyBodyFontToDOM, applyCodeFontToDOM, applyCustomFontSizeToDOM, applyFontSizeToDOM, applyFontWeightToDOM, applyOutlinePrefToDOM, + applySidebarWidthToDOM, resolveInitialCustomFontSize, resolveInitialFontFamily, resolveInitialFontSize, resolveInitialFinderOpenPref, resolveInitialFontWeight, resolveInitialOutlinePref, + resolveInitialSidebarWidth, setSegmentActive, type FinderOpenPref, type FontSize, @@ -158,6 +164,9 @@ async function initTheme(): Promise { const savedOutline = resolveInitialOutlinePref( await store.get(OUTLINE_PREF_KEY) ); + const savedSidebarWidth = resolveInitialSidebarWidth( + await store.get(SIDEBAR_WIDTH_KEY) + ); const storeCustoms = sanitizeCustomThemes( await store.get(CUSTOM_THEMES_KEY) @@ -192,6 +201,7 @@ async function initTheme(): Promise { applyActiveTheme(); applyFontSize(savedFontSize); applyOutlinePref(savedOutline); + applySidebarWidthToDOM(document.documentElement, savedSidebarWidth); applyBodyFontToDOM(document.documentElement, currentBodyFont); applyCodeFontToDOM(document.documentElement, currentCodeFont); applyFontWeightToDOM(document.documentElement, currentFontWeight); @@ -457,6 +467,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 +681,63 @@ 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); + }); +} + // --- Preferences modal --- const prefsBackdrop = document.getElementById( @@ -1519,6 +1591,7 @@ async function init(): Promise { await initTheme(); initSearch(); initPreferences(); + initSidebarResizer(); // Runtime opens (hot-start file association, "Open With", CLI events) appWindow.listen("open-folder", (event) => { diff --git a/src/preferences.test.ts b/src/preferences.test.ts index 73d37da..c88cc4f 100644 --- a/src/preferences.test.ts +++ b/src/preferences.test.ts @@ -23,10 +23,16 @@ import { applyCodeFontToDOM, applyFontWeightToDOM, applyCustomFontSizeToDOM, + applySidebarWidthToDOM, + resolveInitialSidebarWidth, + isValidSidebarWidth, effectiveFontSizePx, PRESET_FONT_SIZE_PX, MIN_CUSTOM_FONT_SIZE, MAX_CUSTOM_FONT_SIZE, + MIN_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + DEFAULT_SIDEBAR_WIDTH, } from "./preferences"; describe("resolveDark", () => { @@ -359,3 +365,41 @@ 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"); + }); +}); diff --git a/src/preferences.ts b/src/preferences.ts index 7f3cf47..ab5eb67 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -35,6 +35,11 @@ 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 FONT_WEIGHT_VALUES = [ "300", "400", @@ -172,6 +177,23 @@ 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 setSegmentActive(group: HTMLElement, value: string): void { for (const btn of group.querySelectorAll("button")) { btn.classList.toggle("active", btn.dataset.value === value); From 2a176495921cc05e36096cb8a04b759c466251bf Mon Sep 17 00:00:00 2001 From: "Refone.Mac" <710196926@qq.com> Date: Fri, 25 Sep 2026 17:02:55 +0800 Subject: [PATCH 2/2] feat(outline): drag to resize the right outline --- index.html | 34 ++++++++++++++++++- src/main.ts | 74 +++++++++++++++++++++++++++++++++++++++++ src/preferences.test.ts | 44 ++++++++++++++++++++++++ src/preferences.ts | 22 ++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 25e7d87..e19b8f0 100644 --- a/index.html +++ b/index.html @@ -294,6 +294,31 @@ 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); @@ -1163,7 +1188,8 @@ ); } - html[data-outline="hidden"] #outline { + html[data-outline="hidden"] #outline, + html[data-outline="hidden"] #outline-resizer { display: none !important; } @@ -1804,6 +1830,8 @@ #sidebar, #outline, + #sidebar-resizer, + #outline-resizer, #empty-state, .copy-btn, .mermaid-fullscreen-btn, @@ -1869,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 { @@ -2109,6 +2139,8 @@

Markdown Viewer

+ +
On this page
diff --git a/src/main.ts b/src/main.ts index e72adc6..06eb29f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,15 +27,19 @@ 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, @@ -43,6 +47,7 @@ import { applyFontSizeToDOM, applyFontWeightToDOM, applyOutlinePrefToDOM, + applyOutlineWidthToDOM, applySidebarWidthToDOM, resolveInitialCustomFontSize, resolveInitialFontFamily, @@ -50,6 +55,7 @@ import { resolveInitialFinderOpenPref, resolveInitialFontWeight, resolveInitialOutlinePref, + resolveInitialOutlineWidth, resolveInitialSidebarWidth, setSegmentActive, type FinderOpenPref, @@ -167,6 +173,9 @@ async function initTheme(): Promise { 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) @@ -202,6 +211,7 @@ async function initTheme(): Promise { applyFontSize(savedFontSize); applyOutlinePref(savedOutline); applySidebarWidthToDOM(document.documentElement, savedSidebarWidth); + applyOutlineWidthToDOM(document.documentElement, savedOutlineWidth); applyBodyFontToDOM(document.documentElement, currentBodyFont); applyCodeFontToDOM(document.documentElement, currentCodeFont); applyFontWeightToDOM(document.documentElement, currentFontWeight); @@ -442,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; @@ -738,6 +751,64 @@ function initSidebarResizer(): void { }); } +// --- 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( @@ -1592,6 +1663,7 @@ async function init(): Promise { initSearch(); initPreferences(); initSidebarResizer(); + initOutlineResizer(); // Runtime opens (hot-start file association, "Open With", CLI events) appWindow.listen("open-folder", (event) => { @@ -1995,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 c88cc4f..c7b7d8e 100644 --- a/src/preferences.test.ts +++ b/src/preferences.test.ts @@ -24,8 +24,11 @@ import { applyFontWeightToDOM, applyCustomFontSizeToDOM, applySidebarWidthToDOM, + applyOutlineWidthToDOM, resolveInitialSidebarWidth, isValidSidebarWidth, + resolveInitialOutlineWidth, + isValidOutlineWidth, effectiveFontSizePx, PRESET_FONT_SIZE_PX, MIN_CUSTOM_FONT_SIZE, @@ -33,6 +36,9 @@ import { MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH, + MIN_OUTLINE_WIDTH, + MAX_OUTLINE_WIDTH, + DEFAULT_OUTLINE_WIDTH, } from "./preferences"; describe("resolveDark", () => { @@ -403,3 +409,41 @@ describe("sidebar width", () => { 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 ab5eb67..b12b588 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -40,6 +40,11 @@ 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", @@ -194,6 +199,23 @@ 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);