feat(workspace): move status pills to tab bar and add undo/redo and actions menu to note header

This commit is contained in:
sandrodach committed 2026-10-30 09:41:02 +01:00
1 parent fc7b2b3a2c
commit 5f27e7ca9c
4 files changed
+394 -87

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Aufgaben-Details: Knopf „Beobachten“ / „Nicht mehr beobachten“ (`POST/DELETE /tasks/{id}/watch`). - Aufgaben-Details: Knopf „Beobachten“ / „Nicht mehr beobachten“ (`POST/DELETE /tasks/{id}/watch`).
### Changed ### Changed
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab. - `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
### Fixed ### Fixed
@@ -18,16 +18,26 @@ import {
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
GitMerge, GitMerge,
Undo2,
Redo2,
MoreHorizontal,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { Presence } from '../../lib/docSync/transport';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocHeaderStatus {
showLive: boolean;
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
otherPeople: Presence[];
avatarsById: Map<string, string>;
}
export interface DocumentEditorProps { export interface DocumentEditorProps {
docId: string; docId: string;
currentUser?: User | null; currentUser?: User | null;
@@ -45,6 +55,7 @@ export interface DocumentEditorProps {
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
/** Opens the dialog to merge this note into another one (own notes only) */ /** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void; onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */ /** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
@@ -196,6 +207,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile, onBackMobile,
onOpenTask, onOpenTask,
onSaveStatusChange, onSaveStatusChange,
onHeaderStatusChange,
onRequestMerge, onRequestMerge,
onDocCreated, onDocCreated,
onDocRemoved, onDocRemoved,
@@ -209,6 +221,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null); const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server // Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null); const engineRef = useRef<DocSyncEngine | null>(null);
@@ -384,6 +398,48 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {}); .catch(() => {});
}, [otherPeople.length]); }, [otherPeople.length]);
useEffect(() => {
onHeaderStatusChange?.({
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
channel: engineState?.channel || 'ws',
otherPeople,
avatarsById,
});
}, [
engineState?.shared,
engineState?.sessions,
engineState?.channel,
otherPeople,
avatarsById,
onHeaderStatusChange,
]);
useEffect(() => {
return () => {
onHeaderStatusChange?.(null);
};
}, [onHeaderStatusChange]);
useEffect(() => {
if (!showActionsMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
setShowActionsMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [showActionsMenu]);
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
@@ -715,7 +771,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<button <button
type="button" type="button"
onClick={onBackMobile} onClick={onBackMobile}
className="lg:hidden btn-icon" className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Zurück zur Seitenleiste" title="Zurück zur Seitenleiste"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
@@ -813,119 +869,129 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Right Action Buttons */} {/* Right Action Buttons */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* How changes of others arrive (only relevant for shared notes) */} {!inTrash && (
{engineState && (engineState.shared || engineState.sessions > 1) && ( <>
<span <button
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border" type="button"
title={ onClick={handleUndo}
engineState.channel === 'ws' disabled={!canEdit || !engineState?.canUndo}
? 'Live-Verbindung über WebSocket' className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
: engineState.channel === 'sse' title="Rückgängig (Strg+Z)"
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)' aria-label="Rückgängig (Strg+Z)"
: engineState.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
> >
<span <Undo2 className="w-3.5 h-3.5" />
className={`w-1.5 h-1.5 rounded-full ${ </button>
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok' <button
}`} type="button"
/> onClick={handleRedo}
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} disabled={!canEdit || !engineState?.canRedo}
</span> 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>
</>
)} )}
{/* Who else has this note open */} {doc && !inTrash && (
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
const ownerDisplayName = doc.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button <button
type="button" type="button"
onClick={() => onOpenShareModal(doc)} onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] 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="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] 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"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen" title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
> >
<Users className="w-3.5 h-3.5" /> <Users className="w-3.5 h-3.5" />
</button> </button>
{onRequestMerge && doc.isOwner && !inTrash && ( )}
<div className="relative" ref={actionsMenuRef}>
<button <button
type="button" type="button"
onClick={() => onRequestMerge(doc)} onClick={() => setShowActionsMenu((prev) => !prev)}
className="btn-icon" aria-expanded={showActionsMenu}
title="Mit anderer Notiz zusammenführen…" aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
> >
<GitMerge className="w-3.5 h-3.5" /> <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> </button>
)} )}
</div>
);
})()}
{!inTrash && <button {onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button" type="button"
onClick={handleTogglePin} role="menuitem"
className={`btn-icon ${ onClick={() => {
doc?.isPinned setShowActionsMenu(false);
? 'text-status-warn bg-status-warn/10 border-status-warn/30' 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"
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
> >
<Pin className="w-3.5 h-3.5" /> <GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
</button>} <span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
<button <button
type="button" type="button"
onClick={handleExportMarkdown} role="menuitem"
className="btn-icon" onClick={() => {
title="Als Markdown (.md) exportieren" 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" /> <Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button> </button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( {canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button <button
type="button" type="button"
onClick={() => onDeleteDoc(docId)} role="menuitem"
className="btn-icon-danger" onClick={() => {
title="Notiz löschen" 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" /> <Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button> </button>
</>
)} )}
</div> </div>
)}
</div>
</div>
</div> </div>
{/* In the trash: read-only with a way back */} {/* In the trash: read-only with a way back */}
@@ -46,7 +46,8 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff'; import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView'; import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog'; import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
@@ -386,6 +387,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved'); const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null); const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
// Drag and Drop state // Drag and Drop state
@@ -1306,10 +1308,83 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button> </button>
</div> </div>
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */} {/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
<div className="hidden sm:flex items-center gap-2"> <div className="hidden sm:flex items-center gap-2">
{subView === 'editor' && selectedDocId ? ( {subView === 'editor' && selectedDocId ? (
/* Save Status Pill */ <>
{/* Live connection pill */}
{docHeaderStatus?.showLive && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title={
docHeaderStatus.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: docHeaderStatus.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: docHeaderStatus.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{docHeaderStatus.channel === 'longpoll'
? 'Live (Long-Polling)'
: docHeaderStatus.channel === 'interval'
? 'Abgleich alle 3 s'
: 'Live'}
</span>
)}
{/* Who else has this note open */}
{docHeaderStatus && (
<PresenceAvatars
people={docHeaderStatus.otherPeople}
avatars={docHeaderStatus.avatarsById}
/>
)}
{/* Share pill */}
{selectedDocObj &&
!selectedDocObj.isArchived &&
(() => {
const isOwner =
selectedDocObj.isOwner !== false &&
(selectedDocObj.userId === currentUser?.id ||
!selectedDocObj.userId ||
currentUser?.role === 'admin');
const sharesCount = selectedDocObj.sharedWith?.length || 0;
const isShared =
sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
if (!isShared) return null;
return isOwner ? (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] 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"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] 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"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
);
})()}
{/* Save Status Pill */}
<div <div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved' docSaveStatus === 'saved'
@@ -1355,6 +1430,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</> </>
)} )}
</div> </div>
</>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -1645,6 +1721,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDocSaveStatus(status); setDocSaveStatus(status);
setDocSaveError(err || null); setDocSaveError(err || null);
}} }}
onHeaderStatusChange={setDocHeaderStatus}
/> />
) : subView === 'tags' ? ( ) : subView === 'tags' ? (
/* ========================================================================= /* =========================================================================
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DocumentEditor } from '../DocumentEditor';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
import { EngineState } from '../../../lib/docSync/engine';
let currentListener: ((state: EngineState) => void) | null = null;
const mockUndo = vi.fn(() => ({ ok: true }));
const mockRedo = vi.fn(() => ({ ok: true }));
const baseEngineState: EngineState = {
ready: true,
status: 'saved',
role: 'owner',
title: 'Test Notiz',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
pendingCount: 0,
shared: true,
sessions: 2,
presence: [{ userId: 'u2', name: 'Bob' }],
lockedByOthers: [],
conflicts: [],
canUndo: false,
canRedo: false,
channel: 'ws',
};
vi.mock('../../../lib/docSync/engine', () => {
return {
DocSyncEngine: class {
subscribe(fn: (state: EngineState) => void) {
currentListener = fn;
fn(baseEngineState);
return () => {
currentListener = null;
};
}
start() {
return Promise.resolve();
}
stop() {}
getState() {
return baseEngineState;
}
setTaskLinks() {}
focusBlock() {}
applyFlat() {}
setTitle() {}
undo() {
return mockUndo();
}
redo() {
return mockRedo();
}
},
};
});
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocument: vi.fn(),
getUsersDirectory: vi.fn(() => Promise.resolve([])),
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
},
};
});
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
id: 'doc_1',
userId: 'u1',
title: 'Test Notiz',
icon: '📝',
folderPath: '',
content: '',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
tags: [],
isPinned: false,
isArchived: false,
isOwner: true,
createdAt: '2026-10-30T08:00:00Z',
updatedAt: '2026-10-30T08:00:00Z',
...overrides,
});
describe('DocumentEditor header actions & status callback (Teil D)', () => {
beforeEach(() => {
vi.clearAllMocks();
const doc = makeDoc();
vi.mocked(api.getDocument).mockResolvedValue(doc);
});
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
const doc = makeDoc({ isOwner: true });
const onHeaderStatusChange = vi.fn();
const onRequestMerge = vi.fn();
const onDeleteDoc = vi.fn();
const onOpenShareModal = vi.fn();
render(
<DocumentEditor
docId="doc_1"
initialDoc={doc}
availableFolders={[]}
onDocMetadataChange={vi.fn()}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={vi.fn()}
onRequestMerge={onRequestMerge}
onHeaderStatusChange={onHeaderStatusChange}
/>
);
await waitFor(() => {
expect(onHeaderStatusChange).toHaveBeenCalledWith(
expect.objectContaining({
showLive: true,
channel: 'ws',
})
);
});
// Undo / Redo buttons disabled initially
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
expect(undoBtn.disabled).toBe(true);
expect(redoBtn.disabled).toBe(true);
// Enable Undo & Redo via engine state update
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
await waitFor(() => {
expect(undoBtn.disabled).toBe(false);
expect(redoBtn.disabled).toBe(false);
});
fireEvent.click(undoBtn);
expect(mockUndo).toHaveBeenCalledTimes(1);
fireEvent.click(redoBtn);
expect(mockRedo).toHaveBeenCalledTimes(1);
// Share button is directly visible
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
fireEvent.click(shareBtn);
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
const moreBtn = screen.getByTitle('Weitere Aktionen');
fireEvent.click(moreBtn);
expect(screen.getByText('Notiz anheften')).toBeTruthy();
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
expect(screen.getByText('Notiz löschen')).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Notiz anheften')).toBeNull();
});
});