prosemirror-tables 源碼解讀

為什么寫這篇文章

公司使用tiptap富文本編輯器,在tiptap的官網(wǎng)有這么一段話Tiptap is a headless wrapper around [ProseMirror](https://prosemirror.net/),這里的headless wrapper意思是“無頭編輯器”,指的是不提供任何UI樣式,完全自由的定制任何想要的UI,特別適合二次開發(fā)。

tiptap是對prosemirror的封裝,在prosemirror的基礎(chǔ)上提供了更友好的API、模塊封裝以及將MVVM的接入封裝在框架內(nèi)部,適用于各種流行框架,使開發(fā)者更容易上手。

tiptap提供大量官方擴展,像本文介紹的prosemirror-tabls,但官方的畢竟是官方,一些樣式或基本功能的改動,就必須要通過修改源碼的方式實現(xiàn)。

名次解釋

PS:理解完概念再往下看,不然容易一臉懵

document

用于表示ProseMirror的整個文檔,使用editor.view.state.doc引用,ProseMirror定義自己的數(shù)據(jù)結(jié)構(gòu)來存儲document內(nèi)容,通過輸出可以看到document是一個Node類型,包含content元素,是一個fragment對象,而每個fragment又包含 0 個或多個字節(jié)點,組成了document解構(gòu),類似于DOM

doc-node.jpg

Schema

用于定義文檔的結(jié)構(gòu)和內(nèi)容。它定義了一組節(jié)點類型和它們的屬性,例如段落、標(biāo)題、鏈接、圖片等等。Schema 是編輯器的模型層,可以通過其 API 創(chuàng)建、操作和驗證文檔中的節(jié)點。每個document都有一個與之相關(guān)的schema,用于描述存在于此document中的nodes類型

Node

文檔中的節(jié)點,節(jié)點是 Schema 中定義的類型之一,整個文檔就是一個Node實例,它的每個子節(jié)點,例如一個段落、一個列表項、一張圖片也是Node的實例。Node的修改遵循Immutable原則,更新時創(chuàng)建一個新的節(jié)點,而不是改變舊的節(jié)點,統(tǒng)一使用dispatch去觸發(fā)更新。

const node = $cell.node(-1);
// 當(dāng)前節(jié)點類型
node.type;
// 節(jié)點的attributes
node.attrs;
// 從指定node中獲取符合條件的子節(jié)點
findChildren(tr.doc, (node) => node.type.name === 'table');

Mark

用于給節(jié)點添加樣式、屬性或其他信息的一種方式。Prosemirror 將行內(nèi)文本視作扁平結(jié)構(gòu)而非 DOM 類似的樹狀結(jié)構(gòu),這樣是為了方便計數(shù)和操作。例如,一個文本節(jié)點可以添加加粗、斜體、下劃線等樣式,也可以添加標(biāo)簽、鏈接等屬性。Mark 本身沒有節(jié)點結(jié)構(gòu),只是對一個節(jié)點的文本內(nèi)容進行修飾。Marks通過Schema創(chuàng)建,用于控制哪些marks存在于哪些節(jié)點以及用于哪些attributes。

State

Prosemirror 的數(shù)據(jù)結(jié)構(gòu)對象,相當(dāng)于是 reactstate,有 viewstateplugin 的局部 state 之分。 如上面的 schema 就定義在其上: state.schema。ProseMirror 使用一個單獨的大對象來保持對編輯器所有 state 的引用(基本上來說,需要創(chuàng)建一個與當(dāng)前編輯器相同的編輯器)

prosemirror-state.jpg

Transaction

繼承自Transform,不僅能追蹤對文檔進行修改的一組操作,還能追蹤state的其他變化,例如選區(qū)更新等。每次更新都會產(chǎn)生一個新的state.transactions(通過state.tr來創(chuàng)建一個transaction實例),描述當(dāng)前state被應(yīng)用的變化,這些變化用來應(yīng)用當(dāng)前state來創(chuàng)建一個更新之后的state,然后這個新的state被用來更新view

此處的state指的是EditorState,描述編輯器的狀態(tài),包含了文檔的內(nèi)容、選區(qū)、當(dāng)前的節(jié)點和標(biāo)記集合等信息。每次編輯器發(fā)生改變時,都會生成一個新的 EditorState。

View

ProseMirror編輯器的視圖層,負(fù)責(zé)渲染文檔內(nèi)容和處理用戶的輸入事件。View 接受來自 EditorState 的更新并將其渲染到屏幕上。同時,它也負(fù)責(zé)處理來自用戶的輸入事件,如鍵盤輸入、鼠標(biāo)點擊等。其中state就是其上的一個屬性:view.state

新建編輯器第一步就是new一個EditorVIew

Plugin

ProseMirror 中的插件,用于擴展編輯器的功能,例如點擊/粘貼/撤銷等。每個插件都是一個包含了一組方法的對象,這些方法可以監(jiān)聽編輯器的事件、修改事務(wù)、渲染視圖等等。每個插件都包含一個key屬性,如prosemirror-tables設(shè)置keytableColumnResizing,通過這個key就可以訪問插件的配置和狀態(tài),而無需訪問插件實例對象。

const pluginState = columnResizingPluginKey.getState(state);

Commands

表示Command函數(shù)集合,每個command函數(shù)定義一些觸發(fā)事件來執(zhí)行各種操作。

Decorations

表示節(jié)點的外觀和行為的對象。它可以用于添加樣式、標(biāo)記、工具提示等效果,以及處理點擊、懸停、拖拽等事件。Decoration 通常是在渲染視圖時應(yīng)用到節(jié)點上的,但也可以在其他情況下使用,如在協(xié)同編輯時標(biāo)記其他用戶的光標(biāo)位置。

用于繪制document view,通過decorations屬性的返回值來創(chuàng)建,包含三種類型

  • Node decorations:增加樣式或其他 DOM 屬性到單個nodeDOM 上,如選中表格時增加的類名
  • Widget decorations:在給定位置插入 DOM node,并不是實際文檔的一部分,如表格拖拽時增加的基線
  • Inline decoration:在給定的 range 中的行內(nèi)楊素插入樣式或?qū)傩?,類似?Node decorations,僅針對行內(nèi)元素

