Canvas圖形編輯器-我的剪貼簿裡究竟有什麼資料

China Soft發表於2024-05-07

https://www.cnblogs.com/WindrunnerMax/p/18176735

在這裡我們先來聊聊我們究竟應該如何操作剪貼簿,也就是我們在瀏覽器的複製貼上事件,並且在此基礎上聊聊我們在Canvas圖形編輯器中應該如何控制焦點以及如何實現複製貼上行為。

  • 線上編輯: https://windrunnermax.github.io/CanvasEditor
  • 開源地址: https://github.com/WindrunnerMax/CanvasEditor

關於Canvas簡歷編輯器專案的相關文章:

  • 社群老給我推Canvas,我也學習Canvas做了個簡歷編輯器
  • Canvas圖形編輯器-資料結構與History(undo/redo)
  • Canvas圖形編輯器-我的剪貼簿裡究竟有什麼資料
  • Canvas簡歷編輯器-圖形繪製與狀態管理(輕量級DOM)
  • Canvas簡歷編輯器-Monorepo+Rspack工程實踐

剪貼簿#

我們在平時使用一些線上文件編輯器的時候,可能會好奇一個問題,為什麼我能夠直接把格式複製出來,而不僅僅是純文字,甚至於說從瀏覽器中複製內容到Office Word都可以保留格式,看起來是不是一件很神奇的事情,不過當我們瞭解到剪貼簿的基本操作之後,就可以瞭解這其中的底層實現了。

說到剪貼簿,我們可能以為我們複製的就是純文字,當然顯然光靠複製純文字我們是做不到這一點的,所以實際上剪貼簿是可以儲存複雜內容的,那麼在這裡我們以Word為例,當我們從Word中複製文字時,其實際上是會在剪貼簿中寫入這麼幾個key值:

Copy
text/plain
text/html
text/rtf
image/png

看著text/plain是不是很眼熟,這明顯就是我們常見的Content-Type或者稱作MIME-Type,所以說我們是不是可以認為剪貼簿是一個Record<string, string>的型別,但是別忽略了我們還有一個image/png型別,因為我們的剪貼簿是可以複製檔案的,所以我們常用的剪貼簿型別就是Record<string, string | File>,例如此時複製這段文字在剪貼簿中就是如下內容

Copy
text/plain
例如此時複製這段文字在剪貼簿中就是如下內容

text/html
<meta charset='utf-8'><strong style="...">例如此時複製這段文字</strong><em style="...">在剪貼簿中就是如下內容</em>

那麼我們貼上的時候就很明顯了,我們只需要從剪貼簿裡讀取內容就可以了,例如我們從語雀複製內容到飛書中,我們在語雀複製的時候會將text/plain以及text/html寫入剪貼簿,在貼上到飛書的時候就可以首先檢查是否有text/htmlkey,如果有的話就可以讀取出來,並且將其解析成為飛書自己的私有格式,就可以透過剪貼簿來保持內容格式貼上到飛書了,如果沒有text/html的話,就直接將text/plain的內容寫到私有的JSON資料即可。

此外,我們還可以考慮到一個問題,在上邊的例子中實際上我們是複製時需要將JSON轉到HTML字串,在貼上時需要將HTML字串轉換為JSON,這都是需要進行序列化與反序列化的,是需要有效能消耗以及內容損失的,所以是不是能減少這部分消耗,那麼當然是可以的,通常來說如果是在應用內直接直接貼上的話,可以直接透過剪貼簿的資料直接compose到當前的JSON即可,這樣就可以更完整地保持內容以及減少對於HTML解析的消耗。例如在飛書中,會有docx/text的獨立Clipboard Key以及data-lark-record-data作為獨立JSON資料來源。

那麼至此我們已經瞭解到剪貼簿的工作原理,緊接著我們就來聊一聊如何進行復制操作,說到複製我們可能通常會想到clipboard.js,如果需要相容性比較高的話可以考慮,但是如果需要在現在瀏覽器中使用的話,則可以直接考慮使用HTML5規範的API完成,在瀏覽器中關於複製的API常用的有兩種,分別是document.execCommand("copy")以及navigator.clipboard.write

對於document.execCommand("copy")來說,我們可以直接藉助textarea + execCommand來執行寫剪貼簿的操作,在這裡需要注意的是如果這個事件必須要是isTrusted的事件,也就是說這個事件必須要是使用者觸發的,例如點選事件、鍵盤事件等等,如果我們在開啟頁面後直接執行這段程式碼的話,則實際上是不會觸發的。此外,如果在控制檯執行這段程式碼的話,寫入剪貼簿是可行的,因為我們通常會用回車這個操作來執行程式碼,所以這個事件是isTrusted的。

