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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
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]
|
[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(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string) => {
|
(blockId: string) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -947,6 +970,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
onUpdateBlocks={handleUpdateBlocks}
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
@@ -1082,6 +1106,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
onUpdateBlocks={handleUpdateBlocks}
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface OutlinerBlockProps {
|
|||||||
isDragTarget?: boolean;
|
isDragTarget?: boolean;
|
||||||
isTargeted?: boolean;
|
isTargeted?: boolean;
|
||||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
|
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
@@ -97,6 +98,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isDragTarget,
|
isDragTarget,
|
||||||
isTargeted,
|
isTargeted,
|
||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
|
onPasteMultiLines,
|
||||||
}) => {
|
}) => {
|
||||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
||||||
@@ -118,12 +120,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onUpdateBlockRef.current = onUpdateBlock;
|
onUpdateBlockRef.current = onUpdateBlock;
|
||||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||||
|
const onPasteMultiLinesRef = useRef(onPasteMultiLines);
|
||||||
|
onPasteMultiLinesRef.current = onPasteMultiLines;
|
||||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||||
onDeleteBlockRef.current = onDeleteBlock;
|
onDeleteBlockRef.current = onDeleteBlock;
|
||||||
const onIndentBlockRef = useRef(onIndentBlock);
|
const onIndentBlockRef = useRef(onIndentBlock);
|
||||||
onIndentBlockRef.current = onIndentBlock;
|
onIndentBlockRef.current = onIndentBlock;
|
||||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||||
onNavigateFocusRef.current = onNavigateFocus;
|
onNavigateFocusRef.current = onNavigateFocus;
|
||||||
|
const isPastingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Track if this input currently has actual focus in an active browser window
|
// Track if this input currently has actual focus in an active browser window
|
||||||
const isFocusedRef = useRef<boolean>(false);
|
const isFocusedRef = useRef<boolean>(false);
|
||||||
@@ -315,6 +320,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
setIsLocallyFocused(false);
|
setIsLocallyFocused(false);
|
||||||
|
if (isPastingRef.current) {
|
||||||
|
isPastingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
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>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
|
|
||||||
@@ -742,6 +774,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
placeholder={
|
placeholder={
|
||||||
index === 0 && level === 0
|
index === 0 && level === 0
|
||||||
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
? '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;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||||
|
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
@@ -33,6 +34,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onUpdateBlocks,
|
onUpdateBlocks,
|
||||||
onCreateBlock,
|
onCreateBlock,
|
||||||
|
onInsertBlocks,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onMoveBlock,
|
onMoveBlock,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
@@ -221,6 +223,149 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
[blocks, canEdit, docId, onCreateBlock]
|
[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(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string, deleteSubtree = false) => {
|
(blockId: string, deleteSubtree = false) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -424,6 +569,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onZoomBlock={onZoomBlock}
|
onZoomBlock={onZoomBlock}
|
||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
|
onPasteMultiLines={handlePasteMultiLines}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onIndentBlock={handleIndentBlock}
|
onIndentBlock={handleIndentBlock}
|
||||||
onNavigateFocus={handleNavigateFocus}
|
onNavigateFocus={handleNavigateFocus}
|
||||||
|
|||||||
Reference in new issue
Block a user