@ooakloowj/tiptap-preset-ohoo 0.1.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.
- package/README.md +49 -0
- package/package.json +40 -0
- package/src/adapters/dictionaryProvider.js +14 -0
- package/src/adapters/imageResolver.js +41 -0
- package/src/adapters/imageUploader.js +51 -0
- package/src/extensions/dictionary/DynamicDictionaryHighlight.js +332 -0
- package/src/extensions/dictionary/index.js +13 -0
- package/src/extensions/index.js +24 -0
- package/src/extensions/internal-link/InternalLinkExtension.js +355 -0
- package/src/extensions/internal-link/index.js +14 -0
- package/src/extensions/ohoo-image/OhooImageExtension.js +277 -0
- package/src/extensions/ohoo-image/OhooImageNodeView.jsx +321 -0
- package/src/extensions/ohoo-image/index.js +17 -0
- package/src/extensions/wikilink/WikiLinkExtension.js +154 -0
- package/src/extensions/wikilink/index.js +33 -0
- package/src/index.js +15 -0
- package/src/preset/createOhooPreset.js +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @ooakloowj/tiptap-preset-ohoo
|
|
2
|
+
|
|
3
|
+
Ohoo 项目专用的 Tiptap preset 与扩展,构建在 `@ooakloowj/tiptap-preset` 之上。
|
|
4
|
+
|
|
5
|
+
## 包含能力
|
|
6
|
+
|
|
7
|
+
- `wikilink`
|
|
8
|
+
- `internal-link`
|
|
9
|
+
- `ohoo-image`
|
|
10
|
+
- `dictionary-highlight`
|
|
11
|
+
- `createOhooPreset()`
|
|
12
|
+
- Ohoo 适配器:`dictionaryProvider`、`imageUploader`、`imageResolver`
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i @ooakloowj/tiptap-preset-ohoo
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 快速使用
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import {
|
|
24
|
+
createOhooPreset,
|
|
25
|
+
createOhooImageUploader,
|
|
26
|
+
} from '@ooakloowj/tiptap-preset-ohoo';
|
|
27
|
+
|
|
28
|
+
const preset = createOhooPreset({
|
|
29
|
+
imageUploader: createOhooImageUploader({
|
|
30
|
+
addImage: (filePath, fileName, options) =>
|
|
31
|
+
contentService.addImage(filePath, fileName, options),
|
|
32
|
+
}),
|
|
33
|
+
metadataAccessor: (type, id) => metadataService.getMetadataSync(type, id),
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 导出
|
|
38
|
+
|
|
39
|
+
- `@ooakloowj/tiptap-preset-ohoo`
|
|
40
|
+
- `@ooakloowj/tiptap-preset-ohoo/extensions`
|
|
41
|
+
- `@ooakloowj/tiptap-preset-ohoo/preset`
|
|
42
|
+
- `@ooakloowj/tiptap-preset-ohoo/adapters/dictionaryProvider`
|
|
43
|
+
- `@ooakloowj/tiptap-preset-ohoo/adapters/imageUploader`
|
|
44
|
+
- `@ooakloowj/tiptap-preset-ohoo/adapters/imageResolver`
|
|
45
|
+
|
|
46
|
+
## 说明
|
|
47
|
+
|
|
48
|
+
- 本包为源码发布(`src`),由宿主项目构建。
|
|
49
|
+
- 图片上传已改为注入 `addImage` 回调,不再直接依赖宿主项目服务路径。
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ooakloowj/tiptap-preset-ohoo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ohoo-specific Tiptap preset and adapters built on top of @ooakloowj/tiptap-preset.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"module": "./src/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"tiptap",
|
|
16
|
+
"editor",
|
|
17
|
+
"ohoo",
|
|
18
|
+
"wikilink",
|
|
19
|
+
"internal-link"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.js",
|
|
23
|
+
"./extensions": "./src/extensions/index.js",
|
|
24
|
+
"./preset": "./src/preset/createOhooPreset.js",
|
|
25
|
+
"./adapters/dictionaryProvider": "./src/adapters/dictionaryProvider.js",
|
|
26
|
+
"./adapters/imageUploader": "./src/adapters/imageUploader.js",
|
|
27
|
+
"./adapters/imageResolver": "./src/adapters/imageResolver.js"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@ooakloowj/tiptap-preset": "^0.1.0",
|
|
31
|
+
"@tauri-apps/api": "^2.0.0",
|
|
32
|
+
"@tauri-apps/plugin-fs": "^2.0.0",
|
|
33
|
+
"@tiptap/core": "^3.6.0",
|
|
34
|
+
"@tiptap/pm": "^3.6.0",
|
|
35
|
+
"@tiptap/react": "^3.6.0",
|
|
36
|
+
"aho-corasick": "^0.1.0",
|
|
37
|
+
"lucide-react": "^0.500.0",
|
|
38
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ohoo 字典词库提供器
|
|
5
|
+
*
|
|
6
|
+
* 返回格式:
|
|
7
|
+
* [{ term: string, definition: string }]
|
|
8
|
+
*/
|
|
9
|
+
export function createOhooDictionaryProvider() {
|
|
10
|
+
return async () => {
|
|
11
|
+
const terms = await invoke('get_all_dictionary_terms');
|
|
12
|
+
return Array.isArray(terms) ? terms : [];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { convertFileSrc, invoke } from '@tauri-apps/api/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ohoo 图片地址解析适配器
|
|
5
|
+
*
|
|
6
|
+
* 输入:节点 src/resourceId
|
|
7
|
+
* 输出:缩略图与原图 URL
|
|
8
|
+
*/
|
|
9
|
+
export function createOhooImageResolver() {
|
|
10
|
+
return async ({ src, resourceId }) => {
|
|
11
|
+
if (!resourceId) {
|
|
12
|
+
return {
|
|
13
|
+
thumbnailSrc: src,
|
|
14
|
+
originalSrc: src,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const resource = await invoke('get_resource', { id: resourceId });
|
|
19
|
+
if (!resource?.filePath) {
|
|
20
|
+
throw new Error(`Resource not found or missing filePath: ${resourceId}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const filePath = resource.filePath;
|
|
24
|
+
let thumbnailPath = filePath;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const pyramid = await invoke('get_image_pyramid', { filePath });
|
|
28
|
+
if (pyramid) {
|
|
29
|
+
thumbnailPath = pyramid.quarter?.path || pyramid.half?.path || pyramid.eighth?.path || filePath;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// pyramid 不可用时回退到原图
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
resourceInfo: resource,
|
|
37
|
+
thumbnailSrc: convertFileSrc(thumbnailPath),
|
|
38
|
+
originalSrc: convertFileSrc(filePath),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { tempDir, join } from '@tauri-apps/api/path';
|
|
2
|
+
import { writeFile as writeBinaryFile } from '@tauri-apps/plugin-fs';
|
|
3
|
+
import { buildOhooUrl } from '../extensions/ohoo-image';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ohoo 图片上传适配器
|
|
7
|
+
*
|
|
8
|
+
* 输出会被 OhooImage 扩展转换为节点属性。
|
|
9
|
+
*/
|
|
10
|
+
export function createOhooImageUploader(options = {}) {
|
|
11
|
+
const {
|
|
12
|
+
addImage,
|
|
13
|
+
context = 'library',
|
|
14
|
+
sourceTypeMap = {
|
|
15
|
+
paste: 'clipboard',
|
|
16
|
+
drop: 'drag_drop',
|
|
17
|
+
},
|
|
18
|
+
} = options;
|
|
19
|
+
|
|
20
|
+
return async (file, { source = 'paste' } = {}) => {
|
|
21
|
+
if (typeof addImage !== 'function') {
|
|
22
|
+
throw new Error('createOhooImageUploader requires options.addImage(filePath, fileName, options)');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
26
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
27
|
+
const tempDirPath = await tempDir();
|
|
28
|
+
const ext = file.type.split('/')[1] || 'png';
|
|
29
|
+
const timestamp = Date.now();
|
|
30
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
31
|
+
const tempFileName = `editor-${source}-${timestamp}-${random}.${ext}`;
|
|
32
|
+
const tempFilePath = await join(tempDirPath, tempFileName);
|
|
33
|
+
await writeBinaryFile(tempFilePath, uint8Array);
|
|
34
|
+
|
|
35
|
+
const result = await addImage(tempFilePath, tempFileName, {
|
|
36
|
+
context,
|
|
37
|
+
sourceType: sourceTypeMap[source] || source,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const resourceId = result?.entityId;
|
|
41
|
+
if (!resourceId) {
|
|
42
|
+
throw new Error('Image upload returned empty entityId');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
src: buildOhooUrl(resourceId),
|
|
47
|
+
alt: file.name || '',
|
|
48
|
+
resourceId,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
3
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
4
|
+
import AhoCorasick from 'aho-corasick';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 辅助函数:检查中英文单词边界
|
|
8
|
+
*/
|
|
9
|
+
function isWordBoundary(text, index) {
|
|
10
|
+
if (index < 0 || index >= text.length) return true;
|
|
11
|
+
const char = text[index];
|
|
12
|
+
return /[\s\p{P}\p{Z}\n\r]/u.test(char);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 查找所有词条(Aho-Corasick 算法优化版)
|
|
17
|
+
* 使用 Decoration(装饰)而不是 Mark(标记),确保不会被保存到文档中
|
|
18
|
+
*
|
|
19
|
+
* 性能改进:
|
|
20
|
+
* - 原算法: O(n × m × p) - 文档节点数 × 词条数 × 文本长度
|
|
21
|
+
* - 优化后: O(n + k) - 文档节点数 + 匹配数量
|
|
22
|
+
*/
|
|
23
|
+
function findTerms(doc, termsMap, sortedTerms) {
|
|
24
|
+
console.log('[DictionaryHighlight] 🔍 findTerms 被调用');
|
|
25
|
+
console.log('[DictionaryHighlight] 📋 词条数量:', sortedTerms?.length);
|
|
26
|
+
console.log('[DictionaryHighlight] 📄 文档大小:', doc.content.size);
|
|
27
|
+
|
|
28
|
+
const decorations = [];
|
|
29
|
+
|
|
30
|
+
// 如果没有词条,直接返回空装饰集
|
|
31
|
+
if (!sortedTerms || sortedTerms.length === 0) {
|
|
32
|
+
console.log('[DictionaryHighlight] ⚠️ 词条为空,跳过');
|
|
33
|
+
return DecorationSet.create(doc, decorations);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 构建 Aho-Corasick 自动机(一次构建,多次匹配)
|
|
37
|
+
const ac = new AhoCorasick();
|
|
38
|
+
|
|
39
|
+
// 添加所有词条到自动机
|
|
40
|
+
for (const term of sortedTerms) {
|
|
41
|
+
ac.add(term, termsMap[term]); // 将定义作为数据存储
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 构建失败函数(必须调用)
|
|
45
|
+
ac.build_fail();
|
|
46
|
+
console.log('[DictionaryHighlight] ✅ Aho-Corasick 自动机构建完成');
|
|
47
|
+
|
|
48
|
+
// 只关心文本节点
|
|
49
|
+
let nodeCount = 0;
|
|
50
|
+
doc.descendants((node, pos) => {
|
|
51
|
+
if (!node.isText || !node.text) return;
|
|
52
|
+
nodeCount++;
|
|
53
|
+
console.log(`[DictionaryHighlight] 📝 检查节点 #${nodeCount}: "${node.text.substring(0, 50)}..."`);
|
|
54
|
+
|
|
55
|
+
// 使用 Aho-Corasick 一次性找到所有匹配
|
|
56
|
+
// 回调函数参数: (匹配的词条, 数据数组, 起始位置)
|
|
57
|
+
ac.search(node.text, (foundWord, dataArray, startIndex) => {
|
|
58
|
+
// 注意:aho-corasick返回的第二个参数是数组
|
|
59
|
+
const definition = Array.isArray(dataArray) ? dataArray[0] : dataArray;
|
|
60
|
+
|
|
61
|
+
// 检查单词边界(英文词条需要边界检查,中文不需要)
|
|
62
|
+
const isChinese = /[\u4e00-\u9fa5]/.test(foundWord);
|
|
63
|
+
let isValidMatch = true;
|
|
64
|
+
|
|
65
|
+
if (!isChinese) {
|
|
66
|
+
const endIndex = startIndex + foundWord.length - 1;
|
|
67
|
+
isValidMatch =
|
|
68
|
+
isWordBoundary(node.text, startIndex - 1) &&
|
|
69
|
+
isWordBoundary(node.text, endIndex + 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isValidMatch) {
|
|
73
|
+
const from = pos + startIndex;
|
|
74
|
+
const to = from + foundWord.length;
|
|
75
|
+
|
|
76
|
+
// 创建 Decoration (装饰),而不是 Mark (标记)
|
|
77
|
+
decorations.push(
|
|
78
|
+
Decoration.inline(from, to, {
|
|
79
|
+
class: 'dictionary-term', // 沿用已有的 CSS
|
|
80
|
+
'data-term': foundWord,
|
|
81
|
+
'data-definition': definition,
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
console.log(`[DictionaryHighlight] 🎯 创建装饰: "${foundWord}" at ${from}-${to}, 定义: ${definition}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
console.log(`[DictionaryHighlight] 📊 共创建 ${decorations.length} 个装饰`);
|
|
91
|
+
return DecorationSet.create(doc, decorations);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 定义插件 Key
|
|
95
|
+
const DictionaryHighlightKey = new PluginKey('dictionaryHighlight');
|
|
96
|
+
|
|
97
|
+
function buildTermsPayload(rawTerms) {
|
|
98
|
+
const termsMap = {};
|
|
99
|
+
if (Array.isArray(rawTerms)) {
|
|
100
|
+
rawTerms.forEach((item) => {
|
|
101
|
+
const term = item?.term;
|
|
102
|
+
if (!term) return;
|
|
103
|
+
termsMap[term] = item?.definition || '';
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sortedTerms = Object.keys(termsMap).sort((a, b) => b.length - a.length);
|
|
108
|
+
return { termsMap, sortedTerms };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 动态字典高亮扩展
|
|
113
|
+
*
|
|
114
|
+
* 特点:
|
|
115
|
+
* 1. 使用 Decoration(装饰)而不是 Mark(标记)
|
|
116
|
+
* 2. Decoration 是纯视觉效果,永远不会被保存到文档中
|
|
117
|
+
* 3. 异步加载词库,不阻塞编辑器初始化
|
|
118
|
+
* 4. 当文档变化时自动重新计算高亮
|
|
119
|
+
* 5. 尊重手动添加的 Mark,不与之冲突
|
|
120
|
+
*/
|
|
121
|
+
export const DynamicDictionaryHighlight = Extension.create({
|
|
122
|
+
name: 'dynamicDictionaryHighlight',
|
|
123
|
+
|
|
124
|
+
addOptions() {
|
|
125
|
+
return {
|
|
126
|
+
dictionaryProvider: async () => [],
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
addProseMirrorPlugins() {
|
|
131
|
+
const dictionaryProvider = this.options.dictionaryProvider;
|
|
132
|
+
|
|
133
|
+
return [
|
|
134
|
+
new Plugin({
|
|
135
|
+
key: DictionaryHighlightKey,
|
|
136
|
+
|
|
137
|
+
// 插件状态:存储词库和计算出的高亮
|
|
138
|
+
state: {
|
|
139
|
+
init() {
|
|
140
|
+
return {
|
|
141
|
+
termsMap: {},
|
|
142
|
+
sortedTerms: [],
|
|
143
|
+
decorations: DecorationSet.empty,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
apply(tr, pluginState, oldState, newState) {
|
|
147
|
+
const { termsMap, sortedTerms } = pluginState;
|
|
148
|
+
|
|
149
|
+
// 如果只是词库更新了(通过 meta),重新计算整个文档
|
|
150
|
+
const meta = tr.getMeta(DictionaryHighlightKey);
|
|
151
|
+
if (meta?.setTerms) {
|
|
152
|
+
const { termsMap, sortedTerms } = meta.setTerms;
|
|
153
|
+
const decorations = findTerms(tr.doc, termsMap, sortedTerms);
|
|
154
|
+
return { termsMap, sortedTerms, decorations };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 如果收到刷新请求,重新加载词库
|
|
158
|
+
if (tr.getMeta('refreshDictionary')) {
|
|
159
|
+
return { ...pluginState, needsRefresh: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 如果没有词条,直接返回(但要在检查 meta 之后)
|
|
163
|
+
if (!sortedTerms || sortedTerms.length === 0) {
|
|
164
|
+
return pluginState;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 如果文档发生变化,智能选择更新策略
|
|
168
|
+
if (tr.docChanged) {
|
|
169
|
+
// 计算变化的总范围
|
|
170
|
+
let totalChangedSize = 0;
|
|
171
|
+
tr.mapping.maps.forEach((stepMap) => {
|
|
172
|
+
stepMap.forEach((oldStart, oldEnd) => {
|
|
173
|
+
totalChangedSize += (oldEnd - oldStart);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 智能判断:如果变化范围小于500字符,使用增量更新;否则全量更新
|
|
178
|
+
const INCREMENTAL_THRESHOLD = 500;
|
|
179
|
+
const useIncremental = totalChangedSize < INCREMENTAL_THRESHOLD;
|
|
180
|
+
|
|
181
|
+
if (!useIncremental) {
|
|
182
|
+
// 大范围变化:全量重新计算(更快)
|
|
183
|
+
const decorations = findTerms(tr.doc, termsMap, sortedTerms);
|
|
184
|
+
return { ...pluginState, decorations };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 小范围变化:增量更新
|
|
188
|
+
// 步骤1: 先用 map() 更新现有 decoration 的位置
|
|
189
|
+
let decorations = pluginState.decorations.map(tr.mapping, tr.doc);
|
|
190
|
+
|
|
191
|
+
// 步骤2: 找出被修改的范围(扩展一点避免边界问题)
|
|
192
|
+
const changedRanges = [];
|
|
193
|
+
tr.mapping.maps.forEach((stepMap, i) => {
|
|
194
|
+
stepMap.forEach((oldStart, oldEnd) => {
|
|
195
|
+
// 计算新位置,并向两边扩展50字符(词条最长可能50字符)
|
|
196
|
+
const EXPAND = 50;
|
|
197
|
+
const newStart = Math.max(0, tr.mapping.slice(i).map(oldStart, -1) - EXPAND);
|
|
198
|
+
const newEnd = Math.min(tr.doc.content.size, tr.mapping.slice(i).map(oldEnd, 1) + EXPAND);
|
|
199
|
+
changedRanges.push({ from: newStart, to: newEnd });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// 步骤3: 移除变化范围内的旧装饰
|
|
204
|
+
changedRanges.forEach(({ from, to }) => {
|
|
205
|
+
decorations = decorations.remove(
|
|
206
|
+
decorations.find(from, to)
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// 步骤4: 只重新计算变化范围内的装饰
|
|
211
|
+
const newDecorations = [];
|
|
212
|
+
|
|
213
|
+
// 构建 Aho-Corasick 自动机(只构建一次,复用)
|
|
214
|
+
const ac = new AhoCorasick();
|
|
215
|
+
for (const term of sortedTerms) {
|
|
216
|
+
ac.add(term, termsMap[term]);
|
|
217
|
+
}
|
|
218
|
+
ac.build_fail();
|
|
219
|
+
|
|
220
|
+
changedRanges.forEach(({ from, to }) => {
|
|
221
|
+
tr.doc.nodesBetween(from, to, (node, pos) => {
|
|
222
|
+
if (!node.isText || !node.text) return;
|
|
223
|
+
|
|
224
|
+
// 搜索并创建装饰
|
|
225
|
+
ac.search(node.text, (foundWord, dataArray, startIndex) => {
|
|
226
|
+
// 注意:aho-corasick返回的第二个参数是数组
|
|
227
|
+
const definition = Array.isArray(dataArray) ? dataArray[0] : dataArray;
|
|
228
|
+
|
|
229
|
+
const isChinese = /[\u4e00-\u9fa5]/.test(foundWord);
|
|
230
|
+
let isValidMatch = true;
|
|
231
|
+
|
|
232
|
+
if (!isChinese) {
|
|
233
|
+
const endIndex = startIndex + foundWord.length - 1;
|
|
234
|
+
isValidMatch =
|
|
235
|
+
isWordBoundary(node.text, startIndex - 1) &&
|
|
236
|
+
isWordBoundary(node.text, endIndex + 1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (isValidMatch) {
|
|
240
|
+
const decorationFrom = pos + startIndex;
|
|
241
|
+
const decorationTo = decorationFrom + foundWord.length;
|
|
242
|
+
|
|
243
|
+
newDecorations.push(
|
|
244
|
+
Decoration.inline(decorationFrom, decorationTo, {
|
|
245
|
+
class: 'dictionary-term',
|
|
246
|
+
'data-term': foundWord,
|
|
247
|
+
'data-definition': definition,
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// 步骤5: 合并新旧装饰
|
|
256
|
+
decorations = decorations.add(tr.doc, newDecorations);
|
|
257
|
+
return { ...pluginState, decorations };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 否则,保持原样(位置可能因其他 transaction 而变化)
|
|
261
|
+
return pluginState;
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// 视图属性:将高亮应用到编辑器
|
|
266
|
+
props: {
|
|
267
|
+
decorations(state) {
|
|
268
|
+
return this.getState(state).decorations;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// 插件视图:处理异步加载词库
|
|
273
|
+
view(editorView) {
|
|
274
|
+
console.log('[DictionaryHighlight] 🚀 插件初始化,开始异步加载词库...');
|
|
275
|
+
|
|
276
|
+
// 加载词库的函数
|
|
277
|
+
const loadTerms = async () => {
|
|
278
|
+
try {
|
|
279
|
+
const allTerms = await dictionaryProvider?.();
|
|
280
|
+
const { termsMap, sortedTerms } = buildTermsPayload(allTerms);
|
|
281
|
+
|
|
282
|
+
if (sortedTerms.length === 0) {
|
|
283
|
+
console.log('[DictionaryHighlight] ℹ️ 词库为空');
|
|
284
|
+
} else {
|
|
285
|
+
console.log(`[DictionaryHighlight] ✅ 成功加载 ${sortedTerms.length} 个词条`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 通过 Transaction (事务) 将词库发送给插件(空词库也会清空旧高亮)
|
|
289
|
+
const { dispatch, state } = editorView;
|
|
290
|
+
const tr = state.tr.setMeta(DictionaryHighlightKey, {
|
|
291
|
+
setTerms: { termsMap, sortedTerms },
|
|
292
|
+
});
|
|
293
|
+
dispatch(tr);
|
|
294
|
+
|
|
295
|
+
if (sortedTerms.length > 0) {
|
|
296
|
+
console.log('[DictionaryHighlight] ✨ 词条高亮已应用');
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error('[DictionaryHighlight] ❌ 加载词库失败:', err);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// 初始加载
|
|
304
|
+
loadTerms();
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
update(view, prevState) {
|
|
308
|
+
// 检查是否需要刷新
|
|
309
|
+
const pluginState = DictionaryHighlightKey.getState(view.state);
|
|
310
|
+
if (pluginState?.needsRefresh) {
|
|
311
|
+
console.log('[DictionaryHighlight] 🔄 收到刷新请求,重新加载词库...');
|
|
312
|
+
loadTerms();
|
|
313
|
+
|
|
314
|
+
// 清除刷新标志
|
|
315
|
+
const { dispatch, state } = view;
|
|
316
|
+
const tr = state.tr.setMeta(DictionaryHighlightKey, {
|
|
317
|
+
setTerms: {
|
|
318
|
+
termsMap: pluginState.termsMap,
|
|
319
|
+
sortedTerms: pluginState.sortedTerms,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
dispatch(tr);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
];
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
export default DynamicDictionaryHighlight;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dictionary Extension
|
|
3
|
+
*
|
|
4
|
+
* 提供字典高亮功能,支持:
|
|
5
|
+
* - 动态加载词库
|
|
6
|
+
* - 使用 Aho-Corasick 算法高效匹配
|
|
7
|
+
* - 使用 Decoration 而非 Mark,不修改文档
|
|
8
|
+
* - 悬停显示词条定义
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// 导出扩展
|
|
12
|
+
export { default } from './DynamicDictionaryHighlight';
|
|
13
|
+
export { DynamicDictionaryHighlight } from './DynamicDictionaryHighlight';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ohoo 专用扩展导出
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
default as WikiLinkExtension,
|
|
7
|
+
configureWikiLink,
|
|
8
|
+
} from './wikilink';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
default as DynamicDictionaryHighlight,
|
|
12
|
+
} from './dictionary';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
default as InternalLinkNode,
|
|
16
|
+
setMetadataAccessor,
|
|
17
|
+
} from './internal-link';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
default as OhooImage,
|
|
21
|
+
OhooImageNodeView,
|
|
22
|
+
parseOhooProtocol,
|
|
23
|
+
buildOhooUrl,
|
|
24
|
+
} from './ohoo-image';
|