Skip to content
Open
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
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ pub struct Settings {
pub interface_zoom: Option<f32>,
#[serde(rename = "customEditorWidthPx")]
pub custom_editor_width_px: Option<u32>,
/// Custom sidebar width in px; `None` means the default width is used.
#[serde(rename = "sidebarWidthPx")]
pub sidebar_width_px: Option<u32>,
#[serde(rename = "ollamaModel")]
pub ollama_model: Option<String>,
#[serde(rename = "foldersEnabled")]
Expand Down
5 changes: 5 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -472,9 +473,11 @@ function AppContent() {
<>
<div
data-sidebar
className={`transition-all duration-500 ease-out overflow-hidden ${!sidebarVisible || focusMode ? "opacity-0 -translate-x-4 w-0 pointer-events-none" : "opacity-100 translate-x-0 w-64"}`}
style={{ width: (!sidebarVisible || focusMode) ? 0 : "var(--sidebar-width, 16rem)" }}
className={`relative transition-all duration-500 ease-out overflow-hidden ${!sidebarVisible || focusMode ? "opacity-0 -translate-x-4 pointer-events-none" : "opacity-100 translate-x-0"}`}
>
<Sidebar onOpenSettings={toggleSettings} />
{sidebarVisible && !focusMode && <SidebarResizeHandle />}
</div>
<Editor
onToggleSidebar={toggleSidebar}
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
onDragEnd={handleDragEnd}
onDragCancel={() => setDragLabel(null)}
>
<div className="relative w-64 h-full bg-bg-secondary border-r border-border flex flex-col select-none">
<div className="relative w-full h-full bg-bg-secondary border-r border-border flex flex-col select-none">
{/* Drag region */}
<div className="h-11 shrink-0" data-tauri-drag-region></div>
<div className="flex items-center justify-between pl-4 pr-3 pb-2 border-b border-border shrink-0">
Expand Down
156 changes: 156 additions & 0 deletions src/components/layout/SidebarResizeHandle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar width"
aria-valuenow={sidebarWidthPx ?? undefined}
aria-valuemin={SIDEBAR_MIN_PX}
aria-valuemax={SIDEBAR_MAX_PX}
tabIndex={0}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize pointer-events-auto group outline-none",
isDragging && "z-20",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
>
<div
className={cn(
"absolute left-1/2 -translate-x-1/2 top-0 h-full w-0.75 rounded-full bg-border transition-opacity duration-150",
isDragging
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus:opacity-100",
)}
/>
{isDragging && (
<div className="absolute top-3 right-2 z-50">
<div className="bg-text text-text-inverse text-xs font-medium px-2.5 py-1 rounded-md shadow-lg whitespace-nowrap">
{Math.round(currentWidth)}px
</div>
</div>
)}
</div>
);
}
57 changes: 57 additions & 0 deletions src/context/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -187,6 +191,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
const [customEditorWidthPx, setCustomEditorWidthPxState] = useState<number>(
DEFAULT_CUSTOM_WIDTH_PX
);
const [sidebarWidthPx, setSidebarWidthPxState] = useState<number | null>(null);
const [customColorsLight, setCustomColorsLightState] = useState<CustomColors>({});
const [customColorsDark, setCustomColorsDarkState] = useState<CustomColors>({});
const [isInitialized, setIsInitialized] = useState(false);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -378,6 +399,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
setEditorWidthState("normal");
setInterfaceZoomState(1.0);
setCustomEditorWidthPxState(DEFAULT_CUSTOM_WIDTH_PX);
setSidebarWidthPxState(null);
setCustomColorsLightState({});
setCustomColorsDarkState({});
try {
Expand All @@ -389,6 +411,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
editorWidth: "normal",
interfaceZoom: 1.0,
customEditorWidthPx: undefined,
sidebarWidthPx: undefined,
customColorsLight: undefined,
customColorsDark: undefined,
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -574,6 +628,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
customEditorWidthPx,
setCustomEditorWidthPx,
setEditorMaxWidthLive,
sidebarWidthPx,
setSidebarWidthPx,
setSidebarWidthLive,
customColorsLight,
customColorsDark,
setCustomColor,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/sidebar.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/types/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface Settings {
textDirection?: TextDirection;
editorWidth?: EditorWidth;
customEditorWidthPx?: number;
sidebarWidthPx?: number;
defaultNoteName?: string;
interfaceZoom?: number;
ollamaModel?: string;
Expand Down