Files
plainleaf/client/codemirror/html_widget.ts
T

252 lines
7.4 KiB
TypeScript

import type { EditorState, Range } from "@codemirror/state";
import { syntaxTree } from "@codemirror/language";
import { Decoration, WidgetType } from "@codemirror/view";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
} from "./util.ts";
import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts";
import { expandMarkdown } from "../markdown_renderer/inline.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import { lezerToParseTree } from "../markdown_parser/parse_tree.ts";
import type { Client } from "../client.ts";
import {
attachWidgetEventHandlers,
buildTranslateUrls,
} from "./widget_util.ts";
import { matchHtmlTagPairs, parseHtmlTag } from "./html_element.ts";
/**
* Widget that renders an HTMLBlock or a slice of inline HTML as HTML, by
* delegating to the existing markdown-to-HTML renderer.
*/
class HtmlWidget extends WidgetType {
constructor(
readonly client: Client,
readonly tree: ParseTree,
readonly cacheKey: string,
readonly inline: boolean,
readonly sourceText: string,
) {
super();
}
override get estimatedHeight(): number {
return this.client.widgetCache.getCachedWidgetHeight(this.cacheKey);
}
override eq(other: WidgetType): boolean {
return (
other instanceof HtmlWidget &&
other.cacheKey === this.cacheKey &&
other.inline === this.inline
);
}
toDOM(): HTMLElement {
const dom = document.createElement(this.inline ? "span" : "div");
dom.classList.add("sb-html-widget");
if (this.inline) {
dom.classList.add("sb-html-widget-inline");
}
void expandMarkdown(
this.client.space,
this.client.currentName(),
this.tree,
this.client.clientSystem.spaceLuaEnv,
{
syntaxExtensions: this.client.config.get("syntaxExtensions", {}),
},
).then((t) => {
dom.innerHTML = renderMarkdownToHtml(t, {
annotationPositions: true,
shortWikiLinks: this.client.config.get("shortWikiLinks", true),
translateUrls: buildTranslateUrls(this.client),
});
setTimeout(() => {
attachWidgetEventHandlers(dom, this.client, this.sourceText);
this.client.widgetCache.setCachedWidgetMeta(this.cacheKey, {
height: dom.clientHeight,
block: true,
});
});
});
return dom;
}
}
/**
* Live preview for block-level HTML (e.g. <details>, <div>, ...).
*
* Replaces the entire HTMLBlock node with a widget when the cursor is
* outside the block.
*/
export function htmlBlockPlugin(client: Client) {
return decoratorStateField((state: EditorState) => {
const widgets: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter: (node) => {
if (node.name !== "HTMLBlock") return;
const { from, to } = node;
if (isCursorInRange(state, [from, to])) return;
const sourceText = state.sliceDoc(from, to);
const parseTree = lezerToParseTree(
state.sliceDoc(0, to),
node.node,
);
widgets.push(invisibleDecoration.range(from, to));
widgets.push(
Decoration.widget({
widget: new HtmlWidget(
client,
parseTree,
`htmlblock:${sourceText}`,
false,
sourceText,
),
block: false,
side: -1,
}).range(from),
);
},
});
return Decoration.set(widgets, true);
});
}
/**
* Live preview for inline HTML tags inside paragraphs (e.g. <kbd>, <marquee>,
* <sub>, ...).
*
* For each paragraph that contains matched HTML tag pairs, replaces only
* the matched <open>...</close> range with an inline widget, leaving the
* surrounding paragraph text editable. Self-closing/void elements (e.g.
* <br/>, <img/>) are replaced individually.
*
* Cursor inside a matched range falls back to raw source for that range.
* Unmatched/orphan HTMLTag nodes are left alone (they render as literal
* text in the read-only renderer too).
*/
export function htmlInlinePlugin(client: Client) {
return decoratorStateField((state: EditorState) => {
const widgets: Range<Decoration>[] = [];
// Inline HTML can occur inside any block that hosts inline content:
// paragraphs, headings, table cells.
const inlineHostBlocks = new Set([
"Paragraph",
"ATXHeading1",
"ATXHeading2",
"ATXHeading3",
"ATXHeading4",
"ATXHeading5",
"ATXHeading6",
"SetextHeading1",
"SetextHeading2",
"TableCell",
]);
syntaxTree(state).iterate({
enter: (node) => {
if (!inlineHostBlocks.has(node.name)) return;
// Collect HTMLTag children of this paragraph.
const tagInfos: {
from: number;
to: number;
text: string;
tagName: string;
isClosing: boolean;
isSelfClosing: boolean;
attributes: string;
parsedAttrs: Record<string, string>;
}[] = [];
let child = node.node.firstChild;
while (child) {
if (child.name === "HTMLTag") {
const text = state.sliceDoc(child.from, child.to);
const parsed = parseHtmlTag(text);
if (parsed) {
tagInfos.push({
from: child.from,
to: child.to,
text,
...parsed,
});
}
}
child = child.nextSibling;
}
if (tagInfos.length === 0) return;
const { pairs, voidElements } = matchHtmlTagPairs(tagInfos);
if (pairs.length === 0 && voidElements.length === 0) return;
// Lazily compute the paragraph ParseTree once if any range qualifies.
let paragraphTree: ParseTree | null = null;
const getParagraphTree = (): ParseTree => {
if (!paragraphTree) {
paragraphTree = lezerToParseTree(
state.sliceDoc(0, node.to),
node.node,
);
}
return paragraphTree;
};
const emitWidget = (rangeFrom: number, rangeTo: number) => {
if (isCursorInRange(state, [rangeFrom, rangeTo])) return;
const ptree = getParagraphTree();
// Pick out the children of the paragraph that fall within the
// matched range. lezerToParseTree interleaves text fillers between
// structural children, and the same [from,to] check picks both up.
const childrenInRange = (ptree.children ?? []).filter((c) =>
c.from !== undefined &&
c.to !== undefined &&
c.from >= rangeFrom &&
c.to <= rangeTo
);
if (childrenInRange.length === 0) return;
const synthetic: ParseTree = {
type: "Paragraph",
from: rangeFrom,
to: rangeTo,
children: childrenInRange,
};
const sourceText = state.sliceDoc(rangeFrom, rangeTo);
widgets.push(
Decoration.replace({
widget: new HtmlWidget(
client,
synthetic,
`htmlinline:${sourceText}`,
true,
sourceText,
),
}).range(rangeFrom, rangeTo),
);
};
for (const pair of pairs) {
emitWidget(pair.open.from, pair.close.to);
}
for (const v of voidElements) {
emitWidget(v.from, v.to);
}
},
});
return Decoration.set(widgets, true);
});
}