Dev #8
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}
|
||||
|
||||
Reference in new issue
Block a user