feat(workspace): render links and rich tokens inline inside blocks with direct edit support
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-25 14:03:38 +01:00
1 parent 7c47f245e2
commit 90a6062c7a
1 file changed
+145 -40
+145 -40
View File
@@ -153,6 +153,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [linkSearchQuery, setLinkSearchQuery] = useState('');
const [linkWebTitle, setLinkWebTitle] = useState('');
const [linkWebUrl, setLinkWebUrl] = useState('');
const [activeEditingBlockId, setActiveEditingBlockId] = useState<string | null>(null);
const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState<string | null>(null);
// Drag and Drop reordering state for blocks
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
@@ -1245,6 +1247,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
if (!linkModalBlockId || !selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
if (block) {
let newContent: string;
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`);
} else {
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
const newContent = `${block.content}${space}[[${docTitle}]] `;
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<WorkspaceViewProps> = ({
}
}
if (block) {
let newContent: string;
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`);
} else {
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
const newContent = `${block.content}${space}[${title}](${url}) `;
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<WorkspaceViewProps> = ({
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,6 +1642,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return (
<span
key={i}
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'
}`}
>
<span
onClick={(e) => {
e.stopPropagation();
if (target) {
@@ -1630,17 +1657,30 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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 ${
target
? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30'
: 'badge-neutral hover:text-main'
}`}
className="inline-flex items-center gap-1 cursor-pointer"
title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`}
>
<span>📄</span>
<span>{linkTitle}</span>
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
</span>
{!isReadOnly && blockId && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingLinkOriginalRaw(part);
setLinkModalBlockId(blockId);
setLinkModalTab('note');
setLinkSearchQuery(linkTitle);
}}
className="ml-0.5 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
title="Notiz-Link bearbeiten"
>
<Edit3 className="w-2.5 h-2.5" />
</button>
)}
</span>
);
}
const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/);
@@ -1648,19 +1688,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const title = webMatch[1] || webMatch[2];
const url = webMatch[2];
return (
<a
<span
key={i}
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"
>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => 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"
className="inline-flex items-center gap-1 cursor-pointer"
title={`Öffne: ${url}`}
>
<span>🌐</span>
<span>{title}</span>
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
</a>
{!isReadOnly && blockId && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingLinkOriginalRaw(part);
setLinkModalBlockId(blockId);
setLinkModalTab('web');
setLinkWebTitle(webMatch[1] || '');
setLinkWebUrl(url);
}}
className="ml-0.5 p-0.5 rounded hover:bg-sky-500/20 opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
title="Link bearbeiten"
>
<Edit3 className="w-2.5 h-2.5" />
</button>
)}
</span>
);
}
if (part.startsWith('#') && part.length > 1) {
@@ -1672,7 +1733,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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}`}
>
<Hash className="w-2.5 h-2.5 opacity-80" />
@@ -1680,7 +1741,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</span>
);
}
return <span key={i}>{part}</span>;
return <span key={i} className="whitespace-pre-wrap">{part}</span>;
});
};
@@ -2754,19 +2815,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
}`}
/>
{(!hasRichTokens(block.content) || activeEditingBlockId === block.id) && !isReadOnly ? (
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
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'
} ${isReadOnly ? 'cursor-default' : ''}`}
}`}
/>
) : (
<div
onClick={() => {
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)}
</div>
)}
{!isReadOnly && (
<button
type="button"
@@ -2778,11 +2860,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button>
)}
</div>
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5 pl-6">
{renderFormattedText(block.content)}
</div>
)}
{/* Progress bar for child todos if any exist */}
{(() => {
@@ -2996,22 +3073,36 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* TYPE: DEFAULT BULLET / PARAGRAPH */}
{(block.type === 'bullet' || block.type === 'paragraph') && (
<div className="space-y-0.5">
{(!hasRichTokens(block.content) || activeEditingBlockId === block.id) && !isReadOnly ? (
<textarea
ref={(el) => (blockInputRefs.current[block.id] = el)}
readOnly={isReadOnly}
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
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 text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint ${
isReadOnly ? 'cursor-default' : ''
}`}
className="w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
/>
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5">
{renderFormattedText(block.content)}
) : (
<div
onClick={() => {
if (!isReadOnly) {
focusBlock(block.id);
}
}}
className={`w-full text-sm text-main leading-relaxed break-words min-h-[22px] ${
isReadOnly ? 'cursor-default' : 'cursor-text'
}`}
>
{renderFormattedText(block.content, block.id)}
</div>
)}
</div>
@@ -3877,8 +3968,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={() => {
const blockId = linkModalBlockId;
const wasEditingLink = Boolean(editingLinkOriginalRaw);
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
setEditingLinkOriginalRaw(null);
if (blockId && !wasEditingLink) focusBlock(blockId);
}}
>
<div
@@ -3891,14 +3984,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="p-1.5 rounded-btn bg-accent/15 text-accent">
<ExternalLink className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Link einfügen</h3>
<h3 className="text-sm font-semibold text-main">
{editingLinkOriginalRaw ? 'Link bearbeiten' : 'Link einfügen'}
</h3>
</div>
<button
type="button"
onClick={() => {
const blockId = linkModalBlockId;
const wasEditingLink = Boolean(editingLinkOriginalRaw);
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
setEditingLinkOriginalRaw(null);
if (blockId && !wasEditingLink) focusBlock(blockId);
}}
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
@@ -3949,8 +4046,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onKeyDown={(e) => {
if (e.key === 'Escape') {
const blockId = linkModalBlockId;
const wasEditingLink = Boolean(editingLinkOriginalRaw);
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
setEditingLinkOriginalRaw(null);
if (blockId && !wasEditingLink) focusBlock(blockId);
} else if (e.key === 'Enter') {
const filtered = docs.filter(
(d) =>
@@ -4031,8 +4130,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onKeyDown={(e) => {
if (e.key === 'Escape') {
const blockId = linkModalBlockId;
const wasEditingLink = Boolean(editingLinkOriginalRaw);
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
setEditingLinkOriginalRaw(null);
if (blockId && !wasEditingLink) focusBlock(blockId);
}
}}
className="input-field w-full h-9 text-xs"
@@ -4050,8 +4151,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onKeyDown={(e) => {
if (e.key === 'Escape') {
const blockId = linkModalBlockId;
const wasEditingLink = Boolean(editingLinkOriginalRaw);
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
setEditingLinkOriginalRaw(null);
if (blockId && !wasEditingLink) focusBlock(blockId);
}
}}
className="input-field w-full h-9 text-xs"
@@ -4063,8 +4166,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="button"
onClick={() => {
const blockId = linkModalBlockId;
const wasEditingLink = Boolean(editingLinkOriginalRaw);
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
setEditingLinkOriginalRaw(null);
if (blockId && !wasEditingLink) focusBlock(blockId);
}}
className="btn-action h-8 px-3 text-xs cursor-pointer"
>
@@ -4075,7 +4180,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
disabled={!linkWebUrl.trim()}
className="btn-primary h-8 px-4 text-xs font-medium cursor-pointer disabled:opacity-50"
>
Link einfügen
{editingLinkOriginalRaw ? 'Link speichern' : 'Link einfügen'}
</button>
</div>
</form>