feat(workspace): numbered lists and reliable markdown paste
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s

- block type 'numbered' (/number): 1., a., i. by nesting, continues on
  Enter, an empty item ends the list; markdown export/import as 1.
- pasting: parser moved to lib/pasteOutline with tests; blank lines split
  paragraphs, numbered lists keep their type, indentation levels follow
  the widths in use (2, 3 or 4 spaces, tabs)
- text that stays in the focused block after pasting (a paragraph with
  line breaks) was dropped; the block now takes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 19:36:15 +01:00
1 parent 9008d7c915
commit 2bfe74e66b
13 files changed
+286 -176

No files matched your search

@@ -746,6 +746,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
} else if (b.type === 'divider') {
lines.push(`${indent}- ---`);
} else if (b.type === 'numbered') {
lines.push(`${indent}1. ${b.content}`);
} else {
lines.push(`${indent}- ${b.content}`);
}
@@ -12,6 +12,7 @@ import {
Heading2,
Heading3,
ListTodo,
ListOrdered,
Minus,
MessageSquare,
ExternalLink,
@@ -54,6 +55,7 @@ export interface SlashCommand {
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
@@ -98,6 +100,8 @@ export interface OutlinerBlockProps {
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
/** Number or letter of a numbered-list item ("1.", "b.", "iii.") */
listMarker?: string;
lockedBy?: string; // someone else is editing this block
canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below
onResolveConflict?: (blockId: string, accept: boolean) => void;
@@ -145,6 +149,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isTargeted,
registerTextareaRef,
onPasteMultiLines,
listMarker,
lockedBy,
canAppendBelow,
onResolveConflict,
@@ -600,6 +605,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
isPastingRef.current = true;
// Pasted text that stays in this block arrives as a prop change while the field has focus: take it
acceptRemoteUntilRef.current = Date.now() + 1000;
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
};
@@ -813,6 +820,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlockRef.current(block.id, -1);
return;
}
// Enter in an empty numbered item at the top ends the list
if (block.type === 'numbered') {
flushDebounce('', { type: 'text' });
return;
}
}
const start = e.currentTarget.selectionStart ?? localContent.length;
@@ -970,7 +982,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
draggable={canEdit}
onDragStart={() => canEdit && onDragStartBlock?.(index)}
onClick={() => onZoomBlock?.(block.id)}
className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${
className={`${
block.type === 'numbered' && listMarker ? 'w-4 h-4 justify-end overflow-visible' : 'w-4 h-4 justify-center'
} flex items-center rounded-btn group/bullet transition-transform ${
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
}`}
title={
@@ -979,6 +993,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
}
>
{block.type === 'numbered' && listMarker ? (
<span
className={`text-xs tabular-nums font-medium leading-none whitespace-nowrap transition-colors ${
activeFocus ? 'text-main' : 'text-muted group-hover/bullet:text-accent'
}`}
>
{listMarker}
</span>
) : (
<span
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
block.collapsed && hasChildren
@@ -992,6 +1015,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: 'opacity-100'
}`}
/>
)}
</button>
{/* If To-Do block: checkbox alongside bullet */}
@@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline';
import { numberedLabels } from '../../lib/outlineList';
export interface OutlinerTreeHandle {
focusFirstBlock: () => void;
@@ -238,13 +240,16 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return result;
}, [blocks, levelOffset]);
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, newContent: string = '') => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType: BlockType = 'text';
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
@@ -273,178 +278,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
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 lines into structured blocks
interface ParsedPasteItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
const parsed: ParsedPasteItem[] = [];
let currentItem: ParsedPasteItem | null = null;
let inCodeBlock = false;
let afterHeading = false;
const pushCurrentItem = () => {
if (currentItem) {
currentItem.content = currentItem.content.trimEnd();
parsed.push(currentItem);
currentItem = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
// 1. Inside a fenced code block
if (inCodeBlock) {
if (currentItem) {
currentItem.content += '\n' + rawLine;
}
if (rawLine.trim().startsWith('```')) {
inCodeBlock = false;
}
continue;
}
// 2. Starts a code fence
if (rawLine.trim().startsWith('```')) {
inCodeBlock = true;
if (afterHeading) {
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
} else if (currentItem) {
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
} else {
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
}
continue;
}
// Measure leading indent
let tabs = 0;
let spaces = 0;
let charIdx = 0;
while (charIdx < rawLine.length) {
if (rawLine[charIdx] === '\t') {
tabs++;
charIdx++;
} else if (rawLine[charIdx] === ' ') {
spaces++;
charIdx++;
} else {
break;
}
}
const strippedLine = rawLine.slice(charIdx);
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
// Blank lines
if (strippedLine.trim() === '') {
if (currentItem) {
currentItem.content += '\n';
}
continue;
}
// Headings: #, ##, ###
const h3Match = strippedLine.match(/^###\s+(.*)$/);
const h2Match = strippedLine.match(/^##\s+(.*)$/);
const h1Match = strippedLine.match(/^#\s+(.*)$/);
if (h1Match || h2Match || h3Match) {
pushCurrentItem();
afterHeading = true;
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
const content = (h1Match || h2Match || h3Match)![1];
parsed.push({ type, content, indent: lineIndent });
continue;
}
// Dividers: ---, ***, ___
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
pushCurrentItem();
afterHeading = false;
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
continue;
}
// Todo items: - [ ], - [x], * [ ], * [x]
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
if (todoMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'todo',
checked: todoMatch[1].toLowerCase() === 'x',
content: todoMatch[2],
indent: lineIndent,
});
continue;
}
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'text',
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
indent: lineIndent,
});
continue;
}
// Regular text line
if (afterHeading) {
// This starts the single bullet point after the heading
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
} else if (currentItem) {
if (currentItem.content.endsWith('\n')) {
currentItem.content += strippedLine;
} else {
currentItem.content += '\n' + strippedLine;
}
} else {
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
}
}
pushCurrentItem();
const parsed = parsePastedLines(rawLines);
if (parsed.length === 0) return;
@@ -792,6 +626,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
listMarker={listLabels.get(block.id)}
onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock}