如何在Quill中解析Delta并替换内容,同时保留选区与光标位置?
在Quill中实现表情与链接自动替换并保留选区
Quill确实基于Delta作为核心数据模型,并且支持Operational Transformation(OT)来处理协同编辑,你的理解是对的。要实现无工具栏的自动替换,核心是监听文本变化事件,基于Delta做内容替换,同时妥善处理光标/选区位置,具体步骤如下:
1. 监听用户输入事件
只响应用户主动输入的内容变化,避免程序自身修改触发循环:
quill.on('text-change', (delta, oldDelta, source) => { if (source !== 'user') return; // 忽略非用户触发的修改 });
2. 保存当前选区位置
在处理替换前,先记录光标或选区的位置,后续用于恢复:
const range = quill.getSelection(); if (!range) return; // 编辑器失焦时跳过处理
3. 生成替换用的Delta操作
表情替换
遍历表情映射,从后往前查找匹配的触发文本,生成对应的删除+插入操作(从后往前处理可避免索引偏移):
const emojiMap = { '<3': '❤️', ':(': '😢', ':)': '😊' }; const text = quill.getText(); const ops = []; Object.entries(emojiMap).forEach(([trigger, emoji]) => { let index = text.lastIndexOf(trigger); while (index !== -1) { // 先保留前面的内容,再删除触发文本,插入表情 ops.push({ retain: index }); ops.push({ delete: trigger.length }); ops.push({ insert: emoji }); index = text.lastIndexOf(trigger, index - 1); } });
链接替换
借助Autolinker识别文本中的链接,生成带link属性的插入操作:
Autolinker.link(text, { replaceFn: (match) => { if (match.getType() === 'url') { const start = match.getOffset(); const length = match.getLength(); const url = match.getUrl(); // 同样从后往前添加操作 ops.push({ retain: start }); ops.push({ delete: length }); ops.push({ insert: match.getOriginalText(), attributes: { link: url } }); } return true; } });
4. 应用Delta并恢复选区
将生成的操作反转(因为是从后往前收集的,需转为从前往后执行),静默更新内容后调整并恢复选区:
if (ops.length > 0) { ops.reverse(); const replacementDelta = new Quill.Constructor.Delta(ops); // 用silent模式避免再次触发text-change quill.updateContents(replacementDelta, 'silent'); // 计算替换导致的光标偏移:表情替换会改变文本长度 let offsetAdjustment = 0; Object.entries(emojiMap).forEach(([trigger, emoji]) => { const matchCount = (text.match(new RegExp(trigger, 'g')) || []).length; offsetAdjustment += matchCount * (emoji.length - trigger.length); }); // 恢复并调整选区位置 quill.setSelection({ index: range.index + offsetAdjustment, length: range.length }); }
完整初始化示例
// 初始化Quill,禁用工具栏 const quill = new Quill('#editor', { theme: 'snow', modules: { toolbar: false } }); // 执行上述替换逻辑的完整代码 const emojiMap = { '<3': '❤️', ':(': '😢', ':)': '😊' }; quill.on('text-change', (delta, oldDelta, source) => { if (source !== 'user') return; const range = quill.getSelection(); if (!range) return; const text = quill.getText(); const ops = []; // 处理表情替换 Object.entries(emojiMap).forEach(([trigger, emoji]) => { let index = text.lastIndexOf(trigger); while (index !== -1) { ops.push({ retain: index }); ops.push({ delete: trigger.length }); ops.push({ insert: emoji }); index = text.lastIndexOf(trigger, index - 1); } }); // 处理链接替换 Autolinker.link(text, { replaceFn: (match) => { if (match.getType() === 'url') { const start = match.getOffset(); const length = match.getLength(); const url = match.getUrl(); ops.push({ retain: start }); ops.push({ delete: length }); ops.push({ insert: match.getOriginalText(), attributes: { link: url } }); } return true; } }); // 应用替换并恢复选区 if (ops.length > 0) { ops.reverse(); const replacementDelta = new Quill.Constructor.Delta(ops); quill.updateContents(replacementDelta, 'silent'); let offsetAdjustment = 0; Object.entries(emojiMap).forEach(([trigger, emoji]) => { const matchCount = (text.match(new RegExp(trigger, 'g')) || []).length; offsetAdjustment += matchCount * (emoji.length - trigger.length); }); quill.setSelection({ index: range.index + offsetAdjustment, length: range.length }); } });
关键注意事项
- 必须用Delta操作修改内容,不能直接操作innerHTML,否则会破坏Quill的内部模型一致性
- 用
silent模式更新内容,防止修改触发新一轮text-change事件,造成循环 - 从后往前处理替换,避免前面的修改导致后续索引偏移
- 表情替换会改变文本长度,需要计算偏移量调整选区;链接替换文本长度不变,无需额外调整
内容的提问来源于stack exchange,提问作者Benoît Lahoz
相关产品推荐
相关产品推荐

