林熙然答辩
最近访问文档列表
- 访问记录生成:在访问文档详情时后台自动生成一条访问记录存到数据表里
- 实现最近访问记录列表查看
文档内容搜索
内容数据格式处理
数据库存储的内容是 SlateJS 内容的 JSON 格式,而实际的内容在里面的 text 字段,递归获取所有 text 字段将匹配到的内容提取出来返回给前端。
模糊搜索
通过关键字对进行文档内容正则匹配,提升搜索体验的容错性和便捷性。
// 递归提取所有text字段
const extractAllTextNodes = (node: unknown, path: string = '') => {
if (!node) return;
if (Array.isArray(node)) {
node.forEach((item, index) => {
extractAllTextNodes(item, `${path}.${index}`);
});
} else if (typeof node === 'object' && node !== null) {
// 提取当前节点的text字段
const objNode = node as Record<string, unknown>;
if (
Object.prototype.hasOwnProperty.call(objNode, 'text') &&
typeof objNode.text === 'string'
) {
allTextNodes.push({ text: objNode.text, path });
}
// 递归处理子节点
for (const key in objNode) {
if (typeof objNode[key] === 'object' && objNode[key] !== null) {
extractAllTextNodes(objNode[key], `${path}.${key}`);
}
}
}
};性能优化
- 采用防抖方式进行搜索,提高性能,避免交互抖动
- 前端展示匹配搜索结果,高亮展示关键字
- 直观呈现匹配逻辑,降低理解成本
- 加速信息定位,提升阅读效率
const highlightText = (text, searchText) => {
if (!searchText || !text) return text;
const currentTheme = getCurrentTheme();
const primaryColor = currentTheme?.colors?.primary || '#1890ff';
// 使用正则表达式进行全局不区分大小写的匹配
const regex = new RegExp(`(${searchText})`, 'gi');
return text.replace(
regex,
`<span style="color: ${primaryColor}; font-weight: 600;">$1</span>`,
);
};AI 生成摘要
数据处理
由于文档内容数据存储格式是 SlateJS 内容的 JSON 字符串,将文档内容进行数据处理,发给千问 AI 接口。
// 提取实际内容
export const getTextFromContent = content => {
if (!content || !Array.isArray(content)) return '';
let extractedText = '';
// 递归提取所有text字段的内容
const extractText = node => {
if (!node) return;
// 如果有text属性,直接添加到结果中
if (node.text !== undefined) {
extractedText += node.text + ' ';
return;
}
// 如果有children属性,递归处理
if (node.children && Array.isArray(node.children)) {
node.children.forEach(child => extractText(child));
}
};
// 处理每个顶层节点
content.forEach(node => extractText(node));
return extractedText.trim();
};安全性处理
对发给 AI 的文字长度进行限制,做安全性处理:
- 避免模型"信息过载"导致的输出质量下降
- 控制响应时间,优化用户体验
- 降低敏感信息泄露概率以及避免长文本信息容易隐藏攻击信息
用户体验优化
- 采用流式输出,减少用户等待时间,提高用户体验
- 支持将生成摘要导出为 PDF,便于文档摘要分享
编辑器部分
工具栏
分为切换块级元素模式和切换文本标记模式:
切换块级元素模式
包含有序列表、无序列表、一级标题、二级标题、左对齐、右对齐、居中对齐:
- 采用
Transforms.setNodes为当前选取设置格式,对格式进行标记 - 在 Element 组件里面配置好每种标记的格式渲染的对应样式
export const toggleBlock = (editor, format) => {
const isActive = isBlockActive(
editor,
format,
isAlignType(format) ? 'align' : 'type',
);
const isList = isListType(format);
// 展开列表节点
Transforms.unwrapNodes(editor, {
match: n =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
isListType(n.type) &&
!isAlignType(format),
split: true,
});
let newProperties;
if (isAlignType(format)) {
newProperties = {
align: isActive ? undefined : format,
};
} else {
newProperties = {
type: isActive ? 'paragraph' : isList ? 'list-item' : format,
};
}
Transforms.setNodes(editor, newProperties);
if (!isActive && isList) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
};切换文本标记模式
包含粗体,斜体,下划线,删除线,文本颜色:
- 采用
Editor.addMark为当前选取设置格式,对格式进行标记 - 在 Leaf 组件里面配置好每种标记的格式渲染的对应样式
export const toggleMark = (editor, format) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};悬浮工具栏
- 采用 Portal 布局,将里面工具栏渲染到最外层 body 中
- 采用相应的位置算法定位悬浮工具栏位置:获取元素相对于可视窗口的位置+页面滚动位置+微调像素
const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.pageYOffset - el.offsetHeight - 6}px`;
el.style.left = `${
rect.left + window.pageXOffset - el.offsetWidth / 2 + rect.width / 2
}px`;快捷键
- Ctrl+Z 后退,Ctrl+Y 前进:通过调用 SlateJS 的
withHistory方法实现 - Ctrl+B 粗体、Ctrl+I 斜体、Ctrl+U 下划线、Ctrl+` 代码行、Ctrl+Shift+X 删除线:
export const HOTKEYS = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
'mod+`': 'code',
'mod+shift+x': 'strikethrough',
};通过 onKeyDown 函数获取键盘操作对对应选取进行标记切换文本模式,使用户进行富文本编辑更加便利,提升用户体验。
历史版本对比
通过用户选择对应的历史版本采用 diff 算法进行对比:
- 有助于用户追踪变更,明确修改脉络
- 便于团队协作者之间协同操作
- 计算出版本相似度,让用户更好的管理历史版本
差异检测
// 判断是新增块、删除块还是差异块
for (let i = 0; i < maxLength; i++) {
const oldBlock = oldBlocks[i];
const newBlock = newBlocks[i];
if (!oldBlock && newBlock) {
// 新增块
differences.push({
type: 'added',
blockIndex: i,
content: newBlock,
lineNumber: i + 1,
});
addedBlocks++;
} else if (oldBlock && !newBlock) {
// 删除块
differences.push({
type: 'deleted',
blockIndex: i,
content: oldBlock,
lineNumber: i + 1,
});
deletedBlocks++;
} else if (oldBlock && newBlock) {
// 比较块内容
const blockDiff = compareBlock(oldBlock, newBlock, i);
if (blockDiff.hasChanges) {
differences.push(blockDiff);
modifiedBlocks++;
}
}
}Diff 差异对比
function diffChildren(oldChildren, newChildren) {
oldChildren = Array.isArray(oldChildren) ? oldChildren : [];
newChildren = Array.isArray(newChildren) ? newChildren : [];
const maxLen = Math.max(oldChildren.length, newChildren.length);
const diffs = [];
for (let i = 0; i < maxLen; i++) {
const oldChild = oldChildren[i] || {};
const newChild = newChildren[i] || {};
const childDiff = {};
// 对比 text
if (oldChild.text !== newChild.text) {
childDiff.text = { old: oldChild.text, new: newChild.text };
}
// 对比所有富文本属性
const allKeys = new Set([
...Object.keys(oldChild),
...Object.keys(newChild),
]);
allKeys.delete('text');
for (const key of allKeys) {
if (oldChild[key] !== newChild[key]) {
childDiff[key] = { old: oldChild[key], new: newChild[key] };
}
}
if (Object.keys(childDiff).length > 0) {
diffs.push({ index: i, diff: childDiff, oldChild, newChild });
} else {
diffs.push({ index: i, diff: null, oldChild, newChild });
}
}
return diffs;
}相似度计算
// 计算相似度
const totalChanges = addedBlocks + deletedBlocks + modifiedBlocks;
const similarity = totalChanges === 0 ? 100 : Math.max(0, 100 - (totalChanges / maxLength) * 100);心得体会
感谢飞书给我的这次机会让我接触到更多的知识,也让我认识到了这群可以并肩作战的队友!
三次线上课程的学习让我收获颇丰,在项目研发与协作方面,明白了团队需明确目标、分工及功能优先级,同时认识到不同团队的开发等规范存在差异,要灵活看待;在 AI 技术相关领域,知晓 AI 浪潮影响深远,技术学习中应结合实践运用 AI 工具,关注能解决问题、提升效率的知识技能,如 AI 自动化编程落地、低代码与 AI 整合等方向;在文档编辑同步块实现上,了解到其是多人实时协作的核心机制,实现时需处理好数据传输、冲突合并等,要结合需求确定同步粒度和频率,也意识到可靠的同步机制对提升用户体验至关重要,技术细节打磨与实际场景适配同样关键。
参与富文本协同编辑器开发,于我是一次大学生涯的跨越式成长。基于 SlateJS 与 YJS 技术栈,我负责版本对比、搜索等模块,从需求拆解到功能落地,啃下技术硬骨头。SlateJS 灵活插件机制,让我实现精准操作轨迹留存、高效内容检索;AI 摘要将智能辅助化为可用功能。团队协作中,对齐 Git 规范、参与 Code Review,在与伙伴磨合里,懂了软件工程是协同作战,养出工程化思维。被问题追着跑的过程,从踩坑到解决,攒下 解决难题的成就感,也让我对前端开发深度与广度有新认知,这实战经验,是未来职业路的宝贵积淀,盼继续用技术折腾,延续成长!
