初探富文本之文檔diff算法

初探富文本之文檔diff算法

當我們實現在線文檔的系統時,通常需要考慮到文檔的版本控制與審覈能力,並且這是這是整個文檔管理流程中的重要環節,那麼在這個環節中通常就需要文檔的diff能力,這樣我們就可以知道文檔的變更情況,例如文檔草稿與線上文檔的差異、私有化版本A與版本B之間的差異等等,本文就以Quill富文本編輯器引擎爲基礎,探討文檔diff算法的實現。

描述

Quill是一個現代富文本編輯器,具備良好的兼容性及強大的可擴展性,還提供了部分開箱即用的功能。Quill是在2012年開源的,Quill的出現給富文本編輯器帶了很多新的東西,也是目前開源編輯器裏面受衆非常大的一款編輯器,至今爲止的生態已經非常的豐富,可以在GitHub等找到大量的示例,包括比較完善的協同實現。

我們首先可以思考一個問題,如果我們描述一段普通文本的話,那麼大概直接輸入就可以了,比如這篇文章本身底層數據結構就是純文本,而內容格式實際上是由編譯器通過詞法和語法編譯出來的,可以將其理解爲序列化和反序列化,而對於富文本編輯器來說,如果在編輯的時候如果高頻地進行序列話和反序列化,那麼性能消耗是不能接受的,所以數據結構就需要儘可能是易於讀寫的例如JSON對象,那麼用JSON來描述富文本的方式也可以多種多樣,但歸根結底就是需要在部分文字上掛載額外的屬性,例如A加粗B斜體的話,就是在A上掛載bold屬性,在B上掛載italic屬性,這樣的數據結構就可以描述出富文本的內容。

對於我們今天要聊的Quill來說,其數據結構描述是quill-delta,這個數據結構的設計非常棒,並且quill-delta同樣也可以是富文本OT協同算法的實現,不過我們在這裏不涉及協同的內容,而我們實際上要關注的diff能力更多的是數據結構層面的內容,也就是說我們diff的實際上是數據,那麼在quill-delta中這樣一段文本數據結構如下所示。當然quill-delta的表達可以非常豐富,通過retaininsertdelete操作可以完成對於整個文檔的內容描述增刪改的能力,我們在後邊實現對比視圖功能的時候會涉及這部分Op

{
  ops: [
    { insert: "那麼在" },
    { insert: "quill-delta", attributes: { inlineCode: true } },
    { insert: "中這樣" },
    { insert: "一段文本", attributes: { italic: true } },
    { insert: "的" },
    { insert: "數據結構", attributes: { bold: true } },
    { insert: "如下所示。\\n" },
  ],
};

看到這個數據結構我們也許會想這不就是一個普通的JSON嘛,那麼我們直接進行JSONdiff是不是就可以了,畢竟現在有很多現成的JSON算法可以用,這個方法對於純insert的文本內容理論上可行的只是粒度不太夠,沒有辦法精確到具體某個字的修改,也就是說依照quill-delta的設計想從A依照diff的結果構造delta進行compose生成到B這件事並不那麼輕鬆,是需要再進行一次轉換的。例如下面的JSON,我們diff的結果是刪除了insert: 1,添加了"insert": "12", "attributes": { "bold": true },而我們實際上作出的變更是對1的樣式添加了bold,並且添加了2附帶bold,那麼想要將這個diff結果應用到A上生成B需要做兩件事,一是更精細化的內容修改,二是將diff的結果轉換爲delta,所以我們需要設計更好的diff算法,儘可能減少整個過程的複雜度。

// A
[
  { "insert": "1" }
]

// B
[
  {
    "insert": "12",
    "attributes": { "bold": true } 
  }
]

diff-delta

在這裏我們的目標是希望實現更細粒度的diff,並且可以直接構造delta並且應用,也就是A.apply(diff(a, b)) = B,實際上在quill-delta中是存在已經實現好的diff算法,在這裏我們只是將其精簡了一些非insert的操作以便於理解,需要注意的是在這裏我們討論的是非協同模式下的diff,如果是已經實現OT的文檔編輯器可以直接從歷史記錄中取出相關的版本Op進行compose + invert即可,並不是必須要進行文檔全文的diff算法。

完整DEMO可以直接在https://codesandbox.io/p/devbox/z9l5sl中打開控制檯查看,在前邊我們提到了使用JSON進行diff後續還需要兩步處理數據,特別是對於粒度的處理看起來更加費勁,那麼針對粒度這個問題上不如我們換個角度思考,我們現在的是要處理富文本,而富文本就是帶屬性的文本,那麼我們是不是就可以採用diff文本的算法,然後針對屬性值進行額外的處理即可,這樣就可以將粒度處理得很細,理論上這種方式看起來是可行的,我們可以繼續沿着這個思路繼續探索下去。

首先是純文本的diff算法,那麼我們可以先簡單瞭解下diff-match-patch使用的的diff算法,該算法通常被認爲是最好的通用diff算法,是由Eugene W. Myers設計的https://neil.fraser.name/writing/diff/myers.pdf,其算法本身在本文就不展開了。由於diff-match-patch本身還存在matchpatch能力,而我們將要用到的算法實際上只需要diff的能力,那麼我們只需要使用fast-diff就可以了,其將匹配和補丁以及所有額外的差異選項都移除,只留下最基本的diff能力,其diff的結果是一個二維數組[FLAG, CONTENT][]

// diff.INSERT === 1;
// diff.EQUAL === 0;
// diff.DELETE === -1;
const origin = "Hello World";
const target = "Hello Diff";
console.log(fastDiff(origin, target)); // [[0, "Hello "], [-1, "World"], [1, "Diff"]]

那麼我們接下來就需要構造字符串了,quill-delta的數據格式在上邊以及提到過了,那麼構造起來也很簡單了,並且我們需要先構造一個Delta對象來承載我們對於deltadiff結果。

export const diffOps = (ops1: Op[], ops2: Op[]) => {
  const group = [ops1, ops2].map((delta) =>
    delta.map((op) => op.insert).join(""),
  );
  const result = diff(group[0], group[1]);

  const target = new Delta();
  const iter1 = new Iterator(ops1);
  const iter2 = new Iterator(ops2);
  // ...
}

這其中的Iterator是我們接下來要進行迭代取塊結構的迭代器,我們可以試想一下,因爲我們diff的結果是N個字的內容,而我們的Deltainsert塊也是N個字,在diff之後就需要對這兩個字符串的子字符串進行處理,所以我們需要對整個DeltaN個字的子字符串迭代處理,這部分數據處理方法我們就封裝在Iterator對象當中,我們需要先來整體看一下整代器的處理方法。

export class Iterator {
  // 存儲`delta`中所有`ops`
  ops: Op[];
  // 當前要處理的`ops index`
  index: number;
  // 當前`insert`字符串偏移量
  offset: number;

  constructor(ops: Op[]) {
    this.ops = ops;
    this.index = 0;
    this.offset = 0;
  }

  hasNext(): boolean {
    // 通過剩餘可處理長度來判斷是否可以繼續處理
    return this.peekLength() < Infinity;
  }

  next(length?: number): Op {
    // ...
  }

  peek(): Op {
    // 取的當前要處理的`op`
    return this.ops[this.index];
  }

  peekLength(): number {
    if (this.ops[this.index]) {
      // 返回當前`op`剩餘可以迭代的`insert`長度
      // 這裏如果我們的索引管理正確 則永遠不應該返回`0`
      return Op.length(this.ops[this.index]) - this.offset;
    } else {
      // 返回最大值
      return Infinity;
    }
  }
}

這其中next方法的處理方式要複雜一些,在next方法中我們的目標主要就是取insert的部分內容,注意我們每次調用insert是不會跨op的,也就是說每次next最多取當前indexop所存儲的insert長度,因爲如果取的內容超過了單個op的長度,其attributes的對應屬性是不一致的,所以不能直接合並,那麼此時我們就需要考慮到如果diff的結果比insert長的情況,也就是是需要將attributes這部分兼容,其實就是將diff結果同樣分塊處理。

next(length?: number): Op {
  if (!length) {
    // 這裏並不是不符合規則的數據要跳過迭代
    // 而是需要將當前`index`的`op insert`迭代完
    length = Infinity;
  }
  // 這裏命名爲`nextOp`實際指向的還是當前`index`的`op`
  const nextOp = this.ops[this.index];
  if (nextOp) {
    // 暫存當前要處理的`insert`偏移量
    const offset = this.offset;
    // 我們是純文檔表達的`InsertOp` 所以就是`insert`字符串的長度
    const opLength = Op.length(nextOp);
    // 這裏表示將要取`next`的長度要比當前`insert`剩餘的長度要長
    if (length >= opLength - offset) {
      // 處理剩餘所有的`insert`的長度
      length = opLength - offset;
      // 此時需要迭代到下一個`op`
      this.index += 1;
      // 重置`insert`索引偏移量
      this.offset = 0;
    } else {
      // 處理傳入的`length`長度的`insert`
      this.offset += length;
    }
    // 這裏是當前`op`攜帶的屬性
    const retOp: Op = {};
    if (nextOp.attributes) {
      // 如果存在的話 需要將其一併放置於`retOp`中
      retOp.attributes = nextOp.attributes;
    }
    // 通過之前暫存的`offset`以及計算的`length`截取`insert`字符串並構造`retOp`
    retOp.insert = (nextOp.insert as string).substr(offset, length);
    // 返回`retOp`
    return retOp;
  } else {
    // 如果`index`已經超出了`ops`的長度則返回空`insert`
    return { insert: "" };
  }
}

當前我們已經可以通過Iterator更細粒度地截取opinsert部分,接下來我們就回到我們對於diff的處理上,首先我們先來看看attributesdiff,簡單來看我們假設目前的數據結構就是Record<string, string>,這樣的話我們可以直接比較兩個attributes即可,diff的本質上是a經過一定計算使其可以變成b,這部分的計算就是diff的結果即a + diff = b,所以我們可以直接將全量的key迭代一下,如果兩個attrs的值不相同則通過判斷b的值來賦給目標attrs即可。

export const diffAttributes = (
  a: AttributeMap = {},
  b: AttributeMap = {},
): AttributeMap | undefined => {
  if (typeof a !== "object") a = {};
  if (typeof b !== "object") b = {};
  const attributes = Object.keys(a)
    .concat(Object.keys(b))
    .reduce<AttributeMap>((attrs, key) => {
      if (a[key] !== b[key]) {
        attrs[key] = b[key] === undefined ? "" : b[key];
      }
      return attrs;
    }, {});
  return Object.keys(attributes).length > 0 ? attributes : undefined;
};

因爲前邊我們實際上已經拆的比較細了,所以最後的環節並不會很複雜,我們的目標是構造a + diff = bdiff的部分,所以在構造diff的過程中要應用的目標是a,我們需要帶着這個目的去看整個流程,否則容易不理解對於delta的操作。在diff的整體流程中我們主要有三部分需要處理,分別是iter1iter2text diff,而我們需要根據diff出的類型分別處理,整體遵循的原則就是取其中較小長度作爲塊處理,在diff.INSERT的部分是從iter2insert置入delta,在diff.DELETE部分是從iter1delete的長度應用到delta,在diff.EQUAL的部分我們需要從iter1iter2分別取得op來處理attributesdiffop兜底替換。

// `diff`的結果 使用`delta`描述
const target = new Delta();
const iter1 = new Iterator(ops1);
const iter2 = new Iterator(ops2);

