diff --git a/frontend/package.json b/frontend/package.json index 7d2eb01..ee43213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx new file mode 100644 index 0000000..91fe1d4 --- /dev/null +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -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 = ({ + docId, + initialDoc, + availableFolders, + onDocMetadataChange, + onDeleteDoc, + onOpenShareModal, + onSelectTag, + onBackMobile, +}) => { + const [doc, setDoc] = useState(initialDoc || null); + const [title, setTitle] = useState(initialDoc?.title || ''); + const [blocks, setBlocks] = useState(() => + ensureNonEmptyBlocks(docId, initialDoc?.blocks) + ); + const [loading, setLoading] = useState(!initialDoc); + const [error, setError] = useState(null); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [showFolderMenu, setShowFolderMenu] = useState(false); + + const titleInputRef = useRef(null); + const titleDebounceRef = useRef(null); + const docRef = useRef(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(); + 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) => { + 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 ( +
+ + Notiz wird geladen… +
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onBackMobile && ( + + )} +
+ ); + } + + return ( +
+ {/* Top Document Action Bar */} +
+
+ {onBackMobile && ( + + )} + + {/* Folder Selector */} +
+ + + {showFolderMenu && canEdit && ( +
+ + {availableFolders.map((folder) => ( + + ))} +
+ )} +
+ + {/* Read-only indicator if shared with read permission */} + {!canEdit && ( + + + Nur Lesen + + )} +
+ + {/* Right Status & Action Buttons */} +
+ {/* Sync Status Pill */} + + {syncStatus === 'connected' ? ( + <> + + Live + + ) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? ( + <> + + Sync… + + ) : ( + <> + + REST-Sync + + )} + + + + + {doc && ( + + )} + + + + {canEdit && doc?.isOwner !== false && ( + + )} +
+
+ + {/* Main Outliner Content Container */} +
+ {/* Document Icon + Title Header */} +
+
+ + {showEmojiPicker && canEdit && ( +
+ {EMOJI_PALETTE.map((emoji) => ( + + ))} +
+ )} +
+ + +
+ + {/* Document #Tags Summary Bar */} + {extractedTags.length > 0 && ( +
+ + {extractedTags.map((tag) => ( + + ))} +
+ )} + + {/* Logseq Hierarchical Outliner Tree */} + +
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx new file mode 100644 index 0000000..61fbe42 --- /dev/null +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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 = ({ + block, + index, + maxAllowedLevel, + hasChildren, + isFocused, + canEdit, + onFocusBlock, + onUpdateBlock, + onCreateBlockBelow, + onDeleteBlock, + onIndentBlock, + onNavigateFocus, + onTagClick, + onDragStartBlock, + onDragOverBlock, + onDropBlock, + isDragTarget, + registerTextareaRef, +}) => { + const [localContent, setLocalContent] = useState(block.content || ''); + const [showTypeMenu, setShowTypeMenu] = useState(false); + const textareaRef = useRef(null); + const debounceTimerRef = useRef(null); + const latestBlockRef = useRef(block); + latestBlockRef.current = block; + const localContentRef = useRef(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) => { + 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) => { + 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) => { + 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 ( +
onDragOverBlock?.(e, index)} + onDrop={() => onDropBlock?.(index)} + > + {/* Indentation Guide Lines */} + {level > 0 && + Array.from({ length: level }).map((_, lvlIdx) => ( +
+ ))} + + {/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */} +
+ {canEdit && ( + + )} + + {hasChildren ? ( + + ) : ( + + )} + + {block.type === 'todo' ? ( + + ) : ( + + )} +
+ + {/* Block Type Quick Popover */} + {showTypeMenu && canEdit && ( +
+ + + + + +
+ )} + + {/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */} +
+