feat(workspace): copy bullets as Markdown, Ctrl+A twice selects the whole note
Ctrl+C over a selection of several bullets now puts Markdown on the clipboard (lib/outlineMarkdown.ts, the format of the Markdown export: tab indent, - [ ] to-dos, headings, callouts, fenced code); pasting it into a note gives the same bullets again. A second Ctrl+A in a bullet selects the whole note. Copying goes through the browser's copy event, so it works on plain HTTP too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da92ffa6b5
commit
1767764912
4 files changed
+169
-8
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { blocksToMarkdown } from '../outlineMarkdown';
|
||||
import { parsePastedLines } from '../pasteOutline';
|
||||
import type { DocumentBlock } from '../types';
|
||||
|
||||
const b = (type: DocumentBlock['type'], content: string, level = 0, extra: Partial<DocumentBlock> = {}): DocumentBlock => ({
|
||||
id: Math.random().toString(36).slice(2),
|
||||
type,
|
||||
content,
|
||||
level,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('blocksToMarkdown', () => {
|
||||
it('writes a nested list with to-dos, headings and code', () => {
|
||||
const md = blocksToMarkdown([
|
||||
b('h2', 'Umzug', 0),
|
||||
b('bullet', 'Server', 1),
|
||||
b('todo', 'Backup prüfen', 2, { checked: true }),
|
||||
b('todo', 'DNS umstellen', 2),
|
||||
b('numbered', 'Erstens', 1),
|
||||
b('code', 'docker compose up -d', 1, { lang: 'bash' }),
|
||||
]);
|
||||
expect(md).toBe(
|
||||
['## Umzug', '', '- Server', '\t- [x] Backup prüfen', '\t- [ ] DNS umstellen', '1. Erstens', '```bash', 'docker compose up -d', '```'].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('starts a copied branch at the left margin', () => {
|
||||
expect(blocksToMarkdown([b('bullet', 'Ast', 3), b('bullet', 'Kind', 4)])).toBe('- Ast\n\t- Kind');
|
||||
});
|
||||
|
||||
it('pasted back, it gives the same bullets', () => {
|
||||
// 'text' is the plain bullet of a note
|
||||
const blocks = [b('text', 'Eins', 0), b('text', 'Zwei', 1), b('todo', 'Drei', 2, { checked: true }), b('text', 'Vier', 0)];
|
||||
const items = parsePastedLines(blocksToMarkdown(blocks).split('\n'));
|
||||
expect(items.map((i) => [i.type, i.content, i.indent, Boolean(i.checked)])).toEqual([
|
||||
['text', 'Eins', 0, false],
|
||||
['text', 'Zwei', 1, false],
|
||||
['todo', 'Drei', 2, true],
|
||||
['text', 'Vier', 0, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DocumentBlock } from './types';
|
||||
|
||||
// Bullets as Markdown, in the format of the server's Markdown export (BlocksToVaultMarkdown): a nested list
|
||||
// with one tab per level, "- [ ]" for to-dos, headings, dividers, callouts and fenced code at the top
|
||||
// level as block elements. Used for copying bullets; pasting it back (pasteOutline.ts) gives the same
|
||||
// structure. Levels count from the shallowest bullet, so a copied branch starts at the left margin.
|
||||
export function blocksToMarkdown(blocks: DocumentBlock[]): string {
|
||||
if (blocks.length === 0) return '';
|
||||
const base = Math.min(...blocks.map((b) => Math.max(0, b.level || 0)));
|
||||
const out: string[] = [];
|
||||
// Children of a top-level heading/callout/code/divider become a list one level up; otherwise their
|
||||
// tab indent would turn them into an indented code block
|
||||
let lift = 0;
|
||||
for (const b of blocks) {
|
||||
let lvl = Math.max(0, (b.level || 0) - base);
|
||||
if (lvl === 0) lift = 0;
|
||||
else lvl = Math.max(0, lvl - lift);
|
||||
const indent = '\t'.repeat(lvl);
|
||||
const content = (b.content || '').replace(/\r\n/g, '\n');
|
||||
const oneLine = content.replace(/\n/g, ' ');
|
||||
// Soft line breaks continue the list item, aligned under its text
|
||||
const cont = (prefix: string) => content.replace(/\n/g, `\n${indent}${prefix}`);
|
||||
const hashes = b.type === 'h1' ? '#' : b.type === 'h2' ? '##' : b.type === 'h3' ? '###' : '';
|
||||
|
||||
if ((b.level || 0) - base <= 0) {
|
||||
if (hashes) {
|
||||
out.push('', `${hashes} ${oneLine}`, '');
|
||||
lift = 1;
|
||||
continue;
|
||||
}
|
||||
if (b.type === 'divider') {
|
||||
out.push('', '---', '');
|
||||
lift = 1;
|
||||
continue;
|
||||
}
|
||||
if (b.type === 'callout') {
|
||||
out.push('', `> ${content.replace(/\n/g, '\n> ')}`, '');
|
||||
lift = 1;
|
||||
continue;
|
||||
}
|
||||
if (b.type === 'code') {
|
||||
out.push('', `\`\`\`${b.lang || ''}`, content, '```', '');
|
||||
lift = 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
switch (b.type) {
|
||||
case 'code':
|
||||
out.push(`${indent}\`\`\`${b.lang || ''}`, `${indent}${content.replace(/\n/g, `\n${indent}`)}`, `${indent}\`\`\``);
|
||||
break;
|
||||
case 'todo':
|
||||
out.push(`${indent}- [${b.checked ? 'x' : ' '}] ${cont(' ')}`);
|
||||
break;
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
out.push(`${indent}- ${hashes} ${oneLine}`);
|
||||
break;
|
||||
case 'divider':
|
||||
out.push(`${indent}- ---`);
|
||||
break;
|
||||
case 'callout':
|
||||
out.push(`${indent}- > ${cont(' > ')}`);
|
||||
break;
|
||||
case 'numbered':
|
||||
out.push(`${indent}1. ${cont(' ')}`);
|
||||
break;
|
||||
default:
|
||||
out.push(content.trim() === '' ? `${indent}-` : `${indent}- ${cont(' ')}`);
|
||||
}
|
||||
}
|
||||
// No blank lines at the start and end, at most one in a row
|
||||
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+|\n+$/g, '');
|
||||
}
|
||||
Reference in new issue
Block a user