prosemirror 為了快速繪制這些類型,通過 decorationSet.create 靜態(tài)方法來創(chuàng)建

import { Plugin, PluginKey } from 'prosemirror-state';
let purplePlugin = new Plugin({
  props: {
    decorations(state) {
      return DecorationSet.create(state.doc, [
        Decoration.inline(0, state.doc.content.size, {
          style: 'color: purple',
        }),
      ]);
    },
  },
});

ResolvedPos

Prosemirror中通過Node.resolve解析位置信息返回的對象,包含了一些位置相關(guān)的信息。它會告訴我們當(dāng)前position的父級node是什么,它在父級node中的偏移量(parentOffset)是多少以及其他信息。

const $cell = doc.resolve(cell);
// 從根節(jié)點開始,父級點的深度,如果直接指向根節(jié)點則為0,如果指定一個頂級節(jié)點,則為1
$cell.deth;
// 該位置相對于父節(jié)點的偏移量
$cell.parentOffset;
// 相當(dāng)于$cell.parent() 獲取父級節(jié)點,$cell.node(-2)獲取父級的父級,以此類推
$cell.node(-1);
// 獲取父節(jié)點的開始位置,相對于doc根節(jié)點的位置,一般用來定位
$cell.start(-1);

Selection

表示當(dāng)前選中內(nèi)容,prosemirror中默認(rèn)定義兩種類型的選區(qū)對象:

  • TextSelection:文本選區(qū),同時也可以表示正常的光標(biāo)(即未選擇任何文本時,此時anchor = head),包含$anchor選區(qū)固定的一側(cè),通常是左側(cè),$head選區(qū)移動的一側(cè),通常是右側(cè)
  • NodeSelection:節(jié)點選區(qū),表示一個節(jié)點被選擇

也可以通過繼承Selection父類來實現(xiàn)自定義的選區(qū)類型,如CellSelection

// 獲取當(dāng)前選區(qū)
const sel = state.selection;
// 使用TextSelection創(chuàng)建文本選區(qū)
const selection = new TextSelection($textAnchor, $textHead);
// 使用NodeSelection創(chuàng)建節(jié)點選區(qū)
const selection = new NodeSelection($pos);
// 使用AllSelection創(chuàng)建覆蓋整個文檔的選區(qū) 可以作為cmd + a的操作
const selection = new AllSelection(doc);
// 用new之后的選區(qū),更新當(dāng)前 transaction 的選區(qū)
state.tr.setSelection(selection);
// 從指定選區(qū)獲取符合條件的父節(jié)點
findParentNode(
  (node) =>
    node.type.spec.tableRole && node.type.spec.tableRole.includes('cell'),
)(selection);

Slice

  • slice of document稱為文檔片段,主要處理復(fù)制粘貼和拖拽之類的操作
  • 兩個position之間的內(nèi)容就是一個文檔片段

源碼目錄

├── README.md
├── cellselection.ts
├── columnresizing.ts
├── commands.ts
├── copypaste.ts
├── fixtables.ts
├── index.html
├── index.ts
├── input.ts
├── schema.ts
├── tablemap.ts
├── tableview.ts
└── util.ts

cellselection.ts

