From 58f02e092786e9e831cf1f99816ab90f356533fa Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 15:35:33 +0100 Subject: [PATCH] fix(workspace): enable automatic multi-line text wrapping for note blocks without horizontal scrolling --- .../src/features/workspace/WorkspaceView.tsx | 471 ++++++------------ 1 file changed, 151 insertions(+), 320 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index bfb0904..ec922f6 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -183,6 +183,12 @@ export const WorkspaceView: React.FC = ({ setActiveCursorOffset(offset); }; + const autoResizeTextarea = (el: HTMLTextAreaElement | null) => { + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }; + // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null); @@ -2180,9 +2186,12 @@ export const WorkspaceView: React.FC = ({ 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; + if (el instanceof HTMLTextAreaElement) { + autoResizeTextarea(el); + } + if ('setSelectionRange' in el && typeof el.value === 'string') { + const segStartAttr = el.getAttribute('data-seg-start'); + const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0; const desiredOffset = activeCursorOffsetRef.current; const localPos = Math.max(0, Math.min(el.value.length, desiredOffset - segStart)); if (document.activeElement !== el) { @@ -2257,6 +2266,9 @@ export const WorkspaceView: React.FC = ({ const nextCursor = cursor + 1; setBlockCursor(block.id, nextCursor); updateBlock(block.id, { content: nextContent }); + if (target instanceof HTMLTextAreaElement) { + setTimeout(() => autoResizeTextarea(target), 0); + } } return; } @@ -2269,7 +2281,7 @@ export const WorkspaceView: React.FC = ({ let afterContent = ''; let updatedCurrent: { id: string; content: string } | undefined = undefined; - if (!fullContent.includes('\n') && target && typeof target.selectionStart === 'number') { + if (target && typeof target.selectionStart === 'number') { const cursor = segStart + target.selectionStart; const before = fullContent.slice(0, cursor); const after = fullContent.slice(cursor); @@ -2278,7 +2290,6 @@ export const WorkspaceView: React.FC = ({ afterContent = after; } } else if (fullContent.endsWith('\n')) { - // Clean up trailing empty line if user pressed Shift+Enter then immediately Enter const trimmedTrailing = fullContent.replace(/\n+$/, ''); if (trimmedTrailing !== fullContent) { updatedCurrent = { id: block.id, content: trimmedTrailing }; @@ -2309,90 +2320,61 @@ export const WorkspaceView: React.FC = ({ deleteSingleBlock(block.id, true); return; } else if ( - segStart > 0 && target && target.selectionStart === 0 && target.selectionEnd === 0 ) { e.preventDefault(); - if (fullContent[segStart - 1] === '\n') { - // Join current line with previous line within the same bullet - const joined = fullContent.slice(0, segStart - 1) + fullContent.slice(segStart); - setBlockCursor(block.id, segStart - 1); - handleContentChange(block, joined); - } else { - // Move cursor into preceding inline token to unwrap it - setBlockCursor(block.id, segStart - 1); + if ((block.level || 0) > 0) { + outdentBlock(block.id); + return; + } + if (selectedDoc && index > 0) { + const prevBlock = selectedDoc.blocks[index - 1]; + if (prevBlock && prevBlock.type !== 'divider') { + const prevLen = (prevBlock.content || '').length; + const mergedContent = (prevBlock.content || '') + fullContent; + const nextBlocks = selectedDoc.blocks + .map((b) => (b.id === prevBlock.id ? { ...b, content: mergedContent } : b)) + .filter((b) => b.id !== block.id); + persistDocument({ ...selectedDoc, blocks: nextBlocks }, true); + focusBlock(prevBlock.id, prevLen); + } } } - } else if (!e.shiftKey && e.key === 'ArrowLeft') { - const target = e.target as HTMLTextAreaElement | HTMLInputElement; - if (segStart > 0 && target && target.selectionStart === 0 && target.selectionEnd === 0) { - e.preventDefault(); - setBlockCursor(block.id, segStart - 1); - return; - } - requestAnimationFrame(() => { - if (target && typeof target.selectionStart === 'number') { - setBlockCursor(block.id, segStart + target.selectionStart); - } - }); - } else if (!e.shiftKey && e.key === 'ArrowRight') { - const target = e.target as HTMLTextAreaElement | HTMLInputElement; - const fullContent = block.content || ''; - if ( - target && - typeof target.selectionStart === 'number' && - target.selectionStart === target.value.length && - segStart + target.value.length < fullContent.length - ) { - e.preventDefault(); - setBlockCursor(block.id, segStart + target.value.length + 1); - return; - } - requestAnimationFrame(() => { - if (target && typeof target.selectionStart === 'number') { - setBlockCursor(block.id, segStart + target.selectionStart); - } - }); } else if (!e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; - if (fullContent.includes('\n') && target && typeof target.selectionStart === 'number') { - const cursor = segStart + target.selectionStart; - const rawLines = fullContent.split('\n'); - let run = 0; - const lineMeta = rawLines.map((lt) => { - const start = run; - const end = start + lt.length; - run = end + 1; - return { start, end, len: lt.length }; - }); - const curLineIdx = lineMeta.findIndex((m) => cursor >= m.start && cursor <= m.end); - if (curLineIdx !== -1) { - const col = cursor - lineMeta[curLineIdx].start; - if (e.key === 'ArrowUp' && curLineIdx > 0) { - e.preventDefault(); - const prevLine = lineMeta[curLineIdx - 1]; - setBlockCursor(block.id, prevLine.start + Math.min(col, prevLine.len)); - return; + if (target && typeof target.selectionStart === 'number') { + const cursor = target.selectionStart; + if (e.key === 'ArrowUp') { + const firstNewline = fullContent.indexOf('\n'); + if (firstNewline === -1 || cursor <= firstNewline) { + if (index > 0 && selectedDoc) { + const prev = selectedDoc.blocks[index - 1]; + if (prev) { + e.preventDefault(); + focusBlock(prev.id); + return; + } + } } - if (e.key === 'ArrowDown' && curLineIdx < lineMeta.length - 1) { - e.preventDefault(); - const nextLine = lineMeta[curLineIdx + 1]; - setBlockCursor(block.id, nextLine.start + Math.min(col, nextLine.len)); - return; + } else if (e.key === 'ArrowDown') { + const lastNewline = fullContent.lastIndexOf('\n'); + if (lastNewline === -1 || cursor > lastNewline) { + if (selectedDoc && index < selectedDoc.blocks.length - 1) { + const next = selectedDoc.blocks[index + 1]; + if (next) { + e.preventDefault(); + focusBlock(next.id, 0); + return; + } + } } } } - } else if (!e.shiftKey && (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') { + setSlashBlockId(null); setActiveEditingBlockId(null); (e.target as HTMLElement)?.blur(); } @@ -2484,6 +2466,9 @@ export const WorkspaceView: React.FC = ({ const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { + if (el instanceof HTMLTextAreaElement) { + autoResizeTextarea(el); + } el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { const segStartAttr = el.getAttribute('data-seg-start'); @@ -3062,8 +3047,9 @@ export const WorkspaceView: React.FC = ({ ); }; - // Live inline token-aware editor: splits multi-line bullets (Shift+Enter) into visual rows and renders - // completed #hashtags and links inline as pills on each line while unwrapping any token touched by the cursor. + // Auto-resizing textarea / formatted view for bullet points and tasks: + // - When editing: renders an auto-expanding textarea that naturally wraps text (no horizontal scrolling). + // - When inactive: renders full rich tokens (Wiki-Links, external URLs, #hashtags) with word wrapping. const renderLiveInlineBlock = ( block: DocumentBlock, originalIndex: number, @@ -3072,240 +3058,59 @@ export const WorkspaceView: React.FC = ({ const text = block.content || ''; if (isReadOnly) { return ( -
+
{renderFormattedText(text, block.id)}
); } const isEditing = activeEditingBlockId === block.id; - const cursor = isEditing ? Math.max(0, Math.min(text.length, activeCursorOffset)) : text.length; - const rawLines = text.split('\n'); - let runningOffset = 0; - const lines = rawLines.map((lineText, lineIdx) => { - const lineStart = runningOffset; - const lineEnd = lineStart + lineText.length; - runningOffset = lineEnd + 1; // +1 for the '\n' delimiter - return { lineIdx, lineText, lineStart, lineEnd }; - }); - - let activeLineIdx = lines.findIndex((l) => cursor >= l.lineStart && cursor <= l.lineEnd); - if (activeLineIdx === -1) { - activeLineIdx = lines.length - 1; + if (!isEditing) { + return ( +
{ + if (isReadOnly) return; + if ((e.target as HTMLElement).closest('button, a')) return; + focusBlock(block.id); + }} + className={`w-full text-sm leading-relaxed break-words whitespace-pre-wrap min-h-[22px] cursor-text ${extraTextClass}`} + > + {text ? ( + renderFormattedText(text, block.id) + ) : ( +   + )} +
+ ); } - type InlineSegment = - | { kind: 'text'; start: number; end: number; text: string } - | { kind: 'token'; start: number; end: number; raw: string }; - return ( -
- {lines.map((line) => { - const isLineActive = isEditing && line.lineIdx === activeLineIdx; - const lineTokens = findRichTokens(line.lineText).map((t) => ({ - start: line.lineStart + t.start, - end: line.lineStart + t.end, - raw: t.raw, - })); - - // Non-active line (or block not currently focused) - if (!isLineActive) { - const segments: InlineSegment[] = []; - let lastIdx = line.lineStart; - for (const t of lineTokens) { - if (t.start > lastIdx) { - 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; - } - if (lastIdx < line.lineEnd) { - segments.push({ - kind: 'text', - start: lastIdx, - end: line.lineEnd, - text: text.slice(lastIdx, line.lineEnd), - }); - } - - return ( -
{ - if (e.target === e.currentTarget) { - e.preventDefault(); - focusBlock(block.id, line.lineEnd); - } - }} - onClick={(e) => { - if (e.target === e.currentTarget) { - focusBlock(block.id, line.lineEnd); - } - }} - className="flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text" - > - {segments.length === 0 ? ( - { - e.preventDefault(); - focusBlock(block.id, line.lineStart); - }} - className="w-full min-h-[22px] inline-block" - > -   - - ) : ( - segments.map((seg, i) => - seg.kind === 'token' ? ( - renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${i}`) - ) : ( - { - e.stopPropagation(); - const sel = window.getSelection(); - const localOffset = - sel && sel.anchorNode && e.currentTarget.contains(sel.anchorNode) - ? sel.anchorOffset - : seg.text.length; - focusBlock(block.id, seg.start + localOffset); - }} - className="whitespace-pre-wrap" - > - {seg.text} - - ) - ) - )} -
- ); +