fix(workspace): restore theme design-system, fix 5x note creation, enforce privacy isolation, fix shared write permission, add drag-drop & slash-commands
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s

This commit is contained in:
sandrodach committed 2026-10-26 21:41:27 +01:00
1 parent 86867b0d6f
commit 85e4010435
7 files changed
+791 -490

No files matched your search

+285 -250
View File
@@ -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: <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 {
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<OutlinerBlockProps> = ({
block,
index,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
@@ -52,6 +71,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlock,
onNavigateFocus,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
onDragOverBlock,
onDropBlock,
@@ -59,7 +79,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
registerTextareaRef,
}) => {
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 debounceTimerRef = useRef<number | null>(null);
const latestBlockRef = useRef<DocumentBlock>(block);
@@ -67,7 +89,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const localContentRef = useRef<string>(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<OutlinerBlockProps> = ({
}
}, [block.content]);
// Cleanup 150ms debounce timer on unmount
useEffect(() => {
return () => {
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 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<OutlinerBlockProps> = ({
[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>) => {
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<OutlinerBlockProps> = ({
} 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<OutlinerBlockProps> = ({
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
};
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 (
<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
? 'border-t-2 border-indigo-500 bg-indigo-50/40 dark:bg-indigo-950/20'
? 'border-t-2 border-accent bg-accent/10'
: isFocused
? 'bg-slate-100/70 dark:bg-slate-800/40'
: 'hover:bg-slate-50/80 dark:hover:bg-slate-800/20'
? 'bg-panel-elevated'
: 'hover:bg-panel'
}`}
style={{ paddingLeft: `${level * 24 + 4}px` }}
style={{ paddingLeft: `${level * 22 + 4}px` }}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
>
{/* Indentation Guide Lines */}
{/* Indentation Guide Line */}
{level > 0 &&
Array.from({ length: level }).map((_, lvlIdx) => (
<div
key={lvlIdx}
className="pointer-events-none absolute top-0 bottom-0 w-px bg-slate-200/80 dark:bg-slate-800"
style={{ left: `${lvlIdx * 24 + 14}px` }}
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
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">
{canEdit && (
<button
type="button"
draggable
onDragStart={() => onDragStartBlock?.(index)}
title="Block ziehen oder Typ ändern"
onClick={() => setShowTypeMenu((prev) => !prev)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 cursor-grab active:cursor-grabbing transition-opacity"
title="Block ziehen oder umwandeln"
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"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
@@ -310,17 +355,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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 ? (
<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>
) : (
<span className="w-4" />
<span className="w-3.5" />
)}
{block.type === 'todo' ? (
@@ -330,217 +375,207 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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 ? (
<CheckSquare className="w-4 h-4" />
) : (
<Square className="w-4 h-4" />
)}
<Check className="w-3 h-3 stroke-[3]" />
</button>
) : (
<button
type="button"
onClick={() => canEdit && setShowTypeMenu((prev) => !prev)}
className="w-5 h-5 flex items-center justify-center rounded-full group/bullet"
title="Klicken für Block-Typ"
onClick={() => {
if (canEdit) {
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
className={`w-2 h-2 rounded-full transition-all ${
className={`w-1.5 h-1.5 rounded-full transition-all ${
block.collapsed && hasChildren
? 'bg-indigo-500 ring-4 ring-indigo-500/20'
? 'bg-accent ring-2 ring-accent/30'
: isFocused
? 'bg-indigo-500 scale-110'
: 'bg-slate-400 dark:bg-slate-500 group-hover/bullet:bg-indigo-400'
? 'bg-accent scale-125'
: 'bg-faint group-hover:bg-accent'
}`}
/>
</button>
)}
</div>
{/* Block Type Quick Popover */}
{showTypeMenu && canEdit && (
<div className="absolute left-8 top-8 z-30 flex items-center gap-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-lg p-1">
<button
type="button"
onClick={() => setBlockType('text')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'text' || block.type === 'paragraph' || block.type === 'bullet'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Standard-Bullet"
>
<Type className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('todo')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'todo'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="To-Do Checkbox"
>
<ListTodo className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('h1')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'h1'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Überschrift 1"
>
<Heading1 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('h2')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'h2'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Überschrift 2"
>
<Heading2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setBlockType('code')}
className={`p-1.5 rounded-lg text-xs flex items-center gap-1 ${
block.type === 'code'
? 'bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title="Code-Block"
>
<Code className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */}
<div className="flex-1 min-w-0 pr-2">
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={() => {
setShowTypeMenu(false);
onFocusBlock(block.id);
}}
onBlur={() => {
if (debounceTimerRef.current !== null) {
flushDebounce(localContent);
}
}}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
index === 0 && level === 0
? 'Schreibe einen Punkt… (Enter = Neuer Punkt, Tab = Einrücken, #tag, [] für To-Do)'
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${textStyleClass}`}
/>
{/* Inline clickable #tag pills */}
{inlineTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1 mt-1">
{inlineTags.map((t) => (
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative">
{block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
</div>
) : block.type === 'code' ? (
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
<span>Code</span>
<button
key={t}
type="button"
onClick={() => onTagClick?.(t)}
className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
className="hover:text-accent"
>
#{t}
Als Text
</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>
)}
{/* Mobile Quick-Action Bar when focused */}
{isFocused && canEdit && (
<div className="flex sm:hidden items-center gap-1 mt-1.5 pt-1 border-t border-slate-200/60 dark:border-slate-800/60">
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent);
onIndentBlock(block.id, -1);
}}
disabled={level === 0}
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
title="Ausrücken (Shift+Tab)"
>
<Outdent className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent);
onIndentBlock(block.id, 1);
}}
disabled={level >= maxAllowedLevel}
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 disabled:opacity-40"
title="Einrücken (Tab)"
>
<Indent className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent, {
type: block.type === 'todo' ? 'text' : 'todo',
});
}}
className="p-1.5 rounded-lg text-xs text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800"
title="Als To-Do umschalten"
>
<ListTodo className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
flushDebounce(localContent);
onCreateBlockBelow(block.id, localContent);
}}
className="p-1.5 rounded-lg text-xs text-indigo-600 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-950/60 ml-auto flex items-center gap-1"
>
<ArrowDownLeft className="w-3.5 h-3.5" />
<span>Neu</span>
</button>
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5">
{filteredSlashCommands.map((cmd, i) => (
<button
key={cmd.type}
type="button"
onMouseDown={(e) => {
e.preventDefault();
applySlashCommand(cmd);
}}
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
i === slashIndex
? 'bg-accent text-on-accent'
: 'text-main hover:bg-panel-elevated'
}`}
>
<div className="flex items-center gap-1.5">
{cmd.icon}
<span className="font-semibold">{cmd.label}</span>
</div>
<span className="text-[10px] opacity-75">{cmd.desc}</span>
</button>
))}
{filteredSlashCommands.length === 0 && (
<div className="p-2 text-center text-xs text-faint">
Kein Befehl gefunden
</div>
)}
</div>
</div>
)}
</div>
{/* Right Hover Delete Button */}
{/* Delete button on hover */}
{canEdit && (
<button
type="button"
onClick={() => onDeleteBlock(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 rounded text-slate-400 hover:text-rose-500 transition-opacity shrink-0"
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"
>
<Trash2 className="w-3.5 h-3.5" />
<X className="w-3.5 h-3.5" />
</button>
)}
</div>