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

+2
View File
@@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
### Added
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`); Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
@@ -16,6 +17,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
### Fixed
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
+1 -1
View File
@@ -63,7 +63,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
@@ -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 });
});
});
+65
View File
@@ -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;
}
+109
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+2
View File
@@ -441,6 +441,8 @@ func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
case "divider":
sb.WriteString(indent + "- ---\n")
case "numbered":
sb.WriteString(indent + "1. " + b.Content + "\n")
default:
sb.WriteString(indent + "- " + b.Content + "\n")
}
+3
View File
@@ -258,6 +258,9 @@ func blocksToVaultMarkdown(blocks []domain.DocumentBlock, attachment func(id str
sb.WriteString(indent + "- ---\n")
case "callout":
sb.WriteString(indent + "- > " + cont(content, " > ") + "\n")
case "numbered":
// Markdown renumbers the items itself
sb.WriteString(indent + "1. " + cont(content, " ") + "\n")
default:
if strings.TrimSpace(content) == "" {
sb.WriteString(indent + "-\n")
+3
View File
@@ -824,6 +824,9 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
level := listLevel(indentWidth(m[1]))
content := m[2]
typ := "bullet"
if t := strings.TrimLeft(line, " \t"); t != "" && t[0] >= '0' && t[0] <= '9' {
typ = "numbered"
}
checked := false
if c := checkboxRe.FindStringSubmatch(content); c != nil {
typ, checked, content = "todo", c[1] != " ", c[2]