Init
This commit is contained in:
commit
0ad405c26e
78 changed files with 9127 additions and 0 deletions
184
src/lib/editor/livepreview.ts
Normal file
184
src/lib/editor/livepreview.ts
Normal 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
|
||||
];
|
||||
}
|
||||
39
src/lib/editor/mdmarks.test.ts
Normal file
39
src/lib/editor/mdmarks.test.ts
Normal 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
225
src/lib/editor/mdmarks.ts
Normal 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;
|
||||
}
|
||||
55
src/lib/editor/pageStyle.ts
Normal file
55
src/lib/editor/pageStyle.ts
Normal 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(';');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue