feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
This commit is contained in:
1 parent
f3b3f07e04
commit
86867b0d6f
17 files changed
+3409
-7178
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user