Dev #7
No files matched your search
@@ -25,6 +25,7 @@ export interface DocumentEditorProps {
|
|||||||
onDeleteDoc: (docId: string) => void;
|
onDeleteDoc: (docId: string) => void;
|
||||||
onOpenShareModal: (doc: Document) => void;
|
onOpenShareModal: (doc: Document) => void;
|
||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
onBackMobile?: () => void;
|
onBackMobile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onDeleteDoc,
|
onDeleteDoc,
|
||||||
onOpenShareModal,
|
onOpenShareModal,
|
||||||
onSelectTag,
|
onSelectTag,
|
||||||
|
onWikiLinkClick,
|
||||||
onBackMobile,
|
onBackMobile,
|
||||||
}) => {
|
}) => {
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
@@ -78,9 +80,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const docRef = useRef<Document | null>(doc);
|
const docRef = useRef<Document | null>(doc);
|
||||||
docRef.current = doc;
|
docRef.current = doc;
|
||||||
|
|
||||||
|
// Crucial fix: Collaborator with write permission can edit!
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
if (!doc) return true;
|
if (!doc) return true;
|
||||||
return doc.isOwner !== false && doc.permission !== 'read';
|
if (doc.isOwner) return true;
|
||||||
|
return doc.permission === 'owner' || doc.permission === 'write';
|
||||||
}, [doc]);
|
}, [doc]);
|
||||||
|
|
||||||
// Handle incoming WebSocket events for THIS document room
|
// Handle incoming WebSocket events for THIS document room
|
||||||
@@ -400,6 +404,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
lines.push(`${indent}- ### ${b.content}`);
|
lines.push(`${indent}- ### ${b.content}`);
|
||||||
} else if (b.type === 'code') {
|
} else if (b.type === 'code') {
|
||||||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||||||
|
} else if (b.type === 'divider') {
|
||||||
|
lines.push(`${indent}- ---`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(`${indent}- ${b.content}`);
|
lines.push(`${indent}- ${b.content}`);
|
||||||
}
|
}
|
||||||
@@ -415,9 +421,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center p-12 text-slate-400">
|
<div className="flex-1 flex items-center justify-center p-12 text-muted">
|
||||||
<Loader2 className="w-6 h-6 animate-spin mr-2 text-indigo-500" />
|
<Loader2 className="w-5 h-5 animate-spin mr-2 text-accent" />
|
||||||
<span className="text-sm">Notiz wird geladen…</span>
|
<span className="text-xs">Notiz wird geladen…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -425,12 +431,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
|
<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>
|
<p className="text-xs text-status-err font-medium mb-2">{error}</p>
|
||||||
{onBackMobile && (
|
{onBackMobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBackMobile}
|
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"
|
className="btn-action px-3 py-1.5 text-xs text-main"
|
||||||
>
|
>
|
||||||
Zurück zur Übersicht
|
Zurück zur Übersicht
|
||||||
</button>
|
</button>
|
||||||
@@ -440,15 +446,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-white dark:bg-slate-900 overflow-y-auto">
|
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto">
|
||||||
{/* Top Document Action Bar */}
|
{/* 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="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2.5 bg-panel border-b border-border shadow-sm">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{onBackMobile && (
|
{onBackMobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBackMobile}
|
onClick={onBackMobile}
|
||||||
className="lg:hidden p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
className="lg:hidden p-1.5 rounded-btn text-muted hover:text-main hover:bg-panel-elevated"
|
||||||
title="Zurück zur Seitenleiste"
|
title="Zurück zur Seitenleiste"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -460,24 +466,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => canEdit && setShowFolderMenu((prev) => !prev)}
|
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"
|
className="btn-action inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted hover:text-main"
|
||||||
title="Ordner wechseln"
|
title="Ordner wechseln"
|
||||||
>
|
>
|
||||||
<FolderIcon className="w-3.5 h-3.5 text-indigo-500" />
|
<FolderIcon className="w-3.5 h-3.5 text-accent" />
|
||||||
<span className="truncate max-w-[160px]">
|
<span className="truncate max-w-[140px]">
|
||||||
{doc?.folderPath ? doc.folderPath : 'Hauptebene'}
|
{doc?.folderPath ? doc.folderPath : 'Hauptebene'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showFolderMenu && canEdit && (
|
{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">
|
<div className="absolute left-0 mt-1 w-52 panel-surface shadow-panel border border-border rounded-card py-1 z-30 max-h-60 overflow-y-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleMoveToFolder('')}
|
onClick={() => handleMoveToFolder('')}
|
||||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 ${
|
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-panel-elevated ${
|
||||||
!doc?.folderPath
|
!doc?.folderPath ? 'font-bold text-accent' : 'text-main'
|
||||||
? 'font-semibold text-indigo-600 dark:text-indigo-400'
|
|
||||||
: 'text-slate-700 dark:text-slate-300'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
/ (Hauptebene)
|
/ (Hauptebene)
|
||||||
@@ -487,10 +491,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
key={folder}
|
key={folder}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleMoveToFolder(folder)}
|
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 ${
|
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-panel-elevated truncate ${
|
||||||
doc?.folderPath === folder
|
doc?.folderPath === folder ? 'font-bold text-accent' : 'text-main'
|
||||||
? 'font-semibold text-indigo-600 dark:text-indigo-400'
|
|
||||||
: 'text-slate-700 dark:text-slate-300'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
📁 {folder}
|
📁 {folder}
|
||||||
@@ -500,9 +502,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read-only indicator if shared with read permission */}
|
{/* Read-only indicator */}
|
||||||
{!canEdit && (
|
{!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">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
|
||||||
<Lock className="w-3 h-3" />
|
<Lock className="w-3 h-3" />
|
||||||
Nur Lesen
|
Nur Lesen
|
||||||
</span>
|
</span>
|
||||||
@@ -510,36 +512,36 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Status & Action Buttons */}
|
{/* Right Status & Action Buttons */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1">
|
||||||
{/* Sync Status Pill */}
|
{/* Sync Status Badge */}
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[10px] font-mono font-medium ${
|
||||||
syncStatus === 'connected'
|
syncStatus === 'connected'
|
||||||
? 'bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400'
|
? 'bg-status-ok/10 text-status-ok border border-status-ok/30'
|
||||||
: syncStatus === 'connecting' || syncStatus === 'reconnecting'
|
: syncStatus === 'connecting' || syncStatus === 'reconnecting'
|
||||||
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-600 dark:text-amber-400'
|
? 'bg-status-warn/10 text-status-warn border border-status-warn/30'
|
||||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400'
|
: 'bg-panel-elevated text-muted border border-border'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
syncStatus === 'connected'
|
syncStatus === 'connected'
|
||||||
? 'Echtzeit-Synchronisation aktiv (1.5s Auto-Flush in SQLite)'
|
? 'Echtzeit-Synchronisation aktiv'
|
||||||
: 'Änderungen werden automatisch per REST gespeichert'
|
: 'Verbindung wird hergestellt…'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{syncStatus === 'connected' ? (
|
{syncStatus === 'connected' ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="w-3 h-3" />
|
<Wifi className="w-2.5 h-2.5" />
|
||||||
<span className="hidden sm:inline">Live</span>
|
<span className="hidden sm:inline">LIVE</span>
|
||||||
</>
|
</>
|
||||||
) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? (
|
) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||||
<span className="hidden sm:inline">Sync…</span>
|
<span className="hidden sm:inline">SYNC…</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="w-3 h-3" />
|
<WifiOff className="w-2.5 h-2.5" />
|
||||||
<span className="hidden sm:inline">REST-Sync</span>
|
<span className="hidden sm:inline">OFFLINE</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -547,10 +549,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTogglePin}
|
onClick={handleTogglePin}
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
className={`p-1.5 rounded-btn transition-colors ${
|
||||||
doc?.isPinned
|
doc?.isPinned
|
||||||
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-500'
|
? 'text-status-warn bg-status-warn/10'
|
||||||
: 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
|
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||||
>
|
>
|
||||||
@@ -561,7 +563,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenShareModal(doc)}
|
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"
|
className="p-1.5 rounded-btn text-muted hover:text-accent hover:bg-panel-elevated transition-colors"
|
||||||
title="Notiz teilen / Berechtigungen"
|
title="Notiz teilen / Berechtigungen"
|
||||||
>
|
>
|
||||||
<Share2 className="w-4 h-4" />
|
<Share2 className="w-4 h-4" />
|
||||||
@@ -571,17 +573,17 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleExportMarkdown}
|
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"
|
className="p-1.5 rounded-btn text-muted hover:text-main hover:bg-panel-elevated transition-colors"
|
||||||
title="Als Markdown (.md) exportieren"
|
title="Als Markdown (.md) exportieren"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{canEdit && doc?.isOwner !== false && (
|
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteDoc(docId)}
|
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"
|
className="p-1.5 rounded-btn text-muted hover:text-status-err hover:bg-status-err/10 transition-colors"
|
||||||
title="Notiz löschen"
|
title="Notiz löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -590,27 +592,27 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Outliner Content Container */}
|
{/* Main Content Area */}
|
||||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-10 py-6 sm:py-8 flex-1">
|
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||||||
{/* Document Icon + Title Header */}
|
{/* Document Icon + Title Header */}
|
||||||
<div className="flex items-center gap-3 mb-4 relative">
|
<div className="flex items-center gap-3 mb-3 relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
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"
|
className="w-10 h-10 rounded-btn bg-panel-elevated border border-border hover:border-strong flex items-center justify-center text-xl transition-colors"
|
||||||
title="Icon ändern"
|
title="Icon ändern"
|
||||||
>
|
>
|
||||||
{doc?.icon || '📝'}
|
{doc?.icon || '📝'}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{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">
|
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
|
||||||
{EMOJI_PALETTE.map((emoji) => (
|
{EMOJI_PALETTE.map((emoji) => (
|
||||||
<button
|
<button
|
||||||
key={emoji}
|
key={emoji}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectIcon(emoji)}
|
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"
|
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
</button>
|
</button>
|
||||||
@@ -626,20 +628,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onChange={handleTitleChange}
|
onChange={handleTitleChange}
|
||||||
placeholder="Titel der Notiz…"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document #Tags Summary Bar */}
|
{/* Extracted #Tags Summary Bar */}
|
||||||
{extractedTags.length > 0 && (
|
{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">
|
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
|
||||||
<Hash className="w-3.5 h-3.5 text-slate-400 mr-0.5" />
|
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
|
||||||
{extractedTags.map((tag) => (
|
{extractedTags.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectTag(tag)}
|
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"
|
className="badge-accent text-[11px] font-medium px-2 py-0.5 rounded-btn hover:opacity-85 transition-opacity"
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</button>
|
</button>
|
||||||
@@ -647,7 +649,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logseq Hierarchical Outliner Tree */}
|
{/* Logseq Outliner Tree */}
|
||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
@@ -657,6 +659,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
CheckSquare,
|
Check,
|
||||||
Square,
|
X,
|
||||||
Trash2,
|
|
||||||
GripVertical,
|
GripVertical,
|
||||||
ArrowDownLeft,
|
|
||||||
Indent,
|
|
||||||
Outdent,
|
|
||||||
Code,
|
Code,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
|
Heading3,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Type,
|
Type,
|
||||||
|
Minus,
|
||||||
|
MessageSquare,
|
||||||
|
Hash,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
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: <ListTodo className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'bullet', label: '/bullet', desc: 'Standard Listenpunkt', icon: <Type className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
export interface OutlinerBlockProps {
|
export interface OutlinerBlockProps {
|
||||||
block: DocumentBlock;
|
block: DocumentBlock;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -31,6 +50,7 @@ export interface OutlinerBlockProps {
|
|||||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
onDragStartBlock?: (index: number) => void;
|
onDragStartBlock?: (index: number) => void;
|
||||||
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
||||||
onDropBlock?: (index: number) => void;
|
onDropBlock?: (index: number) => void;
|
||||||
@@ -41,7 +61,6 @@ export interface OutlinerBlockProps {
|
|||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
block,
|
block,
|
||||||
index,
|
index,
|
||||||
maxAllowedLevel,
|
|
||||||
hasChildren,
|
hasChildren,
|
||||||
isFocused,
|
isFocused,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -52,6 +71,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onIndentBlock,
|
onIndentBlock,
|
||||||
onNavigateFocus,
|
onNavigateFocus,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
|
onWikiLinkClick,
|
||||||
onDragStartBlock,
|
onDragStartBlock,
|
||||||
onDragOverBlock,
|
onDragOverBlock,
|
||||||
onDropBlock,
|
onDropBlock,
|
||||||
@@ -59,7 +79,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
}) => {
|
}) => {
|
||||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||||
const [showTypeMenu, setShowTypeMenu] = useState(false);
|
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||||
|
const [slashIndex, setSlashIndex] = useState(0);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const debounceTimerRef = useRef<number | null>(null);
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
const latestBlockRef = useRef<DocumentBlock>(block);
|
const latestBlockRef = useRef<DocumentBlock>(block);
|
||||||
@@ -67,7 +89,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const localContentRef = useRef<string>(localContent);
|
const localContentRef = useRef<string>(localContent);
|
||||||
localContentRef.current = 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(() => {
|
useEffect(() => {
|
||||||
const isActivelyFocused =
|
const isActivelyFocused =
|
||||||
textareaRef.current !== null && document.activeElement === textareaRef.current;
|
textareaRef.current !== null && document.activeElement === textareaRef.current;
|
||||||
@@ -76,7 +98,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
}, [block.content]);
|
}, [block.content]);
|
||||||
|
|
||||||
// Cleanup 150ms debounce timer on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
@@ -86,12 +107,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-resize textarea height on content or layout changes
|
|
||||||
const adjustHeight = useCallback(() => {
|
const adjustHeight = useCallback(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.style.height = 'auto';
|
el.style.height = 'auto';
|
||||||
el.style.height = `${Math.max(24, el.scrollHeight)}px`;
|
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -119,13 +139,31 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
[onUpdateBlock]
|
[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<HTMLTextAreaElement>) => {
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
let val = e.target.value;
|
let val = e.target.value;
|
||||||
let typeOverride: BlockType | undefined;
|
let typeOverride: BlockType | undefined;
|
||||||
let checkedOverride: boolean | undefined;
|
let checkedOverride: boolean | undefined;
|
||||||
|
|
||||||
// Quick markdown prefix shortcuts at start of block
|
// Quick markdown shortcuts
|
||||||
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
||||||
val = val.replace(/^\[\s?\]\s/, '');
|
val = val.replace(/^\[\s?\]\s/, '');
|
||||||
typeOverride = 'todo';
|
typeOverride = 'todo';
|
||||||
@@ -143,13 +181,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||||
val = val.slice(4);
|
val = val.slice(4);
|
||||||
typeOverride = 'h3';
|
typeOverride = 'h3';
|
||||||
} else if (val.startsWith('/todo ')) {
|
}
|
||||||
val = val.slice(6);
|
|
||||||
typeOverride = 'todo';
|
// Check for slash command invocation
|
||||||
checkedOverride = false;
|
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
|
||||||
} else if (val.startsWith('/code ')) {
|
if (slashMatch) {
|
||||||
val = val.slice(6);
|
setSlashSearch(slashMatch[1]);
|
||||||
typeOverride = 'code';
|
setSlashIndex(0);
|
||||||
|
} else {
|
||||||
|
setSlashSearch(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
@@ -178,16 +218,39 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
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') {
|
if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
flushDebounce(localContent);
|
flushDebounce(localContent);
|
||||||
if (e.shiftKey) {
|
onIndentBlock(block.id, e.shiftKey ? -1 : 1);
|
||||||
onIndentBlock(block.id, -1);
|
|
||||||
} else {
|
|
||||||
onIndentBlock(block.id, 1);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,28 +258,27 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setSlashSearch(null);
|
||||||
flushDebounce(localContent);
|
flushDebounce(localContent);
|
||||||
onCreateBlockBelow(block.id, localContent);
|
onCreateBlockBelow(block.id, localContent);
|
||||||
return;
|
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 === '') {
|
if (e.key === 'Backspace' && localContent === '') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setSlashSearch(null);
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
}
|
}
|
||||||
if ((block.level || 0) > 0) {
|
onDeleteBlock(block.id);
|
||||||
onIndentBlock(block.id, -1);
|
|
||||||
} else {
|
|
||||||
onDeleteBlock(block.id);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ArrowUp / ArrowDown navigation between blocks
|
// 4. Arrow navigation between blocks
|
||||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
||||||
@@ -239,66 +301,49 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const level = Math.max(0, block.level || 0);
|
const level = Math.max(0, block.level || 0);
|
||||||
const inlineTags = Array.from(
|
|
||||||
new Set(
|
// Parse rich tokens (WikiLinks [[Page]] and #Tags)
|
||||||
(localContent.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase())
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-start gap-1.5 py-1 rounded-lg transition-colors ${
|
className={`group relative flex items-start gap-1 py-1 rounded-card transition-colors ${
|
||||||
isDragTarget
|
isDragTarget
|
||||||
? 'border-t-2 border-indigo-500 bg-indigo-50/40 dark:bg-indigo-950/20'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isFocused
|
: isFocused
|
||||||
? 'bg-slate-100/70 dark:bg-slate-800/40'
|
? 'bg-panel-elevated'
|
||||||
: 'hover:bg-slate-50/80 dark:hover:bg-slate-800/20'
|
: 'hover:bg-panel'
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${level * 24 + 4}px` }}
|
style={{ paddingLeft: `${level * 22 + 4}px` }}
|
||||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||||
onDrop={() => onDropBlock?.(index)}
|
onDrop={() => onDropBlock?.(index)}
|
||||||
>
|
>
|
||||||
{/* Indentation Guide Lines */}
|
{/* Indentation Guide Line */}
|
||||||
{level > 0 &&
|
{level > 0 &&
|
||||||
Array.from({ length: level }).map((_, lvlIdx) => (
|
Array.from({ length: level }).map((_, lvlIdx) => (
|
||||||
<div
|
<div
|
||||||
key={lvlIdx}
|
key={lvlIdx}
|
||||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-slate-200/80 dark:bg-slate-800"
|
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
||||||
style={{ left: `${lvlIdx * 24 + 14}px` }}
|
style={{ left: `${lvlIdx * 22 + 12}px` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */}
|
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => onDragStartBlock?.(index)}
|
onDragStart={() => onDragStartBlock?.(index)}
|
||||||
title="Block ziehen oder Typ ändern"
|
title="Block ziehen oder umwandeln"
|
||||||
onClick={() => setShowTypeMenu((prev) => !prev)}
|
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-main cursor-grab active:cursor-grabbing transition-opacity"
|
||||||
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" />
|
<GripVertical className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -310,17 +355,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
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'}
|
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||||
>
|
>
|
||||||
{block.collapsed ? (
|
{block.collapsed ? (
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="w-3.5 h-3.5" />
|
<ChevronDown className="w-3.5 h-3.5 text-accent" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="w-4" />
|
<span className="w-3.5" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{block.type === 'todo' ? (
|
{block.type === 'todo' ? (
|
||||||
@@ -330,217 +375,207 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { checked: !block.checked })
|
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
|
block.checked
|
||||||
? 'text-emerald-500 hover:text-emerald-600'
|
? 'bg-status-ok border-status-ok text-on-accent'
|
||||||
: 'text-slate-400 hover:text-indigo-500'
|
: 'border-strong hover:border-accent text-transparent'
|
||||||
}`}
|
}`}
|
||||||
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||||
>
|
>
|
||||||
{block.checked ? (
|
<Check className="w-3 h-3 stroke-[3]" />
|
||||||
<CheckSquare className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Square className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => canEdit && setShowTypeMenu((prev) => !prev)}
|
onClick={() => {
|
||||||
className="w-5 h-5 flex items-center justify-center rounded-full group/bullet"
|
if (canEdit) {
|
||||||
title="Klicken für Block-Typ"
|
flushDebounce(localContent, {
|
||||||
|
type: block.type === 'todo' ? 'bullet' : 'todo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover"
|
||||||
|
title="Klick: Zu To-Do / Bullet umschalten"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full transition-all ${
|
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||||
block.collapsed && hasChildren
|
block.collapsed && hasChildren
|
||||||
? 'bg-indigo-500 ring-4 ring-indigo-500/20'
|
? 'bg-accent ring-2 ring-accent/30'
|
||||||
: isFocused
|
: isFocused
|
||||||
? 'bg-indigo-500 scale-110'
|
? 'bg-accent scale-125'
|
||||||
: 'bg-slate-400 dark:bg-slate-500 group-hover/bullet:bg-indigo-400'
|
: 'bg-faint group-hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Block Type Quick Popover */}
|
{/* Main Block Content */}
|
||||||
{showTypeMenu && canEdit && (
|
<div className="flex-1 min-w-0 pr-1 relative">
|
||||||
<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">
|
{block.type === 'divider' ? (
|
||||||
<button
|
<div className="py-2">
|
||||||
type="button"
|
<hr className="border-t border-border" />
|
||||||
onClick={() => setBlockType('text')}
|
</div>
|
||||||
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
|
) : block.type === 'code' ? (
|
||||||
block.type === 'text' || block.type === 'paragraph' || block.type === 'bullet'
|
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
|
||||||
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
|
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
|
||||||
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
|
<span>Code</span>
|
||||||
}`}
|
|
||||||
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
|
<button
|
||||||
key={t}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onTagClick?.(t)}
|
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
|
||||||
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"
|
className="hover:text-accent"
|
||||||
>
|
>
|
||||||
#{t}
|
Als Text
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
textareaRef.current = el;
|
||||||
|
registerTextareaRef(block.id, el);
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={localContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onFocus={() => onFocusBlock(block.id)}
|
||||||
|
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : block.type === 'callout' ? (
|
||||||
|
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
textareaRef.current = el;
|
||||||
|
registerTextareaRef(block.id, el);
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={localContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onFocus={() => onFocusBlock(block.id)}
|
||||||
|
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
textareaRef.current = el;
|
||||||
|
registerTextareaRef(block.id, el);
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={localContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onFocus={() => onFocusBlock(block.id)}
|
||||||
|
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
index === 0 && level === 0
|
||||||
|
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
||||||
|
block.type === 'h1'
|
||||||
|
? 'text-lg font-bold text-main tracking-tight'
|
||||||
|
: block.type === 'h2'
|
||||||
|
? 'text-base font-semibold text-main'
|
||||||
|
: block.type === 'h3'
|
||||||
|
? 'text-sm font-semibold text-main'
|
||||||
|
: block.type === 'todo' && block.checked
|
||||||
|
? 'text-sm text-faint line-through'
|
||||||
|
: 'text-sm text-main'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
||||||
|
{hasSpecialTokens && !isFocused && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||||
|
{tokens.map((part, idx) => {
|
||||||
|
if (part.startsWith('#') && part.length > 1) {
|
||||||
|
const tag = part.slice(1);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTagClick?.(tag)}
|
||||||
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
|
||||||
|
>
|
||||||
|
<Hash className="w-2.5 h-2.5 opacity-80" />
|
||||||
|
<span>{tag}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||||
|
const page = part.slice(2, -2).trim();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onWikiLinkClick?.(page)}
|
||||||
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||||
|
<span>{page}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Quick-Action Bar when focused */}
|
{/* Floating Slash Commands Menu */}
|
||||||
{isFocused && canEdit && (
|
{slashSearch !== null && 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">
|
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
|
||||||
<button
|
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||||
type="button"
|
Befehl auswählen
|
||||||
onMouseDown={(e) => {
|
</div>
|
||||||
e.preventDefault();
|
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||||
flushDebounce(localContent);
|
{filteredSlashCommands.map((cmd, i) => (
|
||||||
onIndentBlock(block.id, -1);
|
<button
|
||||||
}}
|
key={cmd.type}
|
||||||
disabled={level === 0}
|
type="button"
|
||||||
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"
|
onMouseDown={(e) => {
|
||||||
title="Ausrücken (Shift+Tab)"
|
e.preventDefault();
|
||||||
>
|
applySlashCommand(cmd);
|
||||||
<Outdent className="w-3.5 h-3.5" />
|
}}
|
||||||
</button>
|
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||||
<button
|
i === slashIndex
|
||||||
type="button"
|
? 'bg-accent text-on-accent'
|
||||||
onMouseDown={(e) => {
|
: 'text-main hover:bg-panel-elevated'
|
||||||
e.preventDefault();
|
}`}
|
||||||
flushDebounce(localContent);
|
>
|
||||||
onIndentBlock(block.id, 1);
|
<div className="flex items-center gap-1.5">
|
||||||
}}
|
{cmd.icon}
|
||||||
disabled={level >= maxAllowedLevel}
|
<span className="font-semibold">{cmd.label}</span>
|
||||||
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"
|
</div>
|
||||||
title="Einrücken (Tab)"
|
<span className="text-[10px] opacity-75">{cmd.desc}</span>
|
||||||
>
|
</button>
|
||||||
<Indent className="w-3.5 h-3.5" />
|
))}
|
||||||
</button>
|
{filteredSlashCommands.length === 0 && (
|
||||||
<button
|
<div className="p-2 text-center text-xs text-faint">
|
||||||
type="button"
|
Kein Befehl gefunden
|
||||||
onMouseDown={(e) => {
|
</div>
|
||||||
e.preventDefault();
|
)}
|
||||||
flushDebounce(localContent, {
|
</div>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Hover Delete Button */}
|
{/* Delete button on hover */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteBlock(block.id)}
|
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"
|
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
|
||||||
title="Block löschen"
|
title="Block löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface OutlinerTreeProps {
|
|||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||||
@@ -23,6 +24,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onMoveBlock,
|
onMoveBlock,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
|
onWikiLinkClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
@@ -271,6 +273,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
onIndentBlock={handleIndentBlock}
|
onIndentBlock={handleIndentBlock}
|
||||||
onNavigateFocus={handleNavigateFocus}
|
onNavigateFocus={handleNavigateFocus}
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
onDragStartBlock={handleDragStartBlock}
|
onDragStartBlock={handleDragStartBlock}
|
||||||
onDragOverBlock={handleDragOverBlock}
|
onDragOverBlock={handleDragOverBlock}
|
||||||
onDropBlock={handleDropBlock}
|
onDropBlock={handleDropBlock}
|
||||||
@@ -300,7 +303,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
onCreateBlock(firstBlock);
|
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"
|
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span>Neuer Punkt</span>
|
<span>Neuer Punkt</span>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+10
-5
@@ -836,7 +836,6 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
|
|
||||||
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
||||||
writeJSON(w, http.StatusCreated, doc)
|
writeJSON(w, http.StatusCreated, doc)
|
||||||
}
|
}
|
||||||
@@ -890,7 +889,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
||||||
}
|
}
|
||||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
|
||||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, doc)
|
writeJSON(w, http.StatusOK, doc)
|
||||||
@@ -936,6 +934,8 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
|||||||
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
||||||
|
|
||||||
if s.wsHub != nil {
|
if s.wsHub != nil {
|
||||||
s.wsHub.CloseDocRoom(docID)
|
s.wsHub.CloseDocRoom(docID)
|
||||||
}
|
}
|
||||||
@@ -943,7 +943,9 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
for _, uid := range authorizedUsers {
|
||||||
|
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,16 +1357,19 @@ func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID = "anonymous"
|
userID = "anonymous"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canWrite := false
|
||||||
if userID != "anonymous" {
|
if userID != "anonymous" {
|
||||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||||
|
if err != nil {
|
||||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write"
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID := r.URL.Query().Get("clientId")
|
clientID := r.URL.Query().Get("clientId")
|
||||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||||
room.ServeDocWS(w, r, userID, clientID)
|
room.ServeDocWS(w, r, userID, clientID, canWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- REALTIME LISTS & SHARING ---
|
// --- REALTIME LISTS & SHARING ---
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type DocWSClient struct {
|
|||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
userID string
|
userID string
|
||||||
clientID string
|
clientID string
|
||||||
|
canWrite bool
|
||||||
send chan []byte
|
send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +434,7 @@ func (r *DocRoom) Flush() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
|
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
|
||||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string) {
|
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) {
|
||||||
conn, err := upgrader.Upgrade(w, req, nil)
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err)
|
log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err)
|
||||||
@@ -453,6 +454,7 @@ func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID st
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
userID: userID,
|
userID: userID,
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
|
canWrite: canWrite,
|
||||||
send: make(chan []byte, 256),
|
send: make(chan []byte, 256),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +511,10 @@ func (c *DocWSClient) readPump() {
|
|||||||
|
|
||||||
var evt DocSyncEvent
|
var evt DocSyncEvent
|
||||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
||||||
|
if !c.canWrite {
|
||||||
|
// Read-only clients cannot mutate the document
|
||||||
|
continue
|
||||||
|
}
|
||||||
c.room.HandleEvent(evt, c)
|
c.room.HandleEvent(evt, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -499,7 +499,9 @@ func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||||
h.BroadcastToAll(event, payload)
|
if docID != "" {
|
||||||
|
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||||
|
|||||||
Reference in new issue
Block a user