feat(workspace): move status pills to tab bar and add undo/redo and actions menu to note header
This commit is contained in:
1 parent
fc7b2b3a2c
commit
5f27e7ca9c
4 files changed
+394
-87
No files matched your search
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user