diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 6fba3ae..b0cdc06 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -72,6 +72,18 @@ export interface OutlinerBlockProps { onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; } +// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw +// text. Walking both strings in parallel maps a caret offset in the rendered text back to the raw text. +export function mapRenderedOffsetToRaw(raw: string, rendered: string, renderedOffset: number): number { + let j = 0; + for (let i = 0; i < renderedOffset && i < rendered.length; i++) { + const idx = raw.indexOf(rendered[i], j); + if (idx === -1) break; + j = idx + 1; + } + return Math.min(j, raw.length); +} + export const OutlinerBlock: React.FC = ({ docId, block, @@ -206,6 +218,41 @@ export const OutlinerBlock: React.FC = ({ }; }, []); + const renderedTextRef = useRef(null); + + // Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer + const focusAtPoint = (clientX: number, clientY: number) => { + const ta = textareaRef.current; + if (!ta) return; + const raw = ta.value; + let pos = raw.length; + const span = renderedTextRef.current; + const doc = document as any; + let node: Node | null = null; + let offset = 0; + if (typeof doc.caretPositionFromPoint === 'function') { + const p = doc.caretPositionFromPoint(clientX, clientY); + if (p) { + node = p.offsetNode; + offset = p.offset; + } + } else if (typeof doc.caretRangeFromPoint === 'function') { + const r = doc.caretRangeFromPoint(clientX, clientY); + if (r) { + node = r.startContainer; + offset = r.startOffset; + } + } + if (span && node && span.contains(node)) { + const range = document.createRange(); + range.setStart(span, 0); + range.setEnd(node, offset); + pos = mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length); + } + ta.focus(); + ta.setSelectionRange(pos, pos); + }; + const adjustHeight = useCallback(() => { const el = textareaRef.current; if (!el) return; @@ -529,9 +576,7 @@ export const OutlinerBlock: React.FC = ({ ? 'border-t-2 border-accent bg-accent/10' : isTargeted ? 'bg-panel-elevated' - : activeFocus - ? 'bg-panel-elevated' - : 'hover:bg-panel' + : '' }`} style={{ paddingLeft: `${visualLevel * 22 + 4}px`, @@ -565,7 +610,7 @@ export const OutlinerBlock: React.FC = ({ onClick={() => flushDebounce(localContent, { collapsed: !block.collapsed }) } - className="p-0.5 text-muted hover:text-accent transition-colors" + className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100" title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'} > {block.collapsed ? ( @@ -584,7 +629,7 @@ export const OutlinerBlock: React.FC = ({ draggable={canEdit} onDragStart={() => canEdit && onDragStartBlock?.(index)} onClick={() => onZoomBlock?.(block.id)} - className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${ + className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${ canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer' }`} title={ @@ -682,11 +727,11 @@ export const OutlinerBlock: React.FC = ({
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
{ + onClick={(e) => { if (!activeFocus && canEdit && textareaRef.current) { - textareaRef.current.focus(); - const len = textareaRef.current.value.length; - textareaRef.current.setSelectionRange(len, len); + // Keep the row's click handler from moving the caret back to the end + e.stopPropagation(); + focusAtPoint(e.clientX, e.clientY); } }} className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${ @@ -704,7 +749,7 @@ export const OutlinerBlock: React.FC = ({ }`} > {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} - + {localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}