feat(workspace): intelligent multi-line clipboard paste with markdown prefix & indentation parsing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-27 21:27:01 +01:00
1 parent fdbf691a8d
commit 6a261e3453
3 files changed
+204

No files matched your search

@@ -544,6 +544,29 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[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<DocumentEditorProps> = ({
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
@@ -1082,6 +1106,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
@@ -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<OutlinerBlockProps> = ({
@@ -97,6 +98,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isDragTarget,
isTargeted,
registerTextareaRef,
onPasteMultiLines,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
@@ -118,12 +120,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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<boolean>(false);
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef<boolean>(false);
@@ -315,6 +320,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
if (!canEdit) return;
@@ -742,6 +774,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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)'
@@ -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<OutlinerTreeHandle, OutlinerTreeProps>(({
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
onInsertBlocks,
onDeleteBlock,
onMoveBlock,
onOpenTask,
@@ -221,6 +223,149 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
[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<OutlinerTreeHandle, OutlinerTreeProps>(({
onZoomBlock={onZoomBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}