// 迭代`diff`結果
result.forEach((item) => {
  let op1: Op;
  let op2: Op;
  // 取出當前`diff`塊的類型和內容
  const [type, content] = item;
  // 當前`diff`塊長度
  let length = content.length; 
  while (length > 0) {
    // 本次循環將要處理的長度
    let opLength = 0; 
    switch (type) {
      // 標識爲插入的內容
      case diff.INSERT: 
        // 取 `iter2`當前`op`剩下可以處理的長度 `diff`塊還未處理的長度 中的較小值
        opLength = Math.min(iter2.peekLength(), length); 
        // 取出`opLength`長度的`op`並置入目標`delta` `iter2`移動`offset/index`指針
        target.push(iter2.next(opLength));
        break;
      // 標識爲刪除的內容
      case diff.DELETE: 
        // 取 `diff`塊還未處理的長度 `iter1`當前`op`剩下可以處理的長度 中的較小值
        opLength = Math.min(length, iter1.peekLength());
        // `iter1`移動`offset/index`指針
        iter1.next(opLength);
        // 目標`delta`需要得到要刪除的長度
        target.delete(opLength);
        break;
      // 標識爲相同的內容
      case diff.EQUAL:
        // 取 `diff`塊還未處理的長度 `iter1`當前`op`剩下可以處理的長度 `iter2`當前`op`剩下可以處理的長度 中的較小值
        opLength = Math.min(iter1.peekLength(), iter2.peekLength(), length);
        // 取出`opLength`長度的`op1` `iter1`移動`offset/index`指針
        op1 = iter1.next(opLength);
        // 取出`opLength`長度的`op2` `iter2`移動`offset/index`指針
        op2 = iter2.next(opLength);
        // 如果兩個`op`的`insert`相同
        if (op1.insert === op2.insert) {
          // 直接將`opLength`長度的`attributes diff`置入
          target.retain(
            opLength,
            diffAttributes(op1.attributes, op2.attributes),
          );
        } else {
          // 直接將`op2`置入目標`delta`並刪除`op1` 兜底策略
          target.push(op2).delete(opLength);
        }
        break;
      default:
        break;
    }
    // 當前`diff`塊剩餘長度 = 當前`diff`塊長度 - 本次循環處理的長度
    length = length - opLength; 
  }
});
// 去掉尾部的空`retain`
return target.chop();

在這裏我們可以舉個例子來看一下diff的效果,具體效果可以從https://codesandbox.io/p/devbox/z9l5slsrc/index.ts中打開控制檯看到效果,主要是演示了對於DELETE EQUAL INSERT的三種diff類型以及生成的delta結果,在此處是ops1 + result = ops2

const ops1: Op[] = [{ insert: "1234567890\n" }];
const ops2: Op[] = [
  { attributes: { bold: "true" }, insert: "45678" },
  { insert: "90123\n" },
];
const result = diffOps(ops1, ops2);
console.log(result);

// 1234567890 4567890123
// DELETE:-1 EQUAL:0 INSERT:1
// [[-1,"123"], [0,"4567890"], [1,"123"], [0,"\n"]]
// [
//   { delete: 3 }, // DELETE 123 
//   { retain: 5, attributes: { bold: "true" } }, // BOLD 45678
//   { retain: 2 }, // RETAIN 90 
//   { insert: "123" } // INSERT 123 
// ];

對比視圖

現在我們的文檔diff算法已經有了,接下來我們就需要切入正題,思考如何將其應用到具體的文檔上。我們可以先從簡單的方式開始,試想一下我們現在是對文檔AB進行了diff得到了patch,那麼我們就可以直接對diff進行修改,構造成我們想要的結構,然後將其應用到A中就可以得到對比視圖了,當然我們也可以A視圖中應用刪除內容,B視圖中應用增加內容,這個方式我們在後邊會繼續聊到。目前我們是想在A中直接得到對比視圖,其實對比視圖無非就是紅色高亮表示刪除,綠色高亮表示新增,而富文本本身可以直接攜帶格式,那麼我們就可以直接藉助於富文本能力來實現高亮功能。

依照這個思路實現的核心算法非常簡單,在這裏我們先不處理對於格式的修改,通過將DELETE的內容換成RETAIN並且附帶紅色的attributes,在INSERT的類型上加入綠色的attributes,並且將修改後的這部分patch組裝到Adelta上,然後將整個delta應用到新的對比視圖當中就可以了,完整DEMO可以參考https://codepen.io/percipient24/pen/eEBOjG

const findDiff = () => {
  const oldContent = quillLeft.getContents();
  const newContent = quillRight.getContents();
  const diff = oldContent.diff(newContent);
  for (let i = 0; i < diff.ops.length; i++) {
    const op = diff.ops[i];
    if (op.insert) {
      op.attributes = { background: "#cce8cc", color: "#003700" };
    }
    if (op.delete) {
      op.retain = op.delete;
      delete op.delete;
      op.attributes = { background: "#e8cccc", color: "#370000",  };
    }
  }
  const adjusted = oldContent.compose(diff);
  quillDiff.setContents(adjusted);
}

