feat(workspace): tab row scrolls away and snaps; pinnable focus-mode bars
A wheel step over the workspace background (tab row, margins) pushes the tab row (notes, to-dos, hashtags) out at the top so the note fills the area, like the Kanban bar; note and sidebar keep scrolling only themselves. In focus mode the header and the folder sidebar can be pinned; the header's shadow is lighter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0dc718919
commit
e618bb5a32
3 files changed
+129
-16
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
Loader2,
|
||||
CalendarDays,
|
||||
Minimize2,
|
||||
PinOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Document,
|
||||
@@ -474,6 +475,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
// Focus mode: the folder sidebar slides in from the left (button, title click or mouse at the edge)
|
||||
const [zenSidebar, setZenSidebar] = useState<'closed' | 'click' | 'hover'>('closed');
|
||||
const [zenActionsTarget, setZenActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||
// Focus mode: header and sidebar can be pinned so they stay instead of sliding back (kept across sessions)
|
||||
const [zenPinTop, setZenPinTop] = useState(() => localStorage.getItem('mindsift_zen_pin_top') === '1');
|
||||
const [zenPinSide, setZenPinSide] = useState(() => localStorage.getItem('mindsift_zen_pin_side') === '1');
|
||||
useEffect(() => {
|
||||
localStorage.setItem('mindsift_zen_pin_top', zenPinTop ? '1' : '0');
|
||||
localStorage.setItem('mindsift_zen_pin_side', zenPinSide ? '1' : '0');
|
||||
}, [zenPinTop, zenPinSide]);
|
||||
const zenSideOpen = zenPinSide || zenSidebar !== 'closed';
|
||||
|
||||
|
||||
// Drag and Drop state
|
||||
@@ -1232,6 +1241,49 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [canFocus, focusMode, zenSidebar]);
|
||||
|
||||
// Desktop: a wheel step down over the background (the tab row, the margins beside and below the workspace)
|
||||
// pushes the tab row (notes, to-dos, hashtags) out at the top and snaps, so the note fills the whole area,
|
||||
// like the bar above the Kanban board; a step up brings it back. Over the note and the folder sidebar the
|
||||
// wheel only scrolls them. The workspace is higher than the app's scroll area by the row (wsBar); that
|
||||
// area stays overflow-hidden and is only scrolled from here.
|
||||
const workspaceRootRef = useRef<HTMLDivElement>(null);
|
||||
const tabRowRef = useRef<HTMLDivElement>(null);
|
||||
const [wsBar, setWsBar] = useState(0);
|
||||
useEffect(() => {
|
||||
const row = tabRowRef.current;
|
||||
if (!row || typeof ResizeObserver === 'undefined') return;
|
||||
// offsetHeight is 0 on phones (the row is hidden there); 12 px is the gap below it
|
||||
const measure = () => setWsBar(row.offsetHeight ? row.offsetHeight + 12 : 0);
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const root = workspaceRootRef.current;
|
||||
const area = root?.closest<HTMLElement>('[data-app-scroll]');
|
||||
if (!root || !area || !wsBar || focusMode) return;
|
||||
let pending = 0; // trackpads send many small steps: move only once they add up
|
||||
let lockedUntil = 0;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.shiftKey || isPhoneWidth()) return;
|
||||
if ((e.target as HTMLElement).closest?.('[data-ws-pane]')) return;
|
||||
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
||||
if (dy === 0) return;
|
||||
const max = area.scrollHeight - area.clientHeight;
|
||||
if (dy > 0 ? area.scrollTop >= max - 1 : area.scrollTop <= 0) return;
|
||||
e.preventDefault();
|
||||
if (Date.now() < lockedUntil) return;
|
||||
pending += dy;
|
||||
if (Math.abs(pending) < 40) return;
|
||||
pending = 0;
|
||||
lockedUntil = Date.now() + 450;
|
||||
area.scrollTo({ top: dy > 0 ? max : 0, behavior: 'smooth' });
|
||||
};
|
||||
area.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => area.removeEventListener('wheel', onWheel);
|
||||
}, [wsBar, focusMode]);
|
||||
|
||||
const allFolderPaths = useMemo(() => {
|
||||
const set = new Set<string>(folders);
|
||||
for (const d of docs) {
|
||||
@@ -1612,10 +1664,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}, [tagsSummary, tagSearchQuery, tagSortBy]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full min-h-0 flex flex-col gap-3">
|
||||
<div
|
||||
ref={workspaceRootRef}
|
||||
className="w-full h-full min-h-0 flex flex-col gap-3"
|
||||
style={wsBar ? { height: `calc(100% + ${wsBar}px)`, flexShrink: 0 } : undefined}
|
||||
>
|
||||
{/* Top Header: Workspace Tabs & Actions */}
|
||||
{/* Phones: the views are switched in the menu at the top, so the row is hidden there */}
|
||||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
<div ref={tabRowRef} className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
{/* Workspace selector tabs & left sidebar toggle */}
|
||||
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
||||
<button
|
||||
@@ -1843,24 +1899,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* Main Workspace Split Pane */}
|
||||
<div
|
||||
data-ws-pane
|
||||
className={
|
||||
focusMode
|
||||
? 'fixed inset-0 z-[45] flex bg-editor focus-mode-enter'
|
||||
? `fixed inset-0 z-[45] flex bg-editor focus-mode-enter ${zenPinTop ? 'lg:pt-[45px]' : ''} ${
|
||||
zenPinSide ? 'lg:pl-80' : ''
|
||||
}`
|
||||
: 'flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden'
|
||||
}
|
||||
>
|
||||
{focusMode && (
|
||||
<>
|
||||
{/* Header slides in when the mouse touches the top edge (large screens) */}
|
||||
<div className="hidden lg:block fixed top-0 inset-x-0 h-3 z-[46] group">
|
||||
<div className="absolute top-0 inset-x-0 flex items-center gap-2 px-6 py-2 bg-panel/95 backdrop-blur-sm border-b border-border shadow-panel -translate-y-full group-hover:translate-y-0 group-focus-within:translate-y-0 transition-transform duration-200">
|
||||
{/* Header slides in when the mouse touches the top edge (large screens); pinned, it stays */}
|
||||
{/* A pinned sidebar takes the full height unless the header is pinned too: the header starts beside it */}
|
||||
<div
|
||||
className={`hidden lg:block fixed top-0 right-0 h-3 z-[46] group ${
|
||||
zenPinSide && !zenPinTop ? 'left-80' : 'left-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0 inset-x-0 flex items-center gap-2 px-6 py-2 bg-panel/95 backdrop-blur-sm border-b border-border shadow-sm transition-transform duration-200 ${
|
||||
zenPinTop ? 'translate-y-0' : '-translate-y-full group-hover:translate-y-0 group-focus-within:translate-y-0'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
|
||||
onClick={() =>
|
||||
zenPinSide ? setZenPinSide(false) : setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))
|
||||
}
|
||||
className="btn-icon h-7 w-7 shrink-0"
|
||||
title="Ordner und Notizen einblenden"
|
||||
aria-label="Ordner und Notizen einblenden"
|
||||
aria-expanded={zenSidebar !== 'closed'}
|
||||
title={zenSideOpen ? 'Ordner und Notizen ausblenden' : 'Ordner und Notizen einblenden'}
|
||||
aria-label={zenSideOpen ? 'Ordner und Notizen ausblenden' : 'Ordner und Notizen einblenden'}
|
||||
aria-expanded={zenSideOpen}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -1873,6 +1943,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{[selectedDocObj?.icon, selectedDocObj?.title].filter(Boolean).join(' ')}
|
||||
</button>
|
||||
<div ref={setZenActionsTarget} className="flex items-center" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
// Unpinned, the header slides away once the mouse leaves (focus would keep it open)
|
||||
if (zenPinTop) e.currentTarget.blur();
|
||||
setZenPinTop((v) => !v);
|
||||
}}
|
||||
className={`btn-icon h-7 w-7 shrink-0 ${zenPinTop ? 'text-accent' : ''}`}
|
||||
title={zenPinTop ? 'Leiste lösen' : 'Leiste anpinnen'}
|
||||
aria-label={zenPinTop ? 'Leiste lösen' : 'Leiste anpinnen'}
|
||||
aria-pressed={zenPinTop}
|
||||
>
|
||||
{zenPinTop ? <PinOff className="w-3.5 h-3.5" /> : <Pin className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocusMode(false)}
|
||||
@@ -1886,11 +1970,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
{/* Wide screens: touching the left edge opens the sidebar (the note sits far from it) */}
|
||||
<div
|
||||
className="hidden xl:block fixed left-0 inset-y-0 w-2 z-[46]"
|
||||
className={`${zenPinSide ? 'hidden' : 'hidden xl:block'} fixed left-0 inset-y-0 w-2 z-[46]`}
|
||||
onMouseEnter={() => setZenSidebar((v) => (v === 'closed' ? 'hover' : v))}
|
||||
aria-hidden
|
||||
/>
|
||||
{zenSidebar === 'click' && (
|
||||
{zenSidebar === 'click' && !zenPinSide && (
|
||||
<div className="fixed inset-0 z-[46] bg-overlay/30" onClick={() => setZenSidebar('closed')} aria-hidden />
|
||||
)}
|
||||
<button
|
||||
@@ -1909,9 +1993,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onMouseLeave={() => setZenSidebar((v) => (v === 'hover' ? 'closed' : v))}
|
||||
className={
|
||||
focusMode
|
||||
? `fixed left-0 inset-y-0 z-[47] w-80 max-w-[85vw] flex flex-col bg-sidebar border-r border-border shadow-panel transition-transform duration-300 ease-out ${
|
||||
zenSidebar !== 'closed' ? 'translate-x-0' : '-translate-x-full'
|
||||
}`
|
||||
? // Pinned: from lg on it stays open beside the note (below a pinned header), without shadow
|
||||
`fixed left-0 inset-y-0 z-[47] w-80 max-w-[85vw] flex flex-col bg-sidebar border-r border-border transition-transform duration-300 ease-out ${
|
||||
zenSidebar !== 'closed'
|
||||
? 'translate-x-0 shadow-panel'
|
||||
: zenPinSide
|
||||
? '-translate-x-full lg:translate-x-0'
|
||||
: '-translate-x-full'
|
||||
} ${zenPinSide && zenPinTop ? 'lg:top-[45px]' : ''}`
|
||||
: `${
|
||||
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
|
||||
? 'hidden'
|
||||
@@ -1956,6 +2045,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Focus mode: pin the sliding sidebar so it stays beside the note */}
|
||||
{focusMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Unpinned, it stays until the mouse leaves it
|
||||
setZenSidebar(zenPinSide ? 'hover' : 'closed');
|
||||
setZenPinSide((v) => !v);
|
||||
}}
|
||||
className={`hidden lg:inline-flex btn-icon h-8 w-8 ${zenPinSide ? 'text-accent' : ''}`}
|
||||
title={zenPinSide ? 'Seitenleiste lösen' : 'Seitenleiste anpinnen'}
|
||||
aria-label={zenPinSide ? 'Seitenleiste lösen' : 'Seitenleiste anpinnen'}
|
||||
aria-pressed={zenPinSide}
|
||||
>
|
||||
{zenPinSide ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user