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

This commit is contained in:
sandrodach committed 2026-10-26 21:14:06 +01:00
1 parent f3b3f07e04
commit 86867b0d6f
17 files changed
+3409 -7178

No files matched your search

@@ -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
View File
@@ -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);
-271
View File
@@ -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);
}
}
+5 -1
View File
@@ -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[];
+304
View File
@@ -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,
};
}
-380
View File
@@ -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);
}
}
}