可以看到這裏的核心代碼就這麼幾行,通過簡單的解決方案實現複雜的需求當然是極好的,在場景不復雜的情況下可以實現同一文檔區域內對比,或者同樣也可以使用兩個視圖分別應用刪除和新增的delta。那麼問題來了,如果場景複雜起來,需要我們在右側表示新增的視圖中可以實時編輯並且展示diff結果的時候,這樣的話將diff-delta直接應用到文檔可能會增加一些問題,除了不斷應用delta到富文本可能造成的性能問題,在有協同的場景下還需要處理本地的Ops以及History,非協同的場景下就需要過濾相關的key避免diff結果落庫。

如果說上述的場景只是在基本功能上提出的進階能力,那麼在搜索/查找的場景下,直接將高亮應用到富文本內容上似乎並不是一個可行的選擇,試想一下如果我們直接將在數據層面上搜索出的內容應用到富文本上來實現高亮,我們就需要承受上邊提到的所有問題,頻繁地更改內容造成的性能損耗也是我們不能接受的。在slate中存在decorate的概念,可以通過構造Range來消費attributes但不會改變文檔內容,這就很符合我們的需求。所以我們同樣需要一種能夠在不修改富文本內容的情況下高亮部分內容,但是我們又不容易像slate一樣在編輯器底層渲染時實現這個能力,那麼其實我們可以換個思路,我們直接在相關位置上加入一個半透明的高亮蒙層就可以了,這樣看起來就簡單很多了,在這裏我們將之稱爲虛擬圖層。

理論上實現虛擬圖層很簡單無非是加一層DOM而已,但是這其中有很多細節需要考慮。首先我們考慮一個問題,如果我們將蒙層放在富文本正上方,也就是z-index是高於富文本層級的話,如果此時我們點擊蒙層,富文本會直接失去焦點,固然我們可以使用event.preventDefault來阻止焦點轉移的默認行爲,但是其他的行爲例如點擊事件等等同樣會造成類似的問題,例如此時富文本中某個按鈕的點擊行爲是用戶自定義的,我們遮擋住按鈕之後點擊事件會被應用到我們的蒙層上,而蒙層並不會是嵌套在按鈕之中的不會觸發冒泡的行爲,所以此時按鈕的點擊事件是不會觸發的,這樣並不符合我們的預期。那麼我們轉變一個思路,如果我們將z-index調整到低於富文本層級的話,事件的問題是可以解決的,但是又造成了新的問題,如果此時富文本的內容本身是帶有背景色的,此時我們再加入蒙層,那麼我們蒙層的顏色是會被原本的背景色遮擋的,而因爲我們的富文本能力通常是插件化的,我們不能控制用戶實現的背景色插件
必須要帶一個透明度,我們的蒙層也需要是一個通用的能力,所以這個方案也有侷限性。其實解決這個問題的方法很簡單,在CSS中有一個名爲pointer-events的屬性,當將其值設置爲none時元素永遠不會成爲鼠標事件的目標,這樣我們就可以解決方案一造成的問題,由此實現比較我們最基本的虛擬圖層樣式與事件處理,此外使用這個屬性會有一個比較有意思的現象,右擊蒙層在控制檯中是無法直接檢查到節點的,必須通過Elements面板才能選中DOM節點而不能反選。

<div style="pointer-events: none;"></div>
<!-- 
  無法直接`inspect`相關元素 可以直接使用`DOM`操作來查找調試
  [...document.querySelectorAll("*")].filter(node => node.style.pointerEvents === "none"); 
-->