Copy
const TEXT_PLAIN = "text/plain";

const data = {"text/plain": "1", "text/html":"<div>1</div>"};
const textarea = document.createElement("textarea");
textarea.addEventListener(
  "copy",
  event => {
    for (const [key, value] of Object.entries(data)) {
      event.clipboardData && event.clipboardData.setData(key, value);
    }
    event.stopPropagation();
    event.preventDefault();
  },
  true
);
textarea.style.position = "fixed";
textarea.style.left = "-999px";
textarea.style.top = "-999px";
textarea.value = data[TEXT_PLAIN];
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);

對於navigator.clipboard來說,如果我們只寫入純文字的話是比較簡單的,直接呼叫write方法即可,只不過需要注意Document is focused,也就是焦點需要在當前頁面內。如果需要在剪貼簿中寫入其他的值,則需要ClipboardItem物件來寫入Blob,在這裡需要注意的是,FireFox只有Nightly中有定義,所以在這裡需要判斷下,如果不存在這個物件的話就需要走降級的複製,可以使用上述的document.execCommand API

Copy
const data = {"text/plain": "1", "text/html":"<div>1</div>"};
if (navigator.clipboard && window.ClipboardItem) {
  const dataItems = {};
  for (const [key, value] of Object.entries(data)) {
    const blob = new Blob([value], { type: key });
    dataItems[key] = blob;
  }
  navigator.clipboard.write([new ClipboardItem(dataItems)]);
}

緊接著我們可以聊下貼上行為,在這裡我們可以用onPaste事件以及navigator.clipboard.read方法,對於navigator.clipboard.read方法來說,我們可以直接讀取並且列印即可,在這裡需要注意的是需要Document is focused,所以這裡我們需要在控制檯延時幾秒,然後將滑鼠點選到頁面上才可以正常列印,此外還有一個問題是列印的types並不完整,可能是必須要規範內的MIME Type才直接支援,自定義的key不支援。

Copy
navigator.clipboard.read().then(res => {
  for (const item of res) {
    const types = item.types;
    for (const type of types) {
      item.getType(type).then(data => {
        const reader = new FileReader();
        reader.readAsText(data, "utf-8");
        reader.onload = () => {
          console.info(type, reader.result);
        };
      });
    }
  }
});

針對onPaste事件,我們可以透過clipboardData獲取更加完整的相關資料,我們可以獲取比較完整的資料以及構造File資料,這裡可以使用下面的程式碼直接在控制檯執行,並且可以將內容貼上到其中,這樣就可以列印出當前剪貼簿的內容了。

Copy
const input = document.createElement("input");
input.style.position = "fixed";
input.style.top = "100px";
input.style.right = "10px";
input.style.zIndex = "999999";
input.style.width = "200px";
input.placeholder = "Read Clipboard On Paste";
input.addEventListener("paste", event => {
  const clipboardData = event.clipboardData || window.clipboardData;
  for (const item of clipboardData.items) {
    console.log(`%c${item.type}`, " color: #fff; padding: 3px 5px;");
    console.log(item.kind === "file" ? item.getAsFile() : clipboardData.getData(item.type));
  }
});
document.body.appendChild(input);

Clipboard模組#

上邊我們已經瞭解到如何操作我們的剪貼簿了,那麼下面我們就需要將其應用在編輯器當中了,不過我們首先需要關注焦點問題,因為在編輯器中我們不能保證所有的焦點都是在編輯器Canvas上的,比如我彈出一個輸入框輸入畫布大小的時候,也是可能會使用貼上行為的,而如果此時進行貼上是會觸發document上的onPaste事件的,那麼此時就有可能錯誤的將不應該貼上的內容插入到剪貼簿當中了,所以我們需要處理焦點,也就是說我們需要確定當前操作是在編輯器上的時候才觸發Copy/Paste行為。

