From 85e40104351ab7d665a02cdfc11756b4b054858a Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 21:41:27 +0100 Subject: [PATCH] fix(workspace): restore theme design-system, fix 5x note creation, enforce privacy isolation, fix shared write permission, add drag-drop & slash-commands --- .../src/features/workspace/DocumentEditor.tsx | 115 ++-- .../src/features/workspace/OutlinerBlock.tsx | 535 ++++++++-------- .../src/features/workspace/OutlinerTree.tsx | 5 +- .../src/features/workspace/WorkspaceView.tsx | 599 +++++++++++++----- internal/api/server.go | 15 +- internal/ws/docroom.go | 8 +- internal/ws/hub.go | 4 +- 7 files changed, 791 insertions(+), 490 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 91fe1d4..f1f7d5e 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -25,6 +25,7 @@ export interface DocumentEditorProps { onDeleteDoc: (docId: string) => void; onOpenShareModal: (doc: Document) => void; onSelectTag: (tag: string) => void; + onWikiLinkClick?: (pageTitle: string) => void; onBackMobile?: () => void; } @@ -61,6 +62,7 @@ export const DocumentEditor: React.FC = ({ onDeleteDoc, onOpenShareModal, onSelectTag, + onWikiLinkClick, onBackMobile, }) => { const [doc, setDoc] = useState(initialDoc || null); @@ -78,9 +80,11 @@ export const DocumentEditor: React.FC = ({ const docRef = useRef(doc); docRef.current = doc; + // Crucial fix: Collaborator with write permission can edit! const canEdit = useMemo(() => { if (!doc) return true; - return doc.isOwner !== false && doc.permission !== 'read'; + if (doc.isOwner) return true; + return doc.permission === 'owner' || doc.permission === 'write'; }, [doc]); // Handle incoming WebSocket events for THIS document room @@ -400,6 +404,8 @@ export const DocumentEditor: React.FC = ({ lines.push(`${indent}- ### ${b.content}`); } else if (b.type === 'code') { lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``); + } else if (b.type === 'divider') { + lines.push(`${indent}- ---`); } else { lines.push(`${indent}- ${b.content}`); } @@ -415,9 +421,9 @@ export const DocumentEditor: React.FC = ({ if (loading) { return ( -
- - Notiz wird geladen… +
+ + Notiz wird geladen…
); } @@ -425,12 +431,12 @@ export const DocumentEditor: React.FC = ({ if (error) { return (
-

{error}

+

{error}

{onBackMobile && ( @@ -440,15 +446,15 @@ export const DocumentEditor: React.FC = ({ } return ( -
+
{/* Top Document Action Bar */} -
+
{onBackMobile && ( {showFolderMenu && canEdit && ( -
+
- {/* Read-only indicator if shared with read permission */} + {/* Read-only indicator */} {!canEdit && ( - + Nur Lesen @@ -510,36 +512,36 @@ export const DocumentEditor: React.FC = ({
{/* Right Status & Action Buttons */} -
- {/* Sync Status Pill */} +
+ {/* Sync Status Badge */} {syncStatus === 'connected' ? ( <> - - Live + + LIVE ) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? ( <> - - Sync… + + SYNC… ) : ( <> - - REST-Sync + + OFFLINE )} @@ -547,10 +549,10 @@ export const DocumentEditor: React.FC = ({ - {canEdit && doc?.isOwner !== false && ( + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
- {/* Main Outliner Content Container */} -
+ {/* Main Content Area */} +
{/* Document Icon + Title Header */} -
+
{showEmojiPicker && canEdit && ( -
+
{EMOJI_PALETTE.map((emoji) => ( @@ -626,20 +628,20 @@ export const DocumentEditor: React.FC = ({ 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" + className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight" />
- {/* Document #Tags Summary Bar */} + {/* Extracted #Tags Summary Bar */} {extractedTags.length > 0 && ( -
- +
+ {extractedTags.map((tag) => ( @@ -647,7 +649,7 @@ export const DocumentEditor: React.FC = ({
)} - {/* Logseq Hierarchical Outliner Tree */} + {/* Logseq Outliner Tree */} = ({ onDeleteBlock={handleDeleteBlock} onMoveBlock={handleMoveBlock} onTagClick={onSelectTag} + onWikiLinkClick={onWikiLinkClick} />
diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 61fbe42..c390540 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -1,22 +1,41 @@ -import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react'; import { ChevronRight, ChevronDown, - CheckSquare, - Square, - Trash2, + Check, + X, GripVertical, - ArrowDownLeft, - Indent, - Outdent, Code, Heading1, Heading2, + Heading3, ListTodo, Type, + Minus, + MessageSquare, + Hash, + ExternalLink, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; +export interface SlashCommand { + type: BlockType; + label: string; + desc: string; + icon: React.ReactNode; +} + +const SLASH_COMMANDS: SlashCommand[] = [ + { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: }, + { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: }, + { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: }, + { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: }, + { type: 'bullet', label: '/bullet', desc: 'Standard Listenpunkt', icon: }, + { type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: }, + { type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: }, + { type: 'divider', label: '/divider', desc: 'Trennlinie', icon: }, +]; + export interface OutlinerBlockProps { block: DocumentBlock; index: number; @@ -31,6 +50,7 @@ export interface OutlinerBlockProps { onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onTagClick?: (tag: string) => void; + onWikiLinkClick?: (pageTitle: string) => void; onDragStartBlock?: (index: number) => void; onDragOverBlock?: (e: React.DragEvent, index: number) => void; onDropBlock?: (index: number) => void; @@ -41,7 +61,6 @@ export interface OutlinerBlockProps { export const OutlinerBlock: React.FC = ({ block, index, - maxAllowedLevel, hasChildren, isFocused, canEdit, @@ -52,6 +71,7 @@ export const OutlinerBlock: React.FC = ({ onIndentBlock, onNavigateFocus, onTagClick, + onWikiLinkClick, onDragStartBlock, onDragOverBlock, onDropBlock, @@ -59,7 +79,9 @@ export const OutlinerBlock: React.FC = ({ registerTextareaRef, }) => { const [localContent, setLocalContent] = useState(block.content || ''); - const [showTypeMenu, setShowTypeMenu] = useState(false); + const [slashSearch, setSlashSearch] = useState(null); + const [slashIndex, setSlashIndex] = useState(0); + const textareaRef = useRef(null); const debounceTimerRef = useRef(null); const latestBlockRef = useRef(block); @@ -67,7 +89,7 @@ export const OutlinerBlock: React.FC = ({ const localContentRef = useRef(localContent); localContentRef.current = localContent; - // Sync incoming remote changes ONLY when this textarea does NOT hold active focus + // Sync incoming remote changes ONLY when this textarea is NOT active useEffect(() => { const isActivelyFocused = textareaRef.current !== null && document.activeElement === textareaRef.current; @@ -76,7 +98,6 @@ export const OutlinerBlock: React.FC = ({ } }, [block.content]); - // Cleanup 150ms debounce timer on unmount useEffect(() => { return () => { if (debounceTimerRef.current !== null) { @@ -86,12 +107,11 @@ export const OutlinerBlock: React.FC = ({ }; }, []); - // 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`; + el.style.height = `${Math.max(22, el.scrollHeight)}px`; }, []); useLayoutEffect(() => { @@ -119,13 +139,31 @@ export const OutlinerBlock: React.FC = ({ [onUpdateBlock] ); + const filteredSlashCommands = useMemo(() => { + if (slashSearch === null) return []; + const q = slashSearch.toLowerCase(); + return SLASH_COMMANDS.filter( + (c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q) + ); + }, [slashSearch]); + + const applySlashCommand = (cmd: SlashCommand) => { + setSlashSearch(null); + const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim(); + setLocalContent(clean); + flushDebounce(clean, { + type: cmd.type, + checked: cmd.type === 'todo' ? Boolean(block.checked) : false, + }); + }; + 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 + // Quick markdown shortcuts if (val.startsWith('[] ') || val.startsWith('[ ] ')) { val = val.replace(/^\[\s?\]\s/, ''); typeOverride = 'todo'; @@ -143,13 +181,15 @@ export const OutlinerBlock: React.FC = ({ } 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'; + } + + // Check for slash command invocation + const slashMatch = val.match(/\/([a-z0-9]*)$/i); + if (slashMatch) { + setSlashSearch(slashMatch[1]); + setSlashIndex(0); + } else { + setSlashSearch(null); } setLocalContent(val); @@ -178,16 +218,39 @@ export const OutlinerBlock: React.FC = ({ const handleKeyDown = (e: React.KeyboardEvent) => { if (!canEdit) return; - // 1. Tab / Shift+Tab -> Strict Outliner Indent / Outdent + // Slash command navigation + if (slashSearch !== null && filteredSlashCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredSlashCommands[slashIndex]; + if (selected) { + applySlashCommand(selected); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setSlashSearch(null); + return; + } + } + + // 1. Tab / Shift+Tab -> Strict Indent / Outdent if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); flushDebounce(localContent); - if (e.shiftKey) { - onIndentBlock(block.id, -1); - } else { - onIndentBlock(block.id, 1); - } + onIndentBlock(block.id, e.shiftKey ? -1 : 1); return; } @@ -195,28 +258,27 @@ export const OutlinerBlock: React.FC = ({ if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); e.stopPropagation(); + setSlashSearch(null); flushDebounce(localContent); onCreateBlockBelow(block.id, localContent); return; } - // 3. Backspace on empty block -> Outdent if indented, or delete block & focus previous + // 3. Backspace on empty block -> IMMEDIATELY DELETE THE BLOCK (regardless of level)! + // Outdenting is strictly done via Shift+Tab. if (e.key === 'Backspace' && localContent === '') { e.preventDefault(); e.stopPropagation(); + setSlashSearch(null); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } - if ((block.level || 0) > 0) { - onIndentBlock(block.id, -1); - } else { - onDeleteBlock(block.id); - } + onDeleteBlock(block.id); return; } - // 4. ArrowUp / ArrowDown navigation between blocks + // 4. Arrow navigation between blocks if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { const el = textareaRef.current; if (el && el.selectionStart === 0 && el.selectionEnd === 0) { @@ -239,66 +301,49 @@ export const OutlinerBlock: React.FC = ({ }; 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()) - ) + + // Parse rich tokens (WikiLinks [[Page]] and #Tags) + const tokens = useMemo(() => { + const parts = (localContent || '').split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); + return parts.filter(Boolean); + }, [localContent]); + + const hasSpecialTokens = tokens.some( + (t) => t.startsWith('#') || (t.startsWith('[[') && t.endsWith(']]')) ); - 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 */} + {/* Indentation Guide Line */} {level > 0 && Array.from({ length: level }).map((_, lvlIdx) => (
))} - {/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */} + {/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
{canEdit && ( @@ -310,17 +355,17 @@ export const OutlinerBlock: React.FC = ({ onClick={() => flushDebounce(localContent, { collapsed: !block.collapsed }) } - className="p-0.5 rounded text-slate-400 hover:text-indigo-500 transition-colors" + className="p-0.5 text-muted hover:text-accent transition-colors" title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'} > {block.collapsed ? ( ) : ( - + )} ) : ( - + )} {block.type === 'todo' ? ( @@ -330,217 +375,207 @@ export const OutlinerBlock: React.FC = ({ onClick={() => flushDebounce(localContent, { checked: !block.checked }) } - className={`p-0.5 rounded transition-colors ${ + className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${ block.checked - ? 'text-emerald-500 hover:text-emerald-600' - : 'text-slate-400 hover:text-indigo-500' + ? 'bg-status-ok border-status-ok text-on-accent' + : 'border-strong hover:border-accent text-transparent' }`} title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'} > - {block.checked ? ( - - ) : ( - - )} + ) : ( )}
- {/* Block Type Quick Popover */} - {showTypeMenu && canEdit && ( -
- - - - - -
- )} - - {/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */} -
-