From d34178aa1a9323becdb233e76bcc57c59e10ce43 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 12:14:03 +0100 Subject: [PATCH] feat(workspace): add slash command link menu for internal notes and web links --- .../src/features/workspace/WorkspaceView.tsx | 347 ++++++++++++++++-- 1 file changed, 325 insertions(+), 22 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 62b16b2..0cf0570 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -26,6 +26,7 @@ import { Italic, Strikethrough, Link, + FileText, Table as TableIcon, Percent, Minus, @@ -68,13 +69,17 @@ interface FolderTreeItem { } interface SlashCommandItem { - type: BlockType; + type?: BlockType; + action?: 'insert_link' | 'insert_note' | 'insert_weblink'; label: string; desc: string; icon: string; } const SLASH_COMMANDS: SlashCommandItem[] = [ + { action: 'insert_link', label: '/link', desc: 'Webseite oder Notiz verlinken', icon: '🔗' }, + { action: 'insert_note', label: '/notiz', desc: 'Notiz verlinken [[Notiz]]', icon: '📄' }, + { action: 'insert_weblink', label: '/web', desc: 'Externe Webseite verlinken [Name](url)', icon: '🌐' }, { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: '☑️' }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: 'H1' }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: 'H2' }, @@ -135,6 +140,13 @@ export const WorkspaceView: React.FC = ({ const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); const slashMenuRef = useRef(null); + // Link Insertion Modal state (/link, /notiz, /web) + const [linkModalBlockId, setLinkModalBlockId] = useState(null); + const [linkModalTab, setLinkModalTab] = useState<'note' | 'web'>('note'); + const [linkSearchQuery, setLinkSearchQuery] = useState(''); + const [linkWebTitle, setLinkWebTitle] = useState(''); + const [linkWebUrl, setLinkWebUrl] = useState(''); + // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); @@ -1154,7 +1166,7 @@ export const WorkspaceView: React.FC = ({ e.preventDefault(); const selected = filteredSlashCommands[slashSelectedIndex]; if (selected) { - applySlashCommand(block.id, selected.type); + applySlashCommand(block.id, selected); } return; } @@ -1236,17 +1248,9 @@ export const WorkspaceView: React.FC = ({ } }, [slashSelectedIndex, slashBlockId]); - const applySlashCommand = (blockId: string, type: BlockType) => { - if (isReadOnly || !selectedDoc) return; - const block = selectedDoc.blocks.find((b) => b.id === blockId); - if (!block) return; - const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim(); - updateBlock(blockId, { type, content: cleanContent, checked: type === 'todo' ? false : undefined }); - setSlashBlockId(null); - setSlashSearch(''); - - // Re-focus immediately into the block's text field and position cursor at end - const focusBlock = () => { + const focusBlock = (blockId: string) => { + setLastFocusedBlockId(blockId); + const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { el.focus(); @@ -1259,14 +1263,79 @@ export const WorkspaceView: React.FC = ({ return false; }; - setLastFocusedBlockId(blockId); setTimeout(() => { - if (!focusBlock()) { - setTimeout(focusBlock, 30); + if (!tryFocus()) { + setTimeout(tryFocus, 30); } }, 20); }; + const applySlashCommand = (blockId: string, cmd: SlashCommandItem) => { + if (isReadOnly || !selectedDoc) return; + const block = selectedDoc.blocks.find((b) => b.id === blockId); + if (!block) return; + const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim(); + + setSlashBlockId(null); + setSlashSearch(''); + + if (cmd.action) { + updateBlock(blockId, { content: cleanContent }); + setLinkModalBlockId(blockId); + setLinkModalTab(cmd.action === 'insert_weblink' ? 'web' : 'note'); + setLinkSearchQuery(''); + setLinkWebTitle(''); + setLinkWebUrl(''); + return; + } + + if (cmd.type) { + updateBlock(blockId, { type: cmd.type, content: cleanContent, checked: cmd.type === 'todo' ? false : undefined }); + focusBlock(blockId); + } + }; + + const handleInsertNoteLink = (docTitle: string) => { + if (!linkModalBlockId || !selectedDoc) return; + const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId); + if (block) { + const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; + const newContent = `${block.content}${space}[[${docTitle}]] `; + updateBlock(linkModalBlockId, { content: newContent }); + } + const targetBlockId = linkModalBlockId; + setLinkModalBlockId(null); + focusBlock(targetBlockId); + }; + + const handleInsertWebLink = () => { + if (!linkModalBlockId || !selectedDoc) return; + const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId); + let title = linkWebTitle.trim(); + let url = linkWebUrl.trim(); + if (!url) return; + if (!/^https?:\/\//i.test(url)) { + url = `https://${url}`; + } + if (!title) { + try { + title = new URL(url).hostname; + } catch { + title = url; + } + } + if (block) { + const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; + const newContent = `${block.content}${space}[${title}](${url}) `; + updateBlock(linkModalBlockId, { content: newContent }); + } + const targetBlockId = linkModalBlockId; + setLinkModalBlockId(null); + setLinkWebTitle(''); + setLinkWebUrl(''); + focusBlock(targetBlockId); + }; + const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => { const parent = blocks[parentIndex]; if (!parent) return []; @@ -1423,9 +1492,9 @@ export const WorkspaceView: React.FC = ({ URL.revokeObjectURL(url); }; - // Helper to render text with clickable Wiki-Links and Hashtags + // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags const renderFormattedText = (text: string) => { - const parts = text.split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); + const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); return parts.map((part, i) => { if (part.startsWith('[[') && part.endsWith(']]')) { const linkTitle = part.slice(2, -2).trim(); @@ -1454,6 +1523,26 @@ export const WorkspaceView: React.FC = ({ ); } + const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/); + if (webMatch) { + const title = webMatch[1] || webMatch[2]; + const url = webMatch[2]; + return ( + e.stopPropagation()} + className="inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors" + title={`Öffne: ${url}`} + > + 🌐 + {title} + + + ); + } if (part.startsWith('#') && part.length > 1) { const tagWord = part.slice(1); return ( @@ -2548,7 +2637,7 @@ export const WorkspaceView: React.FC = ({ )} - {(block.content.includes('[[') || block.content.includes('#')) && ( + {(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
{renderFormattedText(block.content)}
@@ -2754,7 +2843,7 @@ export const WorkspaceView: React.FC = ({ isReadOnly ? 'cursor-default' : '' }`} /> - {(block.content.includes('[[') || block.content.includes('#')) && ( + {(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
{renderFormattedText(block.content)}
@@ -2771,11 +2860,11 @@ export const WorkspaceView: React.FC = ({
{filteredSlashCommands.map((cmd, idx) => (
)} + + {/* LINK INSERTION MODAL (/link, /notiz, /web) */} + {linkModalBlockId && ( +
{ + const blockId = linkModalBlockId; + setLinkModalBlockId(null); + if (blockId) focusBlock(blockId); + }} + > +
e.stopPropagation()} + > + {/* Modal Header */} +
+
+
+ +
+

Link einfügen

+
+ +
+ + {/* Tab Switcher: Notiz vs Webseite */} +
+ + +
+ + {/* Tab Content */} +
+ {linkModalTab === 'note' ? ( +
+
+ + setLinkSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + const blockId = linkModalBlockId; + setLinkModalBlockId(null); + if (blockId) focusBlock(blockId); + } else if (e.key === 'Enter') { + const filtered = docs.filter( + (d) => + d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) || + (d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase())) + ); + if (filtered.length > 0) { + handleInsertNoteLink(filtered[0].title); + } + } + }} + className="input-field w-full pl-9 h-9 text-xs" + /> +
+ +
+ {docs + .filter( + (d) => + !linkSearchQuery.trim() || + d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) || + (d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase())) + ) + .slice(0, 20) + .map((d) => ( + + ))} + + {docs.filter( + (d) => + !linkSearchQuery.trim() || + d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) + ).length === 0 && ( +
+ Keine passende Notiz gefunden +
+ )} +
+
+ ) : ( +
{ + e.preventDefault(); + handleInsertWebLink(); + }} + className="space-y-3" + > +
+ + setLinkWebTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + const blockId = linkModalBlockId; + setLinkModalBlockId(null); + if (blockId) focusBlock(blockId); + } + }} + className="input-field w-full h-9 text-xs" + /> +
+ +
+ + setLinkWebUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + const blockId = linkModalBlockId; + setLinkModalBlockId(null); + if (blockId) focusBlock(blockId); + } + }} + className="input-field w-full h-9 text-xs" + /> +
+ +
+ + +
+
+ )} +
+
+
+ )} ); };