Skip to content

林熙然答辩

最近访问文档列表

  • 访问记录生成:在访问文档详情时后台自动生成一条访问记录存到数据表里
  • 实现最近访问记录列表查看

文档内容搜索

内容数据格式处理

数据库存储的内容是 SlateJS 内容的 JSON 格式,而实际的内容在里面的 text 字段,递归获取所有 text 字段将匹配到的内容提取出来返回给前端。

模糊搜索

通过关键字对进行文档内容正则匹配,提升搜索体验的容错性和便捷性。

javascript
// 递归提取所有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}`);
      }
    }
  }
};

性能优化

  • 采用防抖方式进行搜索,提高性能,避免交互抖动
  • 前端展示匹配搜索结果,高亮展示关键字
    • 直观呈现匹配逻辑,降低理解成本
    • 加速信息定位,提升阅读效率
javascript
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 接口。

javascript
// 提取实际内容
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 组件里面配置好每种标记的格式渲染的对应样式
javascript
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 组件里面配置好每种标记的格式渲染的对应样式
javascript
export const toggleMark = (editor, format) => {
  const isActive = isMarkActive(editor, format);

  if (isActive) {
    Editor.removeMark(editor, format);
  } else {
    Editor.addMark(editor, format, true);
  }
};

悬浮工具栏

  • 采用 Portal 布局,将里面工具栏渲染到最外层 body 中
  • 采用相应的位置算法定位悬浮工具栏位置:获取元素相对于可视窗口的位置+页面滚动位置+微调像素
javascript
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 删除线
javascript
export const HOTKEYS = {
  'mod+b': 'bold',
  'mod+i': 'italic',
  'mod+u': 'underline',
  'mod+`': 'code',
  'mod+shift+x': 'strikethrough',
};

通过 onKeyDown 函数获取键盘操作对对应选取进行标记切换文本模式,使用户进行富文本编辑更加便利,提升用户体验。

历史版本对比

通过用户选择对应的历史版本采用 diff 算法进行对比:

  • 有助于用户追踪变更,明确修改脉络
  • 便于团队协作者之间协同操作
  • 计算出版本相似度,让用户更好的管理历史版本

差异检测

javascript
// 判断是新增块、删除块还是差异块
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 差异对比

javascript
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;
}

相似度计算

javascript
// 计算相似度
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,在与伙伴磨合里,懂了软件工程是协同作战,养出工程化思维。被问题追着跑的过程,从踩坑到解决,攒下 解决难题的成就感,也让我对前端开发深度与广度有新认知,这实战经验,是未来职业路的宝贵积淀,盼继续用技术折腾,延续成长!

Released under the MIT License.