feat(workspace): note actions in the workspace bar on desktop, one-row sidebar header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s

On large screens the editor header is gone: undo/redo, share and the menu
render into the workspace tab bar (portal) after avatars, live, shared and
an icon-only save status; zoom breadcrumbs move above the title. Phones and
tablets keep the editor header. The sidebar header drops its heading and
puts search, new folder and new note in one row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 20:22:39 +01:00
1 parent 375dda982b
commit d408e7d307
3 files changed
+252 -216

No files matched your search

+199 -170
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import {
Pin,
Users,
@@ -63,6 +64,20 @@ export interface DocumentEditorProps {
onDocRemoved?: (docId: string) => void;
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
variant?: 'page' | 'journal';
/** Large screens: the action buttons render into this element (the workspace tab bar) */
actionsTarget?: HTMLElement | null;
}
// Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header
const LARGE_QUERY = '(min-width: 1024px)';
function subscribeLarge(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mq = window.matchMedia(LARGE_QUERY);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}
function isLargeScreen() {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(LARGE_QUERY).matches;
}
import { EmojiPickerModal } from './EmojiPickerModal';
@@ -214,8 +229,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDocCreated,
onDocRemoved,
variant = 'page',
actionsTarget,
}) => {
const inJournal = variant === 'journal';
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
const desktopBar = !inJournal && large && Boolean(actionsTarget);
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
@@ -787,14 +805,189 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
);
}
const breadcrumbNav = (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1 text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[130px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[160px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
);
// Undo/redo, share and the menu; on large screens they sit in the workspace tab bar instead
const actionsBlock = (
<div className="flex shrink-0 items-center gap-1.5">
{!inTrash && (
<>
<button
type="button"
onClick={handleUndo}
disabled={!canEdit || !engineState?.canUndo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Rückgängig (Strg+Z)"
aria-label="Rückgängig (Strg+Z)"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleRedo}
disabled={!canEdit || !engineState?.canRedo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Wiederholen (Strg+Y)"
aria-label="Wiederholen (Strg+Y)"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</>
)}
{doc && !inTrash && (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<div className="relative" ref={actionsMenuRef}>
<button
type="button"
onClick={() => setShowActionsMenu((prev) => !prev)}
aria-expanded={showActionsMenu}
aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showActionsMenu && (
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
>
{!inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
void handleTogglePin();
}}
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"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button>
)}
{onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onRequestMerge(doc);
}}
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"
>
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
handleExportMarkdown();
}}
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"
>
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button>
</>
)}
</div>
)}
</div>
</div>
);
return (
<div
ref={scrollRef}
className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
data-sync-channel={engineState?.channel}
>
{/* Top Document Action Bar (the journal has one for all days) */}
{!inJournal && (
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
{!inJournal && !desktopBar && (
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
<div className="flex flex-1 items-center gap-2 min-w-0">
{onBackMobile && (
@@ -810,48 +1003,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
{zoomedBlock && breadcrumbs.length > 0 ? (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1 text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[130px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[160px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
breadcrumbNav
) : (
<div
className={`flex min-w-0 items-center gap-1.5 text-xs text-muted transition-opacity duration-200 ${
@@ -902,131 +1054,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)}
</div>
{/* Right Action Buttons */}
<div className="flex shrink-0 items-center gap-1.5">
{!inTrash && (
<>
<button
type="button"
onClick={handleUndo}
disabled={!canEdit || !engineState?.canUndo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Rückgängig (Strg+Z)"
aria-label="Rückgängig (Strg+Z)"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleRedo}
disabled={!canEdit || !engineState?.canRedo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Wiederholen (Strg+Y)"
aria-label="Wiederholen (Strg+Y)"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</>
)}
{doc && !inTrash && (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<div className="relative" ref={actionsMenuRef}>
<button
type="button"
onClick={() => setShowActionsMenu((prev) => !prev)}
aria-expanded={showActionsMenu}
aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showActionsMenu && (
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
>
{!inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
void handleTogglePin();
}}
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"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button>
)}
{onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onRequestMerge(doc);
}}
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"
>
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
handleExportMarkdown();
}}
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"
>
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button>
</>
)}
</div>
)}
</div>
</div>
{!desktopBar && actionsBlock}
</div>
)}
@@ -1064,6 +1092,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
inJournal ? 'py-3' : 'py-5'
}`}
>
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)