feat(workspace): focus mode for notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
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:
1 parent
141f87f3a0
commit
226c863a2a
4 files changed
+104
-5
No files matched your search
@@ -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' ? (
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user