feat(workspace): focus mode for notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s

Ctrl+Shift+F or the note menu shows only the note over the whole window
at reading width; the header (title, actions, leave) slides in at the
top edge, Escape leaves; phones get a round exit button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:35:15 +01:00
1 parent 141f87f3a0
commit 226c863a2a
4 files changed
+104 -5

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Added ### Added
- Fokus-Modus für Notizen: `Strg+Umschalt+F` oder „Fokus-Modus starten“ im Drei-Punkte-Menü. Nur die Notiz in angenehmer Lesebreite über dem ganzen Fenster; die Kopfleiste (Titel, Rückgängig, Teilen, Menü, „Beenden“) gleitet herein, wenn die Maus den oberen Rand berührt. `Esc` beendet ihn; am Handy über einen runden Knopf unten rechts.
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`. - **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar. - Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`). - Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
@@ -7,6 +7,7 @@ import {
Trash2, Trash2,
RotateCcw, RotateCcw,
Download, Download,
Maximize2,
FileText, FileText,
Loader2, Loader2,
ArrowLeft, ArrowLeft,
@@ -67,6 +68,9 @@ export interface DocumentEditorProps {
variant?: 'page' | 'journal'; variant?: 'page' | 'journal';
/** Large screens: the action buttons render into this element (the workspace tab bar) */ /** Large screens: the action buttons render into this element (the workspace tab bar) */
actionsTarget?: HTMLElement | null; actionsTarget?: HTMLElement | null;
/** Distraction-free mode: reading width, menu entry to leave it */
focusMode?: boolean;
onToggleFocusMode?: () => void;
} }
// Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header // Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header
@@ -231,6 +235,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDocRemoved, onDocRemoved,
variant = 'page', variant = 'page',
actionsTarget, actionsTarget,
focusMode = false,
onToggleFocusMode,
}) => { }) => {
const inJournal = variant === 'journal'; const inJournal = variant === 'journal';
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false); const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
@@ -971,6 +977,26 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</button> </button>
)} )}
{onToggleFocusMode && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onToggleFocusMode();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
{focusMode ? (
<Minimize2 className="w-3.5 h-3.5 text-accent shrink-0" />
) : (
<Maximize2 className="w-3.5 h-3.5 text-accent shrink-0" />
)}
<span className="flex-1">{focusMode ? 'Fokus-Modus beenden' : 'Fokus-Modus starten'}</span>
<kbd className="hidden sm:inline text-[10px] text-faint font-mono">Strg+⇧+F</kbd>
</button>
)}
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
@@ -1119,7 +1145,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<div <div
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${ className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
inJournal ? 'py-3' : 'py-5' inJournal ? 'py-3' : 'py-5'
}`} } ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
> >
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>} {desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
{zoomedBlock ? ( {zoomedBlock ? (
@@ -24,6 +24,7 @@ import {
AlertTriangle, AlertTriangle,
Loader2, Loader2,
CalendarDays, CalendarDays,
Minimize2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Document, Document,
@@ -428,6 +429,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [docSaveError, setDocSaveError] = useState<string | null>(null); const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null); const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null); const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null);
// Distraction-free mode: only the note, full window, reading width
const [focusMode, setFocusMode] = useState(false);
const [zenActionsTarget, setZenActionsTarget] = useState<HTMLDivElement | null>(null);
// Drag and Drop state // Drag and Drop state
@@ -1035,6 +1039,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId }); writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId });
}, [subView, selectedDocId]); }, [subView, selectedDocId]);
const canFocus = subView === 'editor' && Boolean(selectedDocId);
useEffect(() => {
if (!canFocus) setFocusMode(false);
}, [canFocus]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) {
e.preventDefault();
setFocusMode((v) => !v);
} else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) {
setFocusMode(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [canFocus, focusMode]);
const allFolderPaths = useMemo(() => { const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders); const set = new Set<string>(folders);
for (const d of docs) { for (const d of docs) {
@@ -1551,11 +1572,48 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div> </div>
{/* Main Workspace Split Pane */} {/* Main Workspace Split Pane */}
<div className="flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden"> <div
className={
focusMode
? 'fixed inset-0 z-[45] flex bg-editor focus-mode-enter'
: '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">
<span className="flex-1 min-w-0 truncate text-xs font-medium text-main">
{selectedDocObj?.icon} {selectedDocObj?.title}
</span>
<div ref={setZenActionsTarget} className="flex items-center" />
<button
type="button"
onClick={() => setFocusMode(false)}
className="btn-action h-7 px-2.5 text-xs inline-flex items-center gap-1.5"
title="Fokus-Modus beenden (Esc)"
>
<Minimize2 className="w-3.5 h-3.5" />
Beenden
</button>
</div>
</div>
<button
type="button"
onClick={() => setFocusMode(false)}
className="lg:hidden fixed bottom-4 right-4 z-[46] h-12 w-12 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-main"
title="Fokus-Modus beenden"
aria-label="Fokus-Modus beenden"
>
<Minimize2 className="w-4 h-4" />
</button>
</>
)}
{/* LEFT MASTER SIDEBAR */} {/* LEFT MASTER SIDEBAR */}
<aside <aside
className={`${ className={`${
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed (subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed || focusMode
? 'hidden' ? 'hidden'
: mobileShowDetail : mobileShowDetail
? 'hidden lg:flex' ? 'hidden lg:flex'
@@ -1810,7 +1868,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */} {/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
<main <main
className={`${ className={`${
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex' subView !== 'editor' || mobileShowDetail || isSidebarCollapsed || focusMode ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`} } flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`}
> >
{subView === 'journal' && journalEnabled ? ( {subView === 'journal' && journalEnabled ? (
@@ -1888,7 +1946,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDocSaveError(err || null); setDocSaveError(err || null);
}} }}
onHeaderStatusChange={setDocHeaderStatus} onHeaderStatusChange={setDocHeaderStatus}
actionsTarget={docActionsTarget} actionsTarget={focusMode ? zenActionsTarget : docActionsTarget}
focusMode={focusMode}
onToggleFocusMode={() => setFocusMode((v) => !v)}
/> />
) : subView === 'tags' ? ( ) : subView === 'tags' ? (
/* ========================================================================= /* =========================================================================
+12
View File
@@ -571,3 +571,15 @@ fieldset:disabled :is(button, input, select, textarea) {
--outline-min-text: 0px; --outline-min-text: 0px;
} }
} }
/* Focus mode: the note fades in over the app */
@keyframes focus-mode-in {
from { opacity: 0; transform: scale(0.99); }
to { opacity: 1; transform: none; }
}
.focus-mode-enter {
animation: focus-mode-in 300ms ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.focus-mode-enter { animation: none; }
}