Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 78 additions & 70 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

764 changes: 385 additions & 379 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"esbuild": "^0.28.1",
"eslint": "^9.27.0",
"happy-dom": "^20.8.9",
"jsdom": "^26.1.0",
"markedit-api": "https://github.com/MarkEdit-app/MarkEdit-api#v0.35.0",
"markedit-vite": "https://github.com/MarkEdit-app/MarkEdit-vite#v0.5.0",
"typescript": "^5.0.0",
Expand All @@ -40,6 +41,7 @@
"vitest": "^4.0.18"
},
"dependencies": {
"dompurify": "^3.4.12",
Comment thread
cyanzhong marked this conversation as resolved.
"js-yaml": "^5.2.1",
"katex": "^0.18.4",
"mark.js": "^8.11.1",
Expand Down
114 changes: 100 additions & 14 deletions src/hiddenSyntax/block.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { syntaxTree } from '@codemirror/language';
import { foldedRanges, syntaxTree } from '@codemirror/language';
import { type EditorState, type Range, StateField } from '@codemirror/state';
import { Decoration, type DecorationSet, EditorView } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { BlockMathWidget } from './components/math';
import { MermaidWidget } from './components/mermaid';
import { TableWidget, tableRenderFailed } from './components/table';
import { selectionReveals } from './selection';
import { renderTableBlocks } from '../render';

interface BlockDecorationState {
all: DecorationSet;
Expand All @@ -14,14 +16,33 @@ interface BlockDecorationState {
export const renderedBlockDecorations = StateField.define<BlockDecorationState>({
create: state => createBlockDecorationState(state),
update(value, transaction) {
if (transaction.docChanged) {
return createBlockDecorationState(transaction.state);
if (transaction.docChanged || transaction.reconfigured
|| syntaxTree(transaction.startState) !== syntaxTree(transaction.state)) {
const previous: Range<Decoration>[] = [];
for (const cursor = value.all.iter(); cursor.value !== null; cursor.next()) {
if (cursor.value.spec.widget instanceof TableWidget) {
const from = transaction.changes.mapPos(cursor.from, 1);
const to = transaction.changes.mapPos(cursor.to, -1);
if (from < to) {
previous.push(cursor.value.range(from, to));
}
}
}

return createBlockDecorationState(transaction.state, Decoration.set(previous, true));
}

let all = value.all;
for (const effect of transaction.effects) {
if (effect.is(tableRenderFailed) && effect.value.doc === transaction.state.doc) {
all = all.update({ filter: (from, to) => from !== effect.value.from || to !== effect.value.to });
}
}

if (transaction.selection !== undefined) {
if (transaction.selection !== undefined || transaction.effects.length > 0) {
return {
all: value.all,
visible: hideSelectedBlocks(value.all, transaction.state),
all,
visible: hideSelectedBlocks(all, transaction.state),
};
}

Expand All @@ -30,18 +51,72 @@ export const renderedBlockDecorations = StateField.define<BlockDecorationState>(
provide: field => EditorView.decorations.from(field, value => value.visible),
});

function createBlockDecorationState(state: EditorState): BlockDecorationState {
const all = createBlockDecorations(state);
function createBlockDecorationState(state: EditorState, previous = Decoration.none): BlockDecorationState {
const all = createBlockDecorations(state, previous);
return { all, visible: hideSelectedBlocks(all, state) };
}

function createBlockDecorations(state: EditorState) {
function createBlockDecorations(state: EditorState, previous: DecorationSet) {
const ranges: Range<Decoration>[] = [];
syntaxTree(state).iterate({
const tree = syntaxTree(state);

const context: string[] = [];
tree.iterate({
enter: node => {
const decoration = node.name === 'BlockMath'
? blockMathDecoration(node, state)
: mermaidDecoration(node, state);
if (node.name === 'Document') {
return;
}

const source = state.sliceDoc(state.doc.lineAt(node.from).from, state.doc.lineAt(node.to).to);
if ((node.name !== 'Paragraph' && node.name !== 'Table') || source.includes('[')) {
context.push(source);
}

return false;
},
});

const slice = state.sliceDoc(tree.length);
context.push(slice);

let tables: ReturnType<typeof renderTableBlocks> | undefined;
const renderTables = () => tables ??= renderTableBlocks(state.doc.toString());
const referenceContext = JSON.stringify(context);

tree.iterate({
enter: node => {
let decoration: Range<Decoration> | undefined;
if (node.name === 'Table') {
for (let parent = node.node.parent; parent !== null; parent = parent.parent) {
if (parent.name !== 'Document') {
return false;
}
}

if (tree.length < state.doc.length && node.to >= tree.length) {
return false;
}

const from = state.doc.lineAt(node.from).from;
const to = state.doc.lineAt(node.to).to;

let widget = new TableWidget(state.doc, from, to, renderTables, referenceContext);
previous.between(from, to, (start, end, previousDecoration) => {
const candidate = previousDecoration.spec.widget;
if (start === from && end === to && candidate instanceof TableWidget && widget.eq(candidate)) {
widget = candidate;
}
});

decoration = Decoration.replace({
block: true,
widget,
}).range(from, to);
} else if (__FULL_BUILD__) {
decoration = node.name === 'BlockMath'
? blockMathDecoration(node, state)
: mermaidDecoration(node, state);
}

if (decoration !== undefined) {
ranges.push(decoration);
Expand Down Expand Up @@ -96,6 +171,17 @@ function hideSelectedBlocks(decorations: DecorationSet, state: EditorState) {
}

return decorations.update({
filter: (from, to) => !selectionReveals(state, from, to),
filter: (from, to, decoration) => {
if (selectionReveals(state, from, to)) {
return false;
}

let folded = false;
if (decoration.spec.widget instanceof TableWidget) {
foldedRanges(state).between(from, to, () => { folded = true; });
}

return !folded;
},
});
}
139 changes: 139 additions & 0 deletions src/hiddenSyntax/components/table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { EditorSelection, StateEffect, type Text } from '@codemirror/state';
import { WidgetType, EditorView } from '@codemirror/view';
import DOMPurify from 'dompurify';
import { MarkEdit } from 'markedit-api';
import { editorThemeCss } from '../../styling';
import { resolveImageURL } from '../../features/image';
import type { renderTableBlocks } from '../../render';

export const tableRenderFailed = StateEffect.define<{ doc: Text; from: number; to: number }>();
type RenderTables = () => ReturnType<typeof renderTableBlocks>;

/**
* Render a read-only table, inspired by BlaisedEstais: https://github.com/MarkEdit-app/MarkEdit-preview/issues/188#issuecomment-5696116600
*/
export class TableWidget extends WidgetType {
constructor(
private readonly doc: Text,
private readonly from: number,
private readonly to: number,
private readonly render: RenderTables,
private readonly referenceContext = '',
) {
super();
}

private get source() {
return this.doc.sliceString(this.from, this.to);
}

toDOM(view: EditorView) {
const container = document.createElement('div');
container.className = 'cm-md-syntaxHiddenTable';
const root = container.attachShadow({ mode: 'open' });
const theme = document.createElement('style');
const updateTheme = () => {
theme.textContent = editorThemeCss(MarkEdit.editorConfig?.theme ?? 'github', view.state.facet(EditorView.darkTheme));
view.requestMeasure();
};

updateTheme();
window.addEventListener('editor-colors-changed', updateTheme);
disposables.set(container, () => window.removeEventListener('editor-colors-changed', updateTheme));

const style = document.createElement('style');
style.textContent = `
:host { display: block; }
.markdown-body { font: inherit; min-width: 0; white-space: normal; word-break: normal; overflow-wrap: break-word; overflow-x: auto; }
.markdown-body > table { display: table; width: auto; max-width: min(100%, 960px); margin: 0; overflow: visible; }
.source { white-space: pre-wrap; }
`;

const body = document.createElement('div');
body.className = 'markdown-body source';
body.textContent = this.source;
root.append(theme, style, body);

const reveal = (event: MouseEvent) => {
if (event.button !== 0 || event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
return;
}

event.preventDefault();
event.stopPropagation();
view.dispatch({ selection: EditorSelection.cursor(view.posAtDOM(container)), scrollIntoView: false });
view.focus();
};

container.addEventListener('mousedown', reveal);
container.addEventListener('click', event => event.preventDefault());
root.addEventListener('load', () => view.requestMeasure(), true);
root.addEventListener('error', () => view.requestMeasure(), true);

const fail = () => {
if (container.isConnected) {
const from = view.posAtDOM(container);
view.dispatch({ effects: tableRenderFailed.of({ doc: view.state.doc, from, to: from + this.source.length }) });
}
};

void Promise.all([
this.render(),
__FULL_BUILD__ ? import('../../../styles/katex.css?raw').then(module => module.default) : '',
]).then(([tables, mathCss]) => {
if (!container.isConnected) {
return;
}

const table = tables.find(candidate => candidate.fromLine === this.doc.lineAt(this.from).number
&& candidate.toLine === this.doc.lineAt(this.to).number);
if (table === undefined) {
fail();
return;
}

const fragment = DOMPurify.sanitize(table.html, {
RETURN_DOM_FRAGMENT: true,
FORBID_TAGS: ['style', 'link', 'meta', 'form', 'input', 'button', 'select', 'textarea', 'iframe', 'object', 'embed', 'audio', 'video'],
FORBID_ATTR: ['tabindex', 'autofocus', 'contenteditable'],
SANITIZE_NAMED_PROPS: true,
});

const rendered = fragment.querySelector('table');
if (rendered === null) {
fail();
return;
}

rendered.querySelectorAll('a').forEach(link => link.setAttribute('tabindex', '-1'));
rendered.querySelectorAll('img').forEach(image => {
const source = image.getAttribute('src');
if (source !== null) {
image.src = resolveImageURL(source);
}
});

style.textContent += mathCss;
body.classList.remove('source');
body.replaceChildren(rendered);
view.requestMeasure();
}).catch(fail);

return container;
}

destroy(dom: HTMLElement) {
disposables.get(dom)?.();
disposables.delete(dom);
}

eq(other: TableWidget) {
return other.source === this.source && other.referenceContext === this.referenceContext;
}

ignoreEvent() {
return false;
}
}

const disposables = new WeakMap<HTMLElement, () => void>();
2 changes: 1 addition & 1 deletion src/hiddenSyntax/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const hiddenSyntaxBaseExtension = [
blockquoteBars,
unorderedListBullets,
taskCheckboxes,
...(__FULL_BUILD__ ? [renderedBlockDecorations] : []),
renderedBlockDecorations,
Comment thread
cyanzhong marked this conversation as resolved.
hiddenSyntaxTheme,
];

Expand Down
8 changes: 8 additions & 0 deletions src/hiddenSyntax/theme.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { EditorView } from '@codemirror/view';

export const hiddenSyntaxTheme = EditorView.baseTheme({
'&.cm-md-syntaxHiddenMode .cm-md-syntaxHiddenTable': {
boxSizing: 'border-box',
width: '100%',
padding: '0.5em 6px',
overflow: 'hidden',
contain: 'content',
cursor: 'text',
},
'&.cm-md-syntaxHiddenMode .cm-md-syntaxHiddenCodeBlock': {
'--code-border': 'color-mix(in srgb, currentColor 18%, transparent)',
boxShadow: 'inset 1px 0 var(--code-border), inset -1px 0 var(--code-border)',
Expand Down
33 changes: 33 additions & 0 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,39 @@ export async function renderMarkdown(markdown: string, lineInfo = true) {
return mdit.render(markdown, { lineInfo });
}

export async function renderTableBlocks(markdown: string) {
await pluginsReady;
const environment = { lineInfo: false };
const tokens = mdit.parse(markdown, environment);
const tables: { fromLine: number; toLine: number; html: string }[] = [];

for (let index = 0; index < tokens.length; index += 1) {
const opening = tokens[index];
if (opening.type !== 'table_open' || opening.level !== 0 || opening.map === null) {
continue;
}

let closing = index + 1;
while (closing < tokens.length && tokens[closing].type !== 'table_close') {
closing += 1;
}

if (closing === tokens.length) {
continue;
}

tables.push({
fromLine: opening.map[0] + 1,
toLine: opening.map[1],
html: mdit.renderer.render(tokens.slice(index, closing + 1), mdit.options, environment),
});

index = closing;
}

return tables;
}

export async function headingLineForAnchor(markdown: string, destination: string) {
if (!destination.startsWith('#')) {
return undefined;
Expand Down
6 changes: 6 additions & 0 deletions src/styling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export function coreCss(colorScheme: ColorScheme = 'auto') {
return styles.join('\n');
}

export function editorThemeCss(name: string, isDark: boolean) {
const variants = previewThemes[name.replace(/-(light|dark|dawn)$/, '')] ?? previewThemes['github'];
const colors = isDark ? variants.dark ?? variants.light : variants.light ?? variants.dark;
return `${githubBase}\n${colors}`;
}

export function previewThemeCss(colorScheme: ColorScheme = 'auto') {
if (showRawHtml) {
// System colors that follow color-scheme; needed because the WebView root is transparent.
Expand Down
Loading
Loading