在確定繪製蒙層圖形的方法之後,緊接着我們就需要確認繪製圖形的位置信息。因爲我們的富文本繪製的DOM節點並不是每個字符都帶有獨立的節點,而是有相同attributesops節點是相同的DOM節點,那麼此時問題又來了,我們的diff結果大概率不是某個DOM的完整節點,而是其中的某幾個字,此時想獲取這幾個字的位置信息是不能直接用Element.getBoundingClientRect拿到了,我們需要藉助document.createRange來構造range,在這裏需要注意的是我們處理的是Text節點,只有Text等節點可以設置偏移量,並且startendnode可以直接構造選區,並不需要保持一致。當然Quill中通過editor.getBounds提供了位置信息的獲取,我們可以直接使用其獲取位置信息即可,其本質上也是通過editor.scroll獲取實際DOM並封裝了document.createRange實現,以及處理了各種邊緣case

const el = document.querySelector("xxx");
const textNode = el.firstChild;

const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, 2);

const rect = range.getBoundingClientRect();
console.log(rect);

接下來我們還需要探討一個問題,diff的時候我們不能夠確定當前的結果的長度,在之前已經明確我們是對純文本實現的diff,那麼diff的結果可能會很長,那麼這個很長就有可能出現問題。我們直接通過editor.getBounds(index, length)得到的是rectrectangle,這個Range覆蓋的範圍是矩形,當我們的diff結果只有幾個字的時候,直接獲取rect是沒問題的,而如果我們的diff結果比較長的時候,就會出現兩個獲取位置時需要關注的問題:一個是單行內容過長,在編輯器中一行是無法完整顯示,由此出現了折行的情況;另一個是內容本身就是跨行的,也就是說diff結果是含有\n時的情況。

|  這裏只有一行內容內容內容內容內容 |
|內容內容內容內容內容內容內容內容內 |
|內容內容內容內容。              |

|  這裏有多行內容內容內容。       |
|  這裏有多行內容內容內容內容。    |
|  這裏有多行內容內容內容內容內容。 |

在這裏假設上邊的內容就是diff出的結果,至於究竟是INSERT/DELETE/RETAIN的類型我們暫時不作關注,我們當前的目標是實現高亮,那麼在這兩種情況下,如果直接通過getBounds獲取的rect矩形範圍作高亮的話,很明顯是會有大量的非文本內容即空白區域被高亮的,在這裏我們的表現會是會取的最大範圍的高亮覆蓋,實際上如果只是空白區域覆蓋我們還是可以接受的,但是試想一個情況,如果我們只是其中部分內容做了更改,例如第N行是完整的插入內容,在N+1行的行首同樣插入了一個字,此時由於我們N+1行的width被第N行影響,導致我們的高亮覆蓋了整個行,此時我們的diff高亮結果是不準確的,無論是折行還是跨行的情況下都存在這樣的情況,這樣的表現就是不能接受的了。

那麼接下來我們就需要解決這兩個問題,對於跨行位置計算的問題,在這裏可以採取較爲簡單的思路,我們只需要明確地知道究竟在哪裏出現了行的分割,在此處需要將diff的結果進行分割,也就是我們處理的粒度從文檔級別變化到了行級別。只不過在Quill中並沒有直接提供基於行Range級別的操作,所以我們需要自行維護行級別的index-length,在這裏我們簡單地通過delta insert來全量分割index-length,在這裏同樣也可以editor.scroll.lines來計算,當文檔內容改變時我們同樣也可以基於delta-changes維護索引值。此外如果我們的管理方式是通過多Quill實例來實現Blocks的話,這樣就是天然的Line級別管理,維護索引的能力實現起來會簡單很多,只不過diff的時候就需要一個Block樹級別的diff實現,如果是同idBlock進行diff還好,但是如果有跨Block進行diff的需求實現可能會更加複雜。

const buildLines = (content) => {
  const text = content.ops.map((op) => op.insert || "").join("");
  let index = 0;
  const lines = text.split("\n").map((str) => {
    // 需要注意我們的`length`是包含了`\n`的
    const length = str.length + 1;
    const line = { start: index, length };
    index = index + length;
    return line;
  });
  return lines;
}

當我們有行的index-length索引分割之後,接下來就是將原來的完整diff-index-length分割成Line級別的內容,在這裏需要注意的是行標識節點也就是\nattributes需要特殊處理,因爲這個節點的所有修改都是直接應用到整個行上的,例如當某行從二級標題變成一級標題時就需要將整個行都高亮標記爲樣式變更,當然本身標題可能也會存在內容增刪,這部分高亮是可以疊加不同顏色顯示的,這也是我們需要維護行粒度Range的原因之一。

