tiptap-extension-shiki 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ import { findChildren } from "@tiptap/core";
2
+ import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
3
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
4
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
5
+ import type {
6
+ BundledLanguage,
7
+ BundledTheme,
8
+ HighlighterGeneric,
9
+ SpecialLanguage,
10
+ StringLiteralUnion,
11
+ ThemeRegistrationAny,
12
+ } from "shiki";
13
+
14
+ /**
15
+ * 为整个文档计算语法高亮装饰
16
+ * 该函数遍历文档中的所有自定义节点,为每个节点生成相应的装饰样式
17
+ *
18
+ * @param doc - ProseMirror文档节点
19
+ * @param name - 插件名称,用于识别目标节点类型
20
+ * @param highlighter - Shiki语法高亮器实例
21
+ * @param defaultTheme - 默认主题
22
+ * @param defaultLanguage - 默认编程语言
23
+ * @returns 装饰器集合,用于渲染语法高亮
24
+ */
25
+ function getDecorations({
26
+ doc,
27
+ name,
28
+ highlighter,
29
+ defaultTheme,
30
+ defaultLanguage,
31
+ }: {
32
+ doc: ProsemirrorNode;
33
+ name: string;
34
+ highlighter: HighlighterGeneric<BundledLanguage, BundledTheme>;
35
+ defaultTheme: ThemeRegistrationAny | StringLiteralUnion<string>;
36
+ defaultLanguage: StringLiteralUnion<SpecialLanguage>;
37
+ }) {
38
+ console.log("doc", doc);
39
+
40
+ // 查找文档中所有指定类型的节点(shiki代码块)
41
+ const decorations = findChildren(doc, (node) => {
42
+ console.log("node node ", node);
43
+ return node.type.name === name;
44
+ }).reduce((acc, block) => {
45
+ // 为每个代码块生成装饰
46
+ const nodeDecorations = getSingleNodeDecorations(
47
+ block.node,
48
+ block.pos,
49
+ highlighter,
50
+ defaultTheme,
51
+ defaultLanguage
52
+ );
53
+ return acc.concat(nodeDecorations);
54
+ }, [] as Decoration[]);
55
+
56
+ // 创建装饰器集合
57
+ return DecorationSet.create(doc, decorations);
58
+ }
59
+
60
+ /**
61
+ * 为单个代码块节点生成语法高亮装饰
62
+ * 使用Shiki对代码进行分词,然后为每个token生成相应的装饰样式
63
+ *
64
+ * @param node - 代码块节点
65
+ * @param pos - 节点在文档中的位置
66
+ * @param highlighter - Shiki语法高亮器
67
+ * @param defaultTheme - 默认主题
68
+ * @param defaultLanguage - 默认语言
69
+ * @returns 该节点的所有装饰器数组
70
+ */
71
+ function getSingleNodeDecorations(
72
+ node: ProsemirrorNode,
73
+ pos: number,
74
+ highlighter: HighlighterGeneric<BundledLanguage, BundledTheme>,
75
+ defaultTheme: ThemeRegistrationAny | StringLiteralUnion<string>,
76
+ defaultLanguage: StringLiteralUnion<SpecialLanguage>
77
+ ) {
78
+ const decorations: Decoration[] = [];
79
+
80
+ // 获取代码块的语言和主题属性,如果没有则使用默认值
81
+ const language = node.attrs.language || defaultLanguage;
82
+ const theme = node.attrs.theme || defaultTheme;
83
+
84
+ // 计算文本内容的起始位置(跳过节点标记)
85
+ let startPos = pos + 1;
86
+
87
+ // 使用Shiki对代码进行分词,返回每个token的颜色信息
88
+ const lines = highlighter.codeToTokensBase(node.textContent, {
89
+ lang: language,
90
+ theme: theme as ThemeRegistrationAny,
91
+ });
92
+
93
+ // 遍历每一行的token,为有颜色的token创建装饰
94
+ lines.forEach((line) => {
95
+ line.forEach((token) => {
96
+ const endPos = startPos + token.content.length;
97
+
98
+ // 如果token有颜色信息,创建内联装饰器设置文本颜色
99
+ if (token.color) {
100
+ decorations.push(
101
+ Decoration.inline(startPos, endPos, {
102
+ style: `color: ${token.color}`,
103
+ })
104
+ );
105
+ }
106
+
107
+ // 更新下一个token的起始位置
108
+ startPos = endPos;
109
+ });
110
+
111
+ // 处理换行符(每个line之间的分隔符)
112
+ startPos += 1;
113
+ });
114
+
115
+ return decorations;
116
+ }
117
+
118
+ /**
119
+ * 创建Shiki轻量级语法高亮插件
120
+ * 该插件负责在ProseMirror编辑器中为代码块提供实时的语法高亮显示
121
+ * 通过ProseMirror的装饰器系统实现高性能的语法高亮渲染
122
+ *
123
+ * @param name - 插件名称
124
+ * @param highlighter - Shiki语法高亮器实例
125
+ * @param defaultTheme - 默认主题
126
+ * @param defaultLanguage - 默认编程语言
127
+ * @returns ProseMirror插件实例
128
+ */
129
+ export function ShikiLightPlugin({
130
+ name,
131
+ highlighter,
132
+ defaultTheme,
133
+ defaultLanguage,
134
+ }: {
135
+ name: string;
136
+ highlighter: HighlighterGeneric<BundledLanguage, BundledTheme>;
137
+ defaultTheme: ThemeRegistrationAny | StringLiteralUnion<string>;
138
+ defaultLanguage: StringLiteralUnion<SpecialLanguage>;
139
+ }) {
140
+ const shikiLightPlugin: Plugin = new Plugin({
141
+ key: new PluginKey("shiki"),
142
+
143
+ // 插件状态管理
144
+ state: {
145
+ /**
146
+ * 初始化装饰器
147
+ * 在插件首次加载时为整个文档计算初始的语法高亮装饰
148
+ */
149
+ init: (_, { doc }) => {
150
+ return getDecorations({
151
+ doc,
152
+ name,
153
+ highlighter,
154
+ defaultTheme,
155
+ defaultLanguage,
156
+ });
157
+ },
158
+
159
+ /**
160
+ * 应用事务变化
161
+ * 当文档发生变化时,智能更新受影响的代码块的语法高亮
162
+ * 实现了性能优化:只重新计算变化范围内的装饰
163
+ */
164
+ apply: (transaction, decorationSet, oldState, newState) => {
165
+ // 1. 如果文档内容没有变化,直接映射现有装饰(最高性能)
166
+ if (!transaction.docChanged) {
167
+ return decorationSet.map(transaction.mapping, transaction.doc);
168
+ }
169
+
170
+ // 2. 重新计算受影响的装饰器
171
+ let newDecorationSet = decorationSet.map(
172
+ transaction.mapping,
173
+ transaction.doc
174
+ );
175
+
176
+ // 3. 遍历事务中的每个步骤映射,精确更新受影响的节点
177
+ transaction.mapping.maps.forEach((stepMap) => {
178
+ stepMap.forEach((fromA, toA, fromB, toB) => {
179
+ // 限制计算范围在文档边界内,避免越界错误
180
+ const docSize = newState.doc.content.size;
181
+ const start = Math.max(0, Math.min(fromB, docSize));
182
+ const end = Math.max(0, Math.min(toB, docSize));
183
+
184
+ // 检查范围内的节点是否为代码块类型
185
+ newState.doc.nodesBetween(start, end, (node, pos) => {
186
+ if (node.type.name === "text") return;
187
+ // 先移除这个节点旧的高亮
188
+ const nodeEnd = pos + node.nodeSize;
189
+ // 移除该节点范围内现有的所有装饰
190
+ const oldDecos = newDecorationSet.find(pos, nodeEnd);
191
+ newDecorationSet = newDecorationSet.remove(oldDecos);
192
+
193
+ if (node.type.name === name) {
194
+ // 重新计算该节点的语法高亮装饰
195
+ const newSpecs = getSingleNodeDecorations(
196
+ node,
197
+ pos,
198
+ highlighter,
199
+ defaultTheme,
200
+ defaultLanguage
201
+ );
202
+ newDecorationSet = newDecorationSet.add(newState.doc, newSpecs);
203
+ }
204
+ });
205
+ });
206
+ });
207
+
208
+ return newDecorationSet;
209
+ },
210
+ },
211
+
212
+ // 插件属性:将装饰器提供给编辑器视图
213
+ props: {
214
+ decorations(state) {
215
+ return shikiLightPlugin.getState(state);
216
+ },
217
+ },
218
+ });
219
+
220
+ return shikiLightPlugin;
221
+ }
package/src/index.css ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tiptap Shiki 语法高亮扩展样式
3
+ *
4
+ * 本文件定义了代码块和工具栏的基础样式,包括:
5
+ * - 代码块容器的圆角、字体大小和最大高度设置
6
+ * - pre 标签的内边距和滚动条样式
7
+ * - 工具栏的布局和对齐方式
8
+ */
9
+
10
+ /* 代码块主容器样式 */
11
+ .tiptap-shiki--container {
12
+ /* 设置圆角边框 */
13
+ border-radius: 0.4rem;
14
+
15
+ /* 设置相对较小的字体大小 */
16
+ font-size: 0.875em;
17
+
18
+ /* 嵌套样式:代码内容区域 */
19
+ pre {
20
+ /* 设置内边距,提供舒适的代码阅读空间 */
21
+ padding: 20px;
22
+
23
+ /* 设置最大高度,超出时显示滚动条 */
24
+ max-height: 400px;
25
+
26
+ /* 设置溢出内容为自动滚动 */
27
+ overflow: auto;
28
+ }
29
+ }
30
+
31
+ /* 工具栏样式 */
32
+ .tiptap-shiki--toolbar {
33
+ /* 使用弹性布局排列工具栏元素 */
34
+ display: flex;
35
+
36
+ /* 垂直居中对齐工具栏内容 */
37
+ align-items: center;
38
+
39
+ /* 设置工具栏内边距 */
40
+ padding: 10px;
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Tiptap Shiki 语法高亮扩展
3
+ *
4
+ * 本文件实现了一个基于 Shiki 的 Tiptap 扩展,用于在富文本编辑器中提供语法高亮功能。
5
+ * 该扩展扩展了 Tiptap 的 CodeBlock,添加了以下功能:
6
+ * - 支持多种编程语言的语法高亮
7
+ * - 支持多种主题切换
8
+ * - 自定义工具栏界面
9
+ * - 键盘快捷键支持
10
+ * - 实时语法高亮渲染
11
+ */
12
+
13
+ // 导入 Tiptap 核心组件
14
+ import CodeBlock from "@tiptap/extension-code-block";
15
+ // 导入样式文件
16
+ import "./index.css";
17
+
18
+ // 导入 Shiki 类型定义
19
+ import type {
20
+ BundledLanguage,
21
+ BundledTheme,
22
+ HighlighterGeneric,
23
+ SpecialLanguage,
24
+ StringLiteralUnion,
25
+ ThemeRegistrationAny,
26
+ } from "shiki";
27
+
28
+ // 导入 Shiki 轻量级插件
29
+ import { ShikiLightPlugin } from "./ShikiLightPlugin.js";
30
+
31
+ /**
32
+ * TiptapShiki 扩展类定义
33
+ *
34
+ * 该类扩展了 Tiptap 的 CodeBlock,添加了语法高亮功能和相关配置选项。
35
+ * 使用泛型类型定义扩展选项和节点视图属性。
36
+ *
37
+ * @template T - 扩展选项类型,包含默认主题、语言、高亮器等配置
38
+ * @template U - 节点视图属性类型,定义 DOM 元素的类型
39
+ */
40
+ const TiptapShiki = CodeBlock.extend<
41
+ {
42
+ // 默认语法高亮主题
43
+ defaultTheme: ThemeRegistrationAny | StringLiteralUnion<string>;
44
+ // 默认编程语言
45
+ defaultLanguage: StringLiteralUnion<SpecialLanguage>;
46
+ // 可选的 Shiki 高亮器实例
47
+ highlighter?: HighlighterGeneric<BundledLanguage, BundledTheme>;
48
+ // 是否获取高亮的 HTML 字符串
49
+ getHighlighHTML?: boolean;
50
+ // 自定义工具栏渲染函数
51
+ renderToolbar?: (props: {
52
+ toolbarDOM: HTMLElement;
53
+ language: StringLiteralUnion<SpecialLanguage>;
54
+ theme: ThemeRegistrationAny | StringLiteralUnion<string>;
55
+ setLanguage: (lang: string) => void;
56
+ setTheme: (theme: string) => void;
57
+ }) => void;
58
+ },
59
+ {
60
+ // 节点视图中的 DOM 元素类型定义
61
+ container: HTMLElement;
62
+ shikiCode: HTMLElement;
63
+ contentDOM: HTMLElement
64
+ }
65
+ >({
66
+ // 扩展名称,用于标识这个自定义扩展
67
+ name: "tiptapShiki",
68
+
69
+ /**
70
+ * 添加扩展配置选项
71
+ *
72
+ * 定义了 Shiki 语法高亮的默认配置,包括默认主题、编程语言等。
73
+ * 使用父类的配置选项并添加本扩展特有的选项。
74
+ *
75
+ * @returns 配置对象,包含默认主题、语言和高亮器设置
76
+ */
77
+ addOptions() {
78
+ return {
79
+ // 继承父类的配置选项
80
+ ...this.parent?.(),
81
+ // 默认语法高亮主题
82
+ defaultTheme: "dracula",
83
+ // 默认编程语言
84
+ defaultLanguage: "javascript",
85
+ // 初始化时的高亮器实例(可选)
86
+ highlighter: undefined,
87
+ };
88
+ },
89
+ /**
90
+ * 添加节点属性
91
+ *
92
+ * 定义了代码块节点的自定义属性,包括编程语言和主题。
93
+ * 这些属性用于存储和渲染代码块的语法高亮配置。
94
+ *
95
+ * @returns 属性配置对象,包含语言和主题属性
96
+ */
97
+ addAttributes() {
98
+ return {
99
+ // 继承父类的属性
100
+ ...this.parent?.(),
101
+
102
+ // 编程语言属性
103
+ language: {
104
+ // 默认语言配置
105
+ default: this.options.defaultLanguage || "javascript",
106
+
107
+ /**
108
+ * 从 HTML 元素解析语言属性
109
+ * @param element - HTML 元素
110
+ * @returns 编程语言字符串
111
+ */
112
+ parseHTML: (element) => {
113
+ // 从 data-language 属性获取语言信息
114
+ return element.getAttribute("data-language");
115
+ },
116
+
117
+ /**
118
+ * 渲染 HTML 时设置语言属性
119
+ * @param attributes - 节点属性对象
120
+ * @returns HTML 属性对象
121
+ */
122
+ renderHTML: (attributes) => {
123
+ // 将语言信息存储在 data-language 属性中
124
+ return { "data-language": attributes.language };
125
+ },
126
+ },
127
+
128
+ // 主题属性
129
+ theme: {
130
+ // 默认主题配置
131
+ default: this.options.defaultTheme || "dracula",
132
+
133
+ /**
134
+ * 从 HTML 元素解析主题属性
135
+ * @param element - HTML 元素
136
+ * @returns 主题名称字符串
137
+ */
138
+ parseHTML: (element) => element.getAttribute("data-theme"),
139
+
140
+ /**
141
+ * 渲染 HTML 时设置主题属性
142
+ * @param attributes - 节点属性对象
143
+ * @returns HTML 属性对象
144
+ */
145
+ renderHTML: (attributes) => ({
146
+ "data-theme": attributes.theme,
147
+ }),
148
+ },
149
+ };
150
+ },
151
+
152
+ /**
153
+ * 渲染 HTML
154
+ *
155
+ * 定义了代码块节点在静态 HTML 中的渲染逻辑。
156
+ * 如果配置了 getHighlighHTML 和 highlighter,将使用 Shiki 生成高亮的 HTML;
157
+ * 否则返回标准的 pre/code 标签结构。
158
+ *
159
+ * @param node - ProseMirror 节点对象
160
+ * @param HTMLAttributes - HTML 属性对象
161
+ * @returns HTML 数组或元素
162
+ */
163
+ renderHTML({ node, HTMLAttributes }) {
164
+ // 如果启用了高亮 HTML 生成并且有高亮器实例
165
+ if (this.options.getHighlighHTML && this.options.highlighter) {
166
+ // 获取代码内容、编程语言和主题
167
+ const language = node.attrs.language || this.options.defaultLanguage;
168
+ const theme = node.attrs.theme || this.options.defaultTheme;
169
+ const content = node.textContent;
170
+
171
+ // 使用 Shiki 将代码转换为带样式的 HTML
172
+ const highlightedCode = this.options.highlighter.codeToHtml(content, {
173
+ lang: language,
174
+ theme: theme,
175
+ });
176
+
177
+ // 解析生成的 HTML 字符串并返回第一个元素
178
+ const container = document.createElement("div");
179
+ container.innerHTML = highlightedCode;
180
+ if (container.firstElementChild) return container.firstElementChild;
181
+ }
182
+
183
+ // 返回标准的 pre/code 标签结构
184
+ return ["pre", HTMLAttributes, ["code", 0]];
185
+ },
186
+
187
+ /**
188
+ * 添加节点视图
189
+ *
190
+ * 创建代码块的交互式 DOM 视图,包括工具栏和代码显示区域。
191
+ * 提供了实时的语法高亮和主题切换功能。
192
+ *
193
+ * @returns 节点视图工厂函数
194
+ */
195
+ addNodeView() {
196
+ // 检查是否有高亮器实例
197
+ if (!this.options.highlighter) throw new Error("highlighter is required");
198
+
199
+ return (props) => {
200
+ // 获取视图相关参数
201
+ const { view, getPos, node } = props;
202
+
203
+ // 获取当前主题配置
204
+ const theme = this.options.highlighter.getTheme(
205
+ node.attrs.theme || this.options.defaultTheme
206
+ );
207
+
208
+ // 创建主容器 DOM 元素
209
+ const dom = document.createElement("div");
210
+ dom.classList.add(
211
+ "tiptap-shiki--container",
212
+ "not-prose",
213
+ "shiki",
214
+ "dracula"
215
+ );
216
+ // 应用主题颜色
217
+ dom.style.backgroundColor = theme.bg;
218
+ dom.style.color = theme.fg;
219
+
220
+ // 创建工具栏(如果配置了自定义渲染器)
221
+ let toolbarDOM: HTMLElement | undefined;
222
+ if (
223
+ this.options.renderToolbar &&
224
+ typeof this.options.renderToolbar === "function"
225
+ ) {
226
+ // 创建工具栏容器
227
+ toolbarDOM = document.createElement("div");
228
+ toolbarDOM.classList.add("tiptap-shiki--toolbar");
229
+ toolbarDOM.setAttribute("contenteditable", "false");
230
+
231
+ // 创建节点标记更新函数
232
+ const setNodeMarkup = (attr: Record<string, string>) => {
233
+ if (typeof getPos !== "function") return;
234
+ const pos = getPos();
235
+ if (pos === undefined) return;
236
+ const { tr } = view.state;
237
+ const nowNode = view.state.doc.nodeAt(pos);
238
+
239
+ // 更新节点属性
240
+ tr.setNodeMarkup(pos, this.type, { ...nowNode?.attrs, ...attr });
241
+ view.dispatch(tr);
242
+ };
243
+
244
+ // 创建语言设置函数
245
+ const setLanguage = (language: string) => {
246
+ setNodeMarkup({
247
+ language, // 仅修改当前代码块的编程语言
248
+ });
249
+ };
250
+
251
+ // 创建主题设置函数
252
+ const setTheme = (theme: string) => {
253
+ setNodeMarkup({
254
+ theme, // 仅修改当前代码块的主题
255
+ });
256
+ };
257
+
258
+ // 调用自定义工具栏渲染器
259
+ this.options.renderToolbar({
260
+ language: node.attrs.language,
261
+ theme: node.attrs.theme,
262
+ toolbarDOM,
263
+ setLanguage,
264
+ setTheme,
265
+ });
266
+
267
+ // 将工具栏添加到主容器
268
+ dom.appendChild(toolbarDOM);
269
+ }
270
+
271
+ // 创建代码显示区域
272
+ const preDOM = document.createElement("pre");
273
+ const contentDOM = document.createElement("code");
274
+ contentDOM.classList.add("tiptap-shiki--content");
275
+
276
+ // 组装 DOM 结构
277
+ preDOM.appendChild(contentDOM);
278
+ dom.appendChild(preDOM);
279
+
280
+ // 返回节点视图对象
281
+ return {
282
+ // 主 DOM 元素
283
+ dom: dom,
284
+ // 内容 DOM 元素
285
+ contentDOM,
286
+
287
+ /**
288
+ * 更新节点视图
289
+ * @param updatedNode - 更新后的节点
290
+ * @returns 是否成功更新
291
+ */
292
+ update: (updatedNode) => {
293
+ // 如果不是相同类型的节点,拒绝更新
294
+ return updatedNode.type === node.type;
295
+ },
296
+
297
+ /**
298
+ * 忽略某些 DOM 变化
299
+ * @param mutation - DOM 变化对象
300
+ * @returns 是否忽略该变化
301
+ */
302
+ ignoreMutation: (mutation) => {
303
+ // 忽略工具栏区域的 DOM 变化
304
+ return toolbarDOM?.contains(mutation.target) || false;
305
+ },
306
+ };
307
+ };
308
+ },
309
+
310
+ /**
311
+ * 添加 ProseMirror 插件
312
+ *
313
+ * 注册 Shiki 轻量级语法高亮插件,实现实时语法高亮功能。
314
+ * 继承父类的插件并添加自定义的高亮插件。
315
+ *
316
+ * @returns ProseMirror 插件数组
317
+ */
318
+ addProseMirrorPlugins() {
319
+ // 检查是否有高亮器实例
320
+ if (!this.options.highlighter) throw new Error("highlighter is required");
321
+
322
+ return [
323
+ // 继承父类的插件
324
+ ...(this.parent?.() || []),
325
+ // 添加 Shiki 轻量级高亮插件
326
+ ShikiLightPlugin({
327
+ name: this.name,
328
+ highlighter: this.options.highlighter,
329
+ defaultLanguage: this.options.defaultLanguage,
330
+ defaultTheme: this.options.defaultTheme,
331
+ }),
332
+ ];
333
+ },
334
+ });
335
+
336
+ // 导出命名导出
337
+ export { TiptapShiki };
338
+
339
+ // 导出默认导出
340
+ export default TiptapShiki;
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2015",
4
+ "module": "ES2015",
5
+ "lib": ["es2015", "dom"],
6
+ "moduleResolution": "node",
7
+ "declaration": true,
8
+ "emitDeclarationOnly": true,
9
+ "noEmit": false,
10
+ "rootDir": "./src",
11
+ "outDir": "./dist",
12
+ "skipLibCheck": true,
13
+ "allowSyntheticDefaultImports": true,
14
+ "esModuleInterop": true
15
+ },
16
+ "include": ["src/**/*"]
17
+ }