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
@@ -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}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { listLabel, numberedLabels } from '../outlineList';
|
||||
import type { DocumentBlock } from '../types';
|
||||
|
||||
const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({
|
||||
id, documentId: 'd', type, content: id, level, position: 0,
|
||||
});
|
||||
|
||||
describe('numbered lists', () => {
|
||||
it('formats numbers, letters and roman numerals by depth', () => {
|
||||
expect(listLabel(3, 0)).toBe('3.');
|
||||
expect(listLabel(2, 1)).toBe('b.');
|
||||
expect(listLabel(27, 1)).toBe('aa.');
|
||||
expect(listLabel(4, 2)).toBe('iv.');
|
||||
expect(listLabel(1, 3)).toBe('1.');
|
||||
});
|
||||
|
||||
it('counts siblings, nests and restarts after other blocks', () => {
|
||||
const labels = numberedLabels([
|
||||
blk('a', 0), blk('a1', 1), blk('a2', 1), blk('a2i', 2), blk('b', 0),
|
||||
blk('note', 0, 'text'), blk('c', 0), blk('plain', 1, 'text'), blk('c1', 2),
|
||||
]);
|
||||
expect(Object.fromEntries(labels)).toEqual({
|
||||
a: '1.', a1: 'a.', a2: 'b.', a2i: 'i.', b: '2.', c: '1.', c1: 'a.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePastedLines } from '../pasteOutline';
|
||||
|
||||
const parse = (text: string) => parsePastedLines(text.split('\n'));
|
||||
|
||||
describe('parsePastedLines', () => {
|
||||
it('splits paragraphs at blank lines and keeps wrapped lines together', () => {
|
||||
expect(parse('Erster Absatz\nnoch erster\n\nZweiter Absatz')).toEqual([
|
||||
{ type: 'text', content: 'Erster Absatz\nnoch erster', indent: 0 },
|
||||
{ type: 'text', content: 'Zweiter Absatz', indent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps numbered lists with nesting (3-space indent)', () => {
|
||||
expect(parse('1. Eins\n 1. Unterpunkt\n 1. Tiefer\n2. Zwei')).toEqual([
|
||||
{ type: 'numbered', content: 'Eins', indent: 0 },
|
||||
{ type: 'numbered', content: 'Unterpunkt', indent: 1 },
|
||||
{ type: 'numbered', content: 'Tiefer', indent: 2 },
|
||||
{ type: 'numbered', content: 'Zwei', indent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads headings, todos, bullets and dividers', () => {
|
||||
expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([
|
||||
{ type: 'h1', content: 'Titel', indent: 0 },
|
||||
{ type: 'text', content: 'Text danach', indent: 0 },
|
||||
{ type: 'todo', checked: true, content: 'erledigt', indent: 0 },
|
||||
{ type: 'text', content: 'Punkt', indent: 0 },
|
||||
{ type: 'divider', content: '---', indent: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps fenced code as one block', () => {
|
||||
const items = parse('```go\nfunc main() {}\n\n```\nDanach');
|
||||
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
|
||||
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { DocumentBlock } from './types';
|
||||
|
||||
// Numbered lists in the outliner: consecutive 'numbered' siblings count 1, 2, 3 …; a numbered list
|
||||
// inside another one uses a, b, c, the next level i, ii, iii, then numbers again.
|
||||
|
||||
function alpha(n: number): string {
|
||||
let s = '';
|
||||
while (n > 0) {
|
||||
n--;
|
||||
s = String.fromCharCode(97 + (n % 26)) + s;
|
||||
n = Math.floor(n / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const ROMAN: [number, string][] = [
|
||||
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'],
|
||||
[50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||
];
|
||||
|
||||
function roman(n: number): string {
|
||||
let s = '';
|
||||
for (const [v, r] of ROMAN) {
|
||||
while (n >= v) {
|
||||
s += r;
|
||||
n -= v;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function listLabel(n: number, depth: number): string {
|
||||
switch (depth % 3) {
|
||||
case 1:
|
||||
return `${alpha(n)}.`;
|
||||
case 2:
|
||||
return `${roman(n)}.`;
|
||||
default:
|
||||
return `${n}.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Label ("1.", "b.", "iii.") for every numbered block, in document order. */
|
||||
export function numberedLabels(blocks: DocumentBlock[]): Map<string, string> {
|
||||
const labels = new Map<string, string>();
|
||||
const runs = new Map<number, number>(); // level -> count of the current run
|
||||
const chain: DocumentBlock[] = []; // last block seen per level = ancestors of the next one
|
||||
for (const b of blocks) {
|
||||
const level = Math.max(0, b.level || 0);
|
||||
for (const l of Array.from(runs.keys())) {
|
||||
if (l > level) runs.delete(l);
|
||||
}
|
||||
chain.length = Math.min(chain.length, level);
|
||||
if (b.type === 'numbered') {
|
||||
const n = (runs.get(level) ?? 0) + 1;
|
||||
runs.set(level, n);
|
||||
const depth = chain.filter((a) => a && a.type === 'numbered').length;
|
||||
labels.set(b.id, listLabel(n, depth));
|
||||
} else {
|
||||
runs.delete(level);
|
||||
}
|
||||
chain[level] = b;
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -261,7 +261,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
||||
{ id: 'themes', label: 'Themes' },
|
||||
];
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'numbered' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
||||
|
||||
export interface DocumentBlock {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user