平時我做富文字相關的功能比較多,所以在實現畫板的時候總想按照富文字的設計思路來實現,同樣的因為之前也說過我們需要實現History以及在編輯皮膚富文字的能力,所以焦點就很重要,如果焦點不在畫板上的時候如果按下Undo/Redo鍵畫板是不應該響應的,所以現在就需要有一個狀態來控制當前焦點是否在Canvas上,經過調研發現了兩個方案,方案一是使用document.activeElement,但是Canvas是不會有焦點的,所以需要將tabIndex="-1"屬性賦予Canvas元素,這樣就可以透過activeElement拿到焦點狀態了,方案二是在Canvas上方再覆蓋一層div,透過pointerEvents: none來防止事件的滑鼠指標事件,但是此時透過window.getSelection是可以拿到焦點元素的,此時只需要再判斷焦點元素是不是設定的這個元素就可以了。

當焦點的問題解決之後,我們就可以直接進行剪貼簿的讀寫了,這部分實現就比較簡單了,在複製的時候需要注意到將內容序列化為JSON字串,並且還要寫入一個text/plain的佔位符,這樣可以讓使用者在其他地方貼上的時候是有感知的,對於我們的編輯器自身而言是不需要感知的。

Copy
public static KEY = "SKETCHING_CLIPBOARD_KEY";
private copyFromCanvas = (e: ClipboardEvent, isCut = false) => {
  const clipboardData = e.clipboardData;
  if (clipboardData) {
    const ids = this.editor.selection.getActiveDeltaIds();
    if (ids.size === 0) return void 0;
    const data: Record<string, DeltaLike> = {};
    for (const id of ids) {
      const delta = this.editor.deltaSet.get(id);
      if (!delta) return void 0;
      data[id] = delta.toJSON();
      if (isCut) {
        const parentId = this.editor.state.getDeltaStateParentId(id);
        this.editor.state.apply(new Op(OP_TYPE.DELETE, { id, parentId }));
      }
    }
    const str = TSON.stringify(data);
    str && clipboardData.setData(Clipboard.KEY, str);
    clipboardData.setData("text/plain", "請在編輯器中貼上");
    isCut && this.editor.canvas.mask.clearWithOp();
    e.stopPropagation();
    e.preventDefault();
  }
};

貼上的這部分需要處理一個互動問題,使用者肯定是希望在多選時也可以直接貼上多個圖形的,所以在此處我們需要處理好貼上的位置,在這裡我用的方法是取的所有選中圖形的中點,在使用者觸發貼上行為時將中點對齊到此時滑鼠所在的位置,並且計算好偏移量應用到反序列化的圖形上,這樣就可以做到跟隨使用者的滑鼠進行貼上了,這裡還有一點是需要替換掉貼上圖形的id,這是新的圖形當然就需要有新的唯一識別符號。

Copy
public static KEY = "SKETCHING_CLIPBOARD_KEY";
private onPaste = (e: ClipboardEvent) => {
  if (!this.editor.canvas.isActive()) return void 0;
  const clipboardData = e.clipboardData;
  if (clipboardData) {
    const str = clipboardData.getData(Clipboard.KEY);
    const data = str && TSON.parse<Record<string, DeltaLike>>(str);
    if (data) {
      let range: Range | null = null;
      Object.values(data).forEach(deltaLike => {
        const { x, y, width, height } = deltaLike;
        const current = Range.fromRect(x, y, width, height);
        range = range ? range.compose(current) : current;
      });
      const compose = range as unknown as Range;
      if (compose) {
        const center = compose.center();
        const cursor = this.editor.canvas.root.cursor;
        const { x, y } = center.diff(cursor);
        Object.values(data).forEach(deltaLike => {
          const id = getUniqueId();
          deltaLike.id = id;
          deltaLike.x = deltaLike.x + x;
          deltaLike.y = deltaLike.y + y;
          const delta = DeltaSet.create(deltaLike);
          delta &&
            this.editor.state.apply(new Op(OP_TYPE.INSERT, { delta, parentId: ROOT_DELTA }));
        });
      }
    }
    e.stopPropagation();
    e.preventDefault();
  }
};

最後#

本文我們介紹總結了應該如何操作剪貼簿,也就是我們在瀏覽器的複製貼上行為,並且在此基礎上聊到了在Canvas圖形編輯器中的焦點問題以及如何實現複製貼上行為,雖然暫時不涉及到Canvas本身,但是這都是作為編輯器本身的基礎能力,也是通用的能力可以學習。針對於這個編輯器我們可以介紹的能力還有很多,整體來看會涉及到資料結構、History模組、複製貼上模組、畫布分層、事件管理、無限畫布、按需繪製、效能最佳化、焦點控制、參考線、富文字、快捷鍵、層級控制、渲染順序、事件模擬、PDF排版等等,整體來說還是比較有意思的,歡迎關注我並留意後續的文章。

相關文章