定義CellSelection選區(qū)對象,繼承自Selection

  • drawCellSelection:用于當(dāng)跨單元格選擇時,繪制選區(qū),會添加到tableEditingdecorations為每個選中節(jié)點增加classselectedCell ,tableEditing最后會注冊為Editor的插件使用

columnresizing.ts

定義columnResizing插件,用于實現(xiàn)列拖拽功能,大致思路如下:

  • 插件初始化時,通過以下代為插件添加nodeViews,通過實例化TableView為表格節(jié)點自定義一套渲染邏輯,在初始化的時候為DOM節(jié)點添加了colgroup,然后調(diào)用updateColumnWidth生成每列對應(yīng)的col,有了col之后,我們在調(diào)整列寬的時候就可以通過改變colwidth屬性實時的去改變列寬了。

    plugin.spec!.props!.nodeViews![tableNodeTypes(state.schema).table.name] = (
      node,
      view,
    ) => new View(node, cellMinWidth, view);
    
  • 通過設(shè)置插件的props傳入attribute(控制何時添加類resize-cursor)、handleDOMEvents(定義mousemove、mouseleavemousedown事件)和decorations(調(diào)用handleDecorations方法,在鼠標(biāo)移動到列上時,通過Decoration.widget來繪制所需要的DOM

    • doc.resolve(cell): resolve解析文檔中給定的位置,返回此位置的上下文信息
    • $cell.node(-1): 獲取給定級別的祖先節(jié)點
    • $cell.start(-1): 獲取給定級別節(jié)點到起點的(絕對)位置
    • TableMap.get(table): 獲取當(dāng)前表格數(shù)據(jù),包含 width 列數(shù)、height 行數(shù)、mappospos 形成的數(shù)組
    • 循環(huán) map.height,為當(dāng)前列的每一個td上創(chuàng)建一個div
  • handleMouseMove當(dāng)鼠標(biāo)移動時,修改pluginState從而使得decorations重新繪制DOM

  • handleMouseDown當(dāng)鼠標(biāo)按下時,獲取當(dāng)前位置信息和列寬,并記錄在pluginState

    此方法中重新定義mouseupmousemove事件

    • move:移動的同時從draggedWidth獲取移動寬度,調(diào)用updateColumnsOnResize實時更新colgroup中的colwidth屬性,從而改變每列寬度

    • finish:當(dāng)移動完成后調(diào)用updateColumnWidth方法重置當(dāng)前列的attrs屬性,并將pluginState置為初始狀態(tài)

      // 用來改變給定 position node 的類型或者屬性
      tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth });
      
  • handleMouseLeave當(dāng)鼠標(biāo)離開時,恢復(fù)pluginState為初始狀態(tài),完成列拖拽

commands.ts

定義操作表格的一系列方法

  • selectedRect:獲取表格中的選區(qū),并返回選區(qū)信息、表格起始偏移量、表格信息(TableMap.get(table)的值)和當(dāng)前表格,這個方法很有用,能拿到當(dāng)前表格中的所有信息

    table-info.jpg
  • 剩下的方法都是需要用到的功能函數(shù),像addColumnaddRow

copypaste.ts

用于處理將單元格內(nèi)容粘貼到表格中、或?qū)⑷魏蝺?nèi)容粘貼到單元格選擇中,如用選擇內(nèi)容替換單元格塊。

當(dāng)在單元格中cmd + v觸發(fā)粘貼時,步驟為:

  1. 調(diào)用input.ts中的handlePaste方法,根據(jù)傳入的文檔片段去做相應(yīng)處理

  2. 調(diào)用pastedCells,從文檔片段中獲取單元格的矩形區(qū)域,如果文檔片段的外部節(jié)點不是表格單元格或行,則返回null,如果是的話會根據(jù)當(dāng)前slice傳入ensureRectangular去生成新的一組單元格

    // 判斷是否為單元格或行,主要通過schema中定義的tableRole來判斷
    // 行
    first.type.spec.tableRole === 'row';
    // 單元格
    first.type.spec.tableRole === 'cell';
    first.type.spec.tableRole === 'header_cell';
    
  3. 判斷當(dāng)前選區(qū)是否為CellSelection,即是否選中一個或多個單元格的情況,會調(diào)用clipCells方法根據(jù)生成的cells生成表格新的一組單元格,通過insertCells插入原表格指定位置

    • insertCell:將給定的一組單元格(由 pastedCells 返回)插入表格中 rect 指向的位置
    • growTable:isolateHorizontalisolateVertical主要是為了確保被插入的表格足夠大,足夠容得下插入的單元格
  4. 如果當(dāng)前選區(qū)不是CellSelection,但是pastedCells生成了新的cells,即復(fù)制的是表格單元格,則同樣使用insertCells插入

  5. 不滿足上面兩個條件時,返回false,即不用處理,按瀏覽器默認(rèn)行為處理

