feat(workspace): implement Yjs CRDT real-time collaborative editing (resolves FLAW-01)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s
This commit is contained in:
1 parent
0bdeb5f320
commit
ef8cf8f022
13 files changed
+1028
-85
No files matched your search
@@ -55,6 +55,7 @@ import { CodeBlockItem } from './CodeBlockItem';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -216,7 +217,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
|
||||
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
|
||||
|
||||
// --- DOCUMENT LOCKING STATE ---
|
||||
// --- DOCUMENT CRDT & COLLABORATION STATE ---
|
||||
const crdtSessionRef = useRef<DocumentCRDTSession | null>(null);
|
||||
const [activePeers, setActivePeers] = useState<CollaboratorAwareness[]>([]);
|
||||
|
||||
// --- DOCUMENT LOCKING STATE (FALLBACK) ---
|
||||
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
|
||||
const [holdsLock, setHoldsLock] = useState(false);
|
||||
const holdsLockRef = useRef(false);
|
||||
@@ -228,8 +233,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
||||
|
||||
const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id);
|
||||
const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther;
|
||||
// With CRDT, multiple users can write simultaneously without lockouts
|
||||
const isLockedByOther = false;
|
||||
const isReadOnly = selectedDoc?.permission === 'read';
|
||||
|
||||
// Track server-synced blocks & metadata per document, plus local unsaved block deltas
|
||||
const syncedDocMetaRef = useRef<Map<string, { title: string; icon: string; tagsJson: string; isPinned: boolean }>>(new Map());
|
||||
@@ -418,12 +424,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleBlockFocus = () => {
|
||||
const broadcastAwareness = (blockId: string | null) => {
|
||||
if (!selectedDoc || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
event: 'CRDT_AWARENESS',
|
||||
docId: selectedDoc.id,
|
||||
payload: {
|
||||
userId: currentUser?.id,
|
||||
userName: currentUser?.displayName || currentUser?.username || 'Kollege',
|
||||
activeBlockId: blockId,
|
||||
color: getCollaboratorColor(currentUser?.id || ''),
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleBlockFocus = (blockId?: string) => {
|
||||
isEditorFocusedRef.current = true;
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
if (blockId) {
|
||||
broadcastAwareness(blockId);
|
||||
}
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
}
|
||||
@@ -431,6 +459,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const handleBlockBlur = () => {
|
||||
isEditorFocusedRef.current = false;
|
||||
broadcastAwareness(null);
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
@@ -611,6 +640,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Sync into active CRDT session
|
||||
if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) {
|
||||
crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []);
|
||||
}
|
||||
|
||||
// 1. Update local UI state immediately for responsive, instant typing
|
||||
setSelectedDoc(updatedDoc);
|
||||
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
|
||||
@@ -732,16 +766,73 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const currentUserIdRef = useRef<string | null>(null);
|
||||
currentUserIdRef.current = currentUser?.id || null;
|
||||
|
||||
// Handle document switch and lock status checks
|
||||
// Handle document switch, CRDT session lifecycle, and lock status checks
|
||||
useEffect(() => {
|
||||
const prevId = currentDocIdRef.current;
|
||||
if (prevId && prevId !== selectedDoc?.id) {
|
||||
flushPendingSave();
|
||||
releaseMyLock(prevId);
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: prevId }));
|
||||
} catch {}
|
||||
}
|
||||
if (crdtSessionRef.current) {
|
||||
crdtSessionRef.current.destroy();
|
||||
crdtSessionRef.current = null;
|
||||
}
|
||||
setActivePeers([]);
|
||||
}
|
||||
currentDocIdRef.current = selectedDoc?.id || null;
|
||||
|
||||
if (selectedDoc) {
|
||||
const targetDoc = selectedDoc;
|
||||
const session = new DocumentCRDTSession(targetDoc.id);
|
||||
session.initFromBlocks(targetDoc.blocks || []);
|
||||
session.onLocalUpdate = (b64Update) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
event: 'CRDT_UPDATE',
|
||||
docId: targetDoc.id,
|
||||
payload: { update: b64Update },
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
session.onRemoteChange = (crdtBlocks) => {
|
||||
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
|
||||
};
|
||||
crdtSessionRef.current = session;
|
||||
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ event: 'CRDT_JOIN', docId: targetDoc.id }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
api
|
||||
.getDocumentCRDT(targetDoc.id)
|
||||
.then((res) => {
|
||||
if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) {
|
||||
if (res.state) {
|
||||
crdtSessionRef.current.applyRemoteUpdate(res.state);
|
||||
}
|
||||
if (res.updates && Array.isArray(res.updates)) {
|
||||
crdtSessionRef.current.applyRemoteUpdates(res.updates);
|
||||
}
|
||||
const blocks = crdtSessionRef.current.getBlocks();
|
||||
if (blocks.length > 0) {
|
||||
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
api
|
||||
.getDocLock(selectedDoc.id)
|
||||
.then((res) => {
|
||||
@@ -759,6 +850,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return () => {
|
||||
flushPendingSave();
|
||||
if (currentDocIdRef.current) {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }));
|
||||
} catch {}
|
||||
}
|
||||
if (crdtSessionRef.current) {
|
||||
crdtSessionRef.current.destroy();
|
||||
crdtSessionRef.current = null;
|
||||
}
|
||||
releaseMyLock(currentDocIdRef.current);
|
||||
}
|
||||
};
|
||||
@@ -819,7 +919,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const handleWsMsg = (msg: any) => {
|
||||
if (!msg || !msg.event) return;
|
||||
|
||||
if (msg.event === 'DOC_LOCKED') {
|
||||
if (msg.event === 'CRDT_INIT') {
|
||||
const payload = msg.payload as { docId: string; state?: string; updates?: string[] };
|
||||
if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) {
|
||||
if (payload.state) {
|
||||
crdtSessionRef.current.applyRemoteUpdate(payload.state);
|
||||
}
|
||||
if (payload.updates && Array.isArray(payload.updates)) {
|
||||
crdtSessionRef.current.applyRemoteUpdates(payload.updates);
|
||||
}
|
||||
const blocks = crdtSessionRef.current.getBlocks();
|
||||
if (blocks.length > 0) {
|
||||
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'CRDT_UPDATE') {
|
||||
const payload = msg.payload as { docId: string; update: string; fromUserId?: string };
|
||||
if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) {
|
||||
if (payload.fromUserId !== currentUserIdRef.current) {
|
||||
crdtSessionRef.current.applyRemoteUpdate(payload.update);
|
||||
const blocks = crdtSessionRef.current.getBlocks();
|
||||
if (blocks && blocks.length > 0) {
|
||||
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'CRDT_AWARENESS') {
|
||||
const payload = (msg.payload?.payload || msg.payload) as CollaboratorAwareness;
|
||||
if (payload && payload.userId && payload.userId !== currentUserIdRef.current) {
|
||||
setActivePeers((prev) => {
|
||||
const filtered = prev.filter((c) => c.userId !== payload.userId);
|
||||
return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }];
|
||||
});
|
||||
}
|
||||
} else if (msg.event === 'CRDT_AWARENESS_LEAVE') {
|
||||
const payload = msg.payload as { docId?: string; userId?: string };
|
||||
if (payload && payload.userId) {
|
||||
setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId));
|
||||
}
|
||||
} else if (msg.event === 'DOC_LOCKED') {
|
||||
const payload = msg.payload as DocLockInfo;
|
||||
if (payload && payload.docId === selectedDocIdRef.current) {
|
||||
if (payload.userId !== currentUserIdRef.current) {
|
||||
@@ -975,6 +1115,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
wsReconnectDelayRef.current = 2000;
|
||||
setWsDisconnectReason(null);
|
||||
setWsConnected(true);
|
||||
if (selectedDocIdRef.current) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current }));
|
||||
} catch {}
|
||||
}
|
||||
api.getFolders().then(setCustomFolders).catch(() => {});
|
||||
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||
};
|
||||
@@ -4215,22 +4360,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{holdsLock && (
|
||||
{activePeers.length > 0 ? (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5 animate-pulse"
|
||||
title={`Live-Kollaboration via CRDT aktiv: ${activePeers.map((c) => c.userName).join(', ')}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping" />
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Live ({activePeers.length})</span>
|
||||
</span>
|
||||
) : holdsLock ? (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<span>Schreibzugriff aktiv</span>
|
||||
<span>CRDT Sync aktiv</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isLockedByOther && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1 animate-pulse cursor-help"
|
||||
title={`Diese Notiz wird aktuell von ${activeLock?.userName || 'einem anderen Benutzer'} bearbeitet. Schreibzugriff vorübergehend gesperrt.`}
|
||||
>
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
<span>Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'})</span>
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (
|
||||
<button
|
||||
@@ -4590,6 +4734,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* Block Content Container */}
|
||||
<div className="flex-1 min-w-0 pt-0.5 relative">
|
||||
{/* Live Collaborator Presence Indicator */}
|
||||
{activePeers.filter((c) => c.activeBlockId === block.id).map((c) => (
|
||||
<div
|
||||
key={c.userId}
|
||||
className="absolute -top-3.5 right-1 text-[10px] px-1.5 py-0.5 rounded font-semibold text-white shadow-xs pointer-events-none z-10 flex items-center gap-1 animate-fadeIn"
|
||||
style={{ backgroundColor: c.color }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-ping" />
|
||||
<span>{c.userName}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* TYPE: TODO */}
|
||||
{block.type === 'todo' && (
|
||||
<div className="space-y-1">
|
||||
|
||||
Reference in new issue
Block a user