This commit is contained in:
2025-11-12 02:24:46 +01:00
parent 44c982f015
commit 32ef3ced59
12 changed files with 700 additions and 31 deletions
+222 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import { createPortal } from 'react-dom';
import {
ChevronIcon,
@@ -24,6 +24,80 @@ import useFloatingMenu from '../ui/useFloatingMenu';
import { getTagColorStyle } from '../utils/colors';
import { useSidebarContext } from './SidebarContext';
const SIDEBAR_WIDTH_STORAGE_KEY = 'documentsSidebarWidth';
const SIDEBAR_WIDTH_EVENT = 'sidebar-width-change';
const MIN_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = 640;
const isBrowser = typeof window !== 'undefined';
const isDocumentAvailable = typeof document !== 'undefined';
const notifySidebarWidthChange = (width) => {
if (!isBrowser) {
return;
}
try {
window.dispatchEvent(
new CustomEvent(SIDEBAR_WIDTH_EVENT, {
detail: { width },
}),
);
} catch (error) {
console.warn('Failed to dispatch sidebar width change', error);
}
};
const getSidebarBounds = () => {
if (!isBrowser) {
return {
min: MIN_SIDEBAR_WIDTH,
max: MAX_SIDEBAR_WIDTH,
};
}
const viewportWidth = Math.max(window.innerWidth, 1);
const minFractionWidth = viewportWidth / 6;
const maxFractionWidth = viewportWidth / 3;
const rawMin = Math.max(MIN_SIDEBAR_WIDTH, minFractionWidth);
const rawMax = Math.min(MAX_SIDEBAR_WIDTH, maxFractionWidth);
if (rawMin >= rawMax) {
const fallback = Math.min(Math.max(rawMin, viewportWidth / 4), MAX_SIDEBAR_WIDTH);
return { min: fallback, max: fallback };
}
return { min: rawMin, max: rawMax };
};
const clampSidebarWidth = (value) => {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const { min, max } = getSidebarBounds();
return Math.min(Math.max(value, min), max);
};
const loadStoredSidebarWidth = () => {
if (!isBrowser) {
return null;
}
try {
const raw = window.localStorage?.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : null;
} catch (error) {
console.warn('Failed to read sidebar width', error);
return null;
}
};
const applySidebarWidth = (width) => {
if (!isDocumentAvailable || width == null) {
return;
}
document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
notifySidebarWidthChange(width);
};
const FolderNode = ({
node,
depth,
@@ -204,8 +278,137 @@ const Sidebar = ({
themeMode,
cycleThemeMode,
themeModes,
sidebarSuppressed,
} = useSidebarContext();
const uploadInputRef = useRef(null);
const sidebarRef = useRef(null);
const pendingWidthRef = useRef(null);
const [isResizingSidebar, setIsResizingSidebar] = useState(false);
const [sidebarWidthState, setSidebarWidthState] = useState(() => {
if (!isBrowser) {
return null;
}
const stored = loadStoredSidebarWidth();
return stored != null ? clampSidebarWidth(stored) : null;
});
useEffect(() => {
if (sidebarWidthState != null) {
const clamped = clampSidebarWidth(sidebarWidthState);
if (clamped != null) {
applySidebarWidth(clamped);
}
}
}, [sidebarWidthState]);
useEffect(() => {
if (!isBrowser) {
return undefined;
}
const handleResize = () => {
setSidebarWidthState((prev) => {
if (prev == null) {
return prev;
}
const clamped = clampSidebarWidth(prev);
if (clamped != null && clamped !== prev) {
applySidebarWidth(clamped);
try {
window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clamped)));
} catch (error) {
console.warn('Failed to persist sidebar width', error);
}
return clamped;
}
return prev;
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleSidebarResizePointerDown = useCallback((event) => {
if (!isBrowser || !sidebarRef.current) {
return;
}
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const target = event.currentTarget;
target.setPointerCapture?.(pointerId);
setIsResizingSidebar(true);
const rect = sidebarRef.current.getBoundingClientRect();
const startWidth = rect.width;
const startX = event.clientX;
const updateWidth = (nextWidth) => {
const clamped = clampSidebarWidth(nextWidth);
if (clamped != null) {
pendingWidthRef.current = clamped;
applySidebarWidth(clamped);
}
};
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = moveEvent.clientX - startX;
updateWidth(startWidth + delta);
};
const handlePointerUp = (upEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
target.releasePointerCapture?.(pointerId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
setIsResizingSidebar(false);
if (pendingWidthRef.current != null) {
const finalizedWidth = pendingWidthRef.current;
pendingWidthRef.current = null;
setSidebarWidthState(finalizedWidth);
try {
window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth)));
} catch (error) {
console.warn('Failed to persist sidebar width', error);
}
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}, []);
const handleSidebarResizeKeyDown = useCallback((event) => {
if (!isBrowser) {
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
const baseWidth = pendingWidthRef.current
?? sidebarWidthState
?? sidebarRef.current?.getBoundingClientRect().width;
if (!Number.isFinite(baseWidth)) {
return;
}
event.preventDefault();
const step = event.shiftKey ? 40 : 20;
const delta = event.key === 'ArrowRight' ? step : -step;
const nextWidth = clampSidebarWidth(baseWidth + delta);
if (nextWidth == null) {
return;
}
setSidebarWidthState(nextWidth);
try {
window.localStorage?.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(nextWidth)));
} catch (error) {
console.warn('Failed to persist sidebar width', error);
}
}, [sidebarWidthState]);
const handleCollapse = useCallback(() => {
setCollapsed(true);
}, [setCollapsed]);
@@ -547,8 +750,25 @@ const Sidebar = ({
);
const rootNode = folderNodes.get('root');
const sidebarClassNames = ['sidebar'];
if (isResizingSidebar) {
sidebarClassNames.push('sidebar--resizing');
}
if (sidebarSuppressed) {
sidebarClassNames.push('sidebar--suppressed');
}
return (
<aside className="sidebar">
<aside className={sidebarClassNames.join(' ')} ref={sidebarRef}>
<button
type="button"
className={`sidebar__resize-handle${isResizingSidebar ? ' is-active' : ''}`}
aria-label="Resize sidebar"
onPointerDown={handleSidebarResizePointerDown}
onKeyDown={handleSidebarResizeKeyDown}
>
<span aria-hidden="true" />
</button>
<input
type="file"
ref={uploadInputRef}
+18 -2
View File
@@ -139,6 +139,7 @@ const loadInitialCollapsedState = (defaultValue) => {
export const SidebarProvider = ({ initialCollapsed = false, children }) => {
const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed));
const [sidebarSuppressedState, setSidebarSuppressedState] = useState(false);
const initialTheme = useMemo(() => loadInitialThemeSettings(), []);
const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue);
const [neutralChroma, setNeutralChromaState] = useState(initialTheme.neutralChroma);
@@ -283,10 +284,20 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
setCollapsedState(Boolean(value));
}, []);
const setSidebarSuppressed = useCallback((value) => {
if (typeof value === 'function') {
setSidebarSuppressedState((prev) => Boolean(value(prev)));
return;
}
setSidebarSuppressedState(Boolean(value));
}, []);
const contextValue = useMemo(
() => ({
collapsed,
setCollapsed,
sidebarSuppressed: sidebarSuppressedState,
setSidebarSuppressed,
neutralHue,
setNeutralHue,
neutralChroma,
@@ -307,6 +318,8 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
[
collapsed,
setCollapsed,
sidebarSuppressedState,
setSidebarSuppressed,
neutralHue,
setNeutralHue,
neutralChroma,
@@ -334,8 +347,11 @@ export const useSidebarContext = () => {
};
export const useSidebarControls = () => {
const { setCollapsed } = useSidebarContext();
const openSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
const { setCollapsed, setSidebarSuppressed } = useSidebarContext();
const openSidebar = useCallback(() => {
setSidebarSuppressed(false);
setCollapsed(false);
}, [setCollapsed, setSidebarSuppressed]);
const closeSidebar = useCallback(() => setCollapsed(true), [setCollapsed]);
return { openSidebar, closeSidebar };
};