return (index, length, ignoreLineMarker = true) => {
  const ranges = [];
  // 跟蹤
  let traceLength = length;
  // 可以用二分搜索查找索引首尾 `body`則直接取`lines` 查找結果則需要增加`line`標識
  for (const line of lines) {
    // 當前處理的節點只有`\n`的情況 標識爲行尾並且有獨立的`attributes`
    if (length === 1 && index + length === line.start + line.length) {
      // 如果忽略行標識則直接結束查找
      if (ignoreLineMarker) break;
      // 需要構造整個行內容的`range`
      const payload = { index: line.start, length: line.length - 1 };
      !ignoreLineMarker && payload.length > 0 && ranges.push(payload);
      break;
    }
    // 迭代行 通過行索引構造`range`
    // 判斷當前是否還存在需要分割的內容 需要保證剩餘`range`在`line`的範圍內
    if (
      index < line.start + line.length &&
      line.start <= index + traceLength
    ) {
      const nextIndex = Math.max(line.start, index);
      // 需要比較 追蹤長度/行長度/剩餘行長度
      const nextLength = Math.min(
        traceLength,
        line.length - 1,
        line.start + line.length - nextIndex
      );
      traceLength = traceLength - nextLength;
      // 構造行內`range`
      const payload = { index: nextIndex, length: nextLength };
      if (nextIndex + nextLength === line.start + line.length) {
        // 需要排除邊界恰好爲`\n`的情況
        payload.length--;
      }
      payload.length > 0 && ranges.push(payload);
    } else if (line.start > index + length || traceLength <= 0) {
      // 當前行已經超出範圍或者追蹤長度已經爲`0` 則直接結束查找
      break;
    }
  }
  return ranges;
};

那麼緊接着我們需要解決下一個問題,對於單行內容較長引起折行的問題,因爲在上邊我們已經將diff結果按行粒度劃分好了,所以我們可以主要關注於如何渲染高亮的問題上。在前邊我們提到過了,我們不能直接將調用getBounds得到的rect直接繪製到文本上,那麼我們仔細思考一下,一段文本實際上是不是可以拆爲三段,即首行head、內容body、尾行tail,也就是說只有行首與行尾纔會出現部分高亮的牆狂,這裏就需要單獨計算rect,而body部分必然是完整的rect,直接將其渲染到相關位置就可以了。那麼依照這個理論我們就可以用三個rect來表示單行內容的高亮就足夠了,而實際上getBounds返回的數據是足夠支撐我們分三段處理單行內容的,我們只需要取得首headtailrectbody部分的rect可以直接根據這兩個rect計算出來,我們還是需要根據實際的折行數量分別討論的,如果是隻有單行的情況,那麼只需要head就足夠了,如果是兩行的情況那麼就需要藉助headtail來渲染了,body在這裏起到了佔位的作用,如果是多行的時候,那麼就需要headbodytail渲染各自的內容,來保證圖層的完整性。

// 獲取邊界位置
const startRect = editor.getBounds(range.index, 0);
const endRect = editor.getBounds(range.index + range.length, 0);
// 單行的塊容器
const block = document.createElement("div");
block.style.position = "absolute";
block.style.width = "100%";
block.style.height = "0";
block.style.top = startRect.top + "px";
block.style.pointerEvents = "none";
const head = document.createElement("div");
const body = document.createElement("div");
const tail = document.createElement("div");
// 依據不同情況渲染
if (startRect.top === endRect.top) {
  // 單行(非折行)的情況 `head`
  head.style.marginLeft = startRect.left + "px";
  head.style.height = startRect.height + "px";
  head.style.width = endRect.right - startRect.left + "px";
  head.style.backgroundColor = color;
} else if (endRect.top - startRect.bottom < startRect.height) {
  // 兩行(折單次)的情況 `head + tail` `body`佔位
  head.style.marginLeft = startRect.left + "px";
  head.style.height = startRect.height + "px";
  head.style.width = startRect.width - startRect.left + "px";
  head.style.backgroundColor = color;
  body.style.height = endRect.top - startRect.bottom + "px";
  tail.style.width = endRect.right + "px";
  tail.style.height = endRect.height + "px";
  tail.style.backgroundColor = color;
} else {
  // 多行(折多次)的情況 `head + body + tail`
  head.style.marginLeft = startRect.left + "px";
  head.style.height = startRect.height + "px";
  head.style.width = startRect.width - startRect.left + "px";
  head.style.backgroundColor = color;
  body.style.width = "100%";
  body.style.height = endRect.top - startRect.bottom + "px";
  body.style.backgroundColor = color;
  tail.style.marginLeft = 0;
  tail.style.height = endRect.height + "px";
  tail.style.width = endRect.right + "px";
  tail.style.backgroundColor = color;
}

