Files
timi-web/src/editor/tiptap/PrismCodeBlock.ts
T
2026-08-25 17:35:19 +08:00

187 lines
5.2 KiB
TypeScript

/**
* 带 Prism 装饰的 Tiptap 代码块。
* ProseMirror 文档只保存纯文本,语法高亮通过 Decoration 叠加,避免污染编辑内容。
*/
import CodeBlock from "@tiptap/extension-code-block";
import { findChildren, mergeAttributes } from "@tiptap/vue-3";
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
import type { EditorState, Transaction } from "@tiptap/pm/state";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import Prism from "prismjs";
import { resolvePrismLanguage } from "../../utils/Prism";
/** Prism 分词后用于生成 Decoration 的扁平节点。 */
type HighlightNode = {
text: string;
classes: string[];
};
/** Prism Token 的递归结构。 */
type PrismToken = {
type: string;
alias?: string | string[];
content: string | PrismToken | Array<string | PrismToken>;
};
/** 用于判断事务影响范围的最小结构。 */
type StepRange = {
from?: number;
to?: number;
};
/** 将语言名称转换为代码块 class 名。 */
function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) {
if (!languageClassPrefix || !language) {
return null;
}
return `${languageClassPrefix}${language}`;
}
/** 生成 Prism token 对应的 class 列表。 */
function buildTokenClassList(type: string, alias?: string | string[]) {
const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : [];
return ["token", type, ...aliasList];
}
/** 将嵌套 Prism token 展平成文本片段,便于生成行内装饰。 */
function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] {
return tokens.flatMap((token) => {
if (typeof token === "string") {
return [{
text: token,
classes: className
}];
}
const classes = [...className, ...buildTokenClassList(token.type, token.alias)];
if (typeof token.content === "string") {
return [{
text: token.content,
classes
}];
}
if (Array.isArray(token.content)) {
return parsePrismTokens(token.content, classes);
}
return parsePrismTokens([token.content], classes);
});
}
/** 为文档中的全部代码块生成语法高亮装饰。 */
function getDecorations({
doc,
name,
defaultLanguage
}: {
doc: ProsemirrorNode;
name: string;
defaultLanguage: string | null | undefined;
}) {
const decorations: Decoration[] = [];
findChildren(doc, node => node.type.name === name).forEach((block) => {
let from = block.pos + 1;
const prismLanguage = resolvePrismLanguage(block.node.attrs.language || defaultLanguage);
const grammar = prismLanguage ? Prism.languages[prismLanguage] : null;
const tokens = grammar ? Prism.tokenize(block.node.textContent, grammar) : [block.node.textContent];
parsePrismTokens(tokens).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
decorations.push(Decoration.inline(from, to, {
class: node.classes.join(" ")
}));
}
from = to;
});
});
return DecorationSet.create(doc, decorations);
}
/** 创建只在代码块内容或结构受影响时重新计算高亮的插件。 */
function createPrismPlugin({
name,
defaultLanguage
}: {
name: string;
defaultLanguage: string | null | undefined;
}) {
const prismPlugin: Plugin<any> = new Plugin({
key: new PluginKey("prism"),
state: {
init: (_: unknown, { doc }: EditorState) => getDecorations({
doc,
name,
defaultLanguage
}),
apply: (transaction: Transaction, decorationSet: DecorationSet, oldState: EditorState, newState: EditorState) => {
const oldNodeName = oldState.selection.$head.parent.type.name;
const newNodeName = newState.selection.$head.parent.type.name;
const oldNodes = findChildren(oldState.doc, node => node.type.name === name);
const newNodes = findChildren(newState.doc, node => node.type.name === name);
if (
transaction.docChanged
&& (
[oldNodeName, newNodeName].includes(name)
|| newNodes.length !== oldNodes.length
|| transaction.steps.some((step) => {
const range = step as StepRange;
return range.from !== undefined
&& range.to !== undefined
&& oldNodes.some(node => node.pos >= range.from! && node.pos + node.node.nodeSize <= range.to!);
})
)
) {
return getDecorations({
doc: transaction.doc,
name,
defaultLanguage
});
}
return decorationSet.map(transaction.mapping, transaction.doc);
}
},
props: {
decorations(state: EditorState) {
return prismPlugin.getState(state);
}
}
});
return prismPlugin;
}
/** 自定义代码块渲染和 Prism 装饰插件。 */
export default CodeBlock.extend({
renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) {
const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language);
return [
"pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: languageClassName
}),
[
"code",
{
class: languageClassName
},
0
]
];
},
addProseMirrorPlugins() {
return [
createPrismPlugin({
name: this.name,
defaultLanguage: this.options.defaultLanguage
})
];
}
});