From 90a6062c7a3ab3d98fa8523a346837ada17540d1 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 14:03:38 +0100 Subject: [PATCH] feat(workspace): render links and rich tokens inline inside blocks with direct edit support --- .../src/features/workspace/WorkspaceView.tsx | 259 ++++++++++++------ 1 file changed, 182 insertions(+), 77 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 4b7df01..717e981 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -153,6 +153,8 @@ export const WorkspaceView: React.FC = ({ const [linkSearchQuery, setLinkSearchQuery] = useState(''); const [linkWebTitle, setLinkWebTitle] = useState(''); const [linkWebUrl, setLinkWebUrl] = useState(''); + const [activeEditingBlockId, setActiveEditingBlockId] = useState(null); + const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState(null); // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); @@ -1245,6 +1247,9 @@ export const WorkspaceView: React.FC = ({ setSlashBlockId(null); deleteBlockAndFocusPrev(index); } + } else if (e.key === 'Escape') { + setActiveEditingBlockId(null); + (e.target as HTMLElement)?.blur(); } }; @@ -1327,6 +1332,7 @@ export const WorkspaceView: React.FC = ({ const focusBlock = (blockId: string) => { setLastFocusedBlockId(blockId); + setActiveEditingBlockId(blockId); const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { @@ -1373,6 +1379,7 @@ export const WorkspaceView: React.FC = ({ if (cmd.action) { updateBlock(blockId, { content: cleanContent }); + setEditingLinkOriginalRaw(null); setLinkModalBlockId(blockId); setLinkModalTab(cmd.action === 'insert_weblink' ? 'web' : 'note'); setLinkSearchQuery(''); @@ -1419,13 +1426,18 @@ export const WorkspaceView: React.FC = ({ 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}]] `; + let newContent: string; + if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) { + newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`); + } else { + const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; + newContent = `${block.content}${space}[[${docTitle}]] `; + } updateBlock(linkModalBlockId, { content: newContent }); } - const targetBlockId = linkModalBlockId; setLinkModalBlockId(null); - focusBlock(targetBlockId); + setEditingLinkOriginalRaw(null); + setActiveEditingBlockId(null); }; const handleInsertWebLink = () => { @@ -1445,15 +1457,20 @@ export const WorkspaceView: React.FC = ({ } } if (block) { - const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; - const newContent = `${block.content}${space}[${title}](${url}) `; + let newContent: string; + if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) { + newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`); + } else { + const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; + newContent = `${block.content}${space}[${title}](${url}) `; + } updateBlock(linkModalBlockId, { content: newContent }); } - const targetBlockId = linkModalBlockId; setLinkModalBlockId(null); + setEditingLinkOriginalRaw(null); setLinkWebTitle(''); setLinkWebUrl(''); - focusBlock(targetBlockId); + setActiveEditingBlockId(null); }; const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => { @@ -1612,8 +1629,11 @@ export const WorkspaceView: React.FC = ({ URL.revokeObjectURL(url); }; + const hasRichTokens = (text: string): boolean => + Boolean(text && (text.includes('[[') || text.includes('#') || (text.includes('[') && text.includes('](')))); + // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags - const renderFormattedText = (text: string) => { + const renderFormattedText = (text: string, blockId?: string) => { const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); return parts.map((part, i) => { if (part.startsWith('[[') && part.endsWith(']]')) { @@ -1622,24 +1642,44 @@ export const WorkspaceView: React.FC = ({ return ( { - e.stopPropagation(); - if (target) { - setSelectedDoc(target); - setZoomedBlockId(null); - setWorkspaceMode('editor'); - } - }} - className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${ + className={`inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 border text-xs font-medium rounded-btn transition-colors align-baseline ${ target ? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30' : 'badge-neutral hover:text-main' }`} - title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`} > - 📄 - {linkTitle} - + { + e.stopPropagation(); + if (target) { + setSelectedDoc(target); + setZoomedBlockId(null); + setWorkspaceMode('editor'); + } + }} + className="inline-flex items-center gap-1 cursor-pointer" + title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`} + > + 📄 + {linkTitle} + + + {!isReadOnly && blockId && ( + + )} ); } @@ -1648,19 +1688,40 @@ export const WorkspaceView: React.FC = ({ 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}`} + className="inline-flex items-center gap-1 px-1.5 py-0.5 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 align-baseline" > - 🌐 - {title} - - + e.stopPropagation()} + className="inline-flex items-center gap-1 cursor-pointer" + title={`Öffne: ${url}`} + > + 🌐 + {title} + + + {!isReadOnly && blockId && ( + + )} + ); } if (part.startsWith('#') && part.length > 1) { @@ -1672,7 +1733,7 @@ export const WorkspaceView: React.FC = ({ e.stopPropagation(); openTagPage(tagWord); }} - className="inline-flex items-center gap-0.5 px-1.5 py-0.2 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors" + className="inline-flex items-center gap-0.5 px-1.5 py-0.5 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors align-baseline" title={`Öffne Hashtag-Seite: #${tagWord}`} > @@ -1680,7 +1741,7 @@ export const WorkspaceView: React.FC = ({ ); } - return {part}; + return {part}; }); }; @@ -2754,19 +2815,40 @@ export const WorkspaceView: React.FC = ({ isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' }`} /> - (blockInputRefs.current[block.id] = el)} - type="text" - readOnly={isReadOnly} - value={block.content} - onFocus={handleBlockFocus} - onBlur={handleBlockBlur} - onChange={(e) => handleContentChange(block, e.target.value)} - onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} - className={`w-full bg-transparent text-sm focus:outline-none ${ - block.checked ? 'line-through text-faint' : 'text-main' - } ${isReadOnly ? 'cursor-default' : ''}`} - /> + {(!hasRichTokens(block.content) || activeEditingBlockId === block.id) && !isReadOnly ? ( + (blockInputRefs.current[block.id] = el)} + type="text" + readOnly={isReadOnly} + value={block.content} + onFocus={() => { + setActiveEditingBlockId(block.id); + handleBlockFocus(); + }} + onBlur={() => { + setActiveEditingBlockId((prev) => (prev === block.id ? null : prev)); + handleBlockBlur(); + }} + onChange={(e) => handleContentChange(block, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} + className={`w-full bg-transparent text-sm focus:outline-none ${ + block.checked ? 'line-through text-faint' : 'text-main' + }`} + /> + ) : ( +
{ + if (!isReadOnly) { + focusBlock(block.id); + } + }} + className={`w-full text-sm leading-relaxed break-words min-h-[20px] ${ + block.checked ? 'line-through text-faint' : 'text-main' + } ${isReadOnly ? 'cursor-default' : 'cursor-text'}`} + > + {renderFormattedText(block.content, block.id)} +
+ )} {!isReadOnly && ( )} - {(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && ( -
- {renderFormattedText(block.content)} -
- )} {/* Progress bar for child todos if any exist */} {(() => { @@ -2996,22 +3073,36 @@ export const WorkspaceView: React.FC = ({ {/* TYPE: DEFAULT BULLET / PARAGRAPH */} {(block.type === 'bullet' || block.type === 'paragraph') && (
-