Dev #7
No files matched your search
@@ -16,9 +16,7 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.33"
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Pin,
|
||||
Share2,
|
||||
Trash2,
|
||||
Download,
|
||||
Folder as FolderIcon,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Hash,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useDocumentSync, DocSyncEvent } from '../../lib/useDocumentSync';
|
||||
import { OutlinerTree } from './OutlinerTree';
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
initialDoc?: Document | null;
|
||||
availableFolders: string[];
|
||||
onDocMetadataChange: (updated: Document) => void;
|
||||
onDeleteDoc: (docId: string) => void;
|
||||
onOpenShareModal: (doc: Document) => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
}
|
||||
|
||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: `${docId}-b0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: 0,
|
||||
position: 1000,
|
||||
},
|
||||
];
|
||||
}
|
||||
return blocks.map((b, idx) => ({
|
||||
...b,
|
||||
id: b.id || `${docId}-b${idx}`,
|
||||
documentId: docId,
|
||||
type: b.type || 'text',
|
||||
level: Math.max(0, b.level || 0),
|
||||
position: b.position || (idx + 1) * 1000,
|
||||
}));
|
||||
}
|
||||
|
||||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
docId,
|
||||
initialDoc,
|
||||
availableFolders,
|
||||
onDocMetadataChange,
|
||||
onDeleteDoc,
|
||||
onOpenShareModal,
|
||||
onSelectTag,
|
||||
onBackMobile,
|
||||
}) => {
|
||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||||
ensureNonEmptyBlocks(docId, initialDoc?.blocks)
|
||||
);
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [showFolderMenu, setShowFolderMenu] = useState<boolean>(false);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const titleDebounceRef = useRef<number | null>(null);
|
||||
const docRef = useRef<Document | null>(doc);
|
||||
docRef.current = doc;
|
||||
|
||||
const canEdit = useMemo(() => {
|
||||
if (!doc) return true;
|
||||
return doc.isOwner !== false && doc.permission !== 'read';
|
||||
}, [doc]);
|
||||
|
||||
// Handle incoming WebSocket events for THIS document room
|
||||
const handleRemoteEvent = useCallback(
|
||||
(evt: DocSyncEvent) => {
|
||||
if (evt.docId && evt.docId !== docId) return;
|
||||
|
||||
switch (evt.type) {
|
||||
case 'doc_state': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleFocused =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current;
|
||||
if (!isTitleFocused) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'title_updated': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleFocused =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current;
|
||||
if (!isTitleFocused) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
if (docRef.current) {
|
||||
const updated = { ...docRef.current, title: evt.title };
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_created': {
|
||||
if (!evt.block) return;
|
||||
const incoming = evt.block;
|
||||
setBlocks((prev) => {
|
||||
if (prev.some((b) => b.id === incoming.id)) {
|
||||
return prev.map((b) => (b.id === incoming.id ? incoming : b));
|
||||
}
|
||||
const next = [...prev];
|
||||
if (evt.afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
}
|
||||
}
|
||||
next.push(incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_updated': {
|
||||
const targetId = evt.blockId || evt.block?.id;
|
||||
if (!targetId) return;
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => {
|
||||
if (b.id !== targetId) return b;
|
||||
if (evt.block) {
|
||||
return { ...b, ...evt.block };
|
||||
}
|
||||
return {
|
||||
...b,
|
||||
content: evt.content !== undefined ? evt.content : b.content,
|
||||
level: evt.level !== undefined ? evt.level : b.level,
|
||||
checked: evt.checked !== undefined ? evt.checked : b.checked,
|
||||
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
|
||||
};
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_deleted': {
|
||||
if (!evt.blockId) return;
|
||||
setBlocks((prev) =>
|
||||
ensureNonEmptyBlocks(
|
||||
docId,
|
||||
prev.filter((b) => b.id !== evt.blockId)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_moved': {
|
||||
if (!evt.blockId || evt.newIndex === undefined) return;
|
||||
const targetIndex = evt.newIndex;
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: evt.level !== undefined ? evt.level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blocks_replaced': {
|
||||
if (Array.isArray(evt.blocks)) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[docId, onDocMetadataChange]
|
||||
);
|
||||
|
||||
const {
|
||||
status: syncStatus,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit);
|
||||
|
||||
// Fetch isolated document state on mount
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.getDocument(docId)
|
||||
.then((fetched) => {
|
||||
if (isCancelled) return;
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
const updatedDoc: Document = {
|
||||
...docRef.current,
|
||||
title,
|
||||
blocks,
|
||||
tags: extractedTags,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
docRef.current = updatedDoc;
|
||||
onDocMetadataChange(updatedDoc);
|
||||
}, [blocks, extractedTags, title]);
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canEdit) return;
|
||||
const nextTitle = e.target.value;
|
||||
setTitle(nextTitle);
|
||||
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
}
|
||||
titleDebounceRef.current = window.setTimeout(() => {
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(nextTitle);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||||
);
|
||||
sendBlockUpdated(updatedBlock);
|
||||
},
|
||||
[sendBlockUpdated]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
next.push(newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockCreated]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||||
sendBlockDeleted(blockId, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockDeleted]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number) => {
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: level !== undefined ? level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockMoved]
|
||||
);
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
if (!doc) return;
|
||||
try {
|
||||
const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned });
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectIcon = async (icon: string) => {
|
||||
setShowEmojiPicker(false);
|
||||
if (!doc || !canEdit) return;
|
||||
try {
|
||||
const updated = await api.updateDocument(doc.id, { icon });
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToFolder = async (folderPath: string) => {
|
||||
setShowFolderMenu(false);
|
||||
if (!doc || !canEdit) return;
|
||||
try {
|
||||
const updated = await api.moveDocument(doc.id, folderPath);
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, ''];
|
||||
for (const b of blocks) {
|
||||
const indent = ' '.repeat(Math.max(0, b.level || 0));
|
||||
if (b.type === 'todo') {
|
||||
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
|
||||
} else if (b.type === 'h1') {
|
||||
lines.push(`${indent}- # ${b.content}`);
|
||||
} else if (b.type === 'h2') {
|
||||
lines.push(`${indent}- ## ${b.content}`);
|
||||
} else if (b.type === 'h3') {
|
||||
lines.push(`${indent}- ### ${b.content}`);
|
||||
} else if (b.type === 'code') {
|
||||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||||
} else {
|
||||
lines.push(`${indent}- ${b.content}`);
|
||||
}
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-12 text-slate-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2 text-indigo-500" />
|
||||
<span className="text-sm">Notiz wird geladen…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
|
||||
<p className="text-sm text-rose-500 font-medium mb-2">{error}</p>
|
||||
{onBackMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-white dark:bg-slate-900 overflow-y-auto">
|
||||
{/* Top Document Action Bar */}
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-8 py-3 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200/70 dark:border-slate-800/80">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{onBackMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="lg:hidden p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
title="Zurück zur Seitenleiste"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Folder Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowFolderMenu((prev) => !prev)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700/70 transition-colors"
|
||||
title="Ordner wechseln"
|
||||
>
|
||||
<FolderIcon className="w-3.5 h-3.5 text-indigo-500" />
|
||||
<span className="truncate max-w-[160px]">
|
||||
{doc?.folderPath ? doc.folderPath : 'Hauptebene'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showFolderMenu && canEdit && (
|
||||
<div className="absolute left-0 mt-1 w-52 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl py-1 z-30 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveToFolder('')}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 ${
|
||||
!doc?.folderPath
|
||||
? 'font-semibold text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
/ (Hauptebene)
|
||||
</button>
|
||||
{availableFolders.map((folder) => (
|
||||
<button
|
||||
key={folder}
|
||||
type="button"
|
||||
onClick={() => handleMoveToFolder(folder)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 truncate ${
|
||||
doc?.folderPath === folder
|
||||
? 'font-semibold text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
📁 {folder}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read-only indicator if shared with read permission */}
|
||||
{!canEdit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300">
|
||||
<Lock className="w-3 h-3" />
|
||||
Nur Lesen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Status & Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Sync Status Pill */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium ${
|
||||
syncStatus === 'connected'
|
||||
? 'bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400'
|
||||
: syncStatus === 'connecting' || syncStatus === 'reconnecting'
|
||||
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
title={
|
||||
syncStatus === 'connected'
|
||||
? 'Echtzeit-Synchronisation aktiv (1.5s Auto-Flush in SQLite)'
|
||||
: 'Änderungen werden automatisch per REST gespeichert'
|
||||
}
|
||||
>
|
||||
{syncStatus === 'connected' ? (
|
||||
<>
|
||||
<Wifi className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Live</span>
|
||||
</>
|
||||
) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span className="hidden sm:inline">Sync…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">REST-Sync</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePin}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
doc?.isPinned
|
||||
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-500'
|
||||
: 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||
>
|
||||
<Pin className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{doc && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title="Notiz teilen / Berechtigungen"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMarkdown}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title="Als Markdown (.md) exportieren"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{canEdit && doc?.isOwner !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(docId)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors"
|
||||
title="Notiz löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Outliner Content Container */}
|
||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-10 py-6 sm:py-8 flex-1">
|
||||
{/* Document Icon + Title Header */}
|
||||
<div className="flex items-center gap-3 mb-4 relative">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
||||
className="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800/90 hover:bg-slate-200/70 dark:hover:bg-slate-700 flex items-center justify-center text-2xl transition-colors"
|
||||
title="Icon ändern"
|
||||
>
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div className="absolute left-0 mt-2 p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-xl grid grid-cols-6 gap-1 z-30 w-56">
|
||||
{EMOJI_PALETTE.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handleSelectIcon(emoji)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-lg flex items-center justify-center"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
readOnly={!canEdit}
|
||||
onChange={handleTitleChange}
|
||||
placeholder="Titel der Notiz…"
|
||||
className="flex-1 min-w-0 text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Document #Tags Summary Bar */}
|
||||
{extractedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<Hash className="w-3.5 h-3.5 text-slate-400 mr-0.5" />
|
||||
{extractedTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => onSelectTag(tag)}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logseq Hierarchical Outliner Tree */}
|
||||
<OutlinerTree
|
||||
docId={docId}
|
||||
blocks={blocks}
|
||||
canEdit={canEdit}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onTagClick={onSelectTag}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,548 @@
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ArrowDownLeft,
|
||||
Indent,
|
||||
Outdent,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
ListTodo,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
maxAllowedLevel: number;
|
||||
hasChildren: boolean;
|
||||
isFocused: boolean;
|
||||
canEdit: boolean;
|
||||
onFocusBlock: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onDragStartBlock?: (index: number) => void;
|
||||
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
||||
onDropBlock?: (index: number) => void;
|
||||
isDragTarget?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
block,
|
||||
index,
|
||||
maxAllowedLevel,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
onFocusBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onTagClick,
|
||||
onDragStartBlock,
|
||||
onDragOverBlock,
|
||||
onDropBlock,
|
||||
isDragTarget,
|
||||
registerTextareaRef,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [showTypeMenu, setShowTypeMenu] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const latestBlockRef = useRef<DocumentBlock>(block);
|
||||
latestBlockRef.current = block;
|
||||
const localContentRef = useRef<string>(localContent);
|
||||
localContentRef.current = localContent;
|
||||
|
||||
// Sync incoming remote changes ONLY when this textarea does NOT hold active focus
|
||||
useEffect(() => {
|
||||
const isActivelyFocused =
|
||||
textareaRef.current !== null && document.activeElement === textareaRef.current;
|
||||
if (!isActivelyFocused && block.content !== localContentRef.current) {
|
||||
setLocalContent(block.content || '');
|
||||
}
|
||||
}, [block.content]);
|
||||
|
||||
// Cleanup 150ms debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea height on content or layout changes
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.max(24, el.scrollHeight)}px`;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
}, [localContent, block.type, block.level, adjustHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
|
||||
const flushDebounce = useCallback(
|
||||
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
const nextBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: contentToFlush,
|
||||
...overrides,
|
||||
};
|
||||
onUpdateBlock(nextBlock, true);
|
||||
},
|
||||
[onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
let val = e.target.value;
|
||||
let typeOverride: BlockType | undefined;
|
||||
let checkedOverride: boolean | undefined;
|
||||
|
||||
// Quick markdown prefix shortcuts at start of block
|
||||
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
||||
val = val.replace(/^\[\s?\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = false;
|
||||
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
|
||||
val = val.replace(/^\[[xX]\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = true;
|
||||
} else if (val.startsWith('# ') && block.type !== 'h1') {
|
||||
val = val.slice(2);
|
||||
typeOverride = 'h1';
|
||||
} else if (val.startsWith('## ') && block.type !== 'h2') {
|
||||
val = val.slice(3);
|
||||
typeOverride = 'h2';
|
||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||
val = val.slice(4);
|
||||
typeOverride = 'h3';
|
||||
} else if (val.startsWith('/todo ')) {
|
||||
val = val.slice(6);
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = false;
|
||||
} else if (val.startsWith('/code ')) {
|
||||
val = val.slice(6);
|
||||
typeOverride = 'code';
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
if (typeOverride) {
|
||||
flushDebounce(val, {
|
||||
type: typeOverride,
|
||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
onUpdateBlock({
|
||||
...latestBlockRef.current,
|
||||
content: val,
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
|
||||
// 1. Tab / Shift+Tab -> Strict Outliner Indent / Outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
flushDebounce(localContent);
|
||||
if (e.shiftKey) {
|
||||
onIndentBlock(block.id, -1);
|
||||
} else {
|
||||
onIndentBlock(block.id, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Enter (without Shift) -> Create new block on same level
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelow(block.id, localContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Backspace on empty block -> Outdent if indented, or delete block & focus previous
|
||||
if (e.key === 'Backspace' && localContent === '') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if ((block.level || 0) > 0) {
|
||||
onIndentBlock(block.id, -1);
|
||||
} else {
|
||||
onDeleteBlock(block.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. ArrowUp / ArrowDown navigation between blocks
|
||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocus(block.id, 'up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocus(block.id, 'down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const level = Math.max(0, block.level || 0);
|
||||
const inlineTags = Array.from(
|
||||
new Set(
|
||||
(localContent.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const setBlockType = (newType: BlockType) => {
|
||||
setShowTypeMenu(false);
|
||||
flushDebounce(localContent, {
|
||||
type: newType,
|
||||
checked: newType === 'todo' ? Boolean(block.checked) : false,
|
||||
});
|
||||
};
|
||||
|
||||
const textStyleClass =
|
||||
block.type === 'h1'
|
||||
? 'text-xl font-bold text-slate-900 dark:text-white tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-lg font-semibold text-slate-800 dark:text-slate-100'
|
||||
: block.type === 'h3'
|
||||
? 'text-base font-semibold text-slate-800 dark:text-slate-200'
|
||||
: block.type === 'code'
|
||||
? 'font-mono text-xs bg-slate-900/90 text-emerald-300 px-3 py-2 rounded-lg border border-slate-700/80'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-slate-400 dark:text-slate-500 line-through'
|
||||
: 'text-sm text-slate-800 dark:text-slate-200';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1.5 py-1 rounded-lg transition-colors ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-indigo-500 bg-indigo-50/40 dark:bg-indigo-950/20'
|
||||
: isFocused
|
||||
? 'bg-slate-100/70 dark:bg-slate-800/40'
|
||||
: 'hover:bg-slate-50/80 dark:hover:bg-slate-800/20'
|
||||
}`}
|
||||
style={{ paddingLeft: `${level * 24 + 4}px` }}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
>
|
||||
{/* Indentation Guide Lines */}
|
||||
{level > 0 &&
|
||||
Array.from({ length: level }).map((_, lvlIdx) => (
|
||||
<div
|
||||
key={lvlIdx}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-slate-200/80 dark:bg-slate-800"
|
||||
style={{ left: `${lvlIdx * 24 + 14}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => onDragStartBlock?.(index)}
|
||||
title="Block ziehen oder Typ ändern"
|
||||
onClick={() => setShowTypeMenu((prev) => !prev)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-grab active:cursor-grabbing transition-opacity"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||
}
|
||||
className="p-0.5 rounded text-slate-400 hover:text-indigo-500 transition-colors"
|
||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
{block.type === 'todo' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { checked: !block.checked })
|
||||
}
|
||||
className={`p-0.5 rounded transition-colors ${
|
||||
block.checked
|
||||
? 'text-emerald-500 hover:text-emerald-600'
|
||||
: 'text-slate-400 hover:text-indigo-500'
|
||||
}`}
|
||||
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
{block.checked ? (
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowTypeMenu((prev) => !prev)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded-full group/bullet"
|
||||
title="Klicken für Block-Typ"
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-indigo-500 ring-4 ring-indigo-500/20'
|
||||
: isFocused
|
||||
? 'bg-indigo-500 scale-110'
|
||||
: 'bg-slate-400 dark:bg-slate-500 group-hover/bullet:bg-indigo-400'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Type Quick Popover */}
|
||||
{showTypeMenu && canEdit && (
|
||||
<div className="absolute left-8 top-8 z-30 flex items-center gap-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-lg p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('text')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'text' || block.type === 'paragraph' || block.type === 'bullet'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Standard-Bullet"
|
||||
>
|
||||
<Type className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('todo')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'todo'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="To-Do Checkbox"
|
||||
>
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('h1')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'h1'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Überschrift 1"
|
||||
>
|
||||
<Heading1 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('h2')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'h2'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Überschrift 2"
|
||||
>
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlockType('code')}
|
||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
||||
block.type === 'code'
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title="Code-Block"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */}
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={() => {
|
||||
setShowTypeMenu(false);
|
||||
onFocusBlock(block.id);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
flushDebounce(localContent);
|
||||
}
|
||||
}}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
index === 0 && level === 0
|
||||
? 'Schreibe einen Punkt… (Enter = Neuer Punkt, Tab = Einrücken, #tag, [] für To-Do)'
|
||||
: ''
|
||||
}
|
||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${textStyleClass}`}
|
||||
/>
|
||||
|
||||
{/* Inline clickable #tag pills */}
|
||||
{inlineTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
{inlineTags.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => onTagClick?.(t)}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
|
||||
>
|
||||
#{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Quick-Action Bar when focused */}
|
||||
{isFocused && canEdit && (
|
||||
<div className="flex sm:hidden items-center gap-1 mt-1.5 pt-1 border-t border-slate-200/60 dark:border-slate-800/60">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onIndentBlock(block.id, -1);
|
||||
}}
|
||||
disabled={level === 0}
|
||||
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
|
||||
title="Ausrücken (Shift+Tab)"
|
||||
>
|
||||
<Outdent className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onIndentBlock(block.id, 1);
|
||||
}}
|
||||
disabled={level >= maxAllowedLevel}
|
||||
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
|
||||
title="Einrücken (Tab)"
|
||||
>
|
||||
<Indent className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent, {
|
||||
type: block.type === 'todo' ? 'text' : 'todo',
|
||||
});
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800"
|
||||
title="Als To-Do umschalten"
|
||||
>
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelow(block.id, localContent);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-xs text-indigo-600 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-950/60 ml-auto flex items-center gap-1"
|
||||
>
|
||||
<ArrowDownLeft className="w-3.5 h-3.5" />
|
||||
<span>Neu</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Hover Delete Button */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBlock(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-slate-400 hover:text-rose-500 transition-opacity shrink-0"
|
||||
title="Block löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
|
||||
export interface OutlinerTreeProps {
|
||||
docId: string;
|
||||
blocks: DocumentBlock[];
|
||||
canEdit: boolean;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
}
|
||||
|
||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
docId,
|
||||
blocks,
|
||||
canEdit,
|
||||
onUpdateBlock,
|
||||
onCreateBlock,
|
||||
onDeleteBlock,
|
||||
onMoveBlock,
|
||||
onTagClick,
|
||||
}) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' } | null>(null);
|
||||
|
||||
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
|
||||
if (el) {
|
||||
textareaMapRef.current.set(blockId, el);
|
||||
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
|
||||
const target = pendingFocusRef.current;
|
||||
pendingFocusRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const pos = target.cursor === 'end' ? el.value.length : 0;
|
||||
try {
|
||||
el.setSelectionRange(pos, pos);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
textareaMapRef.current.delete(blockId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusBlockInput = useCallback((blockId: string, cursor: 'start' | 'end' = 'end') => {
|
||||
setFocusedBlockId(blockId);
|
||||
const el = textareaMapRef.current.get(blockId);
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const pos = cursor === 'end' ? el.value.length : 0;
|
||||
try {
|
||||
el.setSelectionRange(pos, pos);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pendingFocusRef.current = { blockId, cursor };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Compute visibility based on collapsed ancestor blocks
|
||||
const visibleEntries = useMemo(() => {
|
||||
const result: {
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
hasChildren: boolean;
|
||||
maxAllowedLevel: number;
|
||||
}[] = [];
|
||||
|
||||
let collapsedAboveLevel: number | null = null;
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
const lvl = Math.max(0, b.level || 0);
|
||||
|
||||
if (collapsedAboveLevel !== null) {
|
||||
if (lvl > collapsedAboveLevel) {
|
||||
continue;
|
||||
} else {
|
||||
collapsedAboveLevel = null;
|
||||
}
|
||||
}
|
||||
|
||||
const nextBlock = blocks[i + 1];
|
||||
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
|
||||
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
|
||||
|
||||
const prevBlock = i > 0 ? blocks[i - 1] : null;
|
||||
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : 0;
|
||||
|
||||
result.push({
|
||||
block: b,
|
||||
index: i,
|
||||
hasChildren,
|
||||
maxAllowedLevel,
|
||||
});
|
||||
|
||||
if (b.collapsed && hasChildren) {
|
||||
collapsedAboveLevel = lvl;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [blocks]);
|
||||
|
||||
const handleCreateBlockBelow = useCallback(
|
||||
(afterBlockId: string, _currentContent: string) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||
const inheritedType =
|
||||
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
|
||||
|
||||
const newBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
documentId: docId,
|
||||
type: inheritedType,
|
||||
content: '',
|
||||
level,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
|
||||
};
|
||||
|
||||
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
|
||||
setFocusedBlockId(newBlock.id);
|
||||
onCreateBlock(newBlock, afterBlockId);
|
||||
},
|
||||
[blocks, canEdit, docId, onCreateBlock]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
// If this is the only block in the document, clear its content/type instead of leaving 0 blocks
|
||||
if (blocks.length <= 1) {
|
||||
const only = blocks[0];
|
||||
onUpdateBlock(
|
||||
{
|
||||
...only,
|
||||
content: '',
|
||||
type: 'text',
|
||||
level: 0,
|
||||
checked: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
|
||||
if (targetFocusBlock) {
|
||||
focusBlockInput(targetFocusBlock.id, 'end');
|
||||
}
|
||||
onDeleteBlock(blockId);
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleIndentBlock = useCallback(
|
||||
(blockId: string, delta: 1 | -1) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const current = blocks[idx];
|
||||
const currentLevel = Math.max(0, current.level || 0);
|
||||
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
|
||||
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
|
||||
|
||||
const nextLevel =
|
||||
delta === 1
|
||||
? Math.min(maxLevel, currentLevel + 1)
|
||||
: Math.max(0, currentLevel - 1);
|
||||
|
||||
if (nextLevel === currentLevel) return;
|
||||
|
||||
onUpdateBlock(
|
||||
{
|
||||
...current,
|
||||
level: nextLevel,
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
[blocks, canEdit, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleNavigateFocus = useCallback(
|
||||
(blockId: string, direction: 'up' | 'down') => {
|
||||
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
|
||||
if (visIdx === -1) return;
|
||||
const targetEntry =
|
||||
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
|
||||
if (targetEntry) {
|
||||
focusBlockInput(
|
||||
targetEntry.block.id,
|
||||
direction === 'up' ? 'end' : 'start'
|
||||
);
|
||||
}
|
||||
},
|
||||
[focusBlockInput, visibleEntries]
|
||||
);
|
||||
|
||||
const handleDragStartBlock = useCallback((index: number) => {
|
||||
setDraggedIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDropBlock = useCallback(
|
||||
(targetIndex: number) => {
|
||||
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const movingBlock = blocks[draggedIndex];
|
||||
if (movingBlock) {
|
||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[blocks, draggedIndex, onMoveBlock]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDrag = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
window.addEventListener('dragend', clearDrag);
|
||||
return () => window.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onTagClick={onTagClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
<div className="pt-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock) {
|
||||
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
|
||||
} else {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
||||
onCreateBlock(firstBlock);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100/80 dark:hover:bg-slate-800/50 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neuer Punkt</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
+1
-77
@@ -602,31 +602,6 @@ 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, payload: string | { state?: string; update?: string }): Promise<{ success: boolean }> {
|
||||
const body = typeof payload === 'string' ? { state: payload } : payload;
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
async sendDocumentAwareness(id: string, payload: any): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async clearDocumentAwareness(id: string): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
||||
method: 'POST',
|
||||
@@ -675,44 +650,6 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
// --- DOCUMENT LOCKING ---
|
||||
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
|
||||
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
|
||||
},
|
||||
|
||||
async lockDocument(
|
||||
docId: string
|
||||
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
|
||||
try {
|
||||
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
|
||||
`/api/v1/documents/${docId}/lock`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
} catch (e: any) {
|
||||
return { locked: true, acquired: false, message: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
|
||||
try {
|
||||
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
return { extended: false };
|
||||
}
|
||||
},
|
||||
|
||||
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
|
||||
try {
|
||||
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch {
|
||||
return { unlocked: false };
|
||||
}
|
||||
},
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
@@ -1398,23 +1335,10 @@ export function sendWebSocketMessage(msg: any): void {
|
||||
// SSE Fallback: dispatch via REST endpoints
|
||||
try {
|
||||
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) {
|
||||
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }).catch(() => {});
|
||||
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
|
||||
api.sendDocumentAwareness(parsed.docId, parsed.payload).catch(() => {});
|
||||
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
|
||||
api.clearDocumentAwareness(parsed.docId).catch(() => {});
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave').catch(() => {});
|
||||
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join').catch(() => {});
|
||||
} else if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
||||
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
|
||||
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
|
||||
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
|
||||
api.lockDocument(parsed.docId).catch(() => {});
|
||||
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
|
||||
api.unlockDocument(parsed.docId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error dispatching fallback REST message for SSE:', err);
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -153,16 +153,18 @@ export interface SystemSettings {
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
}
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
export interface DocumentBlock {
|
||||
id: string;
|
||||
docId?: string;
|
||||
documentId?: string;
|
||||
type: BlockType;
|
||||
content: string;
|
||||
checked?: boolean;
|
||||
lang?: string;
|
||||
level?: number;
|
||||
position?: number;
|
||||
collapsed?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
@@ -211,6 +213,8 @@ export interface Document {
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderId?: string;
|
||||
markdownCache?: string;
|
||||
content: string;
|
||||
blocks: DocumentBlock[];
|
||||
tags: string[];
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { DocumentBlock } from './types';
|
||||
import { getToken, api } from './api';
|
||||
|
||||
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
||||
|
||||
export interface DocSyncEvent {
|
||||
type:
|
||||
| 'doc_state'
|
||||
| 'block_created'
|
||||
| 'block_updated'
|
||||
| 'block_deleted'
|
||||
| 'block_moved'
|
||||
| 'title_updated'
|
||||
| 'blocks_replaced';
|
||||
docId?: string;
|
||||
clientId?: string;
|
||||
userId?: string;
|
||||
title?: string;
|
||||
block?: DocumentBlock;
|
||||
blockId?: string;
|
||||
afterBlockId?: string;
|
||||
newIndex?: number;
|
||||
content?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
blockType?: string;
|
||||
blocks?: DocumentBlock[];
|
||||
}
|
||||
|
||||
export function useDocumentSync(
|
||||
docId: string | null,
|
||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||
canEdit: boolean = true
|
||||
) {
|
||||
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const clientIdRef = useRef<string>(
|
||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
);
|
||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
||||
onRemoteEventRef.current = onRemoteEvent;
|
||||
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const fallbackTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docId) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let retryCount = 0;
|
||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
||||
clientIdRef.current = currentClientId;
|
||||
|
||||
const connect = () => {
|
||||
if (isDisposed) return;
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
||||
docId
|
||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(currentClientId)}`;
|
||||
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (isDisposed) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
retryCount = 0;
|
||||
setStatus('connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (isDisposed) return;
|
||||
try {
|
||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
||||
return;
|
||||
}
|
||||
onRemoteEventRef.current(parsed);
|
||||
} catch {
|
||||
// Ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handled in onclose
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (isDisposed) return;
|
||||
wsRef.current = null;
|
||||
retryCount += 1;
|
||||
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
|
||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
|
||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
const sendRawEvent = useCallback(
|
||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
||||
if (!docId || !canEdit) return;
|
||||
const ws = wsRef.current;
|
||||
const payload: DocSyncEvent = {
|
||||
...evt,
|
||||
docId,
|
||||
clientId: clientIdRef.current,
|
||||
};
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} else if (fallbackFn) {
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
}
|
||||
fallbackTimerRef.current = window.setTimeout(() => {
|
||||
fallbackFn();
|
||||
}, 600);
|
||||
}
|
||||
},
|
||||
[docId, canEdit]
|
||||
);
|
||||
|
||||
const sendBlockCreated = useCallback(
|
||||
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_created',
|
||||
block,
|
||||
afterBlockId,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: [block],
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockUpdated = useCallback(
|
||||
(block: DocumentBlock) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_updated',
|
||||
blockId: block.id,
|
||||
block,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockDeleted = useCallback(
|
||||
(blockId: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_deleted',
|
||||
blockId,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
deletedIds: [blockId],
|
||||
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockMoved = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_moved',
|
||||
blockId,
|
||||
newIndex,
|
||||
level,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: allBlocks,
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendTitleUpdated = useCallback(
|
||||
(title: string) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'title_updated',
|
||||
title,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { title }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlocksReplaced = useCallback(
|
||||
(blocks: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'blocks_replaced',
|
||||
blocks,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { blocks }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
clientId: clientIdRef.current,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
sendBlocksReplaced,
|
||||
};
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { getToken } from './api';
|
||||
|
||||
export type SyncTransportStatus = 'ws' | 'sse' | 'offline';
|
||||
|
||||
export interface YjsHybridProviderOptions {
|
||||
docId: string;
|
||||
ydoc: Y.Doc;
|
||||
onStatusChange?: (status: SyncTransportStatus) => void;
|
||||
onAwareness?: (payload: any) => void;
|
||||
onSynced?: () => void;
|
||||
}
|
||||
|
||||
export class YjsHybridProvider {
|
||||
public readonly docId: string;
|
||||
public readonly ydoc: Y.Doc;
|
||||
private status: SyncTransportStatus = 'offline';
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
private ws: WebSocket | null = null;
|
||||
private eventSource: EventSource | null = null;
|
||||
private wsWatchdogTimer: any = null;
|
||||
private destroyed: boolean = false;
|
||||
private isConnectingWS: boolean = false;
|
||||
private isConnectingSSE: boolean = false;
|
||||
private onStatusChange?: (status: SyncTransportStatus) => void;
|
||||
private onAwareness?: (payload: any) => void;
|
||||
private onSynced?: () => void;
|
||||
|
||||
constructor(options: YjsHybridProviderOptions) {
|
||||
this.docId = options.docId;
|
||||
this.ydoc = options.ydoc;
|
||||
this.onStatusChange = options.onStatusChange;
|
||||
this.onAwareness = options.onAwareness;
|
||||
this.onSynced = options.onSynced;
|
||||
|
||||
// 1. Offline-First: Bind IndexedDB persistence
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`mindsift-doc-${this.docId}`, this.ydoc);
|
||||
this.indexeddbProvider.on('synced', () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.onSynced) {
|
||||
this.onSynced();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Listen to local document updates
|
||||
this.ydoc.on('update', this.handleDocUpdate);
|
||||
|
||||
// 3. Connect via Primary Transport (WebSocket) with 4-second watchdog fallback
|
||||
this.connect();
|
||||
|
||||
// 4. Online/offline window event listeners
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.handleOnline);
|
||||
window.addEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(newStatus: SyncTransportStatus) {
|
||||
if (this.status !== newStatus) {
|
||||
this.status = newStatus;
|
||||
if (this.onStatusChange) {
|
||||
this.onStatusChange(newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus(): SyncTransportStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
private handleOnline = () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.status === 'offline') {
|
||||
this.connect();
|
||||
}
|
||||
};
|
||||
|
||||
private handleOffline = () => {
|
||||
if (this.destroyed) return;
|
||||
this.setStatus('offline');
|
||||
};
|
||||
|
||||
public connect() {
|
||||
if (this.destroyed) return;
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
private connectWebSocket() {
|
||||
if (this.destroyed || this.isConnectingWS) return;
|
||||
this.isConnectingWS = true;
|
||||
|
||||
this.cleanupWebSocket();
|
||||
|
||||
const token = getToken() || '';
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(this.docId)}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
// 4-second watchdog timer: If WebSocket doesn't establish within 4 seconds, fallback to HTTP/SSE
|
||||
this.clearWatchdog();
|
||||
this.wsWatchdogTimer = setTimeout(() => {
|
||||
if (this.status !== 'ws') {
|
||||
console.warn(`[YjsProvider] WebSocket timeout (4s) for doc ${this.docId}. Switching to HTTP fallback (SSE + POST)...`);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.isConnectingWS = false;
|
||||
this.clearWatchdog();
|
||||
this.cleanupSSE();
|
||||
this.setStatus('ws');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const update = new Uint8Array(event.data);
|
||||
Y.applyUpdate(this.ydoc, update, 'remote');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'CRDT_INIT' && data.payload) {
|
||||
this.handleInit(data.payload);
|
||||
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
|
||||
this.handleRemoteUpdate(data.payload);
|
||||
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
|
||||
if (this.onAwareness) {
|
||||
this.onAwareness(data.payload);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[YjsProvider] WS message parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.warn(`[YjsProvider] WebSocket error on doc ${this.docId}:`, err);
|
||||
this.isConnectingWS = false;
|
||||
this.fallbackToHTTP();
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
this.isConnectingWS = false;
|
||||
// If closed abnormally (proxy drop, 1006, etc.), fallback to HTTP
|
||||
if (!this.destroyed && this.status === 'ws') {
|
||||
console.warn(`[YjsProvider] WebSocket closed (code ${event.code}). Falling back to HTTP/SSE...`);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[YjsProvider] WebSocket creation failed:`, e);
|
||||
this.isConnectingWS = false;
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackToHTTP() {
|
||||
if (this.destroyed) return;
|
||||
this.cleanupWebSocket();
|
||||
this.connectSSE();
|
||||
}
|
||||
|
||||
private connectSSE() {
|
||||
if (this.destroyed || this.isConnectingSSE) return;
|
||||
this.isConnectingSSE = true;
|
||||
|
||||
this.cleanupSSE();
|
||||
|
||||
const token = getToken() || '';
|
||||
const sseUrl = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/stream?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
const es = new EventSource(sseUrl);
|
||||
this.eventSource = es;
|
||||
|
||||
es.onopen = () => {
|
||||
this.isConnectingSSE = false;
|
||||
this.setStatus('sse');
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'CRDT_INIT' && data.payload) {
|
||||
this.handleInit(data.payload);
|
||||
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
|
||||
this.handleRemoteUpdate(data.payload);
|
||||
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
|
||||
if (this.onAwareness) {
|
||||
this.onAwareness(data.payload);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
this.isConnectingSSE = false;
|
||||
if (!this.destroyed) {
|
||||
this.setStatus('offline');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
this.isConnectingSSE = false;
|
||||
this.setStatus('offline');
|
||||
}
|
||||
}
|
||||
|
||||
private handleInit(payload: { state?: string; updates?: string[] }) {
|
||||
if (payload.state) {
|
||||
try {
|
||||
const stateBytes = this.base64ToUint8(payload.state);
|
||||
Y.applyUpdate(this.ydoc, stateBytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply initial state:', e);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(payload.updates)) {
|
||||
for (const u of payload.updates) {
|
||||
if (u) {
|
||||
try {
|
||||
const updateBytes = this.base64ToUint8(u);
|
||||
Y.applyUpdate(this.ydoc, updateBytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply initial update:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.onSynced) {
|
||||
this.onSynced();
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteUpdate(payload: { update?: string }) {
|
||||
if (payload.update) {
|
||||
try {
|
||||
const bytes = this.base64ToUint8(payload.update);
|
||||
Y.applyUpdate(this.ydoc, bytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply remote update:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDocUpdate = (update: Uint8Array, origin: any) => {
|
||||
// Only send updates originating locally (ignore 'remote' and 'indexeddb')
|
||||
if (this.destroyed || origin === 'remote' || origin === this.indexeddbProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(update);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[YjsProvider] Failed to send update over WS, falling back to HTTP POST:', e);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream via HTTP POST fallback
|
||||
this.sendUpdateViaHTTP(update);
|
||||
};
|
||||
|
||||
private async sendUpdateViaHTTP(update: Uint8Array) {
|
||||
const token = getToken() || '';
|
||||
const url = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/update?token=${encodeURIComponent(token)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: new Blob([update as any]),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[YjsProvider] HTTP sync update status: ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[YjsProvider] Network error during HTTP sync update (persisted locally in IndexedDB):', e);
|
||||
this.setStatus('offline');
|
||||
}
|
||||
}
|
||||
|
||||
public sendAwareness(payload: any) {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({
|
||||
event: 'CRDT_AWARENESS',
|
||||
docId: this.docId,
|
||||
payload,
|
||||
}));
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// HTTP awareness fallback
|
||||
const token = getToken() || '';
|
||||
fetch(`/api/v1/documents/${encodeURIComponent(this.docId)}/awareness`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
private clearWatchdog() {
|
||||
if (this.wsWatchdogTimer) {
|
||||
clearTimeout(this.wsWatchdogTimer);
|
||||
this.wsWatchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupWebSocket() {
|
||||
this.clearWatchdog();
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupSSE() {
|
||||
if (this.eventSource) {
|
||||
try {
|
||||
this.eventSource.onopen = null;
|
||||
this.eventSource.onmessage = null;
|
||||
this.eventSource.onerror = null;
|
||||
this.eventSource.close();
|
||||
} catch {}
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private base64ToUint8(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.destroyed = true;
|
||||
this.clearWatchdog();
|
||||
this.cleanupWebSocket();
|
||||
this.cleanupSSE();
|
||||
this.ydoc.off('update', this.handleDocUpdate);
|
||||
if (this.indexeddbProvider) {
|
||||
try {
|
||||
this.indexeddbProvider.destroy();
|
||||
} catch {}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('online', this.handleOnline);
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-225
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -32,7 +31,7 @@ type Server struct {
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
if wsHub != nil && store != nil {
|
||||
wsHub.SetCRDTStore(store)
|
||||
wsHub.SetDocStore(store)
|
||||
}
|
||||
srv := &Server{
|
||||
cfg: cfg,
|
||||
@@ -70,8 +69,6 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
|
||||
r.Get("/api/v1/sync/docs/{id}/stream", s.handleDocSyncStream)
|
||||
r.Post("/api/v1/sync/docs/{id}/update", s.handleDocSyncUpdate)
|
||||
r.Get("/api/v1/events", s.handleSSE)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
|
||||
@@ -144,18 +141,6 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||||
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||||
|
||||
// Document Locking
|
||||
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
|
||||
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
|
||||
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||||
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||||
|
||||
// Document CRDT Realtime Sync
|
||||
r.Get("/api/v1/documents/{id}/crdt", s.handleGetDocCRDT)
|
||||
r.Put("/api/v1/documents/{id}/crdt", s.handleSaveDocCRDT)
|
||||
r.Post("/api/v1/documents/{id}/awareness", s.handlePostDocAwareness)
|
||||
r.Delete("/api/v1/documents/{id}/awareness", s.handleDeleteDocAwareness)
|
||||
|
||||
// Shared Lists & Realtime Collaboration
|
||||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||||
r.Post("/api/v1/lists", s.handleCreateList)
|
||||
@@ -864,6 +849,18 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil {
|
||||
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
|
||||
if liveTitle != "" {
|
||||
doc.Title = liveTitle
|
||||
}
|
||||
if len(liveBlocks) > 0 {
|
||||
doc.Blocks = liveBlocks
|
||||
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
|
||||
doc.Content = doc.MarkdownCache
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
@@ -889,6 +886,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
|
||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||
}
|
||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||
@@ -911,6 +911,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||
}
|
||||
|
||||
patchEvent := domain.DocBlocksPatchEvent{
|
||||
DocID: docID,
|
||||
@@ -933,6 +936,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
||||
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.CloseDocRoom(docID)
|
||||
}
|
||||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -1307,30 +1313,7 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- DOCUMENT LOCKING (Neutralized - Yjs CRDT Pessimistic Locks Removed) ---
|
||||
|
||||
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||||
}
|
||||
|
||||
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"locked": false,
|
||||
"acquired": true,
|
||||
"docId": docID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": true})
|
||||
}
|
||||
|
||||
// --- DOCUMENT CRDT REALTIME SYNC & DUAL TRANSPORT ---
|
||||
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
||||
|
||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
token := r.URL.Query().Get("token")
|
||||
@@ -1379,192 +1362,9 @@ func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
clientID := r.URL.Query().Get("clientId")
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocWS(w, r, userID)
|
||||
}
|
||||
|
||||
// GET /api/v1/sync/docs/{id}/stream
|
||||
func (s *Server) handleDocSyncStream(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
|
||||
if userID != "anonymous" {
|
||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocSSE(w, r, userID)
|
||||
}
|
||||
|
||||
// POST /api/v1/sync/docs/{id}/update
|
||||
func (s *Server) handleDocSyncUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
|
||||
return
|
||||
}
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
var updateBytes []byte
|
||||
|
||||
if strings.Contains(contentType, "application/octet-stream") {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Fehler beim Lesen des Payloads")
|
||||
return
|
||||
}
|
||||
updateBytes = body
|
||||
} else {
|
||||
var req struct {
|
||||
Update string `json:"update"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(req.State); err == nil && len(raw) > 0 {
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.CompactState(raw, userID)
|
||||
}
|
||||
}
|
||||
if req.Update != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.Update)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 für Update")
|
||||
return
|
||||
}
|
||||
updateBytes = raw
|
||||
}
|
||||
}
|
||||
|
||||
if len(updateBytes) > 0 {
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ApplyUpdate(updateBytes, userID, nil, nil)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "docId": docID})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
b64State, b64Updates := room.GetInitialSnapshot()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
"updates": b64Updates,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
State string `json:"state"`
|
||||
Update string `json:"update"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
if req.Update != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.Update)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
|
||||
return
|
||||
}
|
||||
room.ApplyUpdate(raw, userID, nil, nil)
|
||||
}
|
||||
if req.State != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.State)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format")
|
||||
return
|
||||
}
|
||||
room.CompactState(raw, userID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handlePostDocAwareness(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
_ = doc
|
||||
|
||||
var payload json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", payload, nil)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDocAwareness(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": userID,
|
||||
}, nil)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
room.ServeDocWS(w, r, userID, clientID)
|
||||
}
|
||||
|
||||
// --- REALTIME LISTS & SHARING ---
|
||||
|
||||
+50
-155
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -842,9 +841,9 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCRDTEndpoints(t *testing.T) {
|
||||
func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "crdt_api_test.db")
|
||||
dbPath := filepath.Join(tmpDir, "docroom_test.db")
|
||||
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -860,59 +859,76 @@ func TestDocumentCRDTEndpoints(t *testing.T) {
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
user, err := store.CreateUser("crdt_api@example.com", "hash", "CRDT API User", "member")
|
||||
user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "API CRDT Doc", "📄", "", nil, nil, false)
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Initial GET /api/v1/documents/{id}/crdt
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil)
|
||||
// 1. Mutate in-memory DocRoom before 1.5s flush fires
|
||||
room := hub.GetOrCreateDocRoom(doc.ID)
|
||||
newContent := "Live RAM Block #ramtag"
|
||||
newLevel := 1
|
||||
room.HandleEvent(ws.DocSyncEvent{
|
||||
Type: "title_updated",
|
||||
ClientID: "cli_1",
|
||||
Title: "RAM Updated Title",
|
||||
}, nil)
|
||||
room.HandleEvent(ws.DocSyncEvent{
|
||||
Type: "block_created",
|
||||
ClientID: "cli_1",
|
||||
AfterBlockID: doc.Blocks[0].ID,
|
||||
Block: &domain.DocumentBlock{
|
||||
ID: "blk_ram_2",
|
||||
Type: "text",
|
||||
Content: newContent,
|
||||
Level: newLevel,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from GET crdt, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var liveDoc domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
|
||||
if liveDoc.Title != "RAM Updated Title" {
|
||||
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
|
||||
}
|
||||
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
|
||||
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
|
||||
}
|
||||
|
||||
// 2. PUT /api/v1/documents/{id}/crdt with base64 state
|
||||
savePayload := []byte(`{"state":"AQIDBA=="}`)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(savePayload))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
// 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
|
||||
room.Flush()
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from PUT crdt, got %d: %s", rec.Code, rec.Body.String())
|
||||
persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Verify state persisted
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from GET crdt, got %d", rec.Code)
|
||||
if persistedDoc.Title != "RAM Updated Title" {
|
||||
t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
|
||||
}
|
||||
var crdtResp struct {
|
||||
DocID string `json:"docId"`
|
||||
State string `json:"state"`
|
||||
if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
|
||||
t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &crdtResp)
|
||||
if crdtResp.State != "AQIDBA==" {
|
||||
t.Fatalf("expected state 'AQIDBA==', got '%s'", crdtResp.State)
|
||||
tags, _ := store.GetWorkspaceTags(user.ID)
|
||||
if len(tags) != 1 || tags[0].Tag != "ramtag" {
|
||||
t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -921,7 +937,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
defer store.Close()
|
||||
|
||||
hub := ws.NewHub()
|
||||
hub.SetCRDTStore(store)
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
@@ -929,7 +944,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Setup user
|
||||
setupBody := []byte(`{
|
||||
"email": "sse_user@example.com",
|
||||
"password": "secretpassword",
|
||||
@@ -952,51 +966,13 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||
token := authResp.Token
|
||||
|
||||
// 2. Create Document
|
||||
doc, err := store.CreateDocument(authResp.User.ID, "", "SSE Collaborative Note", "📄", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create document: %v", err)
|
||||
}
|
||||
|
||||
// 3. Test Awareness POST and DELETE
|
||||
awarenessBody := []byte(`{"userId":"` + authResp.User.ID + `","activeBlockId":"blk_123"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/awareness", bytes.NewReader(awarenessBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from POST awareness, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/documents/"+doc.ID+"/awareness", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from DELETE awareness, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 4. Test Delta CRDT Update via PUT /api/v1/documents/{id}/crdt
|
||||
updateBody := []byte(`{"update":"AQIDBA=="}`)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from PUT crdt update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test SSE Connection GET /api/v1/events
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token+"&docId="+doc.ID, nil).WithContext(ctx)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
// Cancel context after short moment to terminate stream cleanly
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
@@ -1007,92 +983,11 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
|
||||
}
|
||||
if rec.Header().Get("X-Accel-Buffering") != "no" {
|
||||
t.Fatalf("expected X-Accel-Buffering: no, got %s", rec.Header().Get("X-Accel-Buffering"))
|
||||
}
|
||||
bodyStr := rec.Body.String()
|
||||
if !strings.Contains(bodyStr, "SSE_CONNECTED") {
|
||||
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRoomDualTransportAndDebouncedPersistence(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_dual_transport.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-secret-dual-transport",
|
||||
AllowRegistration: true,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("dual@test.local", "hash", "Dual User", "member")
|
||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
|
||||
|
||||
doc, _ := store.CreateDocument(user.ID, "", "Dual Transport Doc", "📄", "", nil, nil, false)
|
||||
|
||||
// 1. Test POST /api/v1/sync/docs/{id}/update with JSON base64
|
||||
upd1 := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader([]byte(`{"update":"`+upd1+`"}`)))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from sync update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 2. Test POST /api/v1/sync/docs/{id}/update with octet-stream binary
|
||||
binaryUpd := []byte{0x04, 0x05, 0x06}
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader(binaryUpd))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from binary sync update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 3. Test GET /api/v1/sync/docs/{id}/stream SSE connection
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/sync/docs/"+doc.ID+"/stream?token="+token, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") {
|
||||
t.Fatalf("expected text/event-stream, got %s", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
bodyStr := rec.Body.String()
|
||||
if !strings.Contains(bodyStr, "CRDT_INIT") {
|
||||
t.Fatalf("expected CRDT_INIT in SSE stream, got %s", bodyStr)
|
||||
}
|
||||
|
||||
// 4. Test Lock Neutrality: document update and block patch work without 423 Locked
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader([]byte(`{"title":"Updated Without Locks"}`)))
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRec, updateReq)
|
||||
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from document update, got %d: %s", updateRec.Code, updateRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,14 +261,48 @@ type SystemSettings struct {
|
||||
|
||||
type DocumentBlock struct {
|
||||
ID string `json:"id"`
|
||||
DocumentID string `json:"documentId,omitempty"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Level int `json:"level"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||
var sb strings.Builder
|
||||
for _, b := range blocks {
|
||||
lvl := b.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
indent := strings.Repeat(" ", lvl)
|
||||
switch b.Type {
|
||||
case "h1":
|
||||
sb.WriteString(indent + "- # " + b.Content + "\n")
|
||||
case "h2":
|
||||
sb.WriteString(indent + "- ## " + b.Content + "\n")
|
||||
case "h3":
|
||||
sb.WriteString(indent + "- ### " + b.Content + "\n")
|
||||
case "todo":
|
||||
mark := "[ ]"
|
||||
if b.Checked {
|
||||
mark = "[x]"
|
||||
}
|
||||
sb.WriteString(indent + "- " + mark + " " + b.Content + "\n")
|
||||
case "code":
|
||||
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
|
||||
case "divider":
|
||||
sb.WriteString(indent + "- ---\n")
|
||||
default:
|
||||
sb.WriteString(indent + "- " + b.Content + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type DocBlocksPatchRequest struct {
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
@@ -316,8 +350,10 @@ type Document struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
MarkdownCache string `json:"markdownCache,omitempty"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderID string `json:"folderId,omitempty"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
|
||||
+162
-152
@@ -162,9 +162,11 @@ func (s *Store) migrate() error {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
space_id TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
folder_id TEXT NOT NULL DEFAULT '',
|
||||
markdown_cache TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
@@ -172,13 +174,40 @@ func (s *Store) migrate() error {
|
||||
folder_path TEXT NOT NULL DEFAULT '',
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
level INT NOT NULL DEFAULT 0,
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL DEFAULT 'bullet',
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
lang TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS block_tags (
|
||||
tag TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
block_id TEXT NOT NULL,
|
||||
PRIMARY KEY(tag, block_id),
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
@@ -208,22 +237,6 @@ func (s *Store) migrate() error {
|
||||
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
|
||||
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_block_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
block_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(doc_id, block_id, tag),
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_pages (
|
||||
user_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
@@ -232,25 +245,6 @@ func (s *Store) migrate() error {
|
||||
PRIMARY KEY(user_id, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_crdt_state (
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
state_blob BLOB NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_crdt_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id TEXT NOT NULL,
|
||||
update_blob BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_crdt_updates_doc ON document_crdt_updates(doc_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -382,17 +376,28 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS document_blocks (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, sort_order REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', content TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, updated_by TEXT NOT NULL DEFAULT '', FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);",
|
||||
"ALTER TABLE documents ADD COLUMN folder_id TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE documents ADD COLUMN markdown_cache TEXT NOT NULL DEFAULT '';",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS blocks (id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', level INT NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
|
||||
}
|
||||
for _, q := range columnMigrations {
|
||||
_, _ = s.db.Exec(q)
|
||||
}
|
||||
|
||||
// Clean up legacy space_id from documents and task_lists if empty or orphaned
|
||||
// Clean up legacy space and old document block/crdt tables
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`)
|
||||
|
||||
// Backfill username for existing accounts where username is empty
|
||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||
@@ -445,7 +450,7 @@ func (s *Store) migrate() error {
|
||||
|
||||
// Reindex block tags if table is newly added or empty
|
||||
var tagCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM block_tags`).Scan(&tagCount)
|
||||
if tagCount == 0 {
|
||||
_ = s.reindexAllDocBlockTags()
|
||||
}
|
||||
@@ -1269,7 +1274,7 @@ func (s *Store) migrateWorkspaceToPureDB() {
|
||||
|
||||
for _, md := range docsToMigrate {
|
||||
var count int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, md.id).Scan(&count)
|
||||
if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" {
|
||||
var blocks []domain.DocumentBlock
|
||||
if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
|
||||
@@ -1327,17 +1332,17 @@ func (s *Store) migrateWorkspaceToPureDB() {
|
||||
|
||||
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, type, content, level, checked, collapsed, lang
|
||||
FROM document_blocks
|
||||
WHERE doc_id = ?
|
||||
ORDER BY sort_order ASC, rowid ASC
|
||||
SELECT id, document_id, type, content, level, position, checked, collapsed, lang
|
||||
FROM blocks
|
||||
WHERE document_id = ?
|
||||
ORDER BY position ASC, rowid ASC
|
||||
`, docID)
|
||||
if err == nil {
|
||||
blocks := make([]domain.DocumentBlock, 0)
|
||||
for rows.Next() {
|
||||
var b domain.DocumentBlock
|
||||
var chk, col int
|
||||
if scanErr := rows.Scan(&b.ID, &b.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil {
|
||||
if scanErr := rows.Scan(&b.ID, &b.DocumentID, &b.Type, &b.Content, &b.Level, &b.Position, &chk, &col, &b.Lang); scanErr == nil {
|
||||
b.Checked = chk != 0
|
||||
b.Collapsed = col != 0
|
||||
blocks = append(blocks, b)
|
||||
@@ -1359,31 +1364,24 @@ func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.Docume
|
||||
return blocks
|
||||
}
|
||||
|
||||
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID)
|
||||
if len(blocks) == 0 {
|
||||
return
|
||||
}
|
||||
func (s *Store) replaceDocBlocksInDB(docID, _ string, blocks []domain.DocumentBlock, now string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
|
||||
if len(blocks) > 0 {
|
||||
stmt, err := s.db.Prepare(`
|
||||
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
doc_id = excluded.doc_id,
|
||||
sort_order = excluded.sort_order,
|
||||
type = excluded.type,
|
||||
document_id = excluded.document_id,
|
||||
content = excluded.content,
|
||||
level = excluded.level,
|
||||
position = excluded.position,
|
||||
type = excluded.type,
|
||||
checked = excluded.checked,
|
||||
collapsed = excluded.collapsed,
|
||||
lang = excluded.lang,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
lang = excluded.lang
|
||||
`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
defer stmt.Close()
|
||||
|
||||
for i, b := range blocks {
|
||||
bID := strings.TrimSpace(b.ID)
|
||||
if bID == "" {
|
||||
@@ -1401,8 +1399,76 @@ func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.Docum
|
||||
if b.Collapsed {
|
||||
col = 1
|
||||
}
|
||||
_, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
|
||||
pos := b.Position
|
||||
if pos <= 0 {
|
||||
pos = float64((i + 1) * 1000)
|
||||
}
|
||||
_, _ = stmt.Exec(bID, docID, b.Content, b.Level, pos, bType, chk, col, b.Lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update markdown_cache on documents
|
||||
mdCache := domain.CompileBlocksToMarkdown(blocks)
|
||||
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, updated_at = ? WHERE id = ?`, mdCache, now, docID)
|
||||
}
|
||||
|
||||
func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) {
|
||||
var title, blocksJSON string
|
||||
err := s.db.QueryRow(`SELECT title, blocks_json FROM documents WHERE id = ?`, docID).Scan(&title, &blocksJSON)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
blocks := s.loadDocBlocks(docID, blocksJSON)
|
||||
return title, blocks, nil
|
||||
}
|
||||
|
||||
// FlushDocumentState persists the in-memory blocks into `blocks`, extracts all #tags into `block_tags`,
|
||||
// and compiles the hierarchical Markdown into `documents.markdown_cache`.
|
||||
func (s *Store) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error {
|
||||
now := domain.NowISO()
|
||||
|
||||
// Normalize positions sequentially
|
||||
normalized := make([]domain.DocumentBlock, len(blocks))
|
||||
var plainBuilder strings.Builder
|
||||
for i, b := range blocks {
|
||||
if strings.TrimSpace(b.ID) == "" {
|
||||
b.ID = domain.NewID("blk")
|
||||
}
|
||||
if b.Type == "" {
|
||||
b.Type = "bullet"
|
||||
}
|
||||
b.DocumentID = docID
|
||||
b.Position = float64((i + 1) * 1000)
|
||||
normalized[i] = b
|
||||
if b.Content != "" {
|
||||
plainBuilder.WriteString(b.Content)
|
||||
plainBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
s.replaceDocBlocksInDB(docID, "", normalized, now)
|
||||
_ = s.indexDocBlockTags(docID, "", normalized)
|
||||
|
||||
mdCache := domain.CompileBlocksToMarkdown(normalized)
|
||||
plainContent := plainBuilder.String()
|
||||
blocksJSON, _ := json.Marshal(normalized)
|
||||
|
||||
if strings.TrimSpace(title) != "" {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE documents
|
||||
SET title = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, title, mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE documents
|
||||
SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions.
|
||||
@@ -1417,16 +1483,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
|
||||
now := domain.NowISO()
|
||||
|
||||
// Ensure document_blocks is seeded if previously only in blocks_json
|
||||
// Ensure blocks table is seeded if previously only in blocks_json
|
||||
var existingCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, docID).Scan(&existingCount)
|
||||
if existingCount == 0 && len(doc.Blocks) > 0 {
|
||||
s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt)
|
||||
}
|
||||
|
||||
// Read current ordered block IDs from DB before patch
|
||||
var existingOrder []string
|
||||
ordRows, err := s.db.Query(`SELECT id FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID)
|
||||
ordRows, err := s.db.Query(`SELECT id FROM blocks WHERE document_id = ? ORDER BY position ASC, rowid ASC`, docID)
|
||||
if err == nil {
|
||||
for ordRows.Next() {
|
||||
var bid string
|
||||
@@ -1445,7 +1511,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
continue
|
||||
}
|
||||
deletedSet[delID] = true
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID)
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ? AND id = ?`, docID, delID)
|
||||
}
|
||||
|
||||
// Filter existingOrder to exclude deleted blocks
|
||||
@@ -1479,18 +1545,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
}
|
||||
defaultSort := float64((len(survivingExisting) + idx + 1) * 1000)
|
||||
_, _ = s.db.Exec(`
|
||||
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
content = excluded.content,
|
||||
level = excluded.level,
|
||||
checked = excluded.checked,
|
||||
collapsed = excluded.collapsed,
|
||||
lang = excluded.lang,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
`, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
|
||||
lang = excluded.lang
|
||||
`, bID, docID, b.Content, b.Level, defaultSort, bType, chk, col, b.Lang)
|
||||
upsertedIDs = append(upsertedIDs, bID)
|
||||
survivingSet[bID] = true
|
||||
}
|
||||
@@ -1506,8 +1570,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
inMerged[bid] = true
|
||||
}
|
||||
}
|
||||
// Any surviving block in DB that was not in req.BlockOrder (e.g. concurrently added by another user)
|
||||
// is inserted right after its predecessor from survivingExisting!
|
||||
for i, bid := range survivingExisting {
|
||||
if inMerged[bid] {
|
||||
continue
|
||||
@@ -1528,7 +1590,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...)
|
||||
inMerged[bid] = true
|
||||
}
|
||||
// Ensure any upserted block not in BlockOrder is also appended
|
||||
for _, bid := range upsertedIDs {
|
||||
if !inMerged[bid] {
|
||||
mergedOrder = append(mergedOrder, bid)
|
||||
@@ -1550,9 +1611,9 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
}
|
||||
}
|
||||
|
||||
// Update sort_order in document_blocks
|
||||
// Update position in blocks
|
||||
for idx, bid := range mergedOrder {
|
||||
_, _ = s.db.Exec(`UPDATE document_blocks SET sort_order = ? WHERE doc_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
|
||||
_, _ = s.db.Exec(`UPDATE blocks SET position = ? WHERE document_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
|
||||
}
|
||||
|
||||
// Reload authoritative block list and update document snapshot
|
||||
@@ -1565,12 +1626,14 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
}
|
||||
}
|
||||
plainContent := plainBuilder.String()
|
||||
mdCache := domain.CompileBlocksToMarkdown(finalBlocks)
|
||||
blocksJSON, _ := json.Marshal(finalBlocks)
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
||||
plainContent, string(blocksJSON), now, docID)
|
||||
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
||||
mdCache, plainContent, string(blocksJSON), now, docID)
|
||||
|
||||
doc.Blocks = finalBlocks
|
||||
doc.MarkdownCache = mdCache
|
||||
doc.Content = plainContent
|
||||
doc.UpdatedAt = now
|
||||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks)
|
||||
@@ -1578,59 +1641,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
||||
return doc, mergedOrder, nil
|
||||
}
|
||||
|
||||
// GetDocumentCRDTState returns the compacted CRDT state blob and any pending updates for a document.
|
||||
func (s *Store) GetDocumentCRDTState(docID string) ([]byte, [][]byte, error) {
|
||||
var stateBlob []byte
|
||||
err := s.db.QueryRow(`SELECT state_blob FROM document_crdt_state WHERE doc_id = ?`, docID).Scan(&stateBlob)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT update_blob FROM document_crdt_updates WHERE doc_id = ? ORDER BY id ASC`, docID)
|
||||
if err != nil {
|
||||
return stateBlob, nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var updates [][]byte
|
||||
for rows.Next() {
|
||||
var u []byte
|
||||
if err := rows.Scan(&u); err == nil && len(u) > 0 {
|
||||
updates = append(updates, u)
|
||||
}
|
||||
}
|
||||
return stateBlob, updates, nil
|
||||
}
|
||||
|
||||
// AppendDocumentCRDTUpdate appends a new CRDT update delta for a document.
|
||||
func (s *Store) AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error {
|
||||
now := domain.NowISO()
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO document_crdt_updates (doc_id, update_blob, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, docID, update, now, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveDocumentCRDTState saves a compacted snapshot of the document CRDT state and prunes older incremental updates.
|
||||
func (s *Store) SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error {
|
||||
now := domain.NowISO()
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO document_crdt_state (doc_id, state_blob, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(doc_id) DO UPDATE SET
|
||||
state_blob = excluded.state_blob,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
`, docID, stateBlob, now, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Prune incremental updates older than the snapshot
|
||||
_, _ = s.db.Exec(`DELETE FROM document_crdt_updates WHERE doc_id = ?`, docID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
|
||||
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
|
||||
userSet := make(map[string]bool)
|
||||
@@ -1964,6 +1974,8 @@ func (s *Store) scanDocumentWithBlocks(rows interface {
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Tags = stripFolderTags(d.Tags)
|
||||
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
|
||||
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
|
||||
d.FolderID = d.FolderPath
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -2303,8 +2315,8 @@ func (s *Store) DeleteDocument(userID, docID string) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||||
return s.DeleteDocShare(userID, docID, userID)
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID)
|
||||
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID)
|
||||
return err
|
||||
@@ -2338,27 +2350,25 @@ func (s *Store) reindexAllDocBlockTags() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||||
func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := domain.NowISO()
|
||||
for _, b := range blocks {
|
||||
if b.Content == "" {
|
||||
continue
|
||||
}
|
||||
tags := domain.ExtractTags(b.Content)
|
||||
for _, tag := range tags {
|
||||
tagID := domain.NewID("dbtag")
|
||||
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
|
||||
_, _ = stmt.Exec(tag, docID, b.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -2384,9 +2394,9 @@ func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
|
||||
|
||||
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
|
||||
FROM doc_block_tags t
|
||||
JOIN documents d ON t.doc_id = d.id
|
||||
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
|
||||
FROM block_tags t
|
||||
JOIN documents d ON t.document_id = d.id
|
||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
SELECT dts2.doc_id
|
||||
@@ -2451,8 +2461,8 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
||||
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||||
COALESCE(u.display_name, '') as owner_name,
|
||||
COALESCE(s.permission, dts.permission, '') as shared_permission
|
||||
FROM doc_block_tags t
|
||||
JOIN documents d ON t.doc_id = d.id
|
||||
FROM block_tags t
|
||||
JOIN documents d ON t.document_id = d.id
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||
LEFT JOIN (
|
||||
@@ -2768,8 +2778,8 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
||||
|
||||
// Delete documents from SQLite, blocks, pins, shares, tags, user_folders
|
||||
for _, id := range deletedDocIDs {
|
||||
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
|
||||
_, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id)
|
||||
|
||||
@@ -683,9 +683,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCRDTState(t *testing.T) {
|
||||
func TestFlushDocumentStateAndSchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "crdt_test.db")
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -693,52 +693,60 @@ func TestDocumentCRDTState(t *testing.T) {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("crdt@example.com", "hash", "CRDT User", "member")
|
||||
user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "CRDT Test Doc", "📝", "", nil, nil, false)
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Initial CRDT state is empty
|
||||
state, updates, err := store.GetDocumentCRDTState(doc.ID)
|
||||
blocks := []domain.DocumentBlock{
|
||||
{
|
||||
ID: "blk_root_1",
|
||||
Type: "text",
|
||||
Content: "Architektur Übersicht #backend #go",
|
||||
Level: 0,
|
||||
},
|
||||
{
|
||||
ID: "blk_child_1",
|
||||
Type: "todo",
|
||||
Content: "SQLite WAL Modus aktivieren #sqlite",
|
||||
Level: 1,
|
||||
Checked: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
|
||||
t.Fatalf("FlushDocumentState failed: %v", err)
|
||||
}
|
||||
|
||||
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentCRDTState failed: %v", err)
|
||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
||||
}
|
||||
if len(state) != 0 || len(updates) != 0 {
|
||||
t.Fatalf("expected empty CRDT state, got state=%d updates=%d", len(state), len(updates))
|
||||
if updatedDoc.Title != "Architektur Notiz" {
|
||||
t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
|
||||
}
|
||||
if len(updatedDoc.Blocks) != 2 {
|
||||
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
|
||||
}
|
||||
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
|
||||
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
|
||||
}
|
||||
if updatedDoc.MarkdownCache == "" {
|
||||
t.Fatalf("expected markdown_cache to be populated")
|
||||
}
|
||||
|
||||
// 2. Append incremental updates
|
||||
u1 := []byte{1, 2, 3, 4}
|
||||
u2 := []byte{5, 6, 7, 8}
|
||||
if err := store.AppendDocumentCRDTUpdate(doc.ID, u1, user.ID); err != nil {
|
||||
t.Fatalf("AppendDocumentCRDTUpdate 1 failed: %v", err)
|
||||
}
|
||||
if err := store.AppendDocumentCRDTUpdate(doc.ID, u2, user.ID); err != nil {
|
||||
t.Fatalf("AppendDocumentCRDTUpdate 2 failed: %v", err)
|
||||
}
|
||||
|
||||
_, updatesAfter, err := store.GetDocumentCRDTState(doc.ID)
|
||||
if err != nil || len(updatesAfter) != 2 {
|
||||
t.Fatalf("expected 2 updates, got %d (err: %v)", len(updatesAfter), err)
|
||||
}
|
||||
|
||||
// 3. Compact snapshot state
|
||||
snapshot := []byte{99, 100, 101}
|
||||
if err := store.SaveDocumentCRDTState(doc.ID, snapshot, user.ID); err != nil {
|
||||
t.Fatalf("SaveDocumentCRDTState failed: %v", err)
|
||||
}
|
||||
|
||||
stateAfter, updatesAfterCompact, err := store.GetDocumentCRDTState(doc.ID)
|
||||
tags, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentCRDTState after compact failed: %v", err)
|
||||
t.Fatalf("GetWorkspaceTags failed: %v", err)
|
||||
}
|
||||
if len(stateAfter) != 3 || len(updatesAfterCompact) != 0 {
|
||||
t.Fatalf("expected compacted state of length 3 and 0 updates, got state=%d updates=%d", len(stateAfter), len(updatesAfterCompact))
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+370
-301
@@ -1,9 +1,7 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -19,158 +17,369 @@ type DocWSClient struct {
|
||||
room *DocRoom
|
||||
conn *websocket.Conn
|
||||
userID string
|
||||
clientID string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// DocSSEClient represents an active SSE connection to a specific document room.
|
||||
type DocSSEClient struct {
|
||||
hub *Hub
|
||||
room *DocRoom
|
||||
id string
|
||||
userID string
|
||||
send chan []byte
|
||||
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
|
||||
type DocSyncEvent struct {
|
||||
Type string `json:"type"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
AfterBlockID string `json:"afterBlockId,omitempty"`
|
||||
NewIndex *int `json:"newIndex,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Level *int `json:"level,omitempty"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
BlockType *string `json:"blockType,omitempty"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// DocRoom coordinates in-memory real-time updates and debounced persistence for one document.
|
||||
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
|
||||
type DocRoom struct {
|
||||
mu sync.Mutex
|
||||
docID string
|
||||
hub *Hub
|
||||
stateBlob []byte
|
||||
pendingUpdates [][]byte
|
||||
persistedUpdatesCount int
|
||||
title string
|
||||
blocks []domain.DocumentBlock
|
||||
loaded bool
|
||||
wsClients map[*DocWSClient]bool
|
||||
sseClients map[*DocSSEClient]bool
|
||||
debounceTimer *time.Timer
|
||||
dirty bool
|
||||
lastUserID string
|
||||
}
|
||||
|
||||
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
|
||||
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.docRooms == nil {
|
||||
h.docRooms = make(map[string]*DocRoom)
|
||||
}
|
||||
|
||||
if room, exists := h.docRooms[docID]; exists {
|
||||
return room
|
||||
}
|
||||
|
||||
room := &DocRoom{
|
||||
room, exists := h.docRooms[docID]
|
||||
if !exists {
|
||||
room = &DocRoom{
|
||||
docID: docID,
|
||||
hub: h,
|
||||
wsClients: make(map[*DocWSClient]bool),
|
||||
sseClients: make(map[*DocSSEClient]bool),
|
||||
}
|
||||
|
||||
// Pre-load current state from CRDT store if available
|
||||
if h.crdtStore != nil {
|
||||
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
|
||||
if err == nil {
|
||||
room.stateBlob = stateBlob
|
||||
room.pendingUpdates = updates
|
||||
room.persistedUpdatesCount = len(updates)
|
||||
}
|
||||
}
|
||||
|
||||
h.docRooms[docID] = room
|
||||
}
|
||||
store := h.docStore
|
||||
h.mu.Unlock()
|
||||
|
||||
room.mu.Lock()
|
||||
if !room.loaded && store != nil {
|
||||
title, blocks, err := store.LoadDocumentState(docID)
|
||||
if err == nil {
|
||||
room.title = title
|
||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
||||
room.loaded = true
|
||||
}
|
||||
}
|
||||
room.mu.Unlock()
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
// ApplyUpdate processes an incremental Yjs binary update in-memory, broadcasts it immediately
|
||||
// to all other connected clients (WS & SSE), and schedules an asynchronous debounced flush to SQLite.
|
||||
func (r *DocRoom) ApplyUpdate(update []byte, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
|
||||
if len(update) == 0 {
|
||||
// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
|
||||
func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
|
||||
h.mu.RLock()
|
||||
room, exists := h.docRooms[docID]
|
||||
h.mu.RUnlock()
|
||||
if !exists || room == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
if !room.loaded {
|
||||
return "", nil, false
|
||||
}
|
||||
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
|
||||
copy(blocksCopy, room.blocks)
|
||||
return room.title, blocksCopy, true
|
||||
}
|
||||
|
||||
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
|
||||
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
|
||||
h.mu.RLock()
|
||||
room, exists := h.docRooms[docID]
|
||||
h.mu.RUnlock()
|
||||
if !exists || room == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.pendingUpdates = append(r.pendingUpdates, update)
|
||||
r.dirty = true
|
||||
if fromUserID != "" {
|
||||
r.lastUserID = fromUserID
|
||||
room.mu.Lock()
|
||||
if title != "" {
|
||||
room.title = title
|
||||
}
|
||||
if blocks != nil {
|
||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
||||
}
|
||||
room.loaded = true
|
||||
room.mu.Unlock()
|
||||
}
|
||||
|
||||
b64Update := base64.StdEncoding.EncodeToString(update)
|
||||
msg := domain.WSMessage{
|
||||
Event: "CRDT_UPDATE",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"update": b64Update,
|
||||
"fromUserId": fromUserID,
|
||||
// CloseDocRoom removes a document room from memory when a document is deleted.
|
||||
func (h *Hub) CloseDocRoom(docID string) {
|
||||
h.mu.Lock()
|
||||
room, exists := h.docRooms[docID]
|
||||
if exists {
|
||||
delete(h.docRooms, docID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if exists && room != nil {
|
||||
room.mu.Lock()
|
||||
if room.debounceTimer != nil {
|
||||
room.debounceTimer.Stop()
|
||||
}
|
||||
room.dirty = false
|
||||
for c := range room.wsClients {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
room.wsClients = make(map[*DocWSClient]bool)
|
||||
room.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
|
||||
if len(blocks) == 0 {
|
||||
return []domain.DocumentBlock{
|
||||
{
|
||||
ID: docID + "-b0",
|
||||
DocumentID: docID,
|
||||
Type: "text",
|
||||
Content: "",
|
||||
Level: 0,
|
||||
Position: 1000,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
msgBytes, _ := json.Marshal(msg)
|
||||
|
||||
// Broadcast to all WS clients except sender
|
||||
for client := range r.wsClients {
|
||||
if senderWS != nil && client == senderWS {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
out := make([]domain.DocumentBlock, len(blocks))
|
||||
for i, b := range blocks {
|
||||
if b.ID == "" {
|
||||
b.ID = domain.NewID("blk")
|
||||
}
|
||||
b.DocumentID = docID
|
||||
if b.Type == "" {
|
||||
b.Type = "text"
|
||||
}
|
||||
if b.Level < 0 {
|
||||
b.Level = 0
|
||||
}
|
||||
b.Position = float64((i + 1) * 1000)
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Broadcast to all SSE clients except sender
|
||||
for client := range r.sseClients {
|
||||
if senderSSE != nil && client == senderSSE {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule 2.5s debounced persistence
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
|
||||
// Also inform legacy room subscribers if any
|
||||
r.hub.BroadcastRoom("doc:"+r.docID, "CRDT_UPDATE", map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"update": b64Update,
|
||||
"fromUserId": fromUserID,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// CompactState replaces the incremental pending updates with a full compacted state blob.
|
||||
func (r *DocRoom) CompactState(stateBlob []byte, fromUserID string) {
|
||||
if len(stateBlob) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.stateBlob = stateBlob
|
||||
r.pendingUpdates = nil
|
||||
r.persistedUpdatesCount = 0
|
||||
r.dirty = true
|
||||
if fromUserID != "" {
|
||||
r.lastUserID = fromUserID
|
||||
}
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastAwareness forwards user awareness state to all other participants in the room.
|
||||
func (r *DocRoom) BroadcastAwareness(payload json.RawMessage, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
|
||||
msg := domain.WSMessage{
|
||||
Event: "CRDT_AWARENESS",
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
msgBytes, _ := json.Marshal(msg)
|
||||
|
||||
// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
|
||||
func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
return r.title, blocksCopy
|
||||
}
|
||||
|
||||
// HandleEvent applies a granular block or title mutation to the in-memory room,
|
||||
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
|
||||
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
|
||||
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
||||
r.mu.Lock()
|
||||
|
||||
evt.DocID = r.docID
|
||||
if sender != nil {
|
||||
if evt.ClientID == "" {
|
||||
evt.ClientID = sender.clientID
|
||||
}
|
||||
evt.UserID = sender.userID
|
||||
}
|
||||
|
||||
mutated := false
|
||||
|
||||
switch evt.Type {
|
||||
case "title_updated":
|
||||
if evt.Title != "" || r.title != evt.Title {
|
||||
r.title = evt.Title
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_created":
|
||||
if evt.Block != nil {
|
||||
newBlock := *evt.Block
|
||||
if newBlock.ID == "" {
|
||||
newBlock.ID = domain.NewID("blk")
|
||||
}
|
||||
newBlock.DocumentID = r.docID
|
||||
if newBlock.Type == "" {
|
||||
newBlock.Type = "text"
|
||||
}
|
||||
if newBlock.Level < 0 {
|
||||
newBlock.Level = 0
|
||||
}
|
||||
|
||||
// Avoid duplicate ID if already created
|
||||
existsIdx := -1
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == newBlock.ID {
|
||||
existsIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existsIdx >= 0 {
|
||||
r.blocks[existsIdx] = newBlock
|
||||
} else {
|
||||
insertIdx := len(r.blocks)
|
||||
if evt.AfterBlockID != "" {
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == evt.AfterBlockID {
|
||||
insertIdx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
|
||||
insertIdx = *evt.NewIndex
|
||||
}
|
||||
|
||||
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
|
||||
updated = append(updated, r.blocks[:insertIdx]...)
|
||||
updated = append(updated, newBlock)
|
||||
updated = append(updated, r.blocks[insertIdx:]...)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, updated)
|
||||
}
|
||||
evt.Block = &newBlock
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_updated":
|
||||
targetID := evt.BlockID
|
||||
if targetID == "" && evt.Block != nil {
|
||||
targetID = evt.Block.ID
|
||||
}
|
||||
if targetID != "" {
|
||||
for i := range r.blocks {
|
||||
if r.blocks[i].ID == targetID {
|
||||
if evt.Block != nil {
|
||||
r.blocks[i].Content = evt.Block.Content
|
||||
r.blocks[i].Level = evt.Block.Level
|
||||
if evt.Block.Type != "" {
|
||||
r.blocks[i].Type = evt.Block.Type
|
||||
}
|
||||
r.blocks[i].Checked = evt.Block.Checked
|
||||
r.blocks[i].Collapsed = evt.Block.Collapsed
|
||||
r.blocks[i].Lang = evt.Block.Lang
|
||||
} else {
|
||||
if evt.Content != nil {
|
||||
r.blocks[i].Content = *evt.Content
|
||||
}
|
||||
if evt.Level != nil {
|
||||
lvl := *evt.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
r.blocks[i].Level = lvl
|
||||
}
|
||||
if evt.Checked != nil {
|
||||
r.blocks[i].Checked = *evt.Checked
|
||||
}
|
||||
if evt.Collapsed != nil {
|
||||
r.blocks[i].Collapsed = *evt.Collapsed
|
||||
}
|
||||
if evt.BlockType != nil && *evt.BlockType != "" {
|
||||
r.blocks[i].Type = *evt.BlockType
|
||||
}
|
||||
}
|
||||
bCopy := r.blocks[i]
|
||||
evt.Block = &bCopy
|
||||
evt.BlockID = targetID
|
||||
mutated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "block_deleted":
|
||||
if evt.BlockID != "" {
|
||||
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
||||
for _, b := range r.blocks {
|
||||
if b.ID != evt.BlockID {
|
||||
filtered = append(filtered, b)
|
||||
}
|
||||
}
|
||||
r.blocks = normalizeRoomBlocks(r.docID, filtered)
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_moved":
|
||||
if evt.BlockID != "" && evt.NewIndex != nil {
|
||||
fromIdx := -1
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == evt.BlockID {
|
||||
fromIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromIdx >= 0 {
|
||||
moving := r.blocks[fromIdx]
|
||||
if evt.Level != nil {
|
||||
lvl := *evt.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
moving.Level = lvl
|
||||
}
|
||||
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
|
||||
without = append(without, r.blocks[:fromIdx]...)
|
||||
without = append(without, r.blocks[fromIdx+1:]...)
|
||||
|
||||
toIdx := *evt.NewIndex
|
||||
if toIdx < 0 {
|
||||
toIdx = 0
|
||||
}
|
||||
if toIdx > len(without) {
|
||||
toIdx = len(without)
|
||||
}
|
||||
|
||||
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
||||
reordered = append(reordered, without[:toIdx]...)
|
||||
reordered = append(reordered, moving)
|
||||
reordered = append(reordered, without[toIdx:]...)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, reordered)
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
case "blocks_replaced":
|
||||
if evt.Blocks != nil {
|
||||
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
|
||||
evt.Blocks = r.blocks
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
r.dirty = true
|
||||
r.loaded = true
|
||||
|
||||
msgBytes, err := json.Marshal(evt)
|
||||
if err == nil {
|
||||
for client := range r.wsClients {
|
||||
if senderWS != nil && client == senderWS {
|
||||
// Strict Echo Protection: never send back to the originating connection or clientId
|
||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
||||
continue
|
||||
}
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
@@ -178,223 +387,114 @@ func (r *DocRoom) BroadcastAwareness(payload json.RawMessage, fromUserID string,
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for client := range r.sseClients {
|
||||
if senderSSE != nil && client == senderSSE {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *DocRoom) scheduleDebouncedFlushLocked() {
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
}
|
||||
r.debounceTimer = time.AfterFunc(2500*time.Millisecond, func() {
|
||||
r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
|
||||
r.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
// Flush persists in-memory pending updates to SQLite asynchronously in a background goroutine.
|
||||
// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
|
||||
func (r *DocRoom) Flush() {
|
||||
r.mu.Lock()
|
||||
if !r.dirty {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
r.debounceTimer = nil
|
||||
}
|
||||
|
||||
// Prepare data to flush
|
||||
docID := r.docID
|
||||
userID := r.lastUserID
|
||||
stateBlob := r.stateBlob
|
||||
var updatesToFlush [][]byte
|
||||
|
||||
if len(stateBlob) > 0 && len(r.pendingUpdates) == 0 {
|
||||
// Compacted state snapshot
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
r.dirty = false
|
||||
r.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
if r.hub.crdtStore != nil {
|
||||
_ = r.hub.crdtStore.SaveDocumentCRDTState(docID, stateBlob, userID)
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
r.hub.mu.RLock()
|
||||
store := r.hub.docStore
|
||||
r.hub.mu.RUnlock()
|
||||
|
||||
if r.persistedUpdatesCount < len(r.pendingUpdates) {
|
||||
updatesToFlush = make([][]byte, len(r.pendingUpdates)-r.persistedUpdatesCount)
|
||||
copy(updatesToFlush, r.pendingUpdates[r.persistedUpdatesCount:])
|
||||
r.persistedUpdatesCount = len(r.pendingUpdates)
|
||||
if store != nil {
|
||||
if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
|
||||
log.Printf("[doc-room] flush error for doc %s: %v", docID, err)
|
||||
}
|
||||
r.dirty = false
|
||||
r.mu.Unlock()
|
||||
|
||||
if len(updatesToFlush) > 0 {
|
||||
go func() {
|
||||
if r.hub.crdtStore != nil {
|
||||
for _, u := range updatesToFlush {
|
||||
_ = r.hub.crdtStore.AppendDocumentCRDTUpdate(docID, u, userID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// GetInitialSnapshot returns the base64-encoded state blob and array of incremental updates.
|
||||
func (r *DocRoom) GetInitialSnapshot() (string, []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
b64State := ""
|
||||
if len(r.stateBlob) > 0 {
|
||||
b64State = base64.StdEncoding.EncodeToString(r.stateBlob)
|
||||
}
|
||||
|
||||
var b64Updates []string
|
||||
for _, u := range r.pendingUpdates {
|
||||
if len(u) > 0 {
|
||||
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
|
||||
}
|
||||
}
|
||||
return b64State, b64Updates
|
||||
}
|
||||
|
||||
// ServeDocWS handles WebSocket connection for GET /api/v1/ws/docs/{id}
|
||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string) {
|
||||
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
|
||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string) {
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if clientID == "" {
|
||||
clientID = req.URL.Query().Get("clientId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = domain.NewID("cli")
|
||||
}
|
||||
|
||||
client := &DocWSClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
conn: conn,
|
||||
userID: userID,
|
||||
clientID: clientID,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.wsClients[client] = true
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
r.mu.Unlock()
|
||||
|
||||
// Send initial snapshot
|
||||
state, updates := r.GetInitialSnapshot()
|
||||
initMsg := domain.WSMessage{
|
||||
Event: "CRDT_INIT",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"state": state,
|
||||
"updates": updates,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
// Immediately send live in-memory state to the newly connected client
|
||||
initEvt := DocSyncEvent{
|
||||
Type: "doc_state",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
Title: title,
|
||||
Blocks: blocksCopy,
|
||||
}
|
||||
initBytes, _ := json.Marshal(initMsg)
|
||||
if initBytes, err := json.Marshal(initEvt); err == nil {
|
||||
client.send <- initBytes
|
||||
}
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// ServeDocSSE handles SSE stream fallback for GET /api/v1/sync/docs/{id}/stream
|
||||
func (r *DocRoom) ServeDocSSE(w http.ResponseWriter, req *http.Request, userID string) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
client := &DocSSEClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
id: domain.NewID("sse-doc"),
|
||||
userID: userID,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sseClients[client] = true
|
||||
r.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.sseClients, client)
|
||||
clientCount := len(r.wsClients) + len(r.sseClients)
|
||||
r.mu.Unlock()
|
||||
|
||||
if clientCount == 0 {
|
||||
r.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
// Send initial connected comment and CRDT_INIT
|
||||
fmt.Fprintf(w, ": connected (transport: sse, docId: %s)\n\n", r.docID)
|
||||
state, updates := r.GetInitialSnapshot()
|
||||
initMsg, _ := json.Marshal(domain.WSMessage{
|
||||
Event: "CRDT_INIT",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"state": state,
|
||||
"updates": updates,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", initMsg)
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
notify := req.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-client.send:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
// Ping to keep proxy connection alive
|
||||
fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocWSClient) readPump() {
|
||||
defer func() {
|
||||
c.room.mu.Lock()
|
||||
delete(c.room.wsClients, c)
|
||||
clientCount := len(c.room.wsClients) + len(c.room.sseClients)
|
||||
remaining := len(c.room.wsClients)
|
||||
c.room.mu.Unlock()
|
||||
|
||||
_ = c.conn.Close()
|
||||
|
||||
// Broadcast awareness leave
|
||||
c.room.BroadcastAwareness([]byte(fmt.Sprintf(`{"userId":"%s","activeBlockId":null}`, c.userID)), c.userID, nil, nil)
|
||||
|
||||
if clientCount == 0 {
|
||||
// Flush immediately when the last client leaves the document room
|
||||
if remaining == 0 {
|
||||
c.room.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(8 * 1024 * 1024) // 8MB limit for binary/text updates
|
||||
c.conn.SetReadLimit(4 * 1024 * 1024)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
@@ -402,51 +502,20 @@ func (c *DocWSClient) readPump() {
|
||||
})
|
||||
|
||||
for {
|
||||
msgType, message, err := c.conn.ReadMessage()
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if msgType == websocket.BinaryMessage {
|
||||
// Direct binary Yjs update
|
||||
c.room.ApplyUpdate(message, c.userID, c, nil)
|
||||
continue
|
||||
}
|
||||
|
||||
// Text JSON message
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(message, &incoming) == nil {
|
||||
switch incoming.Event {
|
||||
case "CRDT_UPDATE":
|
||||
var p struct {
|
||||
Update string `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(incoming.Payload, &p) == nil && p.Update != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
|
||||
c.room.ApplyUpdate(raw, c.userID, c, nil)
|
||||
}
|
||||
}
|
||||
case "CRDT_COMPACT":
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if json.Unmarshal(incoming.Payload, &p) == nil && p.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
|
||||
c.room.CompactState(raw, c.userID)
|
||||
}
|
||||
}
|
||||
case "CRDT_AWARENESS":
|
||||
c.room.BroadcastAwareness(incoming.Payload, c.userID, c, nil)
|
||||
}
|
||||
var evt DocSyncEvent
|
||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
||||
c.room.HandleEvent(evt, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocWSClient) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
ticker := time.NewTicker(25 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
|
||||
+6
-84
@@ -1,7 +1,6 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -47,10 +46,9 @@ type docLockItem struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type CRDTStore interface {
|
||||
GetDocumentCRDTState(docID string) ([]byte, [][]byte, error)
|
||||
AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error
|
||||
SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error
|
||||
type DocStore interface {
|
||||
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
||||
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
@@ -63,7 +61,7 @@ type Hub struct {
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
locks map[string]*docLockItem
|
||||
docRooms map[string]*DocRoom
|
||||
crdtStore CRDTStore
|
||||
docStore DocStore
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
@@ -81,10 +79,10 @@ func NewHub() *Hub {
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) SetCRDTStore(store CRDTStore) {
|
||||
func (h *Hub) SetDocStore(store DocStore) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.crdtStore = store
|
||||
h.docStore = store
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
@@ -688,39 +686,6 @@ func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID st
|
||||
fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
|
||||
flusher.Flush()
|
||||
|
||||
// If initial rooms had doc rooms, send CRDT_INIT
|
||||
if h.crdtStore != nil {
|
||||
for rID := range client.subscribedRooms {
|
||||
if strings.HasPrefix(rID, "doc:") {
|
||||
docID := strings.TrimPrefix(rID, "doc:")
|
||||
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
|
||||
if err == nil && (len(stateBlob) > 0 || len(updates) > 0) {
|
||||
var b64Updates []string
|
||||
for _, u := range updates {
|
||||
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
|
||||
}
|
||||
b64State := ""
|
||||
if len(stateBlob) > 0 {
|
||||
b64State = base64.StdEncoding.EncodeToString(stateBlob)
|
||||
}
|
||||
initMsg := domain.WSMessage{
|
||||
Event: "CRDT_INIT",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
"updates": b64Updates,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
if initBytes, err := json.Marshal(initMsg); err == nil {
|
||||
fmt.Fprintf(w, "data: %s\n\n", initBytes)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -811,49 +776,6 @@ func (c *Client) readPump() {
|
||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
case "CRDT_JOIN":
|
||||
c.hub.JoinRoom(c, "doc:"+docID)
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
b64State, b64Updates := room.GetInitialSnapshot()
|
||||
if len(b64State) > 0 || len(b64Updates) > 0 {
|
||||
c.SendEvent("CRDT_INIT", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
"updates": b64Updates,
|
||||
})
|
||||
}
|
||||
case "CRDT_UPDATE":
|
||||
var p struct {
|
||||
Update string `json:"update"`
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Payload, &p)
|
||||
if p.Update != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
room.ApplyUpdate(raw, c.userID, nil, nil)
|
||||
}
|
||||
}
|
||||
case "CRDT_COMPACT":
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Payload, &p)
|
||||
if p.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
room.CompactState(raw, c.userID)
|
||||
}
|
||||
}
|
||||
case "CRDT_AWARENESS":
|
||||
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", incoming.Payload, c)
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
room.BroadcastAwareness(incoming.Payload, c.userID, nil, nil)
|
||||
case "CRDT_LEAVE":
|
||||
c.hub.LeaveRoom(c, "doc:"+docID)
|
||||
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": c.userID,
|
||||
}, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user