fixtables.ts

定義了tiptap中的fixTables命令,用于檢查文檔中的所有表格并在必要時修復(fù)。通過代碼可以看到fixTables就是遍歷state.doc的所有子節(jié)點,如果是table的話就調(diào)用fixTable。而fixTable修復(fù)表格主要是根據(jù)表格是否存在TableMap.get(table).problems來做處理,problems包含四種類型

  • collision:直譯為“碰撞”,我理解就是單元格相互擠壓,處理方式是通過removeColSpan處理掉對應(yīng)的單元格
  • missing:直譯為”丟失“,處理方式是為丟失的單元格添加必要的單元格
  • overlong_rowspan:直譯為“過長的 rowspan”,處理方式是修改對應(yīng)單元格的rowspan
  • colwidth mismatch:直譯為“寬度不匹配”,處理方式是修改對應(yīng)單元格的colwidth

因為目前我沒遇到過這些錯誤,所以對這些名詞的理解還不是很清晰。

index.ts

定義插件tableEditing,用于處理單元格選擇的繪制、以及創(chuàng)建和使用此類選擇的基本用戶交互。這個插件需要放在所有插件數(shù)組的末尾,因為它處理表格中的鼠標(biāo)事件相當(dāng)廣泛。而其他插件,比如列寬拖動columnResizing插件,需要首先執(zhí)行更具體的行為。
插件的props上定義了以下事件處理函數(shù),這些事件處理函數(shù)如果返回true,說明它們處理了相應(yīng)的事件,如果返回false則還是觸發(fā)瀏覽器對應(yīng)的事件

  • handleDOMEvents:優(yōu)先級最高,會先于其他處理任何發(fā)生在可編輯DOM元素上的事件之前調(diào)用,這里注冊了mousedown函數(shù),調(diào)用input.js中的handleMouseDown事件,處理鼠標(biāo)按下事件
  • handleTripleClick:三次單擊編輯器時調(diào)用,這里會調(diào)用handleTripleClick函數(shù),當(dāng)三次單擊的時候選中當(dāng)前單元格
  • handleKeyDown:當(dāng)編輯器收到 keydown 事件時調(diào)用,這里會調(diào)用handleKeyDown函數(shù),綁定一些操作表格的快捷鍵
  • handlePaste:用于覆蓋粘貼行為,slice是編輯器解析出來的粘貼內(nèi)容,這里會調(diào)用handlePaste函數(shù),上面已經(jīng)說過,就不再重復(fù)

input.ts

定義了一些功能函數(shù),用于鏈接用戶輸入與table相關(guān)功能

schema.ts

  • 定義tablesnode types,分別為table、table_header、table_celltable_row節(jié)點
  • tableNodeTypes(schema)函數(shù)接受schema,返回上述定義的node types,可以用來判斷傳入的schema是否為table節(jié)點

tablemap.ts

定義 TableMap 類,可以參考prosemirror-tables關(guān)于class TableMap的說明,或中文翻譯。這里為了性能考慮,做了緩存處理。如果緩存中不存在對應(yīng)表格的tableMap時,會通過computeMap重新獲取tableMap,并放入緩存中。

tableview.ts

參考

  • 此處定義的TableView繼承自NodeView,一般來說自定義nodeView都是為了更細粒度的控制節(jié)點在編輯器中的表現(xiàn)樣式,如此處用于控制表格列拖拽時的樣式和行為
  • 上面已經(jīng)提到了,會提供給插件columnresizingNodeViews使用,所以要是不用實現(xiàn)列拖拽功能時,這個文件也就沒什么用了

util.ts

定義一些用于處理表格的各種輔助函數(shù)

  • cellAround:根據(jù)傳入的位置返回當(dāng)前單元格的位置信息
  • cellWrapping:根據(jù)傳入的位置返回當(dāng)前單元
  • isInTable:傳入state判斷當(dāng)前選區(qū)是否在表格中
  • selectionCell:傳入state返回當(dāng)前選區(qū)的位置信息
  • pointsAtCell:根據(jù)傳入的位置判斷是否在單元格內(nèi),返回truefalse
  • moveCellForward:獲取當(dāng)前單元格的前一個單元格位置信息
  • inSameTable:判斷當(dāng)前選區(qū)是否屬于同一個表格
  • findCell:找到給定位置的單元格的尺寸
  • colCount:調(diào)用TableMapcolCount方法,返回當(dāng)前單元格的列數(shù)
  • nextCell:根據(jù)傳入的位置,在給定方向上查找下一個單元格
  • removeColSpan:為指定單元格刪除colspan
  • addColSpan:為指定單元格添加colspan,根據(jù)傳入的n來設(shè)定
  • columnIsHeader:判斷當(dāng)前單元格是否為header
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容