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">
|
||||
|
||||
@@ -602,6 +602,17 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async getDocumentCRDT(id: string): Promise<{ docId: string; state?: string; updates?: string[] }> {
|
||||
return request<{ docId: string; state?: string; updates?: string[] }>(`/api/v1/documents/${id}/crdt`);
|
||||
},
|
||||
|
||||
async saveDocumentCRDT(id: string, state: string): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import * as Y from 'yjs';
|
||||
import { BlockType, DocumentBlock } from './types';
|
||||
|
||||
// Convert Uint8Array to Base64
|
||||
export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// Convert Base64 to Uint8Array
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const COLLAB_COLORS = [
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Emerald
|
||||
'#F59E0B', // Amber
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#F97316', // Orange
|
||||
'#14B8A6', // Teal
|
||||
];
|
||||
|
||||
export function getCollaboratorColor(userId: string): string {
|
||||
if (!userId) return COLLAB_COLORS[0];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const idx = Math.abs(hash) % COLLAB_COLORS.length;
|
||||
return COLLAB_COLORS[idx];
|
||||
}
|
||||
|
||||
export interface CollaboratorAwareness {
|
||||
userId: string;
|
||||
userName: string;
|
||||
activeBlockId: string | null;
|
||||
color: string;
|
||||
lastActive: number;
|
||||
}
|
||||
|
||||
export class DocumentCRDTSession {
|
||||
public docId: string;
|
||||
public doc: Y.Doc;
|
||||
private order: Y.Array<string>;
|
||||
private blocks: Y.Map<Y.Map<any>>;
|
||||
private destroyed: boolean = false;
|
||||
|
||||
public onLocalUpdate?: (base64Update: string) => void;
|
||||
public onRemoteChange?: (blocks: DocumentBlock[]) => void;
|
||||
|
||||
constructor(docId: string) {
|
||||
this.docId = docId;
|
||||
this.doc = new Y.Doc({ guid: docId });
|
||||
this.order = this.doc.getArray<string>('blockOrder');
|
||||
this.blocks = this.doc.getMap<Y.Map<any>>('blocks');
|
||||
|
||||
this.doc.on('update', (update: Uint8Array, origin: any) => {
|
||||
if (this.destroyed) return;
|
||||
if (origin !== 'remote') {
|
||||
const b64 = uint8ArrayToBase64(update);
|
||||
if (this.onLocalUpdate) {
|
||||
this.onLocalUpdate(b64);
|
||||
}
|
||||
} else {
|
||||
if (this.onRemoteChange) {
|
||||
this.onRemoteChange(this.getBlocks());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.doc.destroy();
|
||||
}
|
||||
|
||||
public getBlocks(): DocumentBlock[] {
|
||||
const orderList = this.order.toArray();
|
||||
const result: DocumentBlock[] = [];
|
||||
for (const id of orderList) {
|
||||
const bMap = this.blocks.get(id);
|
||||
if (bMap) {
|
||||
result.push({
|
||||
id,
|
||||
docId: this.docId,
|
||||
type: (bMap.get('type') || 'bullet') as BlockType,
|
||||
content: bMap.get('content') || '',
|
||||
level: typeof bMap.get('level') === 'number' ? bMap.get('level') : 0,
|
||||
checked: !!bMap.get('checked'),
|
||||
collapsed: !!bMap.get('collapsed'),
|
||||
lang: bMap.get('lang') || '',
|
||||
sortOrder: typeof bMap.get('sortOrder') === 'number' ? bMap.get('sortOrder') : 0,
|
||||
updatedAt: bMap.get('updatedAt') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public initFromBlocks(initialBlocks: DocumentBlock[]): void {
|
||||
this.doc.transact(() => {
|
||||
if (this.order.length === 0 && initialBlocks.length > 0) {
|
||||
const ids: string[] = [];
|
||||
for (const b of initialBlocks) {
|
||||
ids.push(b.id);
|
||||
const bMap = new Y.Map<any>();
|
||||
bMap.set('id', b.id);
|
||||
bMap.set('type', b.type || 'bullet');
|
||||
bMap.set('content', b.content || '');
|
||||
bMap.set('level', b.level || 0);
|
||||
bMap.set('checked', !!b.checked);
|
||||
bMap.set('collapsed', !!b.collapsed);
|
||||
bMap.set('lang', b.lang || '');
|
||||
bMap.set('sortOrder', b.sortOrder || 0);
|
||||
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
|
||||
this.blocks.set(b.id, bMap);
|
||||
}
|
||||
this.order.push(ids);
|
||||
}
|
||||
}, 'init');
|
||||
}
|
||||
|
||||
public updateBlock(id: string, updates: Partial<DocumentBlock>): void {
|
||||
this.doc.transact(() => {
|
||||
const bMap = this.blocks.get(id);
|
||||
if (bMap) {
|
||||
if (updates.type !== undefined) bMap.set('type', updates.type);
|
||||
if (updates.content !== undefined) bMap.set('content', updates.content);
|
||||
if (updates.level !== undefined) bMap.set('level', updates.level);
|
||||
if (updates.checked !== undefined) bMap.set('checked', updates.checked);
|
||||
if (updates.collapsed !== undefined) bMap.set('collapsed', updates.collapsed);
|
||||
if (updates.lang !== undefined) bMap.set('lang', updates.lang);
|
||||
if (updates.sortOrder !== undefined) bMap.set('sortOrder', updates.sortOrder);
|
||||
bMap.set('updatedAt', new Date().toISOString());
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public insertBlock(index: number, block: DocumentBlock): void {
|
||||
this.doc.transact(() => {
|
||||
const bMap = new Y.Map<any>();
|
||||
bMap.set('id', block.id);
|
||||
bMap.set('type', block.type || 'bullet');
|
||||
bMap.set('content', block.content || '');
|
||||
bMap.set('level', block.level || 0);
|
||||
bMap.set('checked', !!block.checked);
|
||||
bMap.set('collapsed', !!block.collapsed);
|
||||
bMap.set('lang', block.lang || '');
|
||||
bMap.set('sortOrder', block.sortOrder || 0);
|
||||
bMap.set('updatedAt', new Date().toISOString());
|
||||
this.blocks.set(block.id, bMap);
|
||||
|
||||
if (index >= 0 && index <= this.order.length) {
|
||||
this.order.insert(index, [block.id]);
|
||||
} else {
|
||||
this.order.push([block.id]);
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public deleteBlock(id: string): void {
|
||||
this.doc.transact(() => {
|
||||
const currentOrder = this.order.toArray();
|
||||
const idx = currentOrder.indexOf(id);
|
||||
if (idx !== -1) {
|
||||
this.order.delete(idx, 1);
|
||||
}
|
||||
this.blocks.delete(id);
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public deleteMultipleBlocks(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const toDeleteSet = new Set(ids);
|
||||
this.doc.transact(() => {
|
||||
const currentOrder = this.order.toArray();
|
||||
// Delete from order backwards to preserve indices
|
||||
for (let i = currentOrder.length - 1; i >= 0; i--) {
|
||||
if (toDeleteSet.has(currentOrder[i])) {
|
||||
this.order.delete(i, 1);
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
this.blocks.delete(id);
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public reorderBlocks(newOrder: string[]): void {
|
||||
this.doc.transact(() => {
|
||||
this.order.delete(0, this.order.length);
|
||||
this.order.push(newOrder);
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public syncFullBlocks(blocks: DocumentBlock[]): void {
|
||||
this.doc.transact(() => {
|
||||
const nextIds = blocks.map((b) => b.id);
|
||||
const currentIds = new Set(this.order.toArray());
|
||||
|
||||
// Upsert all blocks
|
||||
for (const b of blocks) {
|
||||
let bMap = this.blocks.get(b.id);
|
||||
if (!bMap) {
|
||||
bMap = new Y.Map<any>();
|
||||
this.blocks.set(b.id, bMap);
|
||||
}
|
||||
bMap.set('id', b.id);
|
||||
bMap.set('type', b.type || 'bullet');
|
||||
bMap.set('content', b.content || '');
|
||||
bMap.set('level', b.level || 0);
|
||||
bMap.set('checked', !!b.checked);
|
||||
bMap.set('collapsed', !!b.collapsed);
|
||||
bMap.set('lang', b.lang || '');
|
||||
bMap.set('sortOrder', b.sortOrder || 0);
|
||||
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
|
||||
}
|
||||
|
||||
// Delete blocks no longer present
|
||||
const nextIdSet = new Set(nextIds);
|
||||
currentIds.forEach((id) => {
|
||||
if (!nextIdSet.has(id)) {
|
||||
this.blocks.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update order if different
|
||||
const currentOrder = this.order.toArray();
|
||||
const orderChanged =
|
||||
currentOrder.length !== nextIds.length || currentOrder.some((id, idx) => id !== nextIds[idx]);
|
||||
if (orderChanged) {
|
||||
this.order.delete(0, this.order.length);
|
||||
this.order.push(nextIds);
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public applyRemoteUpdate(base64Update: string): void {
|
||||
try {
|
||||
const bytes = base64ToUint8Array(base64Update);
|
||||
Y.applyUpdate(this.doc, bytes, 'remote');
|
||||
} catch (err) {
|
||||
console.error('[CRDT] Failed to apply remote update:', err);
|
||||
}
|
||||
}
|
||||
|
||||
public applyRemoteUpdates(base64Updates: string[]): void {
|
||||
for (const b64 of base64Updates) {
|
||||
this.applyRemoteUpdate(b64);
|
||||
}
|
||||
}
|
||||
|
||||
public encodeState(): string {
|
||||
const bytes = Y.encodeStateAsUpdate(this.doc);
|
||||
return uint8ArrayToBase64(bytes);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user