mirror of
https://github.com/laurent22/joplin.git
synced 2026-06-18 20:16:34 +02:00
Desktop, Mobile: Editor: Inline rendering: Render inline HTML (colorized text, superscript, subscript, strikethrough) (#14133)
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user