feat(workspace): add /link slash command with note selector and web link modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s

This commit is contained in:
sandrodach committed 2026-10-26 22:19:53 +01:00
1 parent 1bcf6b4f18
commit 06f5f7043c
2 files changed
+345 -5

No files matched your search

@@ -15,12 +15,15 @@ import {
Hash,
ExternalLink,
Maximize2,
Link as LinkIcon,
FileText,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { LinkModal } from './LinkModal';
export interface SlashCommand {
type: BlockType;
type: BlockType | 'link';
label: string;
desc: string;
icon: React.ReactNode;
@@ -28,6 +31,7 @@ export interface SlashCommand {
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon 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" /> },
@@ -89,6 +93,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null);
@@ -158,6 +163,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
setShowLinkModal(true);
return;
}
setLocalContent(clean);
flushDebounce(clean, {
type: cmd.type,
@@ -165,6 +175,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
});
};
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent);
flushDebounce(newContent);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
let val = e.target.value;
@@ -339,14 +359,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const level = Math.max(0, block.level || 0);
const visualLevel = Math.max(0, level - (levelOffset || 0));
// Parse rich tokens (WikiLinks [[Page]] and #Tags)
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
const tokens = useMemo(() => {
const parts = (localContent || '').split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
const parts = (localContent || '').split(
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
);
return parts.filter(Boolean);
}, [localContent]);
const hasSpecialTokens = tokens.some(
(t) => t.startsWith('#') || (t.startsWith('[[') && t.endsWith(']]'))
(t) =>
t.startsWith('#') ||
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t)
);
return (
@@ -619,12 +645,46 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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"
title={`Notiz öffnen: ${page}`}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<FileText className="w-2.5 h-2.5 opacity-80" />
<span>{page}</span>
</button>
);
}
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch) {
const label = mdMatch[1];
const url = mdMatch[2];
return (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={url}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span>{label || url}</span>
</a>
);
}
if (/^https?:\/\//.test(part)) {
return (
<a
key={idx}
href={part}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={part}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span className="truncate max-w-[160px]">{part}</span>
</a>
);
}
return null;
})}
</div>
@@ -689,6 +749,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<X className="w-3.5 h-3.5" />
</button>
)}
{/* Link Insertion Modal */}
{showLinkModal && (
<LinkModal
isOpen={showLinkModal}
onClose={() => setShowLinkModal(false)}
onInsertLink={handleInsertLink}
/>
)}
</div>
);
};