diff --git a/CHANGELOG.md b/CHANGELOG.md index e48b269..2d4022c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`). ### 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. ### Fixed diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index e91efec..ef1d52d 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -18,16 +18,26 @@ import { CloudOff, AlertTriangle, GitMerge, + Undo2, + Redo2, + MoreHorizontal, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; +import { Presence } from '../../lib/docSync/transport'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; -import { PresenceAvatars } from './PresenceAvatars'; import { extractHashtags } from '../../lib/hashtags'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; +export interface DocHeaderStatus { + showLive: boolean; + channel: 'ws' | 'sse' | 'longpoll' | 'interval'; + otherPeople: Presence[]; + avatarsById: Map; +} + export interface DocumentEditorProps { docId: string; currentUser?: User | null; @@ -45,6 +55,7 @@ export interface DocumentEditorProps { onBackMobile?: () => void; onOpenTask?: (taskId: 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) */ onRequestMerge?: (doc: Document) => void; /** 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 = ({ onBackMobile, onOpenTask, onSaveStatusChange, + onHeaderStatusChange, onRequestMerge, onDocCreated, onDocRemoved, @@ -209,6 +221,8 @@ export const DocumentEditor: React.FC = ({ const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); + const [showActionsMenu, setShowActionsMenu] = useState(false); + const actionsMenuRef = useRef(null); // Sync engine (operation log): stores every change locally and syncs it with the server const engineRef = useRef(null); @@ -384,6 +398,48 @@ export const DocumentEditor: React.FC = ({ .catch(() => {}); }, [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 const [notice, setNotice] = useState(null); const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); @@ -715,7 +771,7 @@ export const DocumentEditor: React.FC = ({ + + )} - {/* Who else has this note open */} - - {/* 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'; + {doc && !inTrash && ( + + )} - return ( -
- {isShared && ( - isOwner ? ( - - ) : ( - - ) +
+ + + {showActionsMenu && ( +
+ {!inTrash && ( + + )} + + {onRequestMerge && doc?.isOwner && !inTrash && ( + )} - {onRequestMerge && doc.isOwner && !inTrash && ( - + + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( + <> +
+ + )}
- ); - })()} - - {!inTrash && } - - - - {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( - - )} + )} +
diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index b84dac5..963aae5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -46,7 +46,8 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { FormSheet } from '../../components/FormSheet'; import { sendDocOps } from '../../lib/docSync/oneoff'; 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 { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; @@ -386,6 +387,7 @@ export const WorkspaceView: React.FC = ({ const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); + const [docHeaderStatus, setDocHeaderStatus] = useState(null); // Drag and Drop state @@ -1306,55 +1308,129 @@ export const WorkspaceView: React.FC = ({ - {/* 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) */}
{subView === 'editor' && selectedDocId ? ( - /* Save Status Pill */ -
- {docSaveStatus === 'saved' && ( - <> - - Gespeichert - + <> + {/* Live connection pill */} + {docHeaderStatus?.showLive && ( + + + {docHeaderStatus.channel === 'longpoll' + ? 'Live (Long-Polling)' + : docHeaderStatus.channel === 'interval' + ? 'Abgleich alle 3 s' + : 'Live'} + )} - {docSaveStatus === 'saving' && ( - <> - - Speichert… - + + {/* Who else has this note open */} + {docHeaderStatus && ( + )} - {docSaveStatus === 'unsaved' && ( - <> - - Ungespeichert - - )} - {docSaveStatus === 'error' && ( - <> - - Fehler - - )} -
+ + {/* 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 ? ( + + ) : ( + + ); + })()} + + {/* Save Status Pill */} +
+ {docSaveStatus === 'saved' && ( + <> + + Gespeichert + + )} + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )} +
+ ) : null}
@@ -1645,6 +1721,7 @@ export const WorkspaceView: React.FC = ({ setDocSaveStatus(status); setDocSaveError(err || null); }} + onHeaderStatusChange={setDocHeaderStatus} /> ) : subView === 'tags' ? ( /* ========================================================================= diff --git a/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx new file mode 100644 index 0000000..8466a4c --- /dev/null +++ b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx @@ -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(); + return { + ...actual, + api: { + ...actual.api, + getDocument: vi.fn(), + getUsersDirectory: vi.fn(() => Promise.resolve([])), + getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })), + }, + }; +}); + +const makeDoc = (overrides: Partial = {}): 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( + + ); + + 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(); + }); +});