diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 1bbd009..7e0ed8c 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1081,7 +1081,8 @@ export const WorkspaceView: React.FC = ({ afterIndex: number, level: number, type: BlockType = 'bullet', - initialContent: string = '' + initialContent: string = '', + updatedCurrentBlock?: { id: string; content: string } ) => { if (isReadOnly || !selectedDoc) return; const newBlock: DocumentBlock = { @@ -1092,7 +1093,11 @@ export const WorkspaceView: React.FC = ({ checked: false, }; - const nextBlocks = [...selectedDoc.blocks]; + const nextBlocks = selectedDoc.blocks.map((b) => + updatedCurrentBlock && b.id === updatedCurrentBlock.id + ? { ...b, content: updatedCurrentBlock.content } + : b + ); nextBlocks.splice(afterIndex + 1, 0, newBlock); persistDocument({ ...selectedDoc, blocks: nextBlocks }); setLastFocusedBlockId(newBlock.id); @@ -1247,7 +1252,7 @@ export const WorkspaceView: React.FC = ({ } }, [activeEditingBlockId, activeCursorOffset, selectedDoc]); - // Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands + // Keyboard Navigation: Enter, Shift+Enter, Tab, Shift+Tab, Backspace, / Slash Commands const handleKeyDown = ( e: React.KeyboardEvent, block: DocumentBlock, @@ -1290,22 +1295,57 @@ export const WorkspaceView: React.FC = ({ } } + if (e.key === 'Enter' && e.shiftKey) { + // Shift+Enter: insert a newline within the current bullet point + if (block.type === 'bullet' || block.type === 'paragraph' || block.type === 'todo') { + e.preventDefault(); + setSlashBlockId(null); + const target = e.target as HTMLTextAreaElement | HTMLInputElement; + const localPos = + target && typeof target.selectionStart === 'number' + ? target.selectionStart + : (target?.value || '').length; + const cursor = segStart + localPos; + const fullContent = block.content || ''; + const nextContent = fullContent.slice(0, cursor) + '\n' + fullContent.slice(cursor); + const nextCursor = cursor + 1; + setBlockCursor(block.id, nextCursor); + updateBlock(block.id, { content: nextContent }); + } + return; + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); setSlashBlockId(null); const target = e.target as HTMLTextAreaElement | HTMLInputElement; + const fullContent = block.content || ''; let afterContent = ''; - if (target && typeof target.selectionStart === 'number') { + let updatedCurrent: { id: string; content: string } | undefined = undefined; + + if (!fullContent.includes('\n') && target && typeof target.selectionStart === 'number') { const cursor = segStart + target.selectionStart; - const fullContent = block.content || ''; const before = fullContent.slice(0, cursor); const after = fullContent.slice(cursor); if (after.length > 0) { - updateBlock(block.id, { content: before }); + updatedCurrent = { id: block.id, content: before }; 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 }; + } } - insertNewBulletAfter(index, block.level || 0, block.type === 'todo' ? 'todo' : 'bullet', afterContent); + + insertNewBulletAfter( + index, + block.level || 0, + block.type === 'todo' ? 'todo' : 'bullet', + afterContent, + updatedCurrent + ); return; } else if (e.key === 'Tab') { e.preventDefault(); @@ -1316,7 +1356,8 @@ export const WorkspaceView: React.FC = ({ } } else if (e.key === 'Backspace') { const target = e.target as HTMLTextAreaElement | HTMLInputElement; - if (!block.content || block.content === '') { + const fullContent = block.content || ''; + if (!fullContent || fullContent === '') { if ((block.level || 0) > 0) { e.preventDefault(); outdentBlock(block.id); @@ -1332,12 +1373,77 @@ export const WorkspaceView: React.FC = ({ target.selectionEnd === 0 ) { e.preventDefault(); - setBlockCursor(block.id, segStart); + 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); + } } - } else if ( - !e.shiftKey && - (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') - ) { + } 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 (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.shiftKey && (e.key === 'Home' || e.key === 'End')) { const target = e.target as HTMLTextAreaElement | HTMLInputElement; requestAnimationFrame(() => { if (target && typeof target.selectionStart === 'number') { @@ -1871,14 +1977,33 @@ export const WorkspaceView: React.FC = ({ return {part}; }; - // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags + // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags across multiple lines const renderFormattedText = (text: string, blockId?: string) => { - const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); - return parts.map((part, i) => renderSingleRichToken(part, blockId, i)); + const rawLines = (text || '').split('\n'); + if (rawLines.length <= 1) { + const parts = (text || '').split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); + return parts.map((part, i) => renderSingleRichToken(part, blockId, i)); + } + return ( +
+ {rawLines.map((lineText, lineIdx) => { + const parts = lineText.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); + return ( +
+ {lineText.length === 0 ? ( +   + ) : ( + parts.map((part, i) => renderSingleRichToken(part, blockId, `${lineIdx}-${i}`)) + )} +
+ ); + })} +
+ ); }; - // Live inline token-aware editor: renders completed #hashtags and links inline as pills on the - // exact same line while keeping any token currently touched by the cursor unwrapped as raw text. + // 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. const renderLiveInlineBlock = ( block: DocumentBlock, originalIndex: number, @@ -1893,175 +2018,231 @@ export const WorkspaceView: React.FC = ({ ); } - const allTokens = findRichTokens(text); const isEditing = activeEditingBlockId === block.id; - - if (!isEditing && allTokens.length > 0) { - const segments: ( - | { kind: 'text'; start: number; end: number; text: string } - | { kind: 'token'; start: number; end: number; raw: string } - )[] = []; - let lastIdx = 0; - for (const t of allTokens) { - 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 < text.length) { - segments.push({ kind: 'text', start: lastIdx, end: text.length, text: text.slice(lastIdx) }); - } - - return ( -
focusBlock(block.id, text.length)} - className={`flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text ${extraTextClass}`} - > - {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} - - ) - )} -
- ); - } - 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)) - : []; + + 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; + } 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; +
+ {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 ( - (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); +
{ + if (e.target === e.currentTarget) { + e.preventDefault(); + focusBlock(block.id, line.lineEnd); } }} - onBlur={() => { - setActiveEditingBlockId((prev) => (prev === block.id ? null : prev)); - handleBlockBlur(); + onClick={(e) => { + if (e.target === e.currentTarget) { + focusBlock(block.id, line.lineEnd); + } }} - 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}`} - /> + 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} + + ) + ) + )} +
); } - if (!seg.text) return null; + + // Active line being edited + const renderedTokens = lineTokens.filter((t) => !(cursor >= t.start && cursor <= t.end)); + const segments: InlineSegment[] = []; + let lastIdx = line.lineStart; + 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: line.lineEnd, + text: text.slice(lastIdx, line.lineEnd), + }); + + let activeSegIdx = segments.findIndex( + (s) => s.kind === 'text' && cursor >= s.start && cursor <= s.end + ); + if (activeSegIdx === -1) { + activeSegIdx = segments.length - 1; + } + return ( - { - e.preventDefault(); - focusBlock(block.id, seg.end); + if (e.target === e.currentTarget) { + e.preventDefault(); + focusBlock(block.id, line.lineEnd); + } }} - className="whitespace-pre-wrap text-sm leading-relaxed cursor-text" + className="flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px] cursor-text" > - {seg.text} - + {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} + + ); + })} +
); })}
@@ -3120,7 +3301,7 @@ export const WorkspaceView: React.FC = ({ {/* TYPE: TODO */} {block.type === 'todo' && (
-
+
= ({ onStatsChange(); } }} - className={`w-4 h-4 checkbox-square flex-shrink-0 ${ + className={`w-4 h-4 mt-1 checkbox-square flex-shrink-0 ${ isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' }`} /> @@ -3247,7 +3428,7 @@ export const WorkspaceView: React.FC = ({