This commit is contained in:
Reudy 2026-09-26 19:13:02 +02:00
commit 0ad405c26e
78 changed files with 9127 additions and 0 deletions

View file

@ -0,0 +1,184 @@
// Obsidian-style live preview for CodeMirror 6: markup is hidden and styled,
// except on lines the cursor is on (while the editor has focus).
import { syntaxTree } from '@codemirror/language';
import { commonmarkLanguage, markdown, markdownKeymap } from '@codemirror/lang-markdown';
import { GFM } from '@lezer/markdown';
import { EditorSelection, type Extension, type Range } from '@codemirror/state';
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
keymap,
type DecorationSet,
type ViewUpdate
} from '@codemirror/view';
import { analyze } from './mdmarks';
class BulletWidget extends WidgetType {
eq() {
return true;
}
toDOM() {
const s = document.createElement('span');
s.className = 'md-bullet';
s.textContent = '•';
return s;
}
}
class TaskWidget extends WidgetType {
constructor(
readonly checked: boolean,
readonly pos: number
) {
super();
}
eq(o: TaskWidget) {
return o.checked === this.checked && o.pos === this.pos;
}
toDOM(view: EditorView) {
const box = document.createElement('span');
box.className = 'md-task' + (this.checked ? ' done' : '');
box.setAttribute('role', 'checkbox');
box.setAttribute('aria-checked', String(this.checked));
box.addEventListener('mousedown', (e) => {
e.preventDefault();
view.dispatch({ changes: { from: this.pos + 1, to: this.pos + 2, insert: this.checked ? ' ' : 'x' } });
});
return box;
}
ignoreEvent() {
return true;
}
}
const bullet = Decoration.replace({ widget: new BulletWidget() });
const hidden = Decoration.replace({});
const dimMark = Decoration.mark({ class: 'md-mark' });
const styleMarks = {
strong: Decoration.mark({ class: 'md-strong' }),
em: Decoration.mark({ class: 'md-em' }),
code: Decoration.mark({ class: 'md-code' }),
strike: Decoration.mark({ class: 'md-strike' }),
link: Decoration.mark({ class: 'md-link' })
};
function build(view: EditorView): DecorationSet {
const { state } = view;
const text = state.doc.toString();
const a = analyze(text, syntaxTree(state));
// Lines touched by the selection show their raw markup.
const active = new Set<number>();
if (view.hasFocus) {
for (const r of state.selection.ranges) {
const l1 = state.doc.lineAt(r.from).number;
const l2 = state.doc.lineAt(r.to).number;
for (let n = l1; n <= l2; n++) active.add(n);
}
}
const revealed = (pos: number) => active.has(state.doc.lineAt(pos).number);
const out: Range<Decoration>[] = [];
for (const l of a.lines) {
const cls: string[] = [];
if (l.heading) cls.push(`md-h md-h${l.heading}`);
if (l.code) cls.push('md-codeblock');
if (l.fence) cls.push('md-fence');
if (l.hr) cls.push(revealed(l.from) ? 'md-hr-raw' : 'md-hr');
if (l.quote) cls.push('md-quote');
if (cls.length) {
out.push(
Decoration.line({
class: cls.join(' '),
attributes: l.quote ? { style: `--q:${l.quote}` } : undefined
}).range(l.from)
);
}
}
for (const s of a.styles) if (s.to > s.from) out.push(styleMarks[s.style].range(s.from, s.to));
for (const h of mergeRanges(a.hides)) {
// Plugins can't replace across line breaks; leave such (rare) markup visible.
if (h.to <= h.from || text.slice(h.from, h.to).includes('\n')) continue;
out.push((revealed(h.from) ? dimMark : hidden).range(h.from, h.to));
}
for (const b of a.bullets) if (!revealed(b.from)) out.push(bullet.range(b.from, b.to));
for (const t of a.tasks) {
const inside = state.selection.ranges.some((r) => view.hasFocus && r.to >= t.from && r.from <= t.to);
if (!inside) out.push(Decoration.replace({ widget: new TaskWidget(t.checked, t.from) }).range(t.from, t.to));
}
return Decoration.set(out, true);
}
function mergeRanges(rs: { from: number; to: number }[]) {
const out: { from: number; to: number }[] = [];
for (const r of [...rs].sort((x, y) => x.from - y.from)) {
const last = out[out.length - 1];
if (last && r.from <= last.to) last.to = Math.max(last.to, r.to);
else out.push({ ...r });
}
return out;
}
const preview = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.selectionSet || u.focusChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) {
this.decorations = build(u.view);
}
}
},
{ decorations: (v) => v.decorations }
);
/** Wrap the selection in `mark` (or unwrap it). */
function toggleWrap(mark: string) {
return (view: EditorView) => {
const { state } = view;
view.dispatch(
state.changeByRange((r) => {
const before = state.sliceDoc(r.from - mark.length, r.from);
const after = state.sliceDoc(r.to, r.to + mark.length);
if (before === mark && after === mark) {
return {
changes: [
{ from: r.from - mark.length, to: r.from },
{ from: r.to, to: r.to + mark.length }
],
range: EditorSelection.range(r.from - mark.length, r.to - mark.length)
};
}
return {
changes: [
{ from: r.from, insert: mark },
{ from: r.to, insert: mark }
],
range: EditorSelection.range(r.from + mark.length, r.to + mark.length)
};
})
);
return true;
};
}
export function livePreview(): Extension {
return [
// Same parser configuration as the PDF writer (CommonMark + GFM).
markdown({ base: commonmarkLanguage, extensions: [GFM], addKeymap: false }),
keymap.of([
...markdownKeymap,
{ key: 'Mod-b', run: toggleWrap('**') },
{ key: 'Mod-i', run: toggleWrap('*') },
{ key: 'Mod-e', run: toggleWrap('`') }
]),
preview,
EditorView.lineWrapping
];
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { analyze, renderedRuns } from './mdmarks';
const render = (text: string) => {
const a = analyze(text);
return a.lines.map((l) =>
renderedRuns(text, a, l)
.map((r) => (r.widget ? `<${r.widget}>` : r.styles.size ? `[${[...r.styles].join('+')}:${r.text}]` : r.text))
.join('')
);
};
describe('markdown analysis', () => {
it('hides markup and keeps styles', () => {
expect(render('# Title #')).toEqual(['Title']);
expect(render('a **b** *c* `d` ~~e~~')).toEqual(['a [strong:b] [em:c] [code:d] [strike:e]']);
expect(render('see [docs](http://x "t") now')).toEqual(['see [link:docs] now']);
expect(render('\\*literal')).toEqual(['*literal']);
});
it('renders list markers, tasks and quotes', () => {
expect(render('- one\n- [x] done\n1. first')).toEqual(['<bullet> one', '<task-done> done', '1. first']);
const a = analyze('> q\n> > deep');
expect(a.lines.map((l) => l.quote)).toEqual([1, 2]);
expect(render('> q')).toEqual(['q']);
});
it('marks line kinds', () => {
const a = analyze('## H\n```js\ncode\n```\n---');
expect(a.lines.map((l) => [l.heading, l.code, l.fence, l.hr])).toEqual([
[2, false, false, false],
[0, true, true, false],
[0, true, false, false],
[0, true, true, false],
[0, false, false, true]
]);
expect(render('```js\ncode\n```')).toEqual(['', 'code', '']);
});
});

225
src/lib/editor/mdmarks.ts Normal file
View file

@ -0,0 +1,225 @@
// Shared markdown analysis. The same Lezer tree drives both the CodeMirror
// live-preview decorations (on screen) and the PDF text layout (on export),
// so the two render line for line the same.
import type { Tree } from '@lezer/common';
import { parser as baseParser, GFM } from '@lezer/markdown';
export const mdParser = baseParser.configure(GFM);
export type InlineStyle = 'strong' | 'em' | 'code' | 'strike' | 'link';
export interface LineInfo {
from: number;
to: number;
/** 0 = not a heading. */
heading: number;
code: boolean;
/** Opening/closing ``` line of a fenced block. */
fence: boolean;
hr: boolean;
quote: number;
}
export interface MdAnalysis {
lines: LineInfo[];
styles: { from: number; to: number; style: InlineStyle }[];
/** Markup hidden unless the cursor is on its line. */
hides: { from: number; to: number }[];
/** Unordered list markers rendered as a bullet. */
bullets: { from: number; to: number }[];
tasks: { from: number; to: number; checked: boolean }[];
}
const INLINE: Record<string, InlineStyle> = {
StrongEmphasis: 'strong',
Emphasis: 'em',
InlineCode: 'code',
Strikethrough: 'strike',
Link: 'link',
Autolink: 'link'
};
const HIDE_MARKS = new Set(['EmphasisMark', 'CodeMark', 'StrikethroughMark']);
export function analyze(text: string, tree: Tree = mdParser.parse(text)): MdAnalysis {
const lines: LineInfo[] = [];
for (let from = 0; ; ) {
const nl = text.indexOf('\n', from);
const to = nl < 0 ? text.length : nl;
lines.push({ from, to, heading: 0, code: false, fence: false, hr: false, quote: 0 });
if (nl < 0) break;
from = nl + 1;
}
const lineAt = (pos: number) => {
let lo = 0, hi = lines.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lines[mid].from <= pos) lo = mid;
else hi = mid - 1;
}
return lo;
};
const eachLine = (from: number, to: number, fn: (l: LineInfo) => void) => {
for (let i = lineAt(from); i < lines.length && lines[i].from <= to; i++) fn(lines[i]);
};
const a: MdAnalysis = { lines, styles: [], hides: [], bullets: [], tasks: [] };
const hideWithSpace = (from: number, to: number) => {
if (text[to] === ' ') to++;
a.hides.push({ from, to });
};
tree.iterate({
enter(node) {
const { name, from, to } = node;
const heading = /^(?:ATX|Setext)Heading(\d)$/.exec(name);
if (heading) {
const level = +heading[1];
if (name.startsWith('Setext')) {
// Only the text line is the heading; the underline is markup.
const first = lines[lineAt(from)];
first.heading = level;
} else eachLine(from, to, (l) => (l.heading = level));
return;
}
if (name === 'HeaderMark') {
const parent = node.node.parent?.name ?? '';
const l = lines[lineAt(from)];
if (parent.startsWith('Setext')) a.hides.push({ from, to });
else if (/^\s*$/.test(text.slice(l.from, from))) hideWithSpace(from, to);
else a.hides.push({ from: text[from - 1] === ' ' ? from - 1 : from, to }); // closing #s
return;
}
if (name === 'FencedCode' || name === 'CodeBlock') {
eachLine(from, to, (l) => (l.code = true));
if (name === 'FencedCode') {
const first = lines[lineAt(from)];
first.fence = true;
const last = lines[lineAt(to)];
if (last !== first && /^\s*(```|~~~)/.test(text.slice(last.from, last.to))) last.fence = true;
}
return;
}
if (name === 'CodeInfo' || (name === 'CodeMark' && node.node.parent?.name === 'FencedCode')) {
a.hides.push({ from, to });
return;
}
if (name === 'HorizontalRule') {
eachLine(from, to, (l) => (l.hr = true));
a.hides.push({ from, to });
return;
}
if (name === 'Blockquote') {
eachLine(from, to, (l) => l.quote++);
return;
}
if (name === 'QuoteMark') {
hideWithSpace(from, to);
return;
}
if (name === 'ListMark') {
if (/^[-*+]$/.test(text.slice(from, to)) && !isTaskItem(node.node)) a.bullets.push({ from, to });
else if (/^[-*+]$/.test(text.slice(from, to))) hideWithSpace(from, to);
return;
}
if (name === 'TaskMarker') {
a.tasks.push({ from, to, checked: /x/i.test(text.slice(from, to)) });
return;
}
if (name === 'Escape') {
a.hides.push({ from, to: from + 1 });
return;
}
if (name === 'Image' || name === 'HTMLTag' || name === 'Comment') return false;
const style = INLINE[name];
if (style) a.styles.push({ from, to, style });
if (name === 'Link' || name === 'Autolink') {
// Show only the link text: hide "[" and everything from "]" on.
const marks = node.node.getChildren('LinkMark');
if (name === 'Autolink' || marks.length < 2) {
for (const m of marks) a.hides.push({ from: m.from, to: m.to });
} else {
a.hides.push({ from: marks[0].from, to: marks[0].to });
a.hides.push({ from: marks[1].from, to });
}
return false;
}
if (HIDE_MARKS.has(name)) a.hides.push({ from, to });
}
});
a.hides.sort((x, y) => x.from - y.from);
return a;
}
function isTaskItem(listMark: { parent: { getChild(name: string): unknown } | null }): boolean {
return !!listMark.parent?.getChild('Task');
}
/** Line index containing `pos`. */
export function lineIndexAt(a: MdAnalysis, pos: number): number {
let lo = 0, hi = a.lines.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (a.lines[mid].from <= pos) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** A run of text on one rendered line with uniform style. */
export interface Run {
text: string;
styles: Set<InlineStyle>;
/** Special glyphs drawn instead of text. */
widget?: 'bullet' | 'task' | 'task-done';
}
/**
* Rendered runs of one line with all markup hidden — what a reader sees when
* the cursor is elsewhere (and what the PDF prints).
*/
export function renderedRuns(text: string, a: MdAnalysis, line: LineInfo): Run[] {
const runs: Run[] = [];
const { from, to } = line;
if (line.hr) return runs;
const widgets = [
...a.bullets.map((b) => ({ ...b, widget: 'bullet' as const })),
...a.tasks.map((t) => ({ from: t.from, to: t.to, widget: t.checked ? ('task-done' as const) : ('task' as const) }))
].filter((w) => w.from >= from && w.to <= to);
const hidden = a.hides.filter((h) => h.to > from && h.from < to);
const styles = a.styles.filter((s) => s.to > from && s.from < to);
let pos = from;
while (pos < to) {
const w = widgets.find((w) => w.from === pos);
if (w) {
runs.push({ text: '', styles: new Set(), widget: w.widget });
pos = w.to;
continue;
}
const h = hidden.find((h) => h.from <= pos && h.to > pos);
if (h) {
pos = h.to;
continue;
}
// Advance to the next boundary.
let end = to;
for (const b of [...widgets.map((w) => w.from), ...hidden.map((h) => h.from), ...styles.flatMap((s) => [s.from, s.to])]) {
if (b > pos && b < end) end = b;
}
const set = new Set(styles.filter((s) => s.from <= pos && s.to >= end).map((s) => s.style));
const chunk = text.slice(pos, end);
const prev = runs[runs.length - 1];
if (prev && !prev.widget && sameSet(prev.styles, set)) prev.text += chunk;
else runs.push({ text: chunk, styles: set });
pos = end;
}
return runs;
}
function sameSet(a: Set<string>, b: Set<string>) {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}

View file

@ -0,0 +1,55 @@
// Typography of a page, shared by the on-screen editor (as CSS variables) and
// the PDF text layout. All sizes are PDF points (= CSS px in world space).
export const PAGE_STYLE = {
margin: 48,
fontSize: 11,
lineHeight: 1.6,
headingLineHeight: 1.35,
/** Heading font sizes relative to the body size, h1…h6. */
headingScale: [1.8, 1.45, 1.2, 1.05, 1, 1],
codeScale: 0.92,
quoteIndent: 12,
colors: {
text: '#1f1f1f',
muted: '#8a8a8a',
link: '#6a5acd',
codeBg: '#f1f1ef',
quoteBar: '#c9c9c9',
rule: '#d4d4d4'
}
} as const;
export function lineMetrics(heading: number, code: boolean) {
const s = PAGE_STYLE;
if (heading) {
const size = s.fontSize * s.headingScale[heading - 1];
return { size, height: size * s.headingLineHeight };
}
if (code) return { size: s.fontSize * s.codeScale, height: s.fontSize * s.lineHeight };
return { size: s.fontSize, height: s.fontSize * s.lineHeight };
}
/** CSS custom properties that mirror PAGE_STYLE for the editor. */
export function pageCssVars(): string {
const s = PAGE_STYLE;
const vars: Record<string, string | number> = {
'--pg-margin': `${s.margin}px`,
'--pg-font': `${s.fontSize}px`,
'--pg-lh': s.lineHeight,
'--pg-hlh': s.headingLineHeight,
'--pg-code': `${s.fontSize * s.codeScale}px`,
'--pg-code-lh': `${s.fontSize * s.lineHeight}px`,
'--pg-quote': `${s.quoteIndent}px`,
'--pg-text': s.colors.text,
'--pg-muted': s.colors.muted,
'--pg-link': s.colors.link,
'--pg-code-bg': s.colors.codeBg,
'--pg-quote-bar': s.colors.quoteBar,
'--pg-rule': s.colors.rule
};
s.headingScale.forEach((k, i) => (vars[`--pg-h${i + 1}`] = `${s.fontSize * k}px`));
return Object.entries(vars)
.map(([k, v]) => `${k}:${v}`)
.join(';');
}