富文本編輯器剪貼板模塊基石-序列化與反序列化
在富文本編輯器中,序列化與反序列化是非常重要的環節,其涉及到了編輯器的內容復制、粘貼、導入導出等模塊。當用戶在編輯器中進行復制操作時,富文本內容會被轉換為標準的HTML格式,并存儲在剪貼板中。而在粘貼操作中,編輯器則需要將這些HTML內容解析并轉換為編輯器的私有JSON結構,以便于實現跨編輯器內容的統一管理。
描述
我們平時在使用一些在線文檔編輯器的時候,可能會好奇一個問題,為什么我們能夠直接把格式復制出來,而不僅僅是純文本,甚至于說從瀏覽器中復制內容到Office Word都可以保留格式。這看起來是不是一件很神奇的事情,不過當我們了解到剪貼板的基本操作之后,就可以了解這其中的底層實現了。
說到剪貼板的操作,在執行復制行為的時候,我們可能會認為復制的就是純文本,然而顯然光靠復制純文本我們是做不到上述的功能。所以實際上剪貼板是可以存儲復雜內容的,那么在這里我們以Word為例,當我們從Word中復制文本時,其實際上是會在剪貼板中寫入這么幾個key值:
text/plain
text/html
text/rtf
image/png
看著text/plain是不是很眼熟,這顯然就是我們常見的Content-Type或者稱作MIME-Type,所以說我們是不是可以認為剪貼板是一個Record<string, string>的結構類型。但是別忽略了我們還有一個image/png類型,因為我們的剪貼板是可以直接復制文件的,所以我們常用的剪貼板類型就是Record<string, string | File>,例如此時復制這段文字在剪貼板中就是如下內容。
text/plain
例如此時復制這段文字在剪貼板中就是如下內容
text/html
<meta charset="utf-8"><strong style="...">例如此時復制這段文字</strong><em style="...">在剪貼板中就是如下內容</em>
那么我們執行粘貼操作的時候就很明顯了,只需要從剪貼板里讀取內容就可以。例如我們從語雀復制內容到飛書中時,在語雀復制的時候會將text/plain以及text/html寫入剪貼板,在粘貼到飛書的時候就可以首先檢查是否有text/html的key,如果有的話就可以讀取出來,并且將其解析成為飛書自己的私有格式,就可以通過剪貼板來保持內容格式粘貼到飛書了。而如果沒有text/html的話,就直接將text/plain的內容寫到私有的JSON數據即可。
此外,我們還可以考慮到一個問題,在上邊的例子中實際上我們是復制時需要將JSON轉到HTML字符串,在粘貼時需要將HTML字符串轉換為JSON,這都是需要進行序列化與反序列化的,是需要有性能消耗以及內容損失的,所以是不是能夠減少這部分消耗。通常來說如果是在應用內直接直接粘貼的話,可以直接通過剪貼板的數據直接compose到當前的JSON即可,這樣就可以更完整地保持內容以及減少對于HTML解析的消耗。例如在飛書中,會有docx/text的獨立clipboard key以及data-lark-record-data作為獨立JSON數據源。
那么至此我們已經了解到剪貼板的工作原理,緊接著我們就來聊一聊如何進行序列化的操作。說到復制我們可能通常會想到clipboard.js,如果需要兼容性比較高的話(IE)可以考慮,但是如果需要在現在瀏覽器中使用的話,則可以直接考慮使用HTML5規范的API完成,在瀏覽器中關于復制的API常用的有兩種,分別是document.execCommand("copy")以及navigator.clipboard.write/writeText。
document.execCommand("selectAll");
const res = document.execCommand("copy");
console.log(res); // true
const dataItems: Record<string, Blob> = {};
for (const [key, value] of Object.entries(data)) {
const blob = new Blob([value], { type: key });
dataItems[key] = blob;
}
navigator.clipboard.write([new ClipboardItem(dataItems)])
而對于序列化即粘貼行為,則存在document.execCommand("paste")以及navigator.clipboard.read/readText可用。但是需要注意的是execCommand這個API的調用總是會失敗,clipboard.read則需要用戶主動授權。關于這個問題,我們在先前通過瀏覽器擴展對可信事件的研究也已經有過結論,在擴展中即使保持清單中的clipboardRead權限聲明,也無法直接讀取剪貼板,必須要在Content Script甚至chrome.debugger中才可以執行。
document.addEventListener("paste", (e) => {
const data = e.clipboardData;
console.log(data);
});
const res = document.execCommand("paste");
console.log(res); // false
navigator.clipboard.read().then(res => {
for (const item of res) {
item.getType("text/html").then(console.log).catch(() => null)
}
});
當然這里并不是此時研究的重點,我們關注的是內容的序列化與反序列化,即在富文本編輯器的復制粘貼模塊的設計。當然這個模塊還會有更廣泛的用途,例如序列化的場景有交付Word文檔、輸出Markdown格式等,反序列的場景有導入Markdown文檔等。而我們對于這個模塊的設計,則需要考慮到以下幾個問題:
- 插件化,編輯器中的模塊本身都是插件化的,那么關于剪貼板模塊的設計自然也需要能夠自由擴展序列化/反序列化的格式。特別是在需要精確適配編輯器例如飛書、語雀等的私有格式時,需要能夠自由控制相關行為。
- 普適性,由于富文本需要實現
DOM與選區MODEL的映射,因此生成的DOM結構通常會比較復雜。而當我們從文檔中復制內容到剪貼板時,我們會希望這個結構是更規范化的,以便粘貼到其他平臺例如飛書、Word等時會有更好的解析。 - 完整性,當執行序列化與反序列時,希望能夠保持內容的完整性,即不會因為這個的過程而丟失內容,這里相當于對性能做出讓步而保持內容完整。而對于編輯器本身的格式則關注性能,由于實際注冊的模塊一致,希望能夠直接應用數據而不需要走整個解析過程。
那么本文將會以slate為例,處理嵌套結構的剪貼板模塊設計,并且以quill為例,處理扁平結構的剪貼板模塊設計。并且以飛書文檔的內容為例,分別以行內結構、段落結構、組合結構、嵌入結構、塊級結構為基礎,分類型進行序列化與反序列化的設計。
嵌套結構
slate的基本數據結構是樹形結構的JSON類型,相關的DEMO實現都在https://github.com/WindRunnerMax/DocEditor中。我們先以標題與加粗的格式為例,描述其基礎內容結構:
[
{ children: [{ text: "Editor" }], heading: { type: "h1", id: "W5xjbuxy" } },
{ children: [{ text: "加粗", bold: true }, { text: "格式" }] },
];
實際上slate的數據結構形式非常類似于DOM結構的嵌套格式,甚至于DOM結構與數據結構是完全一一對應的,例如在渲染Embed結構中的零寬字符渲染時也會在數據結構中存在。因此在實現序列化與反序列化的過程中,理論上我們是可以直接實現其JSON結構完全對應為DOM結構的轉換。
然而完全對應的情況只是理想情況下,富文本編輯器對于內容的實際組織形式可能會多種多樣,例如實現引用塊結構時,外層包裹的blockquote標簽可能是數據結構本身存在,也可能是渲染時根據行屬性動態渲染的,這種情況下就不能直接從數據結構的層面上將其序列化為完整的HTML。
// 結構渲染
[
{
blockquote: true,
children:[
{ children: [{ text: "引用塊 Line 1" }] },
{ children: [{ text: "引用塊 Line 2" }] },
]
}
];
// 動態渲染
[
{ children: [{ text: "引用塊 Line 1" }], blockquote: true },
{ children: [{ text: "引用塊 Line 2" }], blockquote: true },
];
此外,我們實現的編輯器必然是需要插件化的,在剪貼板模塊中我們無法準確得知插件究竟是如何組織數據結構的。而在富文本編輯器中有著不成文的規矩,我們寫入剪貼板的內容需要是盡可能規范化的結構,否則就無法跨編輯器粘貼內容。因此我們如果希望能夠保證規范化的數據,就需要在剪貼板模塊提供基本的序列化與反序列化的接口,而具體的實現則歸于插件本身處理。
那么基于這個基本理念,我們首先來看序列化的實現,即JSON結構到HTML的轉換過程。先前我們也提到了,對于編輯器本身的格式則關注性能,由于實際注冊的模塊一致,希望能夠直接應用數據而不需要走整個解析過程,因此我們還需要在剪貼板中額外寫入application/x-doc-editor的key,用來直接存儲Fragment數據。
{
"text/plain": "Editor\n加粗格式",
"text/html": "<h1 id=\"W5xjbuxy\">Editor</h1><div data-line><strong>加粗</strong>格式</div>",
"application/x-doc-editor": '[{"children":[{"text":"Editor"}],"heading":{"type":"h1","id":"W5xjbuxy"}},{"children":[{"text":"加粗","bold":true},{"text":"格式"}]}]',
}
我們接下來需要設想下如何將內容寫入到剪貼板,以及實際觸發的場景。除了常見的Ctrl+C來觸發復制行為外,用戶還有可能希望通過按鈕來觸發復制行為,例如飛書就可以通過工具欄復制整個行/塊結構,因此我們不能直接通過OnCopy事件的clipboardData來寫數據,而是需要主動觸發額外的Copy事件。
前邊也提到了navigator.clipboard.write同樣可以寫入剪貼板,調用這個API是不需要真正觸發Copy事件的,但是當我們使用這個方法寫入數據的時候,可能會拋出異常。此外這個API必須要在HTTPS環境下才能使用,否則會完全沒有這個函數的定義。
在下面的例子中需要焦點在document上,需要在延遲時間內點擊頁面,否則會拋出DOMException。而即使當我們焦點在頁面上,執行后同樣會拋出DOMException,從拋出的異常來看是因為application/x-doc-editor類型不被支持。
(async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
const params = {
"text/plain": "Editor",
"text/html": "<span>Editor</span>",
"application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]',
}
const dataItems = {};
for (const [key, value] of Object.entries(params)) {
const blob = new Blob([value], { type: key });
dataItems[key] = blob;
}
// DOMException: Type application/x-doc-editor not supported on write.
navigator.clipboard.write([new ClipboardItem(dataItems)]);
})();
因為這個API不支持我們寫入自定義的類型,因此我們就需要主動觸發Copy事件來寫入剪貼板,雖然我們同樣可以將這個字段的數據作為HTML的某個屬性值寫入text/html中,但是我們這里還是將其獨立出來處理。那么以同樣的數據,我們使用document.execCommand寫入剪貼板的方式就需要新建textarea元素來實現。
const data = {
"text/plain": "Editor",
"text/html": "<span>Editor</span>",
"application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]',
}
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();
});
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);
當然這里我們能夠很明顯地看到由于textarea.select,我們原本的編輯器焦點會丟失。因此這里我們還需要注意,在執行復制的時候需要記錄當前的選區值,在寫入剪貼板之后先將焦點置于編輯器,之后再恢復選區。
接下來我們來處理插件化的定義,這里的Context非常簡單,只需要記錄當前正在處理的Node以及當前已經處理過后的html節點即可。而在插件中我們需要實現serialize方法,用來將Node序列化為HTML,willSetToClipboard則是Hook定義,當即將寫入剪貼板時會被調用。
// packages/core/src/clipboard/utils/types.ts
/** Fragment => HTML */
export type CopyContext = {
/** Node 基準 */
node: BaseNode;
/** HTML 目標 */
html: Node;
};
// packages/core/src/plugin/modules/declare.ts
abstract class BasePlugin {
/** 將 Fragment 序列化為 HTML */
public serialize?(context: CopyContext): void;
/** 內容即將寫入剪貼板 */
public willSetToClipboard?(context: CopyContext): void;
}
既然我們的具體轉換是在插件中實現的,那么我們主要的工作就是調度插件的執行了。為了方便處理數據,我們這里就不使用Immutable的形式來處理了,我們的Context對象是整個調度過程中保持一致的,即插件中我們所有的方法都是原地處理的。那么調度的方式就直接通過plugin組件調度,調用后從context中獲取html節點即可。
// packages/core/src/plugin/modules/declare.ts
public call<T extends CallerType>(key: T, payload: CallerMap[T], type?: PluginType) {
const plugins = this.current;
for (const plugin of plugins) {
try {
// @ts-expect-error payload match
plugin[key] && isFunction(plugin[key]) && plugin[key](payload);
} catch (error) {
this.editor.logger.warning(`Plugin Exec Error`, plugin, error);
}
}
return payload;
}
const context: CopyContext = { node: child, html: textNode };
this.plugin.call(CALLER_TYPE.SERIALIZE, context);
value.appendChild(context.html);
那么重點的地方就是我們設計的serialize調度方法,我們這里的核心思想是: 當處理到文本行時,我們創建一個空的Fragment節點作為行節點,然后迭代每個文本值,取出當前行的每個Text值創建文本節點,以此創建context對象,然后調度PLUGIN_TYPE.INLINE級別的插件,將序列化后的HTML節點插入到行節點中。
// packages/core/src/clipboard/modules/copy.ts
if (this.reflex.isTextBlock(current)) {
const lineFragment = document.createDocumentFragment();
current.children.forEach(child => {
const text = child.text || "";
const textNode = document.createTextNode(text);
const context: CopyContext = { node: child, html: textNode };
this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.INLINE);
lineFragment.appendChild(context.html);
});
}
然后針對每個行節點,我們同樣需要調度PLUGIN_TYPE.BLOCK級別的插件,將處理過后的內容放置于root節點中,并將內容返回。這樣我們就完成了最基本的文本行的序列化操作,這里我們在DOM節點上加入了額外的標識,這樣可以幫助我們在反序列化的時候能夠冪等地處理。
// packages/core/src/clipboard/modules/copy.ts
const root = rootNode || document.createDocumentFragment();
// ...
const context: CopyContext = { node: current, html: lineFragment };
this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK);
const lineNode = document.createElement("div");
lineNode.setAttribute(LINE_TAG, "true");
lineNode.appendChild(context.html);
root.appendChild(lineNode);
在基本的行結構處理完成后,還需要關注外層的Node節點,這里的數據處理方式與行節點類似。但是這里需要注意的是,這里是遞歸的結構處理,那么這里的JSON結構執行順序就是深度優先遍歷,即先處理文本節點以及行節點,然后再處理外部的塊結構,由內而外地處理,由此來保證整個DOM樹形結構的處理。
// packages/core/src/clipboard/modules/copy.ts
if (this.reflex.isBlock(current)) {
const blockFragment = document.createDocumentFragment();
current.children.forEach(child => this.serialize(child, blockFragment));
const context: CopyContext = { node: current, html: blockFragment };
this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK);
root.appendChild(context.html);
return root as T;
}
而對反序列化的處理則相對簡單,Paste事件是不可以隨意觸發的,必須要由用戶的可信事件來觸發。那么我們就只能通過這個事件來讀取clipboardData中的值,這里需要關注的數據除了先前復制的key,還有files文件字段需要處理。對于反序列化,我們同樣需要在插件中具體實現,同樣是需要原地修改的Context定義。
// packages/core/src/clipboard/utils/types.ts
/** HTML => Fragment */
export type PasteContext = {
/** Node 目標 */
nodes: BaseNode[];
/** HTML 基準 */
html: Node;
/** FILE 基準 */
files?: File[];
};
/** Clipboard => Context */
export type PasteNodesContext = {
/** Node 基準 */
nodes: BaseNode[];
};
// packages/core/src/plugin/modules/declare.ts
abstract class BasePlugin {
/** 將 HTML 反序列化為 Fragment */
public deserialize?(context: PasteContext): void;
/** 粘貼的內容即將應用到編輯器 */
public willApplyPasteNodes?(context: PasteNodesContext): void;
}
這里的調度形式與序列化類似,如果剪貼板中存在application/x-doc-editor的key,則直接讀取這個值。如果存在文件需要處理,則調度所有插件處理,否則則需要讀取text/html的值,如果不存在的話就直接讀取text/plain內容,同樣構造JSON應用到編輯器中。
// packages/core/src/clipboard/modules/paste.ts
const files = Array.from(transfer.files);
const textDoc = transfer.getData(TEXT_DOC);
const textHTML = transfer.getData(TEXT_HTML);
const textPlain = transfer.getData(TEXT_PLAIN);
if (textDoc) {
// ...
}
if (files.length) {
// ...
}
if (textHTML) {
// ...
}
if (textPlain) {
// ...
}
這里的重點是對于text/html的處理,也就是反序列化將HTML節點轉換為Fragment節點,這里的處理方式與序列化類似,同樣是需要遞歸地處理數據。首先需要對HTML使用DOMParser對象進行解析,然后深度優先遍歷由內而外處理每個節點,具體的實現依然需要調度插件來處理。
// packages/core/src/clipboard/modules/paste.ts
const parser = new DOMParser();
const html = parser.parseFromString(textHTML, TEXT_HTML);
// ...
const root: BaseNode[] = [];
// NOTE: 結束條件 `Text`、`Image`等節點都會在此時處理
if (current.childNodes.length === 0) {
if (isDOMText(current)) {
const text = current.textContent || "";
root.push({ text });
} else {
const context: PasteContext = { nodes: root, html: current };
this.plugin.call(CALLER_TYPE.DESERIALIZE, context);
return context.nodes;
}
return root;
}
const children = Array.from(current.childNodes);
for (const child of children) {
const nodes = this.deserialize(child);
nodes.length && root.push(...nodes);
}
const context: PasteContext = { nodes: root, html: current };
this.plugin.call(CALLER_TYPE.DESERIALIZE, context);
return context.nodes;
接下來我們將會以slate為例,處理嵌套結構的剪貼板模塊設計。并且以飛書文檔的內容為源和目標,分別以行內結構、段落結構、組合結構、嵌入結構、塊級結構為基礎,在上述基本模式的調度下,分類型進行序列化與反序列化的插件實現。
行內結構
行內結構指的是加粗、斜體、下劃線、刪除線、行內代碼塊等行內的結構樣式,這里以加粗為例來處理序列化與反序列化。在序列化行內結構部分,我們只需要判斷如果是文本節點,就為其包裹一層strong節點,注意的是我們需要原地處理。
// packages/plugin/src/bold/index.tsx
export class BoldPlugin extends LeafPlugin {
public serialize(context: CopyContext) {
const { node, html } = context;
if (node[BOLD_KEY]) {
const strong = document.createElement("strong");
// NOTE: 采用`Wrap Base Node`加原地替換的方式
strong.appendChild(html);
context.html = strong;
}
}
}
反序列化這部分我們也需要前提處理,我們還需要先處理純文本的內容,這是公共的處理方式,即所有節點都是文本節點時,我們需要加入一級行節點。并且還需要對數據進行格式化,理論上我們應該對所有的節點都過濾一次Normalize,但是這里就簡單地處理空節點數據。
// packages/plugin/src/clipboard/index.ts
export class ClipboardPlugin extends BlockPlugin {
public deserialize(context: PasteContext): void {
const { nodes, html } = context;
if (nodes.every(isText) && isMatchBlockTag(html)) {
context.nodes = [{ children: nodes }];
}
}
public willApplyPasteNodes(context: PasteNodesContext): void {
const nodes = context.nodes;
const queue: BaseNode[] = [...nodes];
while (queue.length) {
const node = queue.shift();
if (!node) continue;
node.children && queue.push(...node.children);
// FIX: 兜底處理無文本節點的情況 例如 <div><div></div></div>
if (node.children && !node.children.length) {
node.children.push({ text: "" });
}
}
}
}
對于內容的處理則是判斷出HTML節點存在加粗的格式后,對當前已經處理的Node節點樹中所有的文本節點實現加粗操作,這里同樣需要原地處理數據。這里我們還封裝了applyMark的方法,用來處理所有的文本節點格式。其實這里有趣的是,因為我們的目標是構造整個JSON,我們就不需要關注使用slate的Transform模塊操作Model。
// packages/plugin/src/clipboard/utils/apply.ts
export class BoldPlugin extends LeafPlugin {
public deserialize(context: PasteContext): void {
const { nodes, html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchTag(html, "strong") || isMatchTag(html, "b") || html.style.fontWeight === "bold") {
// applyMarker packages/plugin/src/clipboard/utils/apply.ts
context.nodes = applyMarker(nodes, { [BOLD_KEY]: true });
}
}
}
段落結構
段落結構指的是標題、行高、文本對齊等結構樣式,這里則以標題為例來處理序列化與反序列化。序列化段落結構,我們只需要Node是標題節點時,構造相關的HTML節點,將本來的節點原地包裝并賦值到context即可,同樣采用嵌套節點的方式。
// packages/plugin/src/heading/index.tsx
export class HeadingPlugin extends BlockPlugin {
public serialize(context: CopyContext): void {
const element = context.node as BlockElement;
const heading = element[HEADING_KEY];
if (!heading) return void 0;
const id = heading.id;
const type = heading.type;
const node = document.createElement(type);
node.id = id;
node.setAttribute("data-type", HEADING_KEY);
node.appendChild(context.html);
context.html = node;
}
}
反序列化則是相反的操作,判斷當前正在處理的HTML節點是否為標題節點,如果是的話就將其轉換為Node節點。這里同樣需要原地處理數據,與行內節點不同的是,需要使用applyLineMarker將所有的行節點加入標題格式。
// packages/plugin/src/heading/index.tsx
export class HeadingPlugin extends BlockPlugin {
public deserialize(context: PasteContext): void {
const { nodes, html } = context;
if (!isHTMLElement(html)) return void 0;
const tagName = html.tagName.toLocaleLowerCase();
if (tagName.startsWith("h") && tagName.length === 2) {
let level = Number(tagName.replace("h", ""));
if (level <= 0 || level > 3) level = 3;
// applyLineMarker packages/plugin/src/clipboard/utils/apply.ts
context.nodes = applyLineMarker(this.editor, nodes, {
[HEADING_KEY]: { type: `h` + level, id: getId() },
});
}
}
}
組合結構
組合結構在這里指的是引用塊、有序列表、無序列表等結構樣式,這里則以引用塊為例來處理序列化與反序列化。序列化組合結構,同樣需要Node是引用塊節點時,構造相關的HTML節點進行包裝。
// packages/plugin/src/quote-block/index.tsx
export class QuoteBlockPlugin extends BlockPlugin {
public serialize(context: CopyContext): void {
const element = context.node as BlockElement;
const quote = element[QUOTE_BLOCK_KEY];
if (!quote) return void 0;
const node = document.createElement("blockquote");
node.setAttribute("data-type", QUOTE_BLOCK_KEY);
node.appendChild(context.html);
context.html = node;
}
}
反序列化同樣是判斷是否為引用塊節點,并且構造對應的Node節點。這里與標題模塊不同的是,標題是將格式應用到相關的行節點上,而引用塊則是在原本的節點上嵌套一層結構。
// packages/plugin/src/quote-block/index.tsx
export class QuoteBlockPlugin extends BlockPlugin {
public deserialize(context: PasteContext): void {
const { nodes, html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchTag(html, "blockquote")) {
const current = applyLineMarker(this.editor, nodes, {
[QUOTE_BLOCK_ITEM_KEY]: true,
});
context.nodes = [{ children: current, [QUOTE_BLOCK_KEY]: true }];
}
}
}
嵌入結構
嵌入結構在這里指的是圖片、視頻、流程圖等結構樣式,這里則以圖片為例來處理序列化與反序列化。序列化嵌入結構,我們只需要Node是圖片節點時,構造相關的HTML節點進行包裝。與之前的節點不同的是,此時我們不需要嵌套DOM節點了,將獨立節點原地替換即可。
// packages/plugin/src/image/index.tsx
export class ImagePlugin extends BlockPlugin {
public serialize(context: CopyContext): void {
const element = context.node as BlockElement;
const img = element[IMAGE_KEY];
if (!img) return void 0;
const node = document.createElement("img");
node.src = img.src;
node.setAttribute("data-type", IMAGE_KEY);
node.appendChild(context.html);
context.html = node;
}
}
對于反序列化的結構,判斷當前正在處理的HTML節點是否為圖片節點,如果是的話就將其轉換為Node節點。與先前的轉換不同的是,我們此時不需要嵌套結構,只需要固定children為零寬字符占位即可。實際上這里還有個常用的操作是,粘貼圖片內容通常需要將原本的src轉儲到我們的服務上,例如飛書的圖片就是臨時鏈接,在生產環境中需要轉儲資源。
// packages/plugin/src/image/index.tsx
export class ImagePlugin extends BlockPlugin {
public deserialize(context: PasteContext): void {
const { html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchTag(html, "img")) {
const src = html.getAttribute("src") || "";
const width = html.getAttribute("data-width") || 100;
const height = html.getAttribute("data-height") || 100;
context.nodes = [
{
[IMAGE_KEY]: {
src: src,
status: IMAGE_STATUS.SUCCESS,
width: Number(width),
height: Number(height),
},
uuid: getId(),
children: [{ text: "" }],
},
];
}
}
}
塊級結構
塊級結構指的是高亮塊、代碼塊、表格等結構樣式,這里則以高亮塊為例來處理序列化與反序列化。高亮塊則是飛書中比較定制的結構,本質上是Editable結構的嵌套,這里的兩層callout嵌套結構則是為了兼容飛書的結構。序列化塊級結構在slate中跟引用結構類似,在外層直接嵌套組合結構即可。
// packages/plugin/src/highlight-block/index.tsx
export class HighlightBlockPlugin extends BlockPlugin {
public serialize(context: CopyContext): void {
const { node: node, html } = context;
if (this.reflex.isBlock(node) && node[HIGHLIGHT_BLOCK_KEY]) {
const colors = node[HIGHLIGHT_BLOCK_KEY]!;
// 提取具體色值
const border = colors.border || "";
const background = colors.background || "";
const regexp = /rgb\((.+)\)/;
const borderVar = RegExec.exec(regexp, border);
const backgroundVar = RegExec.exec(regexp, background);
const style = window.getComputedStyle(document.body);
const borderValue = style.getPropertyValue(borderVar);
const backgroundValue = style.getPropertyValue(backgroundVar);
// 構建 HTML 容器節點
const container = document.createElement("div");
container.setAttribute(HL_DOM_TAG, "true");
container.classList.add("callout-container");
container.style.border = `1px solid rgb(` + borderValue + `)`;
container.style.background = `rgb(` + backgroundValue + `)`;
container.setAttribute("data-emoji-id", "balloon");
const block = document.createElement("div");
block.classList.add("callout-block");
container.appendChild(block);
block.appendChild(html);
context.html = container;
}
}
}
反序列化則是判斷當前正在處理的HTML節點是否為高亮塊節點,如果是的話就將其轉換為Node節點。這里的處理方式同樣與引用塊類似,只是需要在外層嵌套一層結構。
// packages/plugin/src/highlight-block/index.tsx
export class HighlightBlockPlugin extends BlockPlugin {
public deserialize(context: PasteContext): void {
const { nodes, html: node } = context;
if (isHTMLElement(node) && node.classList.contains("callout-block")) {
const border = node.style.borderColor;
const background = node.style.backgroundColor;
const regexp = /rgb\((.+)\)/;
const borderColor = border && RegExec.exec(regexp, border);
const backgroundColor = background && RegExec.exec(regexp, background);
if (!borderColor || !backgroundColor) return void 0;
context.nodes = [
{
[HIGHLIGHT_BLOCK_KEY]: {
border: borderColor,
background: backgroundColor,
},
children: nodes,
},
];
}
}
}
扁平結構
quill的基本數據結構是扁平結構的JSON類型,相關的DEMO實現都在https://github.com/WindRunnerMax/BlockKit中。我們同樣以標題與加粗的格式為例,描述其基礎內容結構:
[
{ insert: "Editor" },
{ attributes: { heading: "h1" }, insert: "\n" },
{ attributes: { bold: "true" }, insert: "加粗" },
{ insert: "格式" },
{ insert: "\n" },
];
序列化的調度方案與slate類似,我們同樣需要在剪貼板模塊提供基本的序列化與反序列化的接口,而具體的實現則歸于插件本身處理。針對序列化的方法,也是按照基本行遍歷的方式,優先處理Delta結構的的文本,再處理行結構的格式。但是由于delta的數據結構是扁平的,因此我們不能直接遞歸處理,而是應該循環到EOL時將當前行的節點更新為新的行節點。
// packages/core/src/clipboard/modules/copy.ts
const root = rootNode || document.createDocumentFragment();
let lineFragment = document.createDocumentFragment();
const ops = normalizeEOL(delta.ops);
for (const op of ops) {
if (isEOLOp(op)) {
const context: SerializeContext = { op, html: lineFragment };
this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context);
let lineNode = context.html as HTMLElement;
if (!isMatchBlockTag(lineNode)) {
lineNode = document.createElement("div");
lineNode.setAttribute(LINE_TAG, "true");
lineNode.appendChild(context.html);
}
root.appendChild(lineNode);
lineFragment = document.createDocumentFragment();
continue;
}
const text = op.insert || "";
const textNode = document.createTextNode(text);
const context: SerializeContext = { op, html: textNode };
this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context);
lineFragment.appendChild(context.html);
}
反序列化的整體流程則與slate更加類似,因為我們同樣都是以HTML為基準處理數據,深度遞歸遍歷優先處理葉子節點,然后以處理過的delta為基準處理額外節點。只不過這里我們最終輸出的數據結構會是扁平的,這樣的話就不需要特別關注Normalize的操作。
// packages/core/src/clipboard/modules/paste.ts
public deserialize(current: Node): Delta {
const delta = new Delta();
// 結束條件 Text Image 等節點都會在此時處理
if (!current.childNodes.length) {
if (isDOMText(current)) {
const text = current.textContent || "";
delta.insert(text);
} else {
const context: DeserializeContext = { delta, html: current };
this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context);
return context.delta;
}
return delta;
}
const children = Array.from(current.childNodes);
for (const child of children) {
const newDelta = this.deserialize(child);
delta.ops.push(...newDelta.ops);
}
const context: DeserializeContext = { delta, html: current };
this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context);
return context.delta;
}
此外,對于塊級嵌套結構的處理,我們的處理方式可能會更加復雜,但是在當前的實現中還并沒有完成,因此暫時還處于設計階段。序列化的處理方式類似于下面的流程,與先前結構不同的是,當處理到塊結構時,直接調用剪貼板的序列化模塊,將內容嵌入即可。
| -- bold ··· <strong> -- |
| -- line -- | | -- <div> ---|
| | -- text ··· <span> ---- | |
| |
root -- lines -- | -- line -- leaves ··· <elements> --------- <div> ---| -- normalize -- html
| |
| -- codeblock -- ref(id) ··· <code> ------- <div> ---|
| |
| -- table -- ref(id) ··· <table> ---------- <div> ---|
反序列化的方式相對更復雜一些,因為我們需要維護嵌套結構的引用關系。雖然本身經過DOMParser解析過后的HTML是嵌套的內容,但是我們的基準解析方法目標是扁平的Delta結構,然而block、table等結構的形式是需要嵌套引用的結構,這個id的關系就需要我們以約定的形式完成。
| -- <b> -- text ··· text|r -- bold|r -- |
| -- <align> -- <h1> -- | | -- head|r -- align|r -- |
| | -- <a> -- text ··· text|r -- link|r -- | |
<body> -- | | -- deltas
| | -- <u> -- text ··· text|r -- unl|r --- | |
| -- <code> -- <div> -- | | -- block|id -- ref|r -- |
| -- <i> -- text ··· text|r -- em|r ---- |
接下來我們將會以delta數據結構為例,處理扁平結構的剪貼板模塊設計。同樣分別以行內結構、段落結構、組合結構、嵌入結構、塊級結構為基礎,在上述基本模式的調度下,分類型進行序列化與反序列化的插件實現。
行內結構
行內結構指的是加粗、斜體、下劃線、刪除線、行內代碼塊等行內的結構樣式,這里以加粗為例來處理序列化與反序列化。序列化行內結構部分基本與slate一致,從這里開始我們采用單元測試的方式執行。
// packages/core/test/clipboard/bold.test.ts
it("serialize", () => {
const plugin = getMockedPlugin({
serialize(context) {
if (context.op.attributes?.bold) {
const strong = document.createElement("strong");
strong.appendChild(context.html);
context.html = strong;
}
},
});
editor.plugin.register(plugin);
const delta = new Delta().insert("Hello", { bold: "true" }).insert("World");
const root = editor.clipboard.copyModule.serialize(delta);
const plainText = getFragmentText(root);
const htmlText = serializeHTML(root);
expect(plainText).toBe("HelloWorld");
expect(htmlText).toBe(`<div data-node="true"><strong>Hello</strong>World</div>`);
});
反序列化部分則是判斷當前正在處理的HTML節點是否為加粗節點,如果是的話就將其轉換為Delta節點。
// packages/core/test/clipboard/bold.test.ts
it("deserialize", () => {
const plugin = getMockedPlugin({
deserialize(context) {
const { delta, html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchHTMLTag(html, "strong") || isMatchHTMLTag(html, "b") || html.style.fontWeight === "bold") {
// applyMarker packages/core/src/clipboard/utils/deserialize.ts
applyMarker(delta, { bold: "true" });
}
},
});
editor.plugin.register(plugin);
const parser = new DOMParser();
const transferHTMLText = `<div><strong>Hello</strong>World</div>`;
const html = parser.parseFromString(transferHTMLText, "text/html");
const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
const delta = new Delta().insert("Hello", { bold: "true" }).insert("World");
expect(rootDelta).toEqual(delta);
});
段落結構
段落結構指的是標題、行高、文本對齊等結構樣式,這里則以標題為例來處理序列化與反序列化。序列化段落結構,我們只需要Node是標題節點時,構造相關的HTML節點,將本來的節點原地包裝并賦值到context即可,同樣采用嵌套節點的方式。
// packages/core/test/clipboard/heading.test.ts
it("serialize", () => {
const plugin = getMockedPlugin({
serialize(context) {
const { op, html } = context;
if (isEOLOp(op) && op.attributes?.heading) {
const element = document.createElement(op.attributes.heading);
element.appendChild(html);
context.html = element;
}
},
});
editor.plugin.register(plugin);
const delta = new MutateDelta().insert("Hello").insert("\n", { heading: "h1" });
const root = editor.clipboard.copyModule.serialize(delta);
const plainText = getFragmentText(root);
const htmlText = serializeHTML(root);
expect(plainText).toBe("Hello");
expect(htmlText).toBe(`<h1>Hello</h1>`);
});
反序列化則是相反的操作,判斷當前正在處理的HTML節點是否為標題節點,如果是的話就將其轉換為Node節點。這里同樣需要原地處理數據,與行內節點不同的是,需要使用applyLineMarker將所有的行節點加入標題格式。
// packages/core/test/clipboard/heading.test.ts
it("deserialize", () => {
const plugin = getMockedPlugin({
deserialize(context) {
const { delta, html } = context;
if (!isHTMLElement(html)) return void 0;
if (["h1", "h2"].indexOf(html.tagName.toLowerCase()) > -1) {
applyLineMarker(delta, { heading: html.tagName.toLowerCase() });
}
},
});
editor.plugin.register(plugin);
const parser = new DOMParser();
const transferHTMLText = `<div><h1>Hello</h1><h2>World</h2></div>`;
const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
const delta = new Delta()
.insert("Hello")
.insert("\n", { heading: "h1" })
.insert("World")
.insert("\n", { heading: "h2" });
expect(rootDelta).toEqual(MutateDelta.from(delta));
});
組合結構
組合結構在這里指的是引用塊、有序列表、無序列表等結構樣式,這里則以引用塊為例來處理序列化與反序列化。序列化組合結構,我同樣需要Node是引用塊節點時,構造相關的HTML節點進行包裝。在扁平結構下類似組合結構的處理方式會是渲染時進行的,因此序列化的過程與先前標題一致。
// packages/core/test/clipboard/quote.test.ts
it("serialize", () => {
const plugin = getMockedPlugin({
serialize(context) {
const { op, html } = context;
if (isEOLOp(op) && op.attributes?.quote) {
const element = document.createElement("blockquote");
element.appendChild(html);
context.html = element;
}
},
});
editor.plugin.register(plugin);
const delta = new MutateDelta().insert("Hello").insert("\n", { quote: "true" });
const root = editor.clipboard.copyModule.serialize(delta);
const plainText = getFragmentText(root);
const htmlText = serializeHTML(root);
expect(plainText).toBe("Hello");
expect(htmlText).toBe(`<blockquote>Hello</blockquote>`);
});
反序列化同樣是判斷是否為引用塊節點,并且構造對應的Node節點。這里與標題模塊不同的是,標題是將格式應用到相關的行節點上,而引用塊則是在原本的節點上嵌套一層結構。反序列化的結構處理方式也類似于標題處理方式,由于在HTML的結構上是嵌套結構,在應用時在所有行節點上加入引用格式。
// packages/core/test/clipboard/quote.test.ts
it("deserialize", () => {
const plugin = getMockedPlugin({
deserialize(context) {
const { delta, html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchHTMLTag(html, "p")) {
applyLineMarker(delta, {});
}
if (isMatchHTMLTag(html, "blockquote")) {
applyLineMarker(delta, { quote: "true" });
}
},
});
editor.plugin.register(plugin);
const parser = new DOMParser();
const transferHTMLText = `<div><blockquote><p>Hello</p><p>World</p></blockquote></div>`;
const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
const delta = new Delta()
.insert("Hello")
.insert("\n", { quote: "true" })
.insert("World")
.insert("\n", { quote: "true" });
expect(rootDelta).toEqual(MutateDelta.from(delta));
});
嵌入結構
嵌入結構在這里指的是圖片、視頻、流程圖等結構樣式,這里則以圖片為例來處理序列化與反序列化。序列化嵌入結構,我們只需要Node是圖片節點時,構造相關的HTML節點進行包裝。與之前的節點不同的是,此時我們不需要嵌套DOM節點了,將獨立節點原地替換即可。
// packages/core/test/clipboard/image.test.ts
it("serialize", () => {
const plugin = getMockedPlugin({
serialize(context) {
const { op } = context;
if (op.attributes?.image && op.attributes.src) {
const element = document.createElement("img");
element.src = op.attributes.src;
context.html = element;
}
},
});
editor.plugin.register(plugin);
const delta = new Delta().insert(" ", {
image: "true",
src: "https://example.com/image.png",
});
const root = editor.clipboard.copyModule.serialize(delta);
const plainText = getFragmentText(root);
const htmlText = serializeHTML(root);
expect(plainText).toBe("");
expect(htmlText).toBe(`<div data-node="true"><img src="https://example.com/image.png"></div>`);
});
對于反序列化的結構,判斷當前正在處理的HTML節點是否為圖片節點,如果是的話就將其轉換為Node節點。同樣的,這里還有個常用的操作是,粘貼圖片內容通常需要將原本的src轉儲到我們的服務上,例如飛書的圖片就是臨時鏈接,在生產環境中需要轉儲資源。
// packages/core/test/clipboard/image.test.ts
it("deserialize", () => {
const plugin = getMockedPlugin({
deserialize(context) {
const { html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchHTMLTag(html, "img")) {
const src = html.getAttribute("src") || "";
const delta = new Delta();
delta.insert(" ", { image: "true", src: src });
context.delta = delta;
}
},
});
editor.plugin.register(plugin);
const parser = new DOMParser();
const transferHTMLText = `<img src="https://example.com/image.png"></img>`;
const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
const delta = new Delta().insert(" ", { image: "true", src: "https://example.com/image.png" });
expect(rootDelta).toEqual(delta);
});
塊級結構
塊級結構
塊級結構指的是高亮塊、代碼塊、表格等結構樣式,這里則以塊結構為例來處理序列化與反序列化。這里的嵌套結構還沒有實現,因此這里僅僅是實現了上述deltas圖示的測試用例,主要的處理方式是當存在引用關系時,主動調用序列化的方式將其寫入到HTML中。
it("serialize", () => {
const block = new Delta().insert("inside");
const inside = editor.clipboard.copyModule.serialize(block);
const plugin = getMockedPlugin({
serialize(context) {
const { op } = context;
if (op.attributes?._ref) {
const element = document.createElement("div");
element.setAttribute("data-block", op.attributes._ref);
element.appendChild(inside);
context.html = element;
}
},
});
editor.plugin.register(plugin);
const delta = new Delta().insert(" ", { _ref: "id" });
const root = editor.clipboard.copyModule.serialize(delta);
const plainText = getFragmentText(root);
const htmlText = serializeHTML(root);
expect(plainText).toBe("inside\n");
expect(htmlText).toBe(
`<div data-node="true"><div data-block="id"><div data-node="true">inside</div></div></div>`
);
});
反序列化則是判斷當前正在處理的HTML節點是否為塊級節點,如果是的話就將其轉換為Node節點。這里的處理方式則是,深度優先遍歷處理節點內容時,若是出現block節點,則生成id并放置于deltas中,然后在ROOT結構中引用該節點。
it("deserialize", () => {
const deltas: Record<string, Delta> = {};
const plugin = getMockedPlugin({
deserialize(context) {
const { html } = context;
if (!isHTMLElement(html)) return void 0;
if (isMatchHTMLTag(html, "div") && html.hasAttribute("data-block")) {
const id = html.getAttribute("data-block")!;
deltas[id] = context.delta;
context.delta = new Delta().insert(" ", { _ref: id });
}
},
});
editor.plugin.register(plugin);
const parser = new DOMParser();
const transferHTMLText = `<div data-node="true"><div data-block="id"><div data-node="true">inside</div></div></div>`;
const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
deltas[ROOT_BLOCK] = rootDelta;
expect(deltas).toEqual({
[ROOT_BLOCK]: new Delta().insert(" ", { _ref: "id" }),
id: new Delta().insert("inside"),
});
});
每日一題
參考
- https://quilljs.com/docs/modules/clipboard
- https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
- https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
- https://github.com/slab/quill/blob/ebe16ca/packages/quill/src/modules/clipboard.ts
- https://github.com/ianstormtaylor/slate/blob/dbd0a3e/packages/slate-dom/src/utils/dom.ts
- https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard

浙公網安備 33010602011771號