diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f54a80..1d1a4108 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -122,6 +122,9 @@ pub struct Settings { pub interface_zoom: Option, #[serde(rename = "customEditorWidthPx")] pub custom_editor_width_px: Option, + /// Custom sidebar width in px; `None` means the default width is used. + #[serde(rename = "sidebarWidthPx")] + pub sidebar_width_px: Option, #[serde(rename = "ollamaModel")] pub ollama_model: Option, #[serde(rename = "foldersEnabled")] diff --git a/src/App.css b/src/App.css index 3b3904d9..8911c87c 100644 --- a/src/App.css +++ b/src/App.css @@ -1054,6 +1054,11 @@ table.not-prose th { transition: none !important; } +/* Suppress sidebar transition during drag resize */ +.sidebar-no-transition [data-sidebar] { + transition: none !important; +} + /* Print styles for clean PDF export */ @page { margin: 0.75in; diff --git a/src/App.tsx b/src/App.tsx index 6bbf132b..b5616821 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { listen } from "@tauri-apps/api/event"; import { GitProvider } from "./context/GitContext"; import { TooltipProvider, Toaster } from "./components/ui"; import { Sidebar } from "./components/layout/Sidebar"; +import { SidebarResizeHandle } from "./components/layout/SidebarResizeHandle"; import { Editor } from "./components/editor/Editor"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { FolderPicker } from "./components/layout/FolderPicker"; @@ -472,9 +473,11 @@ function AppContent() { <>
+ {sidebarVisible && !focusMode && }
setDragLabel(null)} > -
+
{/* Drag region */}
diff --git a/src/components/layout/SidebarResizeHandle.tsx b/src/components/layout/SidebarResizeHandle.tsx new file mode 100644 index 00000000..7b8cad96 --- /dev/null +++ b/src/components/layout/SidebarResizeHandle.tsx @@ -0,0 +1,156 @@ +import { useCallback, useRef, useState } from "react"; +import { useTheme } from "../../context/ThemeContext"; +import { cn } from "../../lib/utils"; +import { SIDEBAR_MIN_PX, SIDEBAR_MAX_PX } from "../../lib/sidebar"; + +/** + * Drag handle rendered on the right edge of the sidebar. + * Supports pointer drag to resize, keyboard arrow keys (Shift = large step), and double-click to reset width. + */ +export function SidebarResizeHandle() { + const { sidebarWidthPx, setSidebarWidthPx, setSidebarWidthLive } = useTheme(); + + const [isDragging, setIsDragging] = useState(false); + const [currentWidth, setCurrentWidth] = useState(0); + + const dragState = useRef<{ + startX: number; + initialWidth: number; + } | null>(null); + + /** Captures the pointer and records the drag start position and initial width. */ + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + (e.currentTarget as HTMLElement).focus(); + + const initialWidth = + sidebarWidthPx ?? (e.currentTarget as HTMLElement).parentElement!.offsetWidth; + dragState.current = { + startX: e.clientX, + initialWidth, + }; + setIsDragging(true); + setCurrentWidth(initialWidth); + document.documentElement.classList.add("sidebar-no-transition"); + }, + [sidebarWidthPx], + ); + + /** Updates the CSS variable live during drag without persisting. */ + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragState.current) return; + const { startX, initialWidth } = dragState.current; + + const delta = e.clientX - startX; + const newWidth = initialWidth + delta; + const clamped = Math.round( + Math.min(Math.max(newWidth, SIDEBAR_MIN_PX), SIDEBAR_MAX_PX), + ); + + setSidebarWidthLive(clamped); + setCurrentWidth(clamped); + }, + [setSidebarWidthLive], + ); + + /** Releases pointer capture and persists the final width to settings. */ + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!dragState.current) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + document.documentElement.classList.remove("sidebar-no-transition"); + + setSidebarWidthPx(currentWidth); + dragState.current = null; + setIsDragging(false); + }, + [currentWidth, setSidebarWidthPx], + ); + + /** Aborts the drag on pointer cancel without saving any width change. */ + const handlePointerCancel = useCallback(() => { + if (!dragState.current) return; + document.documentElement.classList.remove("sidebar-no-transition"); + dragState.current = null; + setIsDragging(false); + }, []); + + /** Resets the sidebar to its default width (removes the override). */ + const handleDoubleClick = useCallback(() => { + document.documentElement.classList.remove("sidebar-no-transition"); + setSidebarWidthPx(null); + }, [setSidebarWidthPx]); + + /** Adjusts width with arrow keys (16 px step, 64 px with Shift); Home/End jump to bounds. */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 64 : 16; + const base = + sidebarWidthPx ?? (e.currentTarget as HTMLElement).parentElement!.offsetWidth; + let next: number | null = null; + + switch (e.key) { + case "ArrowLeft": + next = base - step; + break; + case "ArrowRight": + next = base + step; + break; + case "Home": + next = SIDEBAR_MIN_PX; + break; + case "End": + next = SIDEBAR_MAX_PX; + break; + default: + return; + } + + e.preventDefault(); + setSidebarWidthPx(next); + }, + [sidebarWidthPx, setSidebarWidthPx], + ); + + return ( +
+
+ {isDragging && ( +
+
+ {Math.round(currentWidth)}px +
+
+ )} +
+ ); +} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index f6b3472b..6448be89 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getSettings, updateSettings } from "../services/notes"; +import { SIDEBAR_MIN_PX, SIDEBAR_MAX_PX } from "../lib/sidebar"; import type { ThemeSettings, EditorFontSettings, @@ -112,6 +113,9 @@ interface ThemeContextType { customEditorWidthPx: number; setCustomEditorWidthPx: (px: number) => void; setEditorMaxWidthLive: (value: string) => void; + sidebarWidthPx: number | null; + setSidebarWidthPx: (px: number | null) => void; + setSidebarWidthLive: (px: number) => void; customColorsLight: CustomColors; customColorsDark: CustomColors; setCustomColor: (mode: "light" | "dark", key: ThemeColorKey, value: string) => void; @@ -187,6 +191,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const [customEditorWidthPx, setCustomEditorWidthPxState] = useState( DEFAULT_CUSTOM_WIDTH_PX ); + const [sidebarWidthPx, setSidebarWidthPxState] = useState(null); const [customColorsLight, setCustomColorsLightState] = useState({}); const [customColorsDark, setCustomColorsDarkState] = useState({}); const [isInitialized, setIsInitialized] = useState(false); @@ -242,6 +247,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) { ) { setCustomEditorWidthPxState(settings.customEditorWidthPx); } + if ( + typeof settings.sidebarWidthPx === "number" && + settings.sidebarWidthPx >= SIDEBAR_MIN_PX && + settings.sidebarWidthPx <= SIDEBAR_MAX_PX + ) { + setSidebarWidthPxState(settings.sidebarWidthPx); + } if (settings.customColorsLight) { setCustomColorsLightState(settings.customColorsLight); } @@ -329,6 +341,15 @@ export function ThemeProvider({ children }: ThemeProviderProps) { applyLayoutCSSVariables(editorWidth, customEditorWidthPx); }, [editorWidth, customEditorWidthPx]); + // Apply sidebar width CSS variable whenever it changes (null = no override, fallback to 16rem) + useEffect(() => { + if (sidebarWidthPx === null) { + document.documentElement.style.removeProperty("--sidebar-width"); + } else { + document.documentElement.style.setProperty("--sidebar-width", `${sidebarWidthPx}px`); + } + }, [sidebarWidthPx]); + // Apply interface zoom whenever it changes (suppress transitions during zoom) useEffect(() => { const root = document.documentElement; @@ -378,6 +399,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setEditorWidthState("normal"); setInterfaceZoomState(1.0); setCustomEditorWidthPxState(DEFAULT_CUSTOM_WIDTH_PX); + setSidebarWidthPxState(null); setCustomColorsLightState({}); setCustomColorsDarkState({}); try { @@ -389,6 +411,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { editorWidth: "normal", interfaceZoom: 1.0, customEditorWidthPx: undefined, + sidebarWidthPx: undefined, customColorsLight: undefined, customColorsDark: undefined, }); @@ -461,6 +484,32 @@ export function ThemeProvider({ children }: ThemeProviderProps) { } }, []); + /** + * Persists the clamped sidebar width to settings. + * Pass `null` to remove the override and fall back to the CSS default. + */ + const setSidebarWidthPx = useCallback(async (px: number | null) => { + if (px === null) { + setSidebarWidthPxState(null); + document.documentElement.style.removeProperty("--sidebar-width"); + try { + const settings = await getSettings(); + await updateSettings({ ...settings, sidebarWidthPx: undefined }); + } catch (error) { + console.error("Failed to reset sidebar width:", error); + } + } else { + const clamped = Math.round(Math.min(Math.max(px, SIDEBAR_MIN_PX), SIDEBAR_MAX_PX)); + setSidebarWidthPxState(clamped); + try { + const settings = await getSettings(); + await updateSettings({ ...settings, sidebarWidthPx: clamped }); + } catch (error) { + console.error("Failed to save sidebar width:", error); + } + } + }, []); + // Apply custom color CSS variable overrides whenever theme or colors change useEffect(() => { const root = document.documentElement; @@ -549,6 +598,11 @@ export function ThemeProvider({ children }: ThemeProviderProps) { document.documentElement.style.setProperty("--editor-max-width", value); }, []); + /** Updates `--sidebar-width` CSS variable immediately during drag without writing to settings. */ + const setSidebarWidthLive = useCallback((px: number) => { + document.documentElement.style.setProperty("--sidebar-width", `${px}px`); + }, []); + // Don't render until initialized to prevent flash if (!isInitialized) { return null; @@ -574,6 +628,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) { customEditorWidthPx, setCustomEditorWidthPx, setEditorMaxWidthLive, + sidebarWidthPx, + setSidebarWidthPx, + setSidebarWidthLive, customColorsLight, customColorsDark, setCustomColor, diff --git a/src/lib/sidebar.ts b/src/lib/sidebar.ts new file mode 100644 index 00000000..f4141dcc --- /dev/null +++ b/src/lib/sidebar.ts @@ -0,0 +1,4 @@ +/** Minimum allowed sidebar width in pixels. */ +export const SIDEBAR_MIN_PX = 180; +/** Maximum allowed sidebar width in pixels. */ +export const SIDEBAR_MAX_PX = 800; diff --git a/src/types/note.ts b/src/types/note.ts index 37addc1b..387a9b61 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -53,6 +53,7 @@ export interface Settings { textDirection?: TextDirection; editorWidth?: EditorWidth; customEditorWidthPx?: number; + sidebarWidthPx?: number; defaultNoteName?: string; interfaceZoom?: number; ollamaModel?: string;