feat(workspace): numbered lists and reliable markdown paste
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
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:
1 parent
9008d7c915
commit
2bfe74e66b
13 files changed
+286
-176
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import type { BlockType } from './types';
|
||||
|
||||
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
|
||||
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
|
||||
|
||||
export interface PastedItem {
|
||||
type: BlockType;
|
||||
checked?: boolean;
|
||||
content: string;
|
||||
indent: number;
|
||||
}
|
||||
|
||||
function leadingWidth(line: string): { width: number; rest: string } {
|
||||
let width = 0;
|
||||
let i = 0;
|
||||
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
|
||||
width += line[i] === '\t' ? 4 : 1;
|
||||
i++;
|
||||
}
|
||||
return { width, rest: line.slice(i) };
|
||||
}
|
||||
|
||||
export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||
// Indentation steps differ (2, 3 or 4 spaces, tabs): the distinct widths in use are the levels
|
||||
const widths = Array.from(
|
||||
new Set(rawLines.filter((l) => l.trim() !== '').map((l) => leadingWidth(l).width))
|
||||
).sort((a, b) => a - b);
|
||||
const levelOf = (w: number) => Math.max(0, widths.indexOf(w));
|
||||
|
||||
const items: PastedItem[] = [];
|
||||
let current: PastedItem | null = null;
|
||||
let inCode = false;
|
||||
let afterHeading = false;
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
current.content = current.content.trimEnd();
|
||||
items.push(current);
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of rawLines) {
|
||||
if (inCode) {
|
||||
if (current) (current as PastedItem).content += '\n' + rawLine;
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCode = false;
|
||||
flush();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCode = true;
|
||||
flush();
|
||||
current = { type: 'text', content: rawLine, indent: 0 };
|
||||
afterHeading = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { width, rest } = leadingWidth(rawLine);
|
||||
const indent = levelOf(width);
|
||||
const line = rest.trimEnd();
|
||||
|
||||
// A blank line ends the paragraph or list item
|
||||
if (line === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
flush();
|
||||
afterHeading = true;
|
||||
items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent });
|
||||
continue;
|
||||
}
|
||||
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||
flush();
|
||||
afterHeading = false;
|
||||
items.push({ type: 'divider', content: '---', indent });
|
||||
continue;
|
||||
}
|
||||
|
||||
const todo = line.match(/^[-*+]\s*\[([ xX])\]\s*(.*)$/);
|
||||
const bullet = line.match(/^[-*+•]\s+(.*)$/);
|
||||
const numbered = line.match(/^\d+[.)]\s+(.*)$/);
|
||||
if (todo || bullet || numbered) {
|
||||
flush();
|
||||
afterHeading = false;
|
||||
current = todo
|
||||
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent }
|
||||
: numbered
|
||||
? { type: 'numbered', content: numbered[1], indent }
|
||||
: { type: 'text', content: bullet![1], indent };
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain text: continues the paragraph or list item above (a wrapped line), else starts a paragraph
|
||||
if (current && !afterHeading) {
|
||||
(current as PastedItem).content += '\n' + line;
|
||||
} else {
|
||||
flush();
|
||||
current = { type: 'text', content: line, indent };
|
||||
}
|
||||
afterHeading = false;
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
Reference in new issue
Block a user