wendang 0.0.1 → 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/LICENSE +3 -0
- package/NOTICE.md +7 -0
- package/README.md +101 -2
- package/dist/cjs/document.js +19 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/mindmap.js +22 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/schema.js +11 -0
- package/dist/cjs/shared/anchor.js +201 -0
- package/dist/cjs/shared/callout.js +64 -0
- package/dist/cjs/shared/code-block.js +33 -0
- package/dist/cjs/shared/code-languages.js +51 -0
- package/dist/cjs/shared/doc-compatibility.js +23 -0
- package/dist/cjs/shared/doc-text.js +52 -0
- package/dist/cjs/shared/document-colors.js +61 -0
- package/dist/cjs/shared/inline-comments.js +179 -0
- package/dist/cjs/shared/mind-map-folding.js +14 -0
- package/dist/cjs/shared/mind-map-phase2.js +133 -0
- package/dist/cjs/shared/mind-map.js +445 -0
- package/dist/cjs/shared/mind-topic-text.js +57 -0
- package/dist/cjs/shared/node-catalog.js +62 -0
- package/dist/cjs/shared/presence.js +141 -0
- package/dist/cjs/shared/safe-href.js +36 -0
- package/dist/cjs/shared/table-cell-format.js +18 -0
- package/dist/cjs/shared/table-layout.js +28 -0
- package/dist/cjs/shared/types.js +2 -0
- package/dist/cjs/shared/video-embed.js +102 -0
- package/dist/cjs/shared/workbook.js +152 -0
- package/dist/cjs/spreadsheet.js +17 -0
- package/dist/cjs/types.js +17 -0
- package/dist/cjs/validation/callout.js +17 -0
- package/dist/cjs/validation/doc-node-whitelist.js +71 -0
- package/dist/cjs/validation/error.js +17 -0
- package/dist/cjs/validation/mind-map.js +129 -0
- package/dist/cjs/validation/schema-version.js +5 -0
- package/dist/cjs/validation/schema.js +376 -0
- package/dist/cjs/validation/video-embed.js +17 -0
- package/dist/cjs/validation/workbook.js +17 -0
- package/dist/esm/document.js +3 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/mindmap.js +4 -0
- package/dist/esm/schema.js +3 -0
- package/dist/esm/shared/anchor.js +192 -0
- package/dist/esm/shared/callout.js +56 -0
- package/dist/esm/shared/code-block.js +25 -0
- package/dist/esm/shared/code-languages.js +46 -0
- package/dist/esm/shared/doc-compatibility.js +20 -0
- package/dist/esm/shared/doc-text.js +48 -0
- package/dist/esm/shared/document-colors.js +56 -0
- package/dist/esm/shared/inline-comments.js +173 -0
- package/dist/esm/shared/mind-map-folding.js +10 -0
- package/dist/esm/shared/mind-map-phase2.js +121 -0
- package/dist/esm/shared/mind-map.js +427 -0
- package/dist/esm/shared/mind-topic-text.js +49 -0
- package/dist/esm/shared/node-catalog.js +57 -0
- package/dist/esm/shared/presence.js +130 -0
- package/dist/esm/shared/safe-href.js +32 -0
- package/dist/esm/shared/table-cell-format.js +14 -0
- package/dist/esm/shared/table-layout.js +23 -0
- package/dist/esm/shared/types.js +1 -0
- package/dist/esm/shared/video-embed.js +96 -0
- package/dist/esm/shared/workbook.js +144 -0
- package/dist/esm/spreadsheet.js +1 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/validation/callout.js +1 -0
- package/dist/esm/validation/doc-node-whitelist.js +68 -0
- package/dist/esm/validation/error.js +13 -0
- package/dist/esm/validation/mind-map.js +125 -0
- package/dist/esm/validation/schema-version.js +2 -0
- package/dist/esm/validation/schema.js +372 -0
- package/dist/esm/validation/video-embed.js +1 -0
- package/dist/esm/validation/workbook.js +1 -0
- package/dist/types/document.d.ts +4 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/mindmap.d.ts +4 -0
- package/dist/types/schema.d.ts +4 -0
- package/dist/types/shared/anchor.d.ts +87 -0
- package/dist/types/shared/callout.d.ts +27 -0
- package/dist/types/shared/code-block.d.ts +26 -0
- package/dist/types/shared/code-languages.d.ts +26 -0
- package/dist/types/shared/doc-compatibility.d.ts +1 -0
- package/dist/types/shared/doc-text.d.ts +21 -0
- package/dist/types/shared/document-colors.d.ts +106 -0
- package/dist/types/shared/inline-comments.d.ts +71 -0
- package/dist/types/shared/mind-map-folding.d.ts +5 -0
- package/dist/types/shared/mind-map-phase2.d.ts +19 -0
- package/dist/types/shared/mind-map.d.ts +134 -0
- package/dist/types/shared/mind-topic-text.d.ts +18 -0
- package/dist/types/shared/node-catalog.d.ts +30 -0
- package/dist/types/shared/presence.d.ts +69 -0
- package/dist/types/shared/safe-href.d.ts +3 -0
- package/dist/types/shared/table-cell-format.d.ts +24 -0
- package/dist/types/shared/table-layout.d.ts +7 -0
- package/dist/types/shared/types.d.ts +44 -0
- package/dist/types/shared/video-embed.d.ts +11 -0
- package/dist/types/shared/workbook.d.ts +56 -0
- package/dist/types/spreadsheet.d.ts +1 -0
- package/dist/types/types.d.ts +3 -0
- package/dist/types/validation/callout.d.ts +1 -0
- package/dist/types/validation/doc-node-whitelist.d.ts +21 -0
- package/dist/types/validation/error.d.ts +7 -0
- package/dist/types/validation/mind-map.d.ts +22 -0
- package/dist/types/validation/schema-version.d.ts +2 -0
- package/dist/types/validation/schema.d.ts +49 -0
- package/dist/types/validation/video-embed.d.ts +1 -0
- package/dist/types/validation/workbook.d.ts +1 -0
- package/package.json +87 -5
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 节点目录:编辑器的 nodeView 与只读渲染规则**共用的单一真源**
|
|
3
|
+
* (对齐《Wiki好用技术方案》1.1)。
|
|
4
|
+
*
|
|
5
|
+
* 这里只放纯数据(无 Vue、无 Tiptap 依赖),因此阅读端可以只引它 + Renderer,
|
|
6
|
+
* 而完全不加载编辑器代码;编辑器端再基于同一份目录生成扩展。
|
|
7
|
+
*
|
|
8
|
+
* 新增节点时必须同时更新:
|
|
9
|
+
* 1) 本目录 2) 服务端 doc-schema.service.ts 白名单 3) 提升 schema 版本
|
|
10
|
+
* 否则旧客户端会因「遇到不支持节点」而被服务端拒写(这是预期行为,避免静默丢内容)。
|
|
11
|
+
*/
|
|
12
|
+
export const NODE_CATALOG = [
|
|
13
|
+
{ type: 'doc', kind: 'block', label: '文档', topLevel: true },
|
|
14
|
+
{ type: 'paragraph', kind: 'block', label: '段落', topLevel: true },
|
|
15
|
+
{ type: 'heading', kind: 'block', label: '标题', topLevel: true },
|
|
16
|
+
{ type: 'bulletList', kind: 'block', label: '无序列表', topLevel: true },
|
|
17
|
+
{ type: 'orderedList', kind: 'block', label: '有序列表', topLevel: true },
|
|
18
|
+
{ type: 'taskList', kind: 'block', label: '待办列表', topLevel: true },
|
|
19
|
+
{ type: 'listItem', kind: 'block', label: '列表项' },
|
|
20
|
+
{ type: 'taskItem', kind: 'block', label: '待办项' },
|
|
21
|
+
{ type: 'blockquote', kind: 'block', label: '引用块', topLevel: true },
|
|
22
|
+
{ type: 'codeBlock', kind: 'block', label: '代码块', topLevel: true },
|
|
23
|
+
{ type: 'horizontalRule', kind: 'atom', atom: true, label: '分割线', topLevel: true },
|
|
24
|
+
{ type: 'table', kind: 'block', label: '表格', topLevel: true },
|
|
25
|
+
{ type: 'tableRow', kind: 'block', label: '表格行' },
|
|
26
|
+
{ type: 'tableHeader', kind: 'block', label: '表头单元格' },
|
|
27
|
+
{ type: 'tableCell', kind: 'block', label: '单元格' },
|
|
28
|
+
{ type: 'image', kind: 'atom', atom: true, label: '图片', topLevel: true },
|
|
29
|
+
{ type: 'fileAttachment', kind: 'atom', atom: true, label: '视频或文件', topLevel: true },
|
|
30
|
+
{ type: 'videoEmbed', kind: 'atom', atom: true, label: '嵌入视频', topLevel: true },
|
|
31
|
+
{ type: 'mindMap', kind: 'block', label: '思维导图', topLevel: true },
|
|
32
|
+
{ type: 'mindMapDescription', kind: 'inline', label: '主题描述' },
|
|
33
|
+
{ type: 'mindMapNote', kind: 'inline', label: '主题备注' },
|
|
34
|
+
{ type: 'mindMapRelation', kind: 'block', label: '主题关系' },
|
|
35
|
+
{ type: 'mindMapTopic', kind: 'block', label: '导图主题' },
|
|
36
|
+
{ type: 'callout', kind: 'block', label: '高亮块', topLevel: true },
|
|
37
|
+
// 分栏:columns 是顶层行,column 只在其内部(故 column 无 topLevel)
|
|
38
|
+
{ type: 'columns', kind: 'block', label: '分栏', topLevel: true },
|
|
39
|
+
{ type: 'column', kind: 'block', label: '栏' },
|
|
40
|
+
{ type: 'pageLink', kind: 'atom', atom: true, label: '页面引用' },
|
|
41
|
+
{ type: 'mention', kind: 'atom', atom: true, label: '提及用户' },
|
|
42
|
+
{ type: 'mathInline', kind: 'atom', atom: true, label: '行内公式' },
|
|
43
|
+
{ type: 'mathBlock', kind: 'atom', atom: true, label: '公式块', topLevel: true },
|
|
44
|
+
{ type: 'text', kind: 'text', label: '文本' },
|
|
45
|
+
{ type: 'hardBreak', kind: 'inline', atom: true, label: '换行' },
|
|
46
|
+
];
|
|
47
|
+
const META_BY_TYPE = new Map(NODE_CATALOG.map((n) => [n.type, n]));
|
|
48
|
+
export function nodeMeta(type) {
|
|
49
|
+
return type ? META_BY_TYPE.get(type) : undefined;
|
|
50
|
+
}
|
|
51
|
+
/** 只读渲染遇到未知节点时使用的降级提示(不静默丢弃,向用户明示)。 */
|
|
52
|
+
export const UNKNOWN_NODE_LABEL = '此内容由更高版本的编辑器创建,当前版本无法显示';
|
|
53
|
+
/** 标题级别 → HTML 标签 */
|
|
54
|
+
export function headingTag(level) {
|
|
55
|
+
const n = typeof level === 'number' ? level : 1;
|
|
56
|
+
return `h${Math.min(Math.max(n, 1), 6)}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协同在线状态(presence)的纯逻辑(可单测,不依赖 DOM / Yjs / 网络)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么单独抽出来
|
|
5
|
+
*
|
|
6
|
+
* 在线状态有两个容易做错、且出错后**不报错、只是显示不对**的地方:
|
|
7
|
+
* 1) 颜色分配:同一用户在不同客户端必须得到**相同颜色**(否则"那个蓝色光标是谁"
|
|
8
|
+
* 无法对应),而颜色若只按"连接顺序"分,B 客户端上同一个人的颜色就会变。
|
|
9
|
+
* 因此颜色必须由**用户身份**稳定推导,不能依赖顺序或随机。
|
|
10
|
+
* 2) 对比度:名字气泡上的文字要与背景色对比可读;简单地对 6 个色相取固定亮度,
|
|
11
|
+
* 在浅色模式下会有几个色相偏亮、白字看不清。
|
|
12
|
+
* 这些都能用纯函数锁定,因此放在这里。
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* 稳定的用户色板。
|
|
16
|
+
*
|
|
17
|
+
* 选取标准不是"好看",而是**所有颜色配白字都达到 WCAG AA(≥4.5:1)**——
|
|
18
|
+
* 这样光标/名字气泡永远是"彩色底 + 白字",视觉一致,也不会出现浅底白字看不清。
|
|
19
|
+
* (初版用了偏亮的绿 `#1a9d5a`(3.49:1)与橙 `#d97706`(3.19:1),配白字或深字
|
|
20
|
+
* 都不到 AA;这里换成更深的一档,由单测锁定对比度。)
|
|
21
|
+
*/
|
|
22
|
+
export const PRESENCE_PALETTE = [
|
|
23
|
+
'#2f6feb', // 蓝 4.57:1
|
|
24
|
+
'#127a45', // 绿 5.39:1
|
|
25
|
+
'#b45309', // 棕橙 5.02:1
|
|
26
|
+
'#c026d3', // 紫 4.71:1
|
|
27
|
+
'#dc2626', // 红 4.83:1
|
|
28
|
+
'#0e7490', // 青 5.36:1
|
|
29
|
+
'#7c3aed', // 靛 5.70:1
|
|
30
|
+
'#9a4a08', // 深橙 6.26:1
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* 由用户 id 稳定映射到调色板颜色。
|
|
34
|
+
*
|
|
35
|
+
* 用 FNV-1a 哈希而非 simple sum:简单的字符求和会让 `user1`/`user2`
|
|
36
|
+
* 这类相邻 id 落到同一颜色,而实际用户 id 常是顺序生成的。
|
|
37
|
+
*/
|
|
38
|
+
export function colorForUser(userId) {
|
|
39
|
+
return PRESENCE_PALETTE[hashIndex(userId, PRESENCE_PALETTE.length)];
|
|
40
|
+
}
|
|
41
|
+
/** FNV-1a 32 位哈希 → 取模。 */
|
|
42
|
+
export function hashIndex(input, buckets) {
|
|
43
|
+
let h = 0x811c9dc5;
|
|
44
|
+
for (let i = 0; i < input.length; i++) {
|
|
45
|
+
h ^= input.charCodeAt(i);
|
|
46
|
+
// h *= 16777619(用移位避免大整数精度问题)
|
|
47
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
48
|
+
}
|
|
49
|
+
return buckets > 0 ? h % buckets : 0;
|
|
50
|
+
}
|
|
51
|
+
/** 名字首字(用于头像占位)。中文取首字,英文取首字母大写。 */
|
|
52
|
+
export function initialOf(displayName) {
|
|
53
|
+
const t = displayName.trim();
|
|
54
|
+
if (t === '')
|
|
55
|
+
return '?';
|
|
56
|
+
return t.slice(0, 1).toUpperCase();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 汇总在线用户:去重(同一用户多标签页只显示一次)、排除自己、稳定排序。
|
|
60
|
+
*
|
|
61
|
+
* 稳定性很重要:若按 Map 遍历顺序输出,光标/头像列表会随消息到达顺序抖动。
|
|
62
|
+
* 这里按 displayName 排序,保证界面上顺序稳定。
|
|
63
|
+
*/
|
|
64
|
+
export function collectPresence(states, selfUserId) {
|
|
65
|
+
const byUser = new Map();
|
|
66
|
+
for (const s of states) {
|
|
67
|
+
if (!s.userId)
|
|
68
|
+
continue;
|
|
69
|
+
if (selfUserId && s.userId === selfUserId)
|
|
70
|
+
continue;
|
|
71
|
+
if (!byUser.has(s.userId)) {
|
|
72
|
+
byUser.set(s.userId, {
|
|
73
|
+
userId: s.userId,
|
|
74
|
+
displayName: s.displayName || '匿名',
|
|
75
|
+
color: colorForUser(s.userId),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...byUser.values()].sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 相对亮度(WCAG),用于决定光标/气泡上该用白字还是深字。
|
|
83
|
+
* 返回 0(黑)~1(白)。
|
|
84
|
+
*/
|
|
85
|
+
export function relativeLuminance(hex) {
|
|
86
|
+
const rgb = parseHex(hex);
|
|
87
|
+
if (!rgb)
|
|
88
|
+
return 0;
|
|
89
|
+
const [r, g, b] = rgb.map((v) => {
|
|
90
|
+
const c = v / 255;
|
|
91
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
92
|
+
});
|
|
93
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
94
|
+
}
|
|
95
|
+
/** 前景深色的基准(与 :root 的 --color-text 接近)。 */
|
|
96
|
+
export const DARK_TEXT = '#1f2937';
|
|
97
|
+
/** WCAG 对比度。 */
|
|
98
|
+
export function contrastRatio(a, b) {
|
|
99
|
+
const la = relativeLuminance(a);
|
|
100
|
+
const lb = relativeLuminance(b);
|
|
101
|
+
const hi = Math.max(la, lb);
|
|
102
|
+
const lo = Math.min(la, lb);
|
|
103
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 该背景色上应使用的前景色。
|
|
107
|
+
*
|
|
108
|
+
* 实现为「比较白字与深字各自的对比度,取更高的那个」,而不是用一个亮度阈值判断——
|
|
109
|
+
* 阈值是脆弱的:初版用 0.179 作分界,`#2f6feb`(亮度 0.180)被判为用深字,
|
|
110
|
+
* 但实际白字对比度 4.57 优于深字 3.21,选错了。(由单测发现。)
|
|
111
|
+
*/
|
|
112
|
+
export function readableTextColor(hex) {
|
|
113
|
+
const withWhite = contrastRatio(hex, '#ffffff');
|
|
114
|
+
const withDark = contrastRatio(hex, DARK_TEXT);
|
|
115
|
+
return withWhite >= withDark ? '#ffffff' : DARK_TEXT;
|
|
116
|
+
}
|
|
117
|
+
/** 解析 #rgb / #rrggbb;非法返回 null。 */
|
|
118
|
+
export function parseHex(hex) {
|
|
119
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
120
|
+
if (!m)
|
|
121
|
+
return null;
|
|
122
|
+
let s = m[1];
|
|
123
|
+
if (s.length === 3)
|
|
124
|
+
s = s.split('').map((c) => c + c).join('');
|
|
125
|
+
return [
|
|
126
|
+
parseInt(s.slice(0, 2), 16),
|
|
127
|
+
parseInt(s.slice(2, 4), 16),
|
|
128
|
+
parseInt(s.slice(4, 6), 16),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* href 协议白名单(客户端纵深防御,L4)。
|
|
3
|
+
*
|
|
4
|
+
* 服务端在写入与读取投影时都做了协议白名单(挡 `javascript:`),但历史数据、
|
|
5
|
+
* 导入内容或服务端回归仍可能让危险 href 出现在正文 JSON 里。渲染器直接把它
|
|
6
|
+
* 写进 `<a href>`,等于给浏览器一个可执行入口——因此在**渲染侧**再兜一层:
|
|
7
|
+
* 只放行确定安全的协议;其余(含 `javascript:` / `data:` / `vbscript:` 等)
|
|
8
|
+
* 一律降级为不可点击的锚点(href 置为 `#`)。
|
|
9
|
+
*
|
|
10
|
+
* 允许:http(s)、mailto、以 `/` 或 `#` 开头的站内相对链接,以及相对路径。
|
|
11
|
+
*/
|
|
12
|
+
const SAFE_ABSOLUTE = /^(https?:|mailto:)/i;
|
|
13
|
+
export function isSafeHref(raw) {
|
|
14
|
+
if (typeof raw !== 'string')
|
|
15
|
+
return false;
|
|
16
|
+
// 去掉控制字符与空白后再判断,避免 `java\nscript:` 之类绕过
|
|
17
|
+
const href = raw.replace(/[\u0000-\u001f\u007f\s]/g, '').toLowerCase();
|
|
18
|
+
if (href === '')
|
|
19
|
+
return false;
|
|
20
|
+
if (href.startsWith('/') || href.startsWith('#'))
|
|
21
|
+
return true;
|
|
22
|
+
if (SAFE_ABSOLUTE.test(href))
|
|
23
|
+
return true;
|
|
24
|
+
// 相对路径(无协议、无 `:` 前缀):允许。含 `:` 的一律按协议处理并要求在白名单内。
|
|
25
|
+
if (!href.includes(':'))
|
|
26
|
+
return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
/** 渲染用:安全则原样返回,否则返回 '#'。 */
|
|
30
|
+
export function safeHref(raw) {
|
|
31
|
+
return isSafeHref(raw) ? String(raw) : '#';
|
|
32
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 固定单元格底色,不接受任意 CSS;读写端共用,服务端同步白名单。 */
|
|
2
|
+
export const CELL_BACKGROUNDS = [
|
|
3
|
+
{ name: '默认', value: null },
|
|
4
|
+
{ name: '灰', value: '#f1f3f5' },
|
|
5
|
+
{ name: '黄', value: '#fff3bf' },
|
|
6
|
+
{ name: '绿', value: '#d3f9d8' },
|
|
7
|
+
{ name: '蓝', value: '#d0ebff' },
|
|
8
|
+
{ name: '紫', value: '#e5dbff' },
|
|
9
|
+
{ name: '红', value: '#ffe3e3' },
|
|
10
|
+
];
|
|
11
|
+
export function cellBackground(value) {
|
|
12
|
+
return typeof value === 'string' && CELL_BACKGROUNDS.some((c) => c.value === value)
|
|
13
|
+
? value : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 自动列宽保留可读空间;窄屏在表格内部滚动,不把长文本压成单字列。 */
|
|
2
|
+
export const TABLE_CELL_MIN_WIDTH = 120;
|
|
3
|
+
/** 首行合并单元格的 colwidth 数组包含多个视觉列,必须逐列展开。 */
|
|
4
|
+
export function tableColumnWidths(table) {
|
|
5
|
+
const row = table.content?.find((node) => node.type === 'tableRow');
|
|
6
|
+
const widths = [];
|
|
7
|
+
for (const cell of row?.content ?? []) {
|
|
8
|
+
const span = Number(cell.attrs?.colspan ?? 1);
|
|
9
|
+
const count = Number.isSafeInteger(span) && span > 0 ? Math.min(span, 1000) : 1;
|
|
10
|
+
const colwidth = cell.attrs?.colwidth;
|
|
11
|
+
for (let index = 0; index < count; index += 1) {
|
|
12
|
+
const value = Array.isArray(colwidth) ? colwidth[index] : undefined;
|
|
13
|
+
widths.push(typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return widths;
|
|
17
|
+
}
|
|
18
|
+
/** 未设置宽度的列与编辑器 cellMinWidth 共用同一默认值;保留历史显式列宽。 */
|
|
19
|
+
export function tableMinWidth(widths) {
|
|
20
|
+
return widths.length > 0
|
|
21
|
+
? widths.reduce((sum, width) => sum + (width > 0 ? width : TABLE_CELL_MIN_WIDTH), 0)
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export function normalizeVideoEmbed(raw) {
|
|
2
|
+
if (!raw || typeof raw !== 'object')
|
|
3
|
+
return null;
|
|
4
|
+
const data = raw;
|
|
5
|
+
const validId = data.provider === 'youtube'
|
|
6
|
+
? typeof data.videoId === 'string' && /^[\w-]{11}$/.test(data.videoId)
|
|
7
|
+
: data.provider === 'bilibili' && typeof data.videoId === 'string' &&
|
|
8
|
+
/^(BV[A-Za-z0-9]{10}|av[1-9]\d{0,15})$/.test(data.videoId);
|
|
9
|
+
const page = data.page ?? 1;
|
|
10
|
+
const startSeconds = data.startSeconds ?? 0;
|
|
11
|
+
if (!validId || !Number.isInteger(page) || Number(page) < 1 || Number(page) > 1000 ||
|
|
12
|
+
!Number.isInteger(startSeconds) || Number(startSeconds) < 0 || Number(startSeconds) > 604800)
|
|
13
|
+
return null;
|
|
14
|
+
return { provider: data.provider, videoId: data.videoId,
|
|
15
|
+
page: data.provider === 'youtube' ? 1 : Number(page), startSeconds: Number(startSeconds) };
|
|
16
|
+
}
|
|
17
|
+
function seconds(value) {
|
|
18
|
+
if (!value)
|
|
19
|
+
return 0;
|
|
20
|
+
if (/^\d+$/.test(value))
|
|
21
|
+
return Number(value);
|
|
22
|
+
const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
|
|
23
|
+
if (!match || !match[0])
|
|
24
|
+
return NaN;
|
|
25
|
+
return Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0);
|
|
26
|
+
}
|
|
27
|
+
export function parseVideoUrl(value) {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(value.trim());
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.port)
|
|
36
|
+
return null;
|
|
37
|
+
const host = url.hostname.toLowerCase();
|
|
38
|
+
const path = url.pathname.split('/').filter(Boolean);
|
|
39
|
+
let provider;
|
|
40
|
+
let videoId;
|
|
41
|
+
if (host === 'youtu.be') {
|
|
42
|
+
provider = 'youtube';
|
|
43
|
+
videoId = path.length === 1 ? path[0] : null;
|
|
44
|
+
}
|
|
45
|
+
else if (['youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'].includes(host)) {
|
|
46
|
+
provider = 'youtube';
|
|
47
|
+
videoId = url.pathname === '/watch' ? url.searchParams.get('v')
|
|
48
|
+
: ['embed', 'shorts', 'live'].includes(path[0]) && path.length === 2 ? path[1] : null;
|
|
49
|
+
}
|
|
50
|
+
else if (['bilibili.com', 'www.bilibili.com', 'm.bilibili.com'].includes(host)) {
|
|
51
|
+
provider = 'bilibili';
|
|
52
|
+
videoId = path[0] === 'video' && path.length === 2 ? path[1] : null;
|
|
53
|
+
}
|
|
54
|
+
else if (host === 'player.bilibili.com' && url.pathname === '/player.html') {
|
|
55
|
+
provider = 'bilibili';
|
|
56
|
+
videoId = url.searchParams.get('bvid') ?? (url.searchParams.get('aid') ? `av${url.searchParams.get('aid')}` : null);
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
return null;
|
|
60
|
+
const hashTime = /^#t=(.*)$/.exec(url.hash)?.[1] ?? null;
|
|
61
|
+
return normalizeVideoEmbed({ provider, videoId, page: Number(url.searchParams.get('p') ?? 1),
|
|
62
|
+
startSeconds: seconds(url.searchParams.get('start') ?? url.searchParams.get('t') ?? hashTime) });
|
|
63
|
+
}
|
|
64
|
+
export function videoSourceUrl(data) {
|
|
65
|
+
const safe = normalizeVideoEmbed(data);
|
|
66
|
+
if (!safe)
|
|
67
|
+
return '';
|
|
68
|
+
const url = new URL(safe.provider === 'youtube'
|
|
69
|
+
? `https://www.youtube.com/watch?v=${safe.videoId}`
|
|
70
|
+
: `https://www.bilibili.com/video/${safe.videoId}`);
|
|
71
|
+
if (safe.provider === 'bilibili' && safe.page > 1)
|
|
72
|
+
url.searchParams.set('p', String(safe.page));
|
|
73
|
+
if (safe.startSeconds)
|
|
74
|
+
url.searchParams.set('t', String(safe.startSeconds));
|
|
75
|
+
return url.href;
|
|
76
|
+
}
|
|
77
|
+
export function videoPlayerUrl(data) {
|
|
78
|
+
const safe = normalizeVideoEmbed(data);
|
|
79
|
+
if (!safe)
|
|
80
|
+
return '';
|
|
81
|
+
const url = new URL(safe.provider === 'youtube'
|
|
82
|
+
? `https://www.youtube.com/embed/${safe.videoId}` : 'https://player.bilibili.com/player.html');
|
|
83
|
+
url.searchParams.set('autoplay', '0');
|
|
84
|
+
if (safe.provider === 'youtube') {
|
|
85
|
+
url.searchParams.set('playsinline', '1');
|
|
86
|
+
if (safe.startSeconds)
|
|
87
|
+
url.searchParams.set('start', String(safe.startSeconds));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
url.searchParams.set(safe.videoId.startsWith('BV') ? 'bvid' : 'aid', safe.videoId.startsWith('BV') ? safe.videoId : safe.videoId.slice(2));
|
|
91
|
+
url.searchParams.set('p', String(safe.page));
|
|
92
|
+
url.searchParams.set('t', String(safe.startSeconds));
|
|
93
|
+
url.searchParams.set('danmaku', '0');
|
|
94
|
+
}
|
|
95
|
+
return url.href;
|
|
96
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export const WORKBOOK_LIMITS = { sheets: 20, rows: 10000, columns: 200, cells: 50000, bytes: 4 * 1024 * 1024 };
|
|
2
|
+
export function isWorkbookDocument(value) {
|
|
3
|
+
return !!value && typeof value === 'object' && value.type === 'workbook';
|
|
4
|
+
}
|
|
5
|
+
export function emptyWorkbook(id) {
|
|
6
|
+
return {
|
|
7
|
+
type: 'workbook', formatVersion: 1,
|
|
8
|
+
workbook: {
|
|
9
|
+
id, name: '工作簿', appVersion: '1.1.0', locale: 'zhCN',
|
|
10
|
+
sheetOrder: ['sheet1'],
|
|
11
|
+
sheets: { sheet1: { id: 'sheet1', name: 'Sheet1', rowCount: 1000, columnCount: 26, cellData: {} } },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function record(value) {
|
|
16
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function index(key, upper) {
|
|
19
|
+
return /^(0|[1-9]\d*)$/.test(key) && Number(key) < upper;
|
|
20
|
+
}
|
|
21
|
+
/** Validate the complete snapshot without silently dropping supported engine attributes. */
|
|
22
|
+
export function validateWorkbook(raw) {
|
|
23
|
+
const fail = (message) => { throw new Error(`工作表格式无效:${message}`); };
|
|
24
|
+
if (!isWorkbookDocument(raw) || raw.formatVersion !== 1 || !record(raw.workbook))
|
|
25
|
+
fail('不支持的版本');
|
|
26
|
+
const doc = raw;
|
|
27
|
+
const json = JSON.stringify(doc);
|
|
28
|
+
if (new TextEncoder().encode(json).byteLength > WORKBOOK_LIMITS.bytes)
|
|
29
|
+
fail('超过 4MB');
|
|
30
|
+
let nodes = 0;
|
|
31
|
+
const walk = (value, depth) => {
|
|
32
|
+
if (++nodes > 500000 || depth > 40)
|
|
33
|
+
fail('结构过大或嵌套过深');
|
|
34
|
+
if (typeof value === 'number' && !Number.isFinite(value))
|
|
35
|
+
fail('包含非法数值');
|
|
36
|
+
if (Array.isArray(value))
|
|
37
|
+
value.forEach(item => walk(item, depth + 1));
|
|
38
|
+
else if (record(value)) {
|
|
39
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
40
|
+
fail('包含非法对象');
|
|
41
|
+
for (const [key, item] of Object.entries(value)) {
|
|
42
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key))
|
|
43
|
+
fail('包含非法属性');
|
|
44
|
+
walk(item, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(doc, 0);
|
|
49
|
+
const book = doc.workbook;
|
|
50
|
+
if (typeof book.id !== 'string' || !book.id || book.id.length > 128 ||
|
|
51
|
+
typeof book.name !== 'string' || book.name.length > 256)
|
|
52
|
+
fail('工作簿名称或标识无效');
|
|
53
|
+
if (!Array.isArray(book.sheetOrder) || !book.sheetOrder.length || book.sheetOrder.length > WORKBOOK_LIMITS.sheets ||
|
|
54
|
+
new Set(book.sheetOrder).size !== book.sheetOrder.length || !record(book.sheets) ||
|
|
55
|
+
Object.keys(book.sheets).length !== book.sheetOrder.length)
|
|
56
|
+
fail('表页数量或顺序无效');
|
|
57
|
+
let cells = 0;
|
|
58
|
+
const names = new Set();
|
|
59
|
+
for (const id of book.sheetOrder) {
|
|
60
|
+
if (typeof id !== 'string' || !id || id.length > 128)
|
|
61
|
+
fail('表页标识无效');
|
|
62
|
+
const sheet = book.sheets[id];
|
|
63
|
+
if (!record(sheet) || sheet.id !== id || typeof sheet.name !== 'string' || !sheet.name.trim() ||
|
|
64
|
+
sheet.name.length > 31 || /[\\/*?:[\]]/.test(sheet.name) || names.has(sheet.name.toLowerCase()))
|
|
65
|
+
fail('表页名称无效或重复(最多 31 字符)');
|
|
66
|
+
names.add(sheet.name.toLowerCase());
|
|
67
|
+
if (!Number.isInteger(sheet.rowCount) || sheet.rowCount < 1 || sheet.rowCount > WORKBOOK_LIMITS.rows ||
|
|
68
|
+
!Number.isInteger(sheet.columnCount) || sheet.columnCount < 1 || sheet.columnCount > WORKBOOK_LIMITS.columns)
|
|
69
|
+
fail('行列超出上限');
|
|
70
|
+
if (sheet.cellData !== undefined) {
|
|
71
|
+
if (!record(sheet.cellData))
|
|
72
|
+
fail('单元格结构无效');
|
|
73
|
+
for (const [r, row] of Object.entries(sheet.cellData)) {
|
|
74
|
+
if (!index(r, sheet.rowCount) || !record(row))
|
|
75
|
+
fail('单元格行号无效');
|
|
76
|
+
for (const [c, cell] of Object.entries(row)) {
|
|
77
|
+
if (++cells > WORKBOOK_LIMITS.cells)
|
|
78
|
+
fail('单元格超过 50000 个');
|
|
79
|
+
if (!index(c, sheet.columnCount))
|
|
80
|
+
fail('单元格列号无效');
|
|
81
|
+
if (cell === null)
|
|
82
|
+
continue;
|
|
83
|
+
if (!record(cell))
|
|
84
|
+
fail('单元格结构无效');
|
|
85
|
+
if (cell.v !== undefined && cell.v !== null && !['string', 'number', 'boolean'].includes(typeof cell.v))
|
|
86
|
+
fail('单元格值无效');
|
|
87
|
+
if (typeof cell.v === 'string' && cell.v.length > 16000)
|
|
88
|
+
fail('单元格文字过长');
|
|
89
|
+
if (cell.f !== undefined && (typeof cell.f !== 'string' || cell.f.length > 4000))
|
|
90
|
+
fail('公式无效');
|
|
91
|
+
if (cell.t !== undefined && (!Number.isInteger(cell.t) || Number(cell.t) < 0 || Number(cell.t) > 4))
|
|
92
|
+
fail('单元格类型无效');
|
|
93
|
+
if (cell.s !== undefined && cell.s !== null && typeof cell.s !== 'string' && !record(cell.s))
|
|
94
|
+
fail('单元格样式无效');
|
|
95
|
+
if (cell.p !== undefined && (!record(cell.p) || (cell.p.body !== undefined &&
|
|
96
|
+
(!record(cell.p.body) || typeof cell.p.body.dataStream !== 'string' || cell.p.body.dataStream.length > 16000))))
|
|
97
|
+
fail('单元格富文本无效');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const [data, upper] of [[sheet.rowData, sheet.rowCount], [sheet.columnData, sheet.columnCount]]) {
|
|
102
|
+
if (data !== undefined && (!record(data) || Object.keys(data).some(key => !index(key, upper))))
|
|
103
|
+
fail('行列属性越界');
|
|
104
|
+
}
|
|
105
|
+
if (sheet.mergeData !== undefined) {
|
|
106
|
+
if (!Array.isArray(sheet.mergeData) || sheet.mergeData.length > WORKBOOK_LIMITS.cells)
|
|
107
|
+
fail('合并区域无效');
|
|
108
|
+
const occupied = new Set();
|
|
109
|
+
for (const range of sheet.mergeData) {
|
|
110
|
+
if (!record(range) || ![range.startRow, range.endRow, range.startColumn, range.endColumn].every(Number.isInteger) ||
|
|
111
|
+
range.startRow < 0 || range.endRow < range.startRow || range.endRow >= sheet.rowCount ||
|
|
112
|
+
range.startColumn < 0 || range.endColumn < range.startColumn || range.endColumn >= sheet.columnCount)
|
|
113
|
+
fail('合并区域越界');
|
|
114
|
+
const area = (range.endRow - range.startRow + 1) * (range.endColumn - range.startColumn + 1);
|
|
115
|
+
if (occupied.size + area > 200000)
|
|
116
|
+
fail('合并区域总范围过大');
|
|
117
|
+
for (let r = range.startRow; r <= range.endRow; r++) {
|
|
118
|
+
for (let c = range.startColumn; c <= range.endColumn; c++) {
|
|
119
|
+
const position = r * WORKBOOK_LIMITS.columns + c;
|
|
120
|
+
if (occupied.has(position))
|
|
121
|
+
fail('合并区域重叠');
|
|
122
|
+
occupied.add(position);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return JSON.parse(json);
|
|
129
|
+
}
|
|
130
|
+
export function workbookCellText(cell) {
|
|
131
|
+
if (!cell)
|
|
132
|
+
return '';
|
|
133
|
+
if (cell.p?.body?.dataStream)
|
|
134
|
+
return cell.p.body.dataStream.replace(/[\r\n]+$/, '');
|
|
135
|
+
if (cell.v !== undefined && cell.v !== null)
|
|
136
|
+
return String(cell.v);
|
|
137
|
+
return cell.f ?? '';
|
|
138
|
+
}
|
|
139
|
+
export function workbookText(doc) {
|
|
140
|
+
return doc.workbook.sheetOrder.map(id => {
|
|
141
|
+
const sheet = doc.workbook.sheets[id];
|
|
142
|
+
return `${sheet.name} ${Object.values(sheet.cellData ?? {}).flatMap(row => Object.values(row).map(workbookCellText)).join(' ')}`;
|
|
143
|
+
}).join(' ').slice(0, 200000);
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './shared/workbook.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './shared/types.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../shared/callout.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文档节点白名单(**纯数据,无依赖**)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:白名单需要被三处引用——
|
|
5
|
+
* 1) 服务端校验(doc-schema.service.ts)
|
|
6
|
+
* 2) 一致性测试(与前端 node-catalog 交叉核对)
|
|
7
|
+
* 3) 导出序列化器的防漂移测试
|
|
8
|
+
* 若把它放在 doc-schema.service.ts 里,测试导入会连带加载 Nest 依赖,
|
|
9
|
+
* 导致 spec 在测试环境下加载失败(实测:整个测试文件被静默跳过)。
|
|
10
|
+
*
|
|
11
|
+
* 因此这里只放数据,不放逻辑,任何一方都能安全引用。
|
|
12
|
+
*
|
|
13
|
+
* 新增节点时必须同时更新:
|
|
14
|
+
* 1) 本文件
|
|
15
|
+
* 2) 前端 web/src/shared/node-catalog.ts
|
|
16
|
+
* 3) 导出序列化器 markdown-serializer.ts 的处理分支
|
|
17
|
+
* 三处一致性由测试自动核对(读取真实文件,而非维护测试内副本)。
|
|
18
|
+
*/
|
|
19
|
+
export const ALLOWED_NODES = new Set([
|
|
20
|
+
'mindMapDescription', 'mindMapNote', 'mindMapRelation',
|
|
21
|
+
'mindMap',
|
|
22
|
+
'mindMapTopic',
|
|
23
|
+
'doc',
|
|
24
|
+
'paragraph',
|
|
25
|
+
'text',
|
|
26
|
+
'heading',
|
|
27
|
+
'bulletList',
|
|
28
|
+
'orderedList',
|
|
29
|
+
'listItem',
|
|
30
|
+
// 待办列表:前端编辑器(TaskList/TaskItem)支持,导出序列化器也支持,
|
|
31
|
+
// 但白名单此前漏了这两个节点 —— 导致「用户插入待办列表后保存必被拒」。
|
|
32
|
+
// 由导入测试暴露(详见 doc-schema.service.spec.ts 的三方一致性断言)。
|
|
33
|
+
'taskList',
|
|
34
|
+
'taskItem',
|
|
35
|
+
'blockquote',
|
|
36
|
+
'codeBlock',
|
|
37
|
+
'horizontalRule',
|
|
38
|
+
'hardBreak',
|
|
39
|
+
'image',
|
|
40
|
+
'fileAttachment', // 视频或文件卡片(只存 attachmentId,不存存储地址)
|
|
41
|
+
'videoEmbed',
|
|
42
|
+
'callout', // 高亮块(容器,content: block+)
|
|
43
|
+
// 分栏:columns(行,content: column+)包 column(栏,content: block+)。
|
|
44
|
+
// 两个都要登记——只登记外层时,栏节点会在保存校验处被判为「不支持」而拒绝写入。
|
|
45
|
+
'columns',
|
|
46
|
+
'column',
|
|
47
|
+
'table',
|
|
48
|
+
'tableRow',
|
|
49
|
+
'tableHeader',
|
|
50
|
+
'tableCell',
|
|
51
|
+
'pageLink', // 内部页面引用 [[page]]
|
|
52
|
+
'mention', // @提及用户
|
|
53
|
+
'mathInline', // 行内公式(KaTeX)
|
|
54
|
+
'mathBlock', // 块级公式(KaTeX)
|
|
55
|
+
]);
|
|
56
|
+
/** 允许的标记类型。 */
|
|
57
|
+
export const ALLOWED_MARKS = new Set([
|
|
58
|
+
'bold',
|
|
59
|
+
'italic',
|
|
60
|
+
'strike',
|
|
61
|
+
'code',
|
|
62
|
+
'link',
|
|
63
|
+
// 行内样式:文字颜色与背景高亮。两者的颜色属性必须在
|
|
64
|
+
// doc-schema.service.ts 的 ALLOWED_ATTRS 里同时登记,否则颜色值会被剥离、
|
|
65
|
+
// 只剩一个空标记(实测的"颜色丢失但标记还在"就是这么来的)。
|
|
66
|
+
'textStyle',
|
|
67
|
+
'highlight',
|
|
68
|
+
]);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class ContentValidationError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, status = 422, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.details = details;
|
|
10
|
+
this.name = 'ContentValidationError';
|
|
11
|
+
}
|
|
12
|
+
static validation(message) { return new ContentValidationError('validation.failed', message); }
|
|
13
|
+
}
|