feat(workspace): sliding sidebar in focus mode, calmer workspace bar, smoother journal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m47s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m47s
- focus mode: folders slide in from the left (button, title click, or the left edge on wide screens); picking a note keeps focus mode - workspace bar: borderless live/shared/save pills in one style, divider before the note actions; footer without top line - journal panel and calendar open with an animation, the view fades in - paste and markdown import nest a list under the paragraph introducing it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
226c863a2a
commit
b136e5dfad
9 files changed
+164
-41
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: Ordner und Notizen gleiten von links herein – über den Ordner-Knopf oder einen Klick auf den Titel in der Kopfleiste, auf breiten Bildschirmen auch, wenn die Maus den linken Rand berührt. Eine andere Notiz öffnet sich, ohne den Fokus-Modus zu verlassen; `Esc` schließt zuerst die Leiste.
|
||||||
- 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.
|
- 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.
|
||||||
@@ -15,6 +16,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
|
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber.
|
||||||
|
- Journal: Panel und Kalender öffnen sich mit einer sanften Animation, die Journal-Ansicht blendet ein.
|
||||||
|
- Einfügen und Markdown-Import: Eine Liste direkt nach einem Absatz („Folgende Punkte:“) wird unter diesem Absatz eingerückt.
|
||||||
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
|
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
|
||||||
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
|
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
|
||||||
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
|
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface AppFooterProps {
|
|||||||
|
|
||||||
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
|
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
|
||||||
return (
|
return (
|
||||||
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-2 items-center justify-between text-[11px] text-faint border-t border-border/40 select-none">
|
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-2 items-center justify-between text-[11px] text-faint select-none">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const JournalView: React.FC<JournalViewProps> = ({
|
|||||||
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
|
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 bg-editor">
|
<div className="flex-1 flex flex-col min-h-0 bg-editor view-enter">
|
||||||
{/* One header for all days: the day in view and its sharing */}
|
{/* One header for all days: the day in view and its sharing */}
|
||||||
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
|
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -431,6 +431,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null);
|
const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||||
// Distraction-free mode: only the note, full window, reading width
|
// Distraction-free mode: only the note, full window, reading width
|
||||||
const [focusMode, setFocusMode] = useState(false);
|
const [focusMode, setFocusMode] = useState(false);
|
||||||
|
// 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);
|
const [zenActionsTarget, setZenActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|
||||||
@@ -1043,18 +1045,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canFocus) setFocusMode(false);
|
if (!canFocus) setFocusMode(false);
|
||||||
}, [canFocus]);
|
}, [canFocus]);
|
||||||
|
// Leaving focus mode or picking another note closes the sliding sidebar
|
||||||
|
useEffect(() => {
|
||||||
|
setZenSidebar('closed');
|
||||||
|
}, [focusMode, selectedDocId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) {
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFocusMode((v) => !v);
|
setFocusMode((v) => !v);
|
||||||
} else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) {
|
} else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) {
|
||||||
setFocusMode(false);
|
if (zenSidebar !== 'closed') setZenSidebar('closed');
|
||||||
|
else setFocusMode(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [canFocus, focusMode]);
|
}, [canFocus, focusMode, zenSidebar]);
|
||||||
|
|
||||||
const allFolderPaths = useMemo(() => {
|
const allFolderPaths = useMemo(() => {
|
||||||
const set = new Set<string>(folders);
|
const set = new Set<string>(folders);
|
||||||
@@ -1451,7 +1458,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* Live connection pill */}
|
{/* Live connection pill */}
|
||||||
{docHeaderStatus?.showLive && (
|
{docHeaderStatus?.showLive && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
||||||
title={
|
title={
|
||||||
docHeaderStatus.channel === 'ws'
|
docHeaderStatus.channel === 'ws'
|
||||||
? 'Live-Verbindung über WebSocket'
|
? 'Live-Verbindung über WebSocket'
|
||||||
@@ -1493,7 +1500,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDocShareModal(selectedDocObj)}
|
onClick={() => openDocShareModal(selectedDocObj)}
|
||||||
className="px-2 py-0.5 rounded-btn text-[11px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-semibold text-accent hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||||
>
|
>
|
||||||
<Users className="w-2.5 h-2.5" />
|
<Users className="w-2.5 h-2.5" />
|
||||||
@@ -1503,7 +1510,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDocShareModal(selectedDocObj)}
|
onClick={() => openDocShareModal(selectedDocObj)}
|
||||||
className="px-2 py-0.5 rounded-btn text-[11px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-medium text-muted hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||||
title="Teilen & Berechtigungen öffnen"
|
title="Teilen & Berechtigungen öffnen"
|
||||||
>
|
>
|
||||||
<Users className="w-2.5 h-2.5" />
|
<Users className="w-2.5 h-2.5" />
|
||||||
@@ -1524,14 +1531,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
? 'Ungespeichert'
|
? 'Ungespeichert'
|
||||||
: 'Fehler beim Speichern'
|
: 'Fehler beim Speichern'
|
||||||
}
|
}
|
||||||
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn border transition-colors ${
|
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn transition-colors ${
|
||||||
docSaveStatus === 'saved'
|
docSaveStatus === 'saved'
|
||||||
? 'bg-canvas border-border text-muted'
|
? 'bg-panel'
|
||||||
: docSaveStatus === 'saving'
|
: docSaveStatus === 'saving'
|
||||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
? 'bg-panel animate-pulse'
|
||||||
: docSaveStatus === 'unsaved'
|
: docSaveStatus === 'unsaved'
|
||||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
? 'bg-status-warn/10'
|
||||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
: 'bg-status-err/10'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
docSaveStatus === 'saved'
|
docSaveStatus === 'saved'
|
||||||
@@ -1567,6 +1574,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{/* The open note renders undo/redo, share and its menu here on large screens */}
|
{/* The open note renders undo/redo, share and its menu here on large screens */}
|
||||||
|
{subView === 'editor' && selectedDocId && (
|
||||||
|
<span className="hidden lg:block w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||||
|
)}
|
||||||
<div ref={setDocActionsTarget} className="hidden lg:flex items-center" />
|
<div ref={setDocActionsTarget} className="hidden lg:flex items-center" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1584,9 +1594,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* Header slides in when the mouse touches the top edge (large screens) */}
|
{/* 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="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">
|
<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">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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'}
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
|
||||||
|
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
|
||||||
|
title="Andere Notiz wählen"
|
||||||
|
>
|
||||||
{selectedDocObj?.icon} {selectedDocObj?.title}
|
{selectedDocObj?.icon} {selectedDocObj?.title}
|
||||||
</span>
|
</button>
|
||||||
<div ref={setZenActionsTarget} className="flex items-center" />
|
<div ref={setZenActionsTarget} className="flex items-center" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1599,6 +1624,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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]"
|
||||||
|
onMouseEnter={() => setZenSidebar((v) => (v === 'closed' ? 'hover' : v))}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{zenSidebar === 'click' && (
|
||||||
|
<div className="fixed inset-0 z-[46] bg-overlay/30" onClick={() => setZenSidebar('closed')} aria-hidden />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFocusMode(false)}
|
onClick={() => setFocusMode(false)}
|
||||||
@@ -1612,13 +1646,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
{/* LEFT MASTER SIDEBAR */}
|
{/* LEFT MASTER SIDEBAR */}
|
||||||
<aside
|
<aside
|
||||||
className={`${
|
onMouseLeave={() => setZenSidebar((v) => (v === 'hover' ? 'closed' : v))}
|
||||||
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed || focusMode
|
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'
|
||||||
|
}`
|
||||||
|
: `${
|
||||||
|
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: mobileShowDetail
|
: mobileShowDetail
|
||||||
? 'hidden lg:flex'
|
? 'hidden lg:flex'
|
||||||
: 'flex'
|
: 'flex'
|
||||||
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`}
|
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Sidebar header: search and the create buttons in one row */}
|
{/* Sidebar header: search and the create buttons in one row */}
|
||||||
<div className="p-3 border-b border-border flex items-center gap-1.5">
|
<div className="p-3 border-b border-border flex items-center gap-1.5">
|
||||||
@@ -1660,38 +1701,53 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close) */}
|
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close).
|
||||||
|
Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. */}
|
||||||
{journalEnabled && (
|
{journalEnabled && (
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={`rounded-card border-2 transition-[background-color,border-color,padding] duration-300 ease-out ${
|
||||||
subView === 'journal'
|
subView === 'journal' ? 'border-accent/40 bg-panel-elevated p-2.5' : 'border-transparent p-0'
|
||||||
? 'rounded-card border-2 border-accent/40 bg-panel-elevated p-2.5'
|
}`}
|
||||||
: 'border-b border-border pb-1.5 -mb-1.5'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleJournal}
|
onClick={toggleJournal}
|
||||||
aria-expanded={subView === 'journal'}
|
aria-expanded={subView === 'journal'}
|
||||||
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
|
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
|
||||||
className={`w-full flex items-center gap-2 text-left cursor-pointer transition-colors ${
|
className={`w-full flex items-center gap-2 text-left cursor-pointer rounded-btn transition-all duration-300 ${
|
||||||
subView === 'journal'
|
subView === 'journal'
|
||||||
? 'h-11 sm:h-7 px-0.5 text-sm font-bold text-main'
|
? 'h-11 sm:h-7 px-0.5 text-sm font-bold text-main'
|
||||||
: 'h-11 sm:h-8 px-2 rounded-btn text-xs text-main border border-transparent hover:bg-panel-elevated'
|
: 'h-11 sm:h-8 px-2 text-xs text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CalendarDays className={subView === 'journal' ? 'w-4 h-4 text-accent' : 'w-3.5 h-3.5 text-accent'} />
|
<CalendarDays
|
||||||
|
className={`text-accent transition-all duration-300 ${subView === 'journal' ? 'w-4 h-4' : 'w-3.5 h-3.5'}`}
|
||||||
|
/>
|
||||||
<span>Journal</span>
|
<span>Journal</span>
|
||||||
</button>
|
</button>
|
||||||
{subView === 'journal' && (
|
<div
|
||||||
<div className="hidden lg:block pt-2">
|
className={`hidden lg:grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
|
||||||
|
subView === 'journal' ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||||
|
}`}
|
||||||
|
aria-hidden={subView !== 'journal'}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden min-h-0">
|
||||||
|
<div className="pt-2">
|
||||||
<JournalCalendar
|
<JournalCalendar
|
||||||
activeDate={journalActive}
|
activeDate={journalActive}
|
||||||
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
||||||
onSelect={jumpToJournalDay}
|
onSelect={jumpToJournalDay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`h-px bg-border mt-1.5 transition-opacity duration-300 ${
|
||||||
|
subView === 'journal' ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||||
|
|||||||
@@ -583,3 +583,15 @@ fieldset:disabled :is(button, input, select, textarea) {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.focus-mode-enter { animation: none; }
|
.focus-mode-enter { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Views that replace each other (journal ↔ note) fade in instead of jumping */
|
||||||
|
@keyframes view-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.view-enter {
|
||||||
|
animation: view-in 280ms ease-out;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.view-enter { animation: none; }
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ describe('parsePastedLines', () => {
|
|||||||
expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([
|
expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([
|
||||||
{ type: 'h1', content: 'Titel', indent: 0 },
|
{ type: 'h1', content: 'Titel', indent: 0 },
|
||||||
{ type: 'text', content: 'Text danach', indent: 1 },
|
{ type: 'text', content: 'Text danach', indent: 1 },
|
||||||
{ type: 'todo', checked: true, content: 'erledigt', indent: 1 },
|
{ type: 'todo', checked: true, content: 'erledigt', indent: 2 },
|
||||||
{ type: 'text', content: 'Punkt', indent: 1 },
|
{ type: 'text', content: 'Punkt', indent: 2 },
|
||||||
{ type: 'text', content: 'tiefer', indent: 2 },
|
{ type: 'text', content: 'tiefer', indent: 3 },
|
||||||
{ type: 'divider', content: '---', indent: 0 },
|
{ type: 'divider', content: '---', indent: 0 },
|
||||||
{ type: 'text', content: 'Ohne Abschnitt', indent: 0 },
|
{ type: 'text', content: 'Ohne Abschnitt', indent: 0 },
|
||||||
]);
|
]);
|
||||||
@@ -49,4 +49,17 @@ describe('parsePastedLines', () => {
|
|||||||
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
|
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
|
||||||
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
|
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('nests a list under the paragraph that introduces it', () => {
|
||||||
|
expect(parse('# Plan\nFolgende Punkte:\n\n- eins\n - tiefer\n- zwei\n\nDanach Text\n1. a\n2. b')).toEqual([
|
||||||
|
{ type: 'h1', content: 'Plan', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Folgende Punkte:', indent: 1 },
|
||||||
|
{ type: 'text', content: 'eins', indent: 2 },
|
||||||
|
{ type: 'text', content: 'tiefer', indent: 3 },
|
||||||
|
{ type: 'text', content: 'zwei', indent: 2 },
|
||||||
|
{ type: 'text', content: 'Danach Text', indent: 1 },
|
||||||
|
{ type: 'numbered', content: 'a', indent: 2 },
|
||||||
|
{ type: 'numbered', content: 'b', indent: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -33,6 +33,11 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
|||||||
let inCode = false;
|
let inCode = false;
|
||||||
let afterHeading = false;
|
let afterHeading = false;
|
||||||
const headings: { rank: number; level: number }[] = []; // open headings, innermost last
|
const headings: { rank: number; level: number }[] = []; // open headings, innermost last
|
||||||
|
// A list right after a paragraph ("Folgende Punkte:") nests under it: level of that paragraph,
|
||||||
|
// and the extra indent of the current list run
|
||||||
|
let paragraphLevel: number | null = null;
|
||||||
|
let listBoost = 0;
|
||||||
|
let inList = false;
|
||||||
const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0);
|
const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0);
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
@@ -57,6 +62,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
|||||||
flush();
|
flush();
|
||||||
current = { type: 'text', content: rawLine, indent: sectionLevel() };
|
current = { type: 'text', content: rawLine, indent: sectionLevel() };
|
||||||
afterHeading = false;
|
afterHeading = false;
|
||||||
|
paragraphLevel = null;
|
||||||
|
inList = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +81,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
|||||||
if (heading) {
|
if (heading) {
|
||||||
flush();
|
flush();
|
||||||
afterHeading = true;
|
afterHeading = true;
|
||||||
|
paragraphLevel = null;
|
||||||
|
inList = false;
|
||||||
const rank = heading[1].length;
|
const rank = heading[1].length;
|
||||||
while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop();
|
while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop();
|
||||||
const level = sectionLevel();
|
const level = sectionLevel();
|
||||||
@@ -85,6 +94,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
|||||||
flush();
|
flush();
|
||||||
afterHeading = false;
|
afterHeading = false;
|
||||||
headings.length = 0; // a divider closes the sections
|
headings.length = 0; // a divider closes the sections
|
||||||
|
paragraphLevel = null;
|
||||||
|
inList = false;
|
||||||
items.push({ type: 'divider', content: '---', indent: 0 });
|
items.push({ type: 'divider', content: '---', indent: 0 });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -95,11 +106,16 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
|||||||
if (todo || bullet || numbered) {
|
if (todo || bullet || numbered) {
|
||||||
flush();
|
flush();
|
||||||
afterHeading = false;
|
afterHeading = false;
|
||||||
|
if (!inList) {
|
||||||
|
inList = true;
|
||||||
|
listBoost = paragraphLevel !== null && indent <= paragraphLevel ? paragraphLevel + 1 - indent : 0;
|
||||||
|
}
|
||||||
|
const level = indent + listBoost;
|
||||||
current = todo
|
current = todo
|
||||||
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent }
|
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent: level }
|
||||||
: numbered
|
: numbered
|
||||||
? { type: 'numbered', content: numbered[1], indent }
|
? { type: 'numbered', content: numbered[1], indent: level }
|
||||||
: { type: 'text', content: bullet![1], indent };
|
: { type: 'text', content: bullet![1], indent: level };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +125,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
|||||||
} else {
|
} else {
|
||||||
flush();
|
flush();
|
||||||
current = { type: 'text', content: line, indent };
|
current = { type: 'text', content: line, indent };
|
||||||
|
paragraphLevel = indent;
|
||||||
|
inList = false;
|
||||||
}
|
}
|
||||||
afterHeading = false;
|
afterHeading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,14 +744,22 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper
|
var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper
|
||||||
var indents []int // indent widths of the open list levels
|
var indents []int // indent widths of the open list levels
|
||||||
inList := false // the previous line belonged to a list item (for continuation lines)
|
inList := false // the previous line belonged to a list item (for continuation lines)
|
||||||
|
// A list right after a paragraph ("Folgende Punkte:") nests under that paragraph
|
||||||
|
afterParagraph, listBase := false, 0
|
||||||
listLevel := func(width int) int {
|
listLevel := func(width int) int {
|
||||||
|
if len(indents) == 0 {
|
||||||
|
listBase = base
|
||||||
|
if afterParagraph {
|
||||||
|
listBase = base + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
for len(indents) > 0 && indents[len(indents)-1] > width {
|
for len(indents) > 0 && indents[len(indents)-1] > width {
|
||||||
indents = indents[:len(indents)-1]
|
indents = indents[:len(indents)-1]
|
||||||
}
|
}
|
||||||
if len(indents) == 0 || indents[len(indents)-1] < width {
|
if len(indents) == 0 || indents[len(indents)-1] < width {
|
||||||
indents = append(indents, width)
|
indents = append(indents, width)
|
||||||
}
|
}
|
||||||
return base + len(indents) - 1
|
return listBase + len(indents) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < len(lines); i++ {
|
for i := 0; i < len(lines); i++ {
|
||||||
@@ -781,7 +789,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
b := newBlock("code", strings.Join(code, "\n"), level)
|
b := newBlock("code", strings.Join(code, "\n"), level)
|
||||||
b.Lang = lang
|
b.Lang = lang
|
||||||
blocks = append(blocks, b)
|
blocks = append(blocks, b)
|
||||||
inList = false
|
inList, afterParagraph = false, false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,14 +807,14 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
}
|
}
|
||||||
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings)))
|
blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings)))
|
||||||
headings = append(headings, rank)
|
headings = append(headings, rank)
|
||||||
base, indents, inList = len(headings), nil, false
|
base, indents, inList, afterParagraph = len(headings), nil, false, false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||||
blocks = append(blocks, newBlock("divider", "", 0))
|
blocks = append(blocks, newBlock("divider", "", 0))
|
||||||
base, headings, indents, inList = 0, nil, nil, false
|
base, headings, indents, inList, afterParagraph = 0, nil, nil, false, false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -825,7 +833,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
}
|
}
|
||||||
i--
|
i--
|
||||||
blocks = append(blocks, newBlock("callout", strings.Join(parts, "\n"), base))
|
blocks = append(blocks, newBlock("callout", strings.Join(parts, "\n"), base))
|
||||||
indents, inList = nil, false
|
indents, inList, afterParagraph = nil, false, false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,7 +883,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
}
|
}
|
||||||
i--
|
i--
|
||||||
blocks = append(blocks, newBlock("bullet", strings.Join(para, "\n"), base))
|
blocks = append(blocks, newBlock("bullet", strings.Join(para, "\n"), base))
|
||||||
indents, inList = nil, false
|
indents, inList, afterParagraph = nil, false, true
|
||||||
}
|
}
|
||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,3 +235,15 @@ func TestMarkdownHeadingsNestTheirSections(t *testing.T) {
|
|||||||
t.Fatalf("got %v\nwant %v", got, want)
|
t.Fatalf("got %v\nwant %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkdownListNestsUnderItsParagraph(t *testing.T) {
|
||||||
|
blocks := MarkdownToBlocks("# Plan\nFolgende Punkte:\n\n- eins\n - tiefer\n- zwei\n\nDanach Text\n\n1. a")
|
||||||
|
var got []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
got = append(got, fmt.Sprintf("%d:%s:%s", b.Level, b.Type, b.Content))
|
||||||
|
}
|
||||||
|
want := []string{"0:h1:Plan", "1:bullet:Folgende Punkte:", "2:bullet:eins", "3:bullet:tiefer", "2:bullet:zwei", "1:bullet:Danach Text", "2:numbered:a"}
|
||||||
|
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||||
|
t.Fatalf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user