Desktop, Mobile: Editor: Inline rendering: Render inline HTML (colorized text, superscript, subscript, strikethrough) (#14133)

This commit is contained in:
Henry Heino
2026-01-18 11:28:15 +00:00
committed by GitHub
parent 340fba7af5
commit c8878a18bf
7 changed files with 218 additions and 26 deletions
@@ -3,39 +3,17 @@ import { EditorSelection } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import uslug from '@joplin/fork-uslug/lib/uslug';
import { SyntaxNodeRef } from '@lezer/common';
import htmlNodeInfo from '../utils/htmlNodeInfo';
const jumpToHash = (view: EditorView, hash: string) => {
const state = view.state;
const timeout = 1_000; // Maximum time to spend parsing the syntax tree
let targetLocation: number|undefined = undefined;
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
const makeEnterNode = (offset: number) => (node: SyntaxNodeRef) => {
const nodeToText = (node: SyntaxNodeRef) => {
return state.sliceDoc(node.from + offset, node.to + offset);
};
// Returns the attribute with the given name for [node]
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string) => {
if (node.from === node.to) return null; // Empty
const content = node.node.resolveInner(node.from + 1);
// Search for the "id" attribute
const attributes = content.getChildren('Attribute');
for (const attribute of attributes) {
const nameNode = attribute.getChild('AttributeName');
const valueNode = attribute.getChild('AttributeValue');
if (nameNode && valueNode) {
const name = nodeToText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
if (name === attrName) {
return removeQuotes(nodeToText(valueNode));
}
}
}
return null;
};
const found = targetLocation !== undefined;
if (found) return false; // Skip this node
@@ -46,13 +24,14 @@ const jumpToHash = (view: EditorView, hash: string) => {
.replace(/^#+\s/, '') // Leading #s in headers
.replace(/\n-+$/, ''); // Trailing --s in headers
matches = hash === uslug(nodeText);
} else if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
} else if (node.name === 'HTMLBlock') {
// CodeMirror adds HTML information to Markdown documents using overlays attached
// to HTMLTag and HTMLBlock nodes.
// Use .enter to enter the overlay and visit the HTML nodes:
node.node.enter(node.from, 1).toTree().iterate({ enter: makeEnterNode(node.from) });
} else if (node.name === 'OpenTag') {
matches = getHtmlNodeAttr(node, 'id') === hash || getHtmlNodeAttr(node, 'name') === hash;
} else if (node.name === 'OpenTag' || node.name === 'HTMLTag') {
const htmlNodeDetails = htmlNodeInfo(node, state);
matches = htmlNodeDetails.getAttr('id') === hash || htmlNodeDetails.getAttr('name') === hash;
}
if (matches) {
@@ -4,6 +4,7 @@ import replaceBulletLists from './replaceBulletLists';
import replaceCheckboxes from './replaceCheckboxes';
import replaceDividers from './replaceDividers';
import replaceFormatCharacters from './replaceFormatCharacters';
import replaceInlineHtml from './replaceInlineHtml';
export default () => {
return [
@@ -13,5 +14,6 @@ export default () => {
replaceBackslashEscapes,
replaceDividers,
addFormattingClasses,
replaceInlineHtml,
];
};
@@ -0,0 +1,27 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../../testing/createTestEditor';
import replaceInlineHtml from './replaceInlineHtml';
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
const editor = await createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
expectedTags,
[replaceInlineHtml],
);
return editor;
};
describe('replaceInlineHtml', () => {
test.each([
{ markdown: '<sup>Test</sup>', expectedTagsQuery: 'sup' },
{ markdown: '<strike>Test</strike>', expectedTagsQuery: 'strike' },
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedTagsQuery: 'span[style]' },
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedTagsQuery: 'span[style]' },
])('should render inline HTML (case %#)', async ({ markdown, expectedTagsQuery }) => {
// Add additional newlines: Ensure that the cursor isn't initially on the same line as the content to be rendered:
const editor = await createEditor(`\n\n${markdown}\n\n`);
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
});
});
@@ -0,0 +1,90 @@
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
import { Decoration } from '@codemirror/view';
import htmlNodeInfo, { HtmlNodeInfo } from '../../utils/htmlNodeInfo';
import { SyntaxNodeRef } from '@lezer/common';
import { EditorState } from '@codemirror/state';
const hideDecoration = Decoration.replace({});
type OnRenderTagContent = (openingTag: HtmlNodeInfo)=> Decoration;
const createHtmlReplacementExtension = (tagName: string, onRenderContent: OnRenderTagContent) => {
const isMatchingTag = (info: HtmlNodeInfo) => {
return info.tagName().toLowerCase() === tagName;
};
const isMatchingOpeningTag = (info: HtmlNodeInfo) => {
return isMatchingTag(info) && info.opening;
};
const isMatchingClosingTag = (info: HtmlNodeInfo) => {
return isMatchingTag(info) && info.closing;
};
const findClosingTag = (openingTag: SyntaxNodeRef, state: EditorState) => {
const openingTagInfo = htmlNodeInfo(openingTag, state);
// Self-closing?
if (openingTagInfo.closing) {
return openingTag;
}
let cursor = openingTag.node.nextSibling;
let nestedTagCounter = 1;
// Find the matching closing tag
for (; !!cursor && nestedTagCounter > 0; cursor = cursor.nextSibling) {
const info = htmlNodeInfo(cursor, state);
if (isMatchingOpeningTag(info)) {
nestedTagCounter ++;
} else if (isMatchingClosingTag(info)) {
nestedTagCounter --;
}
if (nestedTagCounter === 0) {
break;
}
}
return cursor;
};
const hideTags = makeInlineReplaceExtension({
createDecoration: (node, state) => {
const info = htmlNodeInfo(node, state);
return info && isMatchingTag(info) ? hideDecoration : null;
},
});
const styleContent = makeInlineReplaceExtension({
createDecoration: (node, state) => {
const info = htmlNodeInfo(node, state);
if (!info || !isMatchingOpeningTag(info)) return null;
return onRenderContent(info);
},
getDecorationRange(node, state) {
const closingTag = findClosingTag(node, state);
if (closingTag) {
return [node.to, closingTag.from];
} else {
return null;
}
},
});
return [hideTags, styleContent];
};
export default [
createHtmlReplacementExtension('sub', () => Decoration.mark({ tagName: 'sub' })),
createHtmlReplacementExtension('sup', () => Decoration.mark({ tagName: 'sup' })),
createHtmlReplacementExtension('strike', () => Decoration.mark({ tagName: 'strike' })),
createHtmlReplacementExtension('span', (info) => {
const styles = info.getAttr('style') ?? '';
const colorMatch = styles.match(/color:\s*(#?[a-z0-9A-Z]+|rgba?\([0-9, ]+\))(;|$)/);
return Decoration.mark({
attributes: {
style: colorMatch ? `color: ${colorMatch[1]};` : '',
},
});
}),
].flat();
@@ -0,0 +1,88 @@
import { EditorState } from '@codemirror/state';
import { SyntaxNodeRef } from '@lezer/common';
export interface HtmlNodeInfo {
node: SyntaxNodeRef;
opening: boolean;
closing: boolean;
from: number;
to: number;
tagName: ()=> string;
getAttr: (attributeName: string)=> string;
}
type OnGetNodeContent = (node: SyntaxNodeRef)=> string;
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string, getText: OnGetNodeContent) => {
if (node.from === node.to) return null; // Empty
const content = node.node.resolveInner(node.from + 1);
// Search for the "id" attribute
const attributes = content.getChildren('Attribute');
for (const attribute of attributes) {
const nameNode = attribute.getChild('AttributeName');
const valueNode = attribute.getChild('AttributeValue');
if (nameNode && valueNode) {
const name = getText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
if (name === attrName) {
return removeQuotes(getText(valueNode));
}
}
}
return null;
};
// Utility function to access CodeMirror HTML node information, based on
// the corresponding Markdown node.
const htmlNodeInfo = (node: SyntaxNodeRef, state: EditorState, offset = 0): HtmlNodeInfo|null => {
// Already an HTML node?
if (node.name === 'OpenTag' || node.name === 'CloseTag' || node.name === 'SelfClosingTag') {
const getNodeText = (childNode: SyntaxNodeRef) => state.sliceDoc(childNode.from + offset, childNode.to + offset);
const selfClosing = node.name === 'SelfClosingTag';
return {
node,
opening: node.name === 'OpenTag' || selfClosing,
closing: node.name === 'CloseTag' || selfClosing,
from: node.from + offset,
to: node.to + offset,
tagName: () => {
const nodeText = getNodeText(node).trim();
const tagNameMatch = nodeText.match(/^<\/?([^>\s]+)/);
if (tagNameMatch) {
return tagNameMatch[1];
}
return null;
},
getAttr: (name: string) => {
return getHtmlNodeAttr(node, name, getNodeText);
},
};
}
// Convert Markdown HTML nodes to HTML nodes
if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
const globalOffset = node.from + offset;
let resolved: HtmlNodeInfo|null = null;
// CodeMirror adds HTML information to Markdown documents using overlays attached
// to HTMLTag and HTMLBlock nodes.
// Use .enter to enter the overlay and visit the HTML nodes:
node.node.enter(node.from, 1).toTree().iterate({
enter: (subNode) => {
resolved ??= htmlNodeInfo(subNode, state, globalOffset);
return !resolved;
},
});
return resolved;
}
return null;
};
export default htmlNodeInfo;