From 06f5f7043c27946ef344d87419e8baf809e8f900 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 22:19:53 +0100 Subject: [PATCH] feat(workspace): add /link slash command with note selector and web link modal --- frontend/src/features/workspace/LinkModal.tsx | 271 ++++++++++++++++++ .../src/features/workspace/OutlinerBlock.tsx | 79 ++++- 2 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/workspace/LinkModal.tsx diff --git a/frontend/src/features/workspace/LinkModal.tsx b/frontend/src/features/workspace/LinkModal.tsx new file mode 100644 index 0000000..d6b6d7c --- /dev/null +++ b/frontend/src/features/workspace/LinkModal.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { Link as LinkIcon, FileText, Globe, X, Search, ExternalLink, Loader2 } from 'lucide-react'; +import { api } from '../../lib/api'; +import { Document } from '../../lib/types'; + +export interface LinkModalProps { + isOpen: boolean; + onClose: () => void; + onInsertLink: (markdown: string) => void; + availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[]; +} + +export const LinkModal: React.FC = ({ + isOpen, + onClose, + onInsertLink, + availableDocs, +}) => { + const [tab, setTab] = useState<'note' | 'web'>('note'); + const [search, setSearch] = useState(''); + const [docs, setDocs] = useState([]); + const [loading, setLoading] = useState(false); + + const [webTitle, setWebTitle] = useState(''); + const [webUrl, setWebUrl] = useState(''); + + const searchInputRef = useRef(null); + const webTitleInputRef = useRef(null); + + // Load documents when modal opens + useEffect(() => { + if (!isOpen) return; + if (availableDocs && availableDocs.length > 0) { + setDocs(availableDocs as Document[]); + return; + } + let isCancelled = false; + setLoading(true); + api + .getDocuments() + .then((fetched) => { + if (!isCancelled) { + setDocs(fetched); + setLoading(false); + } + }) + .catch(() => { + if (!isCancelled) { + setLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [isOpen, availableDocs]); + + // Focus input on mount or tab change + useEffect(() => { + if (!isOpen) return; + const timer = setTimeout(() => { + if (tab === 'note') { + searchInputRef.current?.focus(); + } else { + webTitleInputRef.current?.focus(); + } + }, 50); + return () => clearTimeout(timer); + }, [isOpen, tab]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const filteredDocs = useMemo(() => { + if (!search.trim()) return docs; + const q = search.toLowerCase(); + return docs.filter( + (d) => + d.title.toLowerCase().includes(q) || + (d.folderPath && d.folderPath.toLowerCase().includes(q)) + ); + }, [docs, search]); + + const handleSelectNote = (doc: { title: string }) => { + const noteTitle = doc.title.trim() || 'Notiz'; + onInsertLink(`[[${noteTitle}]]`); + }; + + const handleWebLinkSubmit = (e: React.FormEvent) => { + e.preventDefault(); + let url = webUrl.trim(); + if (!url) return; + if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) { + url = `https://${url}`; + } + const label = webTitle.trim() || url; + onInsertLink(`[${label}](${url})`); + }; + + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Modal Header */} +
+
+ +

Link einfügen

+
+ +
+ + {/* Tab Switcher */} +
+ + +
+ + {/* Modal Body */} +
+ {tab === 'note' ? ( +
+
+ + setSearch(e.target.value)} + placeholder="Notiz suchen…" + className="w-full bg-input border border-border focus:border-accent text-main text-xs pl-8 pr-3 py-1.5 rounded-btn focus:outline-none transition-colors" + /> +
+ + {loading ? ( +
+ + Notizen werden geladen… +
+ ) : ( +
+ {filteredDocs.map((d) => ( + + ))} + + {filteredDocs.length === 0 && ( +
+ {search + ? `Keine Notiz für "${search}" gefunden.` + : 'Keine Notizen im Workspace vorhanden.'} +
+ )} +
+ )} +
+ ) : ( +
+
+ + setWebTitle(e.target.value)} + placeholder="z. B. MindSift Dokumentation" + className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors" + /> +
+ +
+ + setWebUrl(e.target.value)} + placeholder="https://example.com" + className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors" + /> +
+ +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 018451e..03f4635 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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: }, + { type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', 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: }, @@ -89,6 +93,7 @@ export const OutlinerBlock: React.FC = ({ const [localContent, setLocalContent] = useState(block.content || ''); const [slashSearch, setSlashSearch] = useState(null); const [slashIndex, setSlashIndex] = useState(0); + const [showLinkModal, setShowLinkModal] = useState(false); const textareaRef = useRef(null); const debounceTimerRef = useRef(null); @@ -158,6 +163,11 @@ export const OutlinerBlock: React.FC = ({ 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 = ({ }); }; + 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) => { if (!canEdit) return; let val = e.target.value; @@ -339,14 +359,20 @@ export const OutlinerBlock: React.FC = ({ 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 = ({ 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}`} > - + {page} ); } + const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/); + if (mdMatch) { + const label = mdMatch[1]; + const url = mdMatch[2]; + return ( + + + {label || url} + + ); + } + if (/^https?:\/\//.test(part)) { + return ( + + + {part} + + ); + } return null; })} @@ -689,6 +749,15 @@ export const OutlinerBlock: React.FC = ({ )} + + {/* Link Insertion Modal */} + {showLinkModal && ( + setShowLinkModal(false)} + onInsertLink={handleInsertLink} + /> + )} ); };