From 6a261e345362925ce6f06126e4bc07f532ed40da Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 21:27:01 +0100 Subject: [PATCH] feat(workspace): intelligent multi-line clipboard paste with markdown prefix & indentation parsing --- .../src/features/workspace/DocumentEditor.tsx | 25 +++ .../src/features/workspace/OutlinerBlock.tsx | 33 ++++ .../src/features/workspace/OutlinerTree.tsx | 146 ++++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 28f7657..8b8c1b7 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -544,6 +544,29 @@ export const DocumentEditor: React.FC = ({ [docId, canEdit] ); + const handleInsertBlocks = useCallback( + (afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => { + if (!canEdit) return; + setBlocks((prev) => { + const next = [...prev]; + const idx = next.findIndex((b) => b.id === afterBlockId); + if (idx >= 0) { + next[idx] = updatedCurrentBlock; + next.splice(idx + 1, 0, ...newBlocks); + } else { + next.push(updatedCurrentBlock, ...newBlocks); + } + const normalized = ensureNonEmptyBlocks(docId, next); + currentBlocksRef.current = normalized; + return normalized; + }); + window.setTimeout(() => { + executeAutoSave(true); + }, 50); + }, + [docId, canEdit, executeAutoSave] + ); + const handleDeleteBlock = useCallback( (blockId: string) => { if (!canEdit) return; @@ -947,6 +970,7 @@ export const DocumentEditor: React.FC = ({ onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} + onInsertBlocks={handleInsertBlocks} onDeleteBlock={handleDeleteBlock} onMoveBlock={handleMoveBlock} onOpenTask={onOpenTask} @@ -1082,6 +1106,7 @@ export const DocumentEditor: React.FC = ({ onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} + onInsertBlocks={handleInsertBlocks} onDeleteBlock={handleDeleteBlock} onMoveBlock={handleMoveBlock} onOpenTask={onOpenTask} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index db804b4..122307e 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -69,6 +69,7 @@ export interface OutlinerBlockProps { isDragTarget?: boolean; isTargeted?: boolean; registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; + onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; } export const OutlinerBlock: React.FC = ({ @@ -97,6 +98,7 @@ export const OutlinerBlock: React.FC = ({ isDragTarget, isTargeted, registerTextareaRef, + onPasteMultiLines, }) => { const [localContent, setLocalContent] = useState(block.content || ''); const [isLocallyFocused, setIsLocallyFocused] = useState(false); @@ -118,12 +120,15 @@ export const OutlinerBlock: React.FC = ({ onUpdateBlockRef.current = onUpdateBlock; const onCreateBlockBelowRef = useRef(onCreateBlockBelow); onCreateBlockBelowRef.current = onCreateBlockBelow; + const onPasteMultiLinesRef = useRef(onPasteMultiLines); + onPasteMultiLinesRef.current = onPasteMultiLines; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; const onIndentBlockRef = useRef(onIndentBlock); onIndentBlockRef.current = onIndentBlock; const onNavigateFocusRef = useRef(onNavigateFocus); onNavigateFocusRef.current = onNavigateFocus; + const isPastingRef = useRef(false); // Track if this input currently has actual focus in an active browser window const isFocusedRef = useRef(false); @@ -315,6 +320,10 @@ export const OutlinerBlock: React.FC = ({ const handleBlur = () => { isFocusedRef.current = false; setIsLocallyFocused(false); + if (isPastingRef.current) { + isPastingRef.current = false; + return; + } if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; @@ -324,6 +333,29 @@ export const OutlinerBlock: React.FC = ({ } }; + const handlePaste = (e: React.ClipboardEvent) => { + if (!canEdit || block.type === 'code') return; + const pastedText = e.clipboardData.getData('text'); + if (!pastedText || !pastedText.includes('\n')) return; + + e.preventDefault(); + const rawLines = pastedText.split(/\r?\n/); + if (rawLines.length <= 1) return; + + const start = e.currentTarget.selectionStart ?? localContent.length; + const end = e.currentTarget.selectionEnd ?? start; + const textBefore = localContent.slice(0, start); + const textAfter = localContent.slice(end); + + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + isPastingRef.current = true; + onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (!canEdit) return; @@ -742,6 +774,7 @@ export const OutlinerBlock: React.FC = ({ onBlur={handleBlur} onChange={handleContentChange} onKeyDown={handleKeyDown} + onPaste={handlePaste} placeholder={ index === 0 && level === 0 ? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)' diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index c217670..53ffade 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -16,6 +16,7 @@ export interface OutlinerTreeProps { onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlocks?: (updated: DocumentBlock[]) => void; onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void; + onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void; onDeleteBlock: (blockId: string) => void; onMoveBlock: (blockId: string, newIndex: number, level?: number) => void; onOpenTask?: (taskId: string) => void; @@ -33,6 +34,7 @@ export const OutlinerTree = forwardRef(({ onUpdateBlock, onUpdateBlocks, onCreateBlock, + onInsertBlocks, onDeleteBlock, onMoveBlock, onOpenTask, @@ -221,6 +223,149 @@ export const OutlinerTree = forwardRef(({ [blocks, canEdit, docId, onCreateBlock] ); + const handlePasteMultiLines = useCallback( + (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => { + if (!canEdit || rawLines.length === 0) return; + const idx = blocks.findIndex((b) => b.id === blockId); + if (idx === -1) return; + + const currentBlock = blocks[idx]; + const baseLevel = Math.max(0, currentBlock.level || 0); + + // Detect indentation style (2-space vs 4-space tabs) + let detectedSpaceStep = 2; + const leadingSpacesList: number[] = []; + for (const line of rawLines) { + if (!line.trim()) continue; + let sp = 0; + while (sp < line.length && line[sp] === ' ') sp++; + if (sp > 0) leadingSpacesList.push(sp); + } + if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) { + detectedSpaceStep = 4; + } + + // Parse line indentation and markdown prefixes + const parsed = rawLines.map((line) => { + let tabs = 0; + let spaces = 0; + let i = 0; + while (i < line.length) { + if (line[i] === '\t') { + tabs++; + i++; + } else if (line[i] === ' ') { + spaces++; + i++; + } else { + break; + } + } + const rawContent = line.slice(i); + const indent = tabs + Math.floor(spaces / detectedSpaceStep); + + // Todo: - [ ] or - [x] or * [ ] or * [x] + const todoMatch = rawContent.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/); + if (todoMatch) { + return { + type: 'todo' as BlockType, + checked: todoMatch[1].toLowerCase() === 'x', + content: todoMatch[2], + indent, + }; + } + + // Headings: #, ##, ### + const h3Match = rawContent.match(/^###\s+(.*)$/); + if (h3Match) { + return { type: 'h3' as BlockType, content: h3Match[1], indent }; + } + const h2Match = rawContent.match(/^##\s+(.*)$/); + if (h2Match) { + return { type: 'h2' as BlockType, content: h2Match[1], indent }; + } + const h1Match = rawContent.match(/^#\s+(.*)$/); + if (h1Match) { + return { type: 'h1' as BlockType, content: h1Match[1], indent }; + } + + // Bullets: - or * or •, or numbered lists: 1. or 1) + const bulletMatch = rawContent.match(/^[-*•]\s+(.*)$/); + if (bulletMatch) { + return { type: 'text' as BlockType, content: bulletMatch[1], indent }; + } + const numMatch = rawContent.match(/^\d+[\.\)]\s+(.*)$/); + if (numMatch) { + return { type: 'text' as BlockType, content: numMatch[1], indent }; + } + + return { + type: 'text' as BlockType, + content: rawContent, + indent, + }; + }); + + const line0 = parsed[0]; + const baseIndent = line0.indent; + const isSingle = parsed.length === 1; + const line0Content = textBefore + line0.content + (isSingle ? textAfter : ''); + + const isCurrentEmpty = (currentBlock.content || '').trim() === ''; + const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type); + const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked; + + const updatedCurrentBlock: DocumentBlock = { + ...currentBlock, + content: line0Content, + type: adoptedType, + checked: adoptedChecked, + }; + + const newBlocks: DocumentBlock[] = []; + const nowBase = Date.now().toString(36); + + for (let j = 1; j < parsed.length; j++) { + const p = parsed[j]; + const isLastLine = j === parsed.length - 1; + const lineContent = isLastLine ? (p.content + textAfter) : p.content; + const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent)); + + newBlocks.push({ + id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`, + documentId: docId, + type: p.type, + checked: p.checked || false, + content: lineContent, + level: lineLevel, + collapsed: false, + position: (currentBlock.position || 1000) + j * 10, + }); + } + + const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id; + const existingEl = textareaMapRef.current.get(lastTargetId); + if (existingEl) { + pendingFocusRef.current = null; + focusBlockInput(lastTargetId, 'end'); + } else { + pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' }; + } + setFocusedBlockId(lastTargetId); + + if (onInsertBlocks) { + onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks); + } else { + onUpdateBlock(updatedCurrentBlock, true); + for (let k = 0; k < newBlocks.length; k++) { + const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id; + onCreateBlock(newBlocks[k], afterId); + } + } + }, + [blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock] + ); + const handleDeleteBlock = useCallback( (blockId: string, deleteSubtree = false) => { if (!canEdit) return; @@ -424,6 +569,7 @@ export const OutlinerTree = forwardRef(({ onZoomBlock={onZoomBlock} onUpdateBlock={onUpdateBlock} onCreateBlockBelow={handleCreateBlockBelow} + onPasteMultiLines={handlePasteMultiLines} onDeleteBlock={handleDeleteBlock} onIndentBlock={handleIndentBlock} onNavigateFocus={handleNavigateFocus}