From 2bfe74e66bce8f4b361b122d3ea970f69021d0fe Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 19:36:15 +0100 Subject: [PATCH] feat(workspace): numbered lists and reliable markdown paste - 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 --- CHANGELOG.md | 2 + README.md | 2 +- .../src/features/workspace/DocumentEditor.tsx | 2 + .../src/features/workspace/OutlinerBlock.tsx | 26 ++- .../src/features/workspace/OutlinerTree.tsx | 181 +----------------- .../src/lib/__tests__/outlineList.test.ts | 27 +++ .../src/lib/__tests__/pasteOutline.test.ts | 38 ++++ frontend/src/lib/outlineList.ts | 65 +++++++ frontend/src/lib/pasteOutline.ts | 109 +++++++++++ frontend/src/lib/types.ts | 2 +- internal/domain/models.go | 2 + internal/storage/export_markdown.go | 3 + internal/storage/import_markdown.go | 3 + 13 files changed, 286 insertions(+), 176 deletions(-) create mode 100644 frontend/src/lib/__tests__/outlineList.test.ts create mode 100644 frontend/src/lib/__tests__/pasteOutline.test.ts create mode 100644 frontend/src/lib/outlineList.ts create mode 100644 frontend/src/lib/pasteOutline.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8b9ba..4c78739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d316fbc..629ac9c 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 5821d2b..24bcce9 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -746,6 +746,8 @@ export const DocumentEditor: React.FC = ({ 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}`); } diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 9a22cd0..39182a2 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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: }, + { type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: , aliases: ['nummer', 'liste', 'ol'] }, { type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: }, @@ -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 = ({ isTargeted, registerTextareaRef, onPasteMultiLines, + listMarker, lockedBy, canAppendBelow, onResolveConflict, @@ -600,6 +605,8 @@ export const OutlinerBlock: React.FC = ({ } 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 = ({ 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 = ({ 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 = ({ : 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)' } > + {block.type === 'numbered' && listMarker ? ( + + {listMarker} + + ) : ( = ({ : 'opacity-100' }`} /> + )} {/* If To-Do block: checkbox alongside bullet */} diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 1bdc613..3b87473 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -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(({ 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(({ 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(({ onUpdateBlock={onUpdateBlock} onCreateBlockBelow={handleCreateBlockBelow} onPasteMultiLines={handlePasteMultiLines} + listMarker={listLabels.get(block.id)} onDeleteBlock={handleDeleteBlock} onMergeWithPrevious={handleMergeWithPrevious} onIndentBlock={handleIndentBlock} diff --git a/frontend/src/lib/__tests__/outlineList.test.ts b/frontend/src/lib/__tests__/outlineList.test.ts new file mode 100644 index 0000000..4230088 --- /dev/null +++ b/frontend/src/lib/__tests__/outlineList.test.ts @@ -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.', + }); + }); +}); diff --git a/frontend/src/lib/__tests__/pasteOutline.test.ts b/frontend/src/lib/__tests__/pasteOutline.test.ts new file mode 100644 index 0000000..b5eca0b --- /dev/null +++ b/frontend/src/lib/__tests__/pasteOutline.test.ts @@ -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 }); + }); +}); diff --git a/frontend/src/lib/outlineList.ts b/frontend/src/lib/outlineList.ts new file mode 100644 index 0000000..f18aa8e --- /dev/null +++ b/frontend/src/lib/outlineList.ts @@ -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 { + const labels = new Map(); + const runs = new Map(); // 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; +} diff --git a/frontend/src/lib/pasteOutline.ts b/frontend/src/lib/pasteOutline.ts new file mode 100644 index 0000000..3a59900 --- /dev/null +++ b/frontend/src/lib/pasteOutline.ts @@ -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; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ff4869a..a15f238 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/internal/domain/models.go b/internal/domain/models.go index 41c8774..f6a9487 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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") } diff --git a/internal/storage/export_markdown.go b/internal/storage/export_markdown.go index 413f3e6..6d53f82 100644 --- a/internal/storage/export_markdown.go +++ b/internal/storage/export_markdown.go @@ -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") diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index 99d065c..7095ac6 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -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]