Add integration tests

This commit is contained in:
gsb
2026-04-29 22:15:19 +00:00
parent 3e8d3388f6
commit 005db2f431
11 changed files with 1544 additions and 114 deletions
+5
View File
@@ -30,6 +30,11 @@
font-size: 0.85em;
}
[data-speculative]::before,
[data-speculative]::after {
content: none !important;
}
#ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after {
content: "**";
+1 -1
View File
@@ -4,7 +4,7 @@
* Replace this file with your own theme to customize the look.
*/
@import "../ribbit-core.css";
@import "../../ribbit-core.css";
a {
text-decoration: none;
+581 -103
View File
@@ -57,65 +57,598 @@ export class RibbitEditor extends Ribbit {
#bindEvents(): void {
let debounceTimer: number | undefined;
let lastThrottle = 0;
this.element.addEventListener('input', () => {
if (this.state === this.states.VIEW) {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.invalidateCache();
const now = Date.now();
if (now - lastThrottle >= 150) {
lastThrottle = now;
this.refreshPreview();
}
this.ensureBlockStructure();
this.transformCurrentBlock();
this.updateEditingContext();
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
this.refreshPreview();
this.notifyChange();
}, 150);
}, 300);
});
this.element.addEventListener('keydown', (e: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (e.key === 'Enter') {
this.handleEnter(e);
}
});
this.element.addEventListener('keyup', (e: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (e.key.startsWith('Arrow')) {
this.closeOrphanedSpeculative();
this.updateEditingContext();
}
});
this.element.addEventListener('blur', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.closeOrphanedSpeculative();
});
this.element.addEventListener('focusout', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.closeOrphanedSpeculative();
});
document.addEventListener('click', (e: MouseEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (!this.element.contains(e.target as Node)) {
this.closeAllSpeculative();
}
});
document.addEventListener('selectionchange', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.closeOrphanedSpeculative();
this.updateEditingContext();
});
}
/**
* Re-render the WYSIWYG preview from the current content.
* Applies speculative rendering for unclosed inline delimiters
* at the cursor position, and uses toHtmlPreview for visible syntax.
* Find the block-level element containing the cursor.
*/
refreshPreview(): void {
if (this.state !== this.states.WYSIWYG) {
return;
}
const cursorInfo = this.getCursorInfo();
const text = this.element.textContent || '';
const lines = text.split('\n');
// Speculatively close unclosed delimiters on the cursor line
if (cursorInfo) {
const inlineTags = this.converter.getInlineTags();
const sorted = [...inlineTags].sort((a, b) =>
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
);
for (const tag of sorted) {
if (tag.openPattern && tag.delimiter) {
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'g');
const count = (before.match(re) || []).length;
if (count % 2 === 1) {
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
break;
/**
* Ensure the editor contains valid block structure.
* Wraps bare <br> and <div> elements in <p> tags.
*/
private ensureBlockStructure(): void {
for (const child of Array.from(this.element.childNodes)) {
if (child.nodeType === 1) {
const element = child as HTMLElement;
if (element.tagName === 'BR') {
const p = document.createElement('p');
p.innerHTML = '<br>';
element.replaceWith(p);
} else if (element.tagName === 'DIV') {
const p = document.createElement('p');
while (element.firstChild) {
p.appendChild(element.firstChild);
}
if (!p.firstChild) {
p.innerHTML = '<br>';
}
element.replaceWith(p);
// Restore cursor inside the new <p>
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = document.createRange();
const target = p.lastChild || p;
if (target.nodeType === 3) {
range.setStart(target, target.textContent?.length || 0);
} else {
range.selectNodeContents(target);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
}
}
}
}
if (!this.element.firstChild) {
this.element.innerHTML = '<p><br></p>';
}
}
const html = this.converter.toHTML(lines.join('\n'));
this.updatePreview(html, cursorInfo);
private findCurrentBlock(): HTMLElement | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return null;
}
let node: Node | null = sel.anchorNode;
// If cursor is in a text node directly inside the editor,
// wrap it in a <p> first (browsers don't always do this).
if (node && node.nodeType === 3 && node.parentNode === this.element) {
const p = document.createElement('p');
node.parentNode.insertBefore(p, node);
p.appendChild(node);
// Restore cursor inside the new <p>
const range = document.createRange();
range.setStart(node, sel.anchorOffset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return p;
}
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
if (element.tagName === 'LI' || element.parentNode === this.element) {
return element;
}
}
node = node.parentNode;
}
return null;
}
/**
* Check the current block's text for markdown patterns and
* transform the DOM element in-place if a pattern matches.
*/
private transformCurrentBlock(): void {
const block = this.findCurrentBlock();
if (!block) {
return;
}
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
// Heading: # through ######
const headingMatch = text.match(/^(#{1,6})\s/);
if (headingMatch) {
const level = headingMatch[1].length;
const targetTag = 'H' + level;
if (block.tagName !== targetTag) {
this.replaceBlock(block, targetTag, headingMatch[0].length);
return;
}
}
// Blockquote: >
if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
this.replaceBlock(block, 'BLOCKQUOTE', 2);
return;
}
// Horizontal rule: --- or *** or ___
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
const hr = document.createElement('hr');
const p = document.createElement('p');
p.innerHTML = '<br>';
block.replaceWith(hr, p);
const range = document.createRange();
range.setStart(p, 0);
range.collapse(true);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
return;
}
// Unordered list: - or *
if (/^[-*]\s/.test(text) && block.tagName !== 'LI') {
this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1);
return;
}
// Ordered list: 1.
if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') {
this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1);
return;
}
// Fenced code: ```
if (text.startsWith('```') && block.tagName !== 'PRE') {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = '';
pre.appendChild(code);
block.replaceWith(pre);
const range = document.createRange();
range.setStart(code, 0);
range.collapse(true);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
return;
}
// Inline transforms: flatten to markdown, transform, rebuild DOM
this.transformInline(block);
}
/**
* Convert a block's DOM children to a mixed string where completed
* inline elements are preserved as HTML and only speculative/text
* content is flattened to markdown. Completed elements are wrapped
* in sentinel markers so the regex engine skips them.
*/
private blockToMarkdown(block: HTMLElement): string {
let md = '';
for (const child of Array.from(block.childNodes)) {
md += this.nodeToMarkdown(child);
}
return md;
}
private nodeToMarkdown(node: Node): string {
if (node.nodeType === 3) {
return (node.textContent || '').replace(/\u200B/g, '');
}
if (node.nodeType !== 1) { return ''; }
const element = node as HTMLElement;
const specDelim = element.getAttribute('data-speculative');
if (specDelim) {
// Speculative: restore opener delimiter + flatten children
let inner = '';
for (const child of Array.from(element.childNodes)) {
inner += this.nodeToMarkdown(child);
}
return specDelim + inner;
}
const tag = this.findTagForElement(element);
if (tag?.delimiter) {
// Completed element: preserve as HTML, wrapped in sentinels
// so the complete-pair regex won't match across it
return '\x01' + element.outerHTML + '\x02';
}
// Unknown element: flatten children
let inner = '';
for (const child of Array.from(element.childNodes)) {
inner += this.nodeToMarkdown(child);
}
return inner;
}
/**
* Find the Tag definition that matches an HTML element.
*/
private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null {
const inlineTags = this.converter.getInlineTags();
for (const tag of inlineTags) {
if (!tag.delimiter) continue;
if (typeof tag.selector === 'string') {
const selectors = tag.selector.split(',');
if (selectors.some(s => el.tagName === s.trim())) {
return tag;
}
}
}
return null;
}
/**
* Flatten the block to markdown, find and apply inline transforms,
* then rebuild the DOM from the result.
*/
private transformInline(block: HTMLElement): void {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
let md = this.blockToMarkdown(block);
if (md.replace(/\s/g, '').length < 2) return;
const inlineTags = this.converter.getInlineTags();
const sorted = [...inlineTags]
.filter(tag => tag.delimiter)
.sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50));
// Build regex for each tag with exact-delimiter matching.
// [^\x01\x02] prevents matching across preserved HTML elements.
const tagRegexes = sorted.map(tag => {
const delim = tag.delimiter!;
const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return {
tag,
complete: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+?)(?<!${ec})${escaped}`),
open: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+)$`),
};
});
// Apply complete pairs repeatedly until none match
const forbiddenChildren: Record<string, string[]> = {
'strong': ['strong', 'b'],
'em': ['em', 'i'],
'code': ['code', 'strong', 'b', 'em', 'i', 'a'],
};
let changed = true;
while (changed) {
changed = false;
for (const { tag, complete } of tagRegexes) {
const match = md.match(complete);
if (match && match.index !== undefined) {
const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase();
// Skip if wrapping would create forbidden nesting
const banned = forbiddenChildren[tagName];
if (banned && banned.some(t => match[1].includes('<' + t))) {
continue;
}
const content = tagName === 'code'
? match[1].replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
: match[1];
const inner = tag.name === 'boldItalic'
? `\x01<${tagName}><strong>${content}</strong></${tagName}>\x02`
: `\x01<${tagName}>${content}</${tagName}>\x02`;
md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length);
changed = true;
break;
}
}
}
// Strip sentinel markers now that complete-pair matching is done
md = md.replace(/[\x01\x02]/g, '');
// Check for one unclosed opener (speculative)
let speculativeTag: typeof sorted[0] | null = null;
let speculativeMatch: RegExpMatchArray | null = null;
for (const { tag, open } of tagRegexes) {
const match = md.match(open);
if (match && match.index !== undefined) {
// Make sure this isn't inside an HTML tag we just created
const before = md.slice(0, match.index);
if (!before.endsWith('<') && !before.endsWith('/')) {
speculativeTag = tag;
speculativeMatch = match;
break;
}
}
}
// Rebuild the DOM
if (speculativeMatch && speculativeTag) {
const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase();
const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length);
// Check for forbidden nesting before wrapping
const probe = document.createElement('div');
probe.innerHTML = inside;
const banned = forbiddenChildren[tagName];
const wouldNest = banned && banned.some(tag => probe.querySelector(tag));
if (!wouldNest) {
const before = md.slice(0, speculativeMatch.index!);
const wrapper = document.createElement(tagName);
wrapper.classList.add('ribbit-editing');
wrapper.setAttribute('data-speculative', speculativeTag.delimiter!);
wrapper.innerHTML = inside;
this.sanitizeNesting(wrapper);
block.innerHTML = '';
if (before) {
block.appendChild(document.createTextNode(before));
}
block.appendChild(wrapper);
// ZWS after wrapper so arrow-right can escape the element
block.appendChild(document.createTextNode('\u200B'));
// Cursor at end of speculative element
this.placeCursorAtEnd(wrapper);
} else {
// Forbidden nesting — fall through to plain innerHTML
block.innerHTML = md;
this.sanitizeNesting(block);
if (block.lastChild && block.lastChild.nodeType === 1) {
block.appendChild(document.createTextNode('\u200B'));
}
this.placeCursorAtEnd(block);
}
} else {
block.innerHTML = md;
this.sanitizeNesting(block);
// If the block ends with an HTML element, append a ZWS text
// node so the cursor lands outside the element, not inside it.
if (block.lastChild && block.lastChild.nodeType === 1) {
block.appendChild(document.createTextNode('\u200B'));
}
this.placeCursorAtEnd(block);
}
}
/**
* Place the cursor at the end of an element's content.
*/
private placeCursorAtEnd(el: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
// Find the deepest last text node
let target: Node = el;
while (target.lastChild) {
target = target.lastChild;
}
if (target.nodeType === 3) {
range.setStart(target, target.textContent?.length || 0);
} else {
range.selectNodeContents(target);
range.collapse(false);
}
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
/**
/**
* Replace a block element with a new tag, stripping the prefix
* and preserving cursor position.
*/
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
const newEl = document.createElement(newTag);
const content = (block.textContent || '').slice(prefixLength);
if (content) {
newEl.textContent = content;
} else {
newEl.innerHTML = '<br>';
}
block.replaceWith(newEl);
newEl.classList.add('ribbit-editing');
// Place cursor at start of content
const range = document.createRange();
if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
range.setStart(newEl.firstChild, 0);
} else {
range.setStart(newEl, 0);
}
range.collapse(true);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
/**
* Replace a block element with a list (ul/ol) containing one item.
*/
private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
const list = document.createElement(listTag);
const li = document.createElement('li');
const content = (block.textContent || '').slice(prefixLength);
if (content) {
li.textContent = content;
} else {
li.innerHTML = '<br>';
}
list.appendChild(li);
block.replaceWith(list);
const range = document.createRange();
if (li.firstChild && li.firstChild.nodeType === 3) {
range.setStart(li.firstChild, 0);
} else {
range.setStart(li, 0);
}
range.collapse(true);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
/**
* Handle Enter key: strip syntax decorations from the current
* block before the browser creates a new line.
*/
private handleEnter(e: KeyboardEvent): void {
const prev = this.element.querySelector('.ribbit-editing');
if (prev) {
prev.classList.remove('ribbit-editing');
prev.removeAttribute('data-speculative');
}
}
/**
* Close any speculative elements that the cursor is no longer inside.
* Called on every selection change — handles arrow keys, clicks,
* tab switches, and any other cursor movement.
*/
/**
* Unwrap a speculative element, replacing it with its children.
* An orphaned speculative element was never completed — it should
* not become permanent formatting.
*/
private unwrapSpeculative(element: HTMLElement): void {
this.unwrapElement(element);
}
/**
* Replace an element with its children, preserving content.
*/
private unwrapElement(element: HTMLElement): void {
const parent = element.parentNode;
if (!parent) { return; }
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
}
/**
* Remove forbidden nesting from a block element.
* For example, <em> inside <em>, <strong> inside <code>, etc.
*/
private sanitizeNesting(block: HTMLElement): void {
const rules: Record<string, string[]> = {
'STRONG': ['STRONG', 'B'],
'B': ['STRONG', 'B'],
'EM': ['EM', 'I'],
'I': ['EM', 'I'],
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'],
};
let found = true;
while (found) {
found = false;
for (const [parent, forbidden] of Object.entries(rules)) {
const parents = block.querySelectorAll(parent.toLowerCase());
for (const parentEl of Array.from(parents)) {
for (const tag of forbidden) {
const nested = parentEl.querySelector(tag.toLowerCase());
if (nested && nested !== parentEl) {
this.unwrapElement(nested as HTMLElement);
found = true;
}
}
}
}
}
}
private closeAllSpeculative(): void {
for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) {
this.unwrapSpeculative(element as HTMLElement);
}
}
private closeOrphanedSpeculative(): void {
const speculative = this.element.querySelectorAll('[data-speculative]');
if (speculative.length === 0) { return; }
const sel = window.getSelection();
const anchor = sel?.anchorNode;
for (const el of Array.from(speculative)) {
const htmlEl = el as HTMLElement;
let inside = false;
let node: Node | null = anchor || null;
while (node) {
if (node === htmlEl) {
inside = true;
break;
}
node = node.parentNode;
}
if (!inside) {
this.unwrapSpeculative(htmlEl);
}
}
}
/**
@@ -144,79 +677,20 @@ export class RibbitEditor extends Ribbit {
}
}
/**
* Get the cursor's line index and offset within that line.
*/
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return null;
}
const range = sel.getRangeAt(0);
const preRange = document.createRange();
preRange.selectNodeContents(this.element);
preRange.setEnd(range.startContainer, range.startOffset);
const absoluteOffset = preRange.toString().length;
const text = this.element.textContent || '';
const beforeCursor = text.slice(0, absoluteOffset);
const lineIndex = beforeCursor.split('\n').length - 1;
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const offset = absoluteOffset - lineStart;
return { lineIndex, offset, absoluteOffset };
}
/**
* Replace the editor's HTML and restore the cursor to its
* previous text offset position.
*/
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
this.element.innerHTML = html;
if (!cursorInfo) {
return;
}
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
let remaining = cursorInfo.absoluteOffset;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (remaining <= node.length) {
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
break;
}
remaining -= node.length;
}
this.updateEditingContext();
}
htmlToMarkdown(html?: string): string {
return this.converter.toMarkdown(html || this.element.innerHTML);
}
getMarkdown(): string {
if (this.cachedMarkdown !== null) {
return this.cachedMarkdown;
}
if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, '');
html = html.replace(/<\/div>/ig, '\n');
this.cachedMarkdown = decodeHtmlEntities(html);
return decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
} else {
this.cachedMarkdown = this.element.textContent || '';
return this.htmlToMarkdown(this.element.innerHTML);
}
return this.cachedMarkdown;
return this.element.textContent || '';
}
wysiwyg(): void {
@@ -229,6 +703,10 @@ export class RibbitEditor extends Ribbit {
}
this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML();
// Ensure there's at least one block element for the cursor
if (!this.element.firstElementChild) {
this.element.innerHTML = '<p><br></p>';
}
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
const macroEl = el as HTMLElement;
if (macroEl.dataset.editable === 'false') {
+3 -3
View File
@@ -249,14 +249,14 @@ export class Ribbit {
const revision = await this.collaboration.getRevision(id);
if (!revision) return;
this.cachedMarkdown = revision.content;
this.cachedHTML = null;
this.cachedHTML = this.markdownToHTML(revision.content);
this.collaboration.sendUpdate(revision.content);
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.getHTML();
this.element.innerHTML = this.cachedHTML;
}
this.emitter.emit('change', {
markdown: revision.content,
html: this.getHTML(),
html: this.cachedHTML,
});
}
+18 -4
View File
@@ -21,9 +21,24 @@ import type { Tag, Converter, ListItem, ListResult, InlineTagDef } from './types
*/
export function inlineTag(def: InlineTagDef): Tag {
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
const openPattern = new RegExp(escaped + '(.+)$');
/*
* CommonMark flanking delimiter rules:
* - Opening delimiter must be preceded by start/whitespace/punctuation
* and followed by a non-whitespace character.
* - Closing delimiter must be preceded by a non-whitespace character
* and followed by end/whitespace/punctuation.
*
* This prevents mid-word matches like "2*3*4" from being treated as
* emphasis while still allowing "**bold** text" and "*italic*." to work.
*/
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
const ec = def.delimiter[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const openFlank = `(?:^|(?<=${punctuation}))${escaped}(?!${ec})(?=\\S)`;
const closeFlank = `(?<=\\S)(?<!${ec})${escaped}(?=$|${punctuation}|(?!${ec}))`;
const matchPattern = new RegExp(`^${escaped}(?!${ec})(?=\\S)(.+?)(?<=\\S)(?<!${ec})${escaped}`);
const globalPattern = new RegExp(openFlank + '(.+?)' + closeFlank, 'g');
const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false;
@@ -33,7 +48,6 @@ export function inlineTag(def: InlineTagDef): Tag {
precedence: def.precedence ?? 50,
recursive,
pattern: globalPattern,
openPattern,
delimiter: def.delimiter,
template: `${def.delimiter}text${def.delimiter}`,
replaceSelection: true,
+4 -1
View File
@@ -75,7 +75,10 @@ export class ThemeManager {
}
const previous = this.active;
this.active = theme;
this.loadCSS(name);
// Only load CSS when switching themes, not on initial set
if (previous !== theme) {
this.loadCSS(name);
}
this.onSwitch(theme, previous);
}
-1
View File
@@ -36,7 +36,6 @@ export interface Tag {
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string;
openPattern?: RegExp;
delimiter?: string;
precedence?: number;
recursive?: boolean;