解決了上述兩個問題之後,我們就可以將delta應用到diff算法獲取結果,並且將其按行劃分構造出新的Range,在這裏我們想要實現的是左視圖體現DELETE內容,右視圖體現INSERT + RETAIN的內容,在這裏我們只需要根據diff的不同類型,分別將構造出的Range存儲到不同的數組中,最後在根據Range藉助editor.getBounds獲取位置信息,構造新的圖層DOM在相關位置實現高亮即可。

const diffDelta = () => {
  const prevContent = prev.getContents();
  const nextContent = next.getContents();
  // ...
  // 構造基本數據
  const toPrevRanges = buildLines(prevContent);
  const toNextRanges = buildLines(nextContent);
  const diff = prevContent.diff(nextContent);
  const inserts = [];
  const retains = [];
  const deletes = [];
  let prevIndex = 0;
  let nextIndex = 0;
  // 迭代`diff`結果並進行轉換
  for (const op of diff.ops) {
    if (op.delete !== undefined) {
      // `DELETE`的內容需要置於左視圖 紅色高亮
      deletes.push(...toPrevRanges(prevIndex, op.delete));
      prevIndex = prevIndex + op.delete;
    } else if (op.retain !== undefined) {
      if (op.attributes) {
        // `RETAIN`的內容需要置於右視圖 紫色高亮
        retains.push(...toNextRanges(nextIndex, op.retain, false));
      }
      prevIndex = prevIndex + op.retain;
      nextIndex = nextIndex + op.retain;
    } else if (op.insert !== undefined) {
      // `INSERT`的內容需要置於右視圖 綠色高亮
      inserts.push(...toNextRanges(nextIndex, op.insert.length));
      nextIndex = nextIndex + op.insert.length;
    }
  }
  // 根據轉換的結果渲染`DOM`
  buildLayerDOM(prev, deleteRangeDOM, deletes, "rgba(245, 63, 63, 0.3)");
  buildLayerDOM(next, insertRangeDOM, inserts, "rgba(0, 180, 42, 0.3)");
  buildLayerDOM(next, retainRangeDOM, retains, "rgba(114, 46, 209, 0.3)");
};
// `diff`渲染時機
prev.on("text-change", _.debounce(diffDelta, 300));
next.on("text-change", _.debounce(diffDelta, 300));
window.onload = diffDelta;

總結一下整體的流程,實現基於虛擬圖層的diff我們需要 diff算法、構造Range、計算Rect、渲染DOM,實際上想要做好整個能力還是比較複雜的,特別是有很多邊界case需要處理,例如某些文字應用了不同字體或者一些樣式,導致渲染高度跟普通文本不一樣,而diff的邊緣又恰好落在了此處就可能會造成我們的rect計算出現問題,從而導致渲染圖層節點的樣式出現問題。在這裏我們還是沒有處理類似的問題,只是將整個流程打通,沒有特別關注於邊緣case,完整的DEMO可以直接訪問https://codesandbox.io/p/sandbox/quill-diff-view-369jt6查看。

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://quilljs.com/docs/api/
https://zhuanlan.zhihu.com/p/370480813
https://www.npmjs.com/package/quill-delta
https://github.com/quilljs/quill/issues/1125
https://developer.mozilla.org/zh-CN/docs/Web/API/Range
https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createRange
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章