diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 717e981..d8c8409 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react'; import { FilePlus, FolderPlus, @@ -154,8 +154,16 @@ export const WorkspaceView: React.FC = ({ const [linkWebTitle, setLinkWebTitle] = useState(''); const [linkWebUrl, setLinkWebUrl] = useState(''); const [activeEditingBlockId, setActiveEditingBlockId] = useState(null); + const [activeCursorOffset, setActiveCursorOffset] = useState(0); + const activeCursorOffsetRef = useRef(0); const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState(null); + const setBlockCursor = (blockId: string | null, offset: number) => { + activeCursorOffsetRef.current = offset; + setActiveEditingBlockId(blockId); + setActiveCursorOffset(offset); + }; + // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); @@ -1045,6 +1053,7 @@ export const WorkspaceView: React.FC = ({ nextBlocks.splice(afterIndex + 1, 0, newBlock); persistDocument({ ...selectedDoc, blocks: nextBlocks }); setLastFocusedBlockId(newBlock.id); + setBlockCursor(newBlock.id, 0); // Focus into the new element next tick setTimeout(() => { @@ -1134,6 +1143,7 @@ export const WorkspaceView: React.FC = ({ if (prevBlock) { setLastFocusedBlockId(prevBlock.id); + setBlockCursor(prevBlock.id, (prevBlock.content || '').length); setTimeout(() => { const el = blockInputRefs.current[prevBlock.id]; if (el) { @@ -1172,11 +1182,34 @@ export const WorkspaceView: React.FC = ({ } }; + useLayoutEffect(() => { + if (!activeEditingBlockId) return; + const el = blockInputRefs.current[activeEditingBlockId]; + if (!el) return; + const segStartAttr = el.getAttribute('data-seg-start'); + if (segStartAttr !== null && 'setSelectionRange' in el && typeof el.value === 'string') { + const segStart = parseInt(segStartAttr, 10) || 0; + const desiredOffset = activeCursorOffsetRef.current; + const localPos = Math.max(0, Math.min(el.value.length, desiredOffset - segStart)); + if (document.activeElement !== el) { + el.focus(); + } + try { + if (el.selectionStart !== localPos || el.selectionEnd !== localPos) { + el.setSelectionRange(localPos, localPos); + } + } catch { + // Ignore selection range errors on unsupported inputs + } + } + }, [activeEditingBlockId, activeCursorOffset, selectedDoc]); + // Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands const handleKeyDown = ( e: React.KeyboardEvent, block: DocumentBlock, - index: number + index: number, + segStart = 0 ) => { if (isReadOnly) return; ensureLock(); @@ -1220,7 +1253,7 @@ export const WorkspaceView: React.FC = ({ const target = e.target as HTMLTextAreaElement | HTMLInputElement; let afterContent = ''; if (target && typeof target.selectionStart === 'number') { - const cursor = target.selectionStart; + const cursor = segStart + target.selectionStart; const fullContent = block.content || ''; const before = fullContent.slice(0, cursor); const after = fullContent.slice(cursor); @@ -1238,15 +1271,36 @@ export const WorkspaceView: React.FC = ({ } else { indentBlock(index); } - } else if (e.key === 'Backspace' && (!block.content || block.content === '')) { - if ((block.level || 0) > 0) { + } else if (e.key === 'Backspace') { + const target = e.target as HTMLTextAreaElement | HTMLInputElement; + if (!block.content || block.content === '') { + if ((block.level || 0) > 0) { + e.preventDefault(); + outdentBlock(block.id); + } else if (selectedDoc?.blocks && selectedDoc.blocks.length > 1) { + e.preventDefault(); + setSlashBlockId(null); + deleteBlockAndFocusPrev(index); + } + } else if ( + segStart > 0 && + target && + target.selectionStart === 0 && + target.selectionEnd === 0 + ) { e.preventDefault(); - outdentBlock(block.id); - } else if (selectedDoc?.blocks && selectedDoc.blocks.length > 1) { - e.preventDefault(); - setSlashBlockId(null); - deleteBlockAndFocusPrev(index); + setBlockCursor(block.id, segStart); } + } else if ( + !e.shiftKey && + (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') + ) { + const target = e.target as HTMLTextAreaElement | HTMLInputElement; + requestAnimationFrame(() => { + if (target && typeof target.selectionStart === 'number') { + setBlockCursor(block.id, segStart + target.selectionStart); + } + }); } else if (e.key === 'Escape') { setActiveEditingBlockId(null); (e.target as HTMLElement)?.blur(); @@ -1330,16 +1384,22 @@ export const WorkspaceView: React.FC = ({ } }, [slashSelectedIndex, slashBlockId]); - const focusBlock = (blockId: string) => { + const focusBlock = (blockId: string, customOffset?: number) => { setLastFocusedBlockId(blockId); - setActiveEditingBlockId(blockId); + const blk = selectedDoc?.blocks.find((b) => b.id === blockId); + const targetOffset = customOffset !== undefined ? customOffset : (blk?.content || '').length; + setBlockCursor(blockId, targetOffset); + const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { + const segStartAttr = el.getAttribute('data-seg-start'); + const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0; const len = (el as any).value.length; - (el as any).setSelectionRange(len, len); + const localPos = Math.max(0, Math.min(len, targetOffset - segStart)); + (el as any).setSelectionRange(localPos, localPos); } return true; } @@ -1424,25 +1484,31 @@ export const WorkspaceView: React.FC = ({ const handleInsertNoteLink = (docTitle: string) => { if (!linkModalBlockId || !selectedDoc) return; - const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId); + const targetBlockId = linkModalBlockId; + const block = selectedDoc.blocks.find((b) => b.id === targetBlockId); + let nextCursor = 0; if (block) { let newContent: string; if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) { newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`); + if (!newContent.endsWith(' ')) newContent += ' '; } else { - const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; - newContent = `${block.content}${space}[[${docTitle}]] `; + const base = block.content || ''; + const space = base.length > 0 && !base.endsWith(' ') ? ' ' : ''; + newContent = `${base}${space}[[${docTitle}]] `; } - updateBlock(linkModalBlockId, { content: newContent }); + nextCursor = newContent.length; + updateBlock(targetBlockId, { content: newContent }); } setLinkModalBlockId(null); setEditingLinkOriginalRaw(null); - setActiveEditingBlockId(null); + focusBlock(targetBlockId, nextCursor); }; const handleInsertWebLink = () => { if (!linkModalBlockId || !selectedDoc) return; - const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId); + const targetBlockId = linkModalBlockId; + const block = selectedDoc.blocks.find((b) => b.id === targetBlockId); let title = linkWebTitle.trim(); let url = linkWebUrl.trim(); if (!url) return; @@ -1456,21 +1522,25 @@ export const WorkspaceView: React.FC = ({ title = url; } } + let nextCursor = 0; if (block) { let newContent: string; if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) { newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`); + if (!newContent.endsWith(' ')) newContent += ' '; } else { - const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; - newContent = `${block.content}${space}[${title}](${url}) `; + const base = block.content || ''; + const space = base.length > 0 && !base.endsWith(' ') ? ' ' : ''; + newContent = `${base}${space}[${title}](${url}) `; } - updateBlock(linkModalBlockId, { content: newContent }); + nextCursor = newContent.length; + updateBlock(targetBlockId, { content: newContent }); } setLinkModalBlockId(null); setEditingLinkOriginalRaw(null); setLinkWebTitle(''); setLinkWebUrl(''); - setActiveEditingBlockId(null); + focusBlock(targetBlockId, nextCursor); }; const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => { @@ -1629,120 +1699,330 @@ export const WorkspaceView: React.FC = ({ URL.revokeObjectURL(url); }; - const hasRichTokens = (text: string): boolean => - Boolean(text && (text.includes('[[') || text.includes('#') || (text.includes('[') && text.includes('](')))); + const findRichTokens = (text: string): { start: number; end: number; raw: string }[] => { + if (!text) return []; + const regex = /(\[\[[^\]]+\]\]|\[[^\]]*\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g; + const tokens: { start: number; end: number; raw: string }[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + tokens.push({ + start: match.index, + end: match.index + match[0].length, + raw: match[0], + }); + } + return tokens; + }; + + const renderSingleRichToken = (part: string, blockId: string | undefined, key: React.Key) => { + if (part.startsWith('[[') && part.endsWith(']]')) { + const linkTitle = part.slice(2, -2).trim(); + const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase()); + return ( + + { + 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 && ( + + )} + + ); + } + + 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 cursor-pointer" + title={`Öffne: ${url}`} + > + 🌐 + {title} + + + {!isReadOnly && blockId && ( + + )} + + ); + } + + if (part.startsWith('#') && part.length > 1) { + const tagWord = part.slice(1); + return ( + { + e.stopPropagation(); + openTagPage(tagWord); + }} + 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}`} + > + + {tagWord} + + ); + } + + return {part}; + }; // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags 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(']]')) { - const linkTitle = part.slice(2, -2).trim(); - const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase()); - return ( - - { - 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 && ( - - )} - - ); - } - 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 cursor-pointer" - title={`Öffne: ${url}`} - > - 🌐 - {title} - - - {!isReadOnly && blockId && ( - - )} - - ); - } - if (part.startsWith('#') && part.length > 1) { - const tagWord = part.slice(1); - return ( - { - e.stopPropagation(); - openTagPage(tagWord); - }} - 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}`} - > - - {tagWord} - - ); - } - return {part}; + {seg.text} + + ) + )} + + ); + } + + const cursor = isEditing ? Math.max(0, Math.min(text.length, activeCursorOffset)) : text.length; + const renderedTokens = isEditing + ? allTokens.filter((t) => !(cursor >= t.start && cursor <= t.end)) + : []; + + type InlineSegment = + | { kind: 'text'; start: number; end: number; text: string } + | { kind: 'token'; start: number; end: number; raw: string }; + + const segments: InlineSegment[] = []; + let lastIdx = 0; + for (const t of renderedTokens) { + segments.push({ + kind: 'text', + start: lastIdx, + end: t.start, + text: text.slice(lastIdx, t.start), + }); + segments.push({ + kind: 'token', + start: t.start, + end: t.end, + raw: t.raw, + }); + lastIdx = t.end; + } + segments.push({ + kind: 'text', + start: lastIdx, + end: text.length, + text: text.slice(lastIdx), }); + + let activeSegIdx = segments.findIndex( + (s) => s.kind === 'text' && cursor >= s.start && cursor <= s.end + ); + if (activeSegIdx === -1) { + activeSegIdx = segments.length - 1; + } + + return ( +
{ + if (e.target === e.currentTarget) { + e.preventDefault(); + focusBlock(block.id, text.length); + } + }} + className={`flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px] cursor-text ${extraTextClass}`} + > + {segments.map((seg, idx) => { + if (seg.kind === 'token') { + return renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${idx}`); + } + if (idx === activeSegIdx) { + const isLastSeg = idx === segments.length - 1; + return ( + (blockInputRefs.current[block.id] = el)} + data-seg-start={seg.start} + type="text" + readOnly={isReadOnly} + value={seg.text} + style={ + !isLastSeg + ? { width: `${Math.max(1, seg.text.length + 0.5)}ch` } + : undefined + } + onFocus={(e) => { + const localPos = + typeof e.currentTarget.selectionStart === 'number' + ? e.currentTarget.selectionStart + : seg.text.length; + setBlockCursor(block.id, seg.start + localPos); + handleBlockFocus(); + }} + onClick={(e) => { + if ( + e.currentTarget.selectionStart === e.currentTarget.selectionEnd && + typeof e.currentTarget.selectionStart === 'number' + ) { + setBlockCursor(block.id, seg.start + e.currentTarget.selectionStart); + } + }} + onBlur={() => { + setActiveEditingBlockId((prev) => (prev === block.id ? null : prev)); + handleBlockBlur(); + }} + onChange={(e) => { + const newSegVal = e.target.value; + const localCursor = + typeof e.target.selectionStart === 'number' + ? e.target.selectionStart + : newSegVal.length; + const newFull = text.slice(0, seg.start) + newSegVal + text.slice(seg.end); + const newGlobalCursor = seg.start + localCursor; + setBlockCursor(block.id, newGlobalCursor); + handleContentChange(block, newFull); + }} + onKeyDown={(e) => handleKeyDown(e, block, originalIndex, seg.start)} + className={`${ + isLastSeg ? 'flex-1 min-w-[60px]' : 'flex-shrink-0' + } bg-transparent text-sm leading-relaxed focus:outline-none placeholder:text-faint ${extraTextClass}`} + /> + ); + } + if (!seg.text) return null; + return ( + { + e.preventDefault(); + focusBlock(block.id, seg.end); + }} + className="whitespace-pre-wrap text-sm leading-relaxed cursor-text" + > + {seg.text} + + ); + })} +
+ ); }; // --- GLOBAL TO-DO ROLLUP AGGREGATOR --- @@ -2815,39 +3095,10 @@ export const WorkspaceView: React.FC = ({ isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' }`} /> - {(!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)} -
+ {renderLiveInlineBlock( + block, + originalIndex, + block.checked ? 'line-through text-faint' : 'text-main' )} {!isReadOnly && (