prosemirror-tables 源碼解讀

為什么寫這篇文章

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

tiptap是對prosemirror的封裝,在prosemirror的基礎上提供了更友好的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é)點類型和它們的屬性,例如段落柔逼、標題蒋譬、鏈接、圖片等等愉适。Schema 是編輯器的模型層犯助,可以通過其 API 創(chuàng)建、操作和驗證文檔中的節(jié)點维咸。每個document都有一個與之相關的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);
// 當前節(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é)點可以添加加粗、斜體耸别、下劃線等樣式健芭,也可以添加標簽、鏈接等屬性秀姐。Mark 本身沒有節(jié)點結(jié)構(gòu)慈迈,只是對一個節(jié)點的文本內(nèi)容進行修飾。Marks通過Schema創(chuàng)建省有,用于控制哪些marks存在于哪些節(jié)點以及用于哪些attributes痒留。

State

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

prosemirror-state.jpg

Transaction

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

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

View

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

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

Plugin

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

const pluginState = columnResizingPluginKey.getState(state);

Commands

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

Decorations

表示節(jié)點的外觀和行為的對象啄栓。它可以用于添加樣式娄帖、標記、工具提示等效果昙楚,以及處理點擊近速、懸停、拖拽等事件堪旧。Decoration 通常是在渲染視圖時應用到節(jié)點上的削葱,但也可以在其他情況下使用,如在協(xié)同編輯時標記其他用戶的光標位置淳梦。

用于繪制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解析位置信息返回的對象蜘醋,包含了一些位置相關的信息挂捅。它會告訴我們當前position的父級node是什么,它在父級node中的偏移量(parentOffset)是多少以及其他信息。

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

Selection

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

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

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

// 獲取當前選區(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ū),更新當前 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稱為文檔片段赎败,主要處理復制粘貼和拖拽之類的操作
  • 兩個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:用于當跨單元格選擇時,繪制選區(qū)僵刮,會添加到tableEditingdecorations為每個選中節(jié)點增加classselectedCell 据忘,tableEditing最后會注冊為Editor的插件使用

columnresizing.ts

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

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

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

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

  • handleMouseDown當鼠標按下時未辆,獲取當前位置信息和列寬窟绷,并記錄在pluginState

    此方法中重新定義mouseupmousemove事件

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

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

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

commands.ts

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

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

    table-info.jpg
  • 剩下的方法都是需要用到的功能函數(shù)姊途,像addColumn涉瘾、addRow

copypaste.ts

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

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

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

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

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

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

  5. 不滿足上面兩個條件時驯妄,返回false,即不用處理合砂,按瀏覽器默認行為處理

fixtables.ts

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

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

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

index.ts

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

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

input.ts

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

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關于class TableMap的說明竞思,或中文翻譯表谊。這里為了性能考慮,做了緩存處理盖喷。如果緩存中不存在對應表格的tableMap時爆办,會通過computeMap重新獲取tableMap,并放入緩存中课梳。

tableview.ts

參考

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

util.ts

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

  • cellAround:根據(jù)傳入的位置返回當前單元格的位置信息
  • cellWrapping:根據(jù)傳入的位置返回當前單元
  • isInTable:傳入state判斷當前選區(qū)是否在表格中
  • selectionCell:傳入state返回當前選區(qū)的位置信息
  • pointsAtCell:根據(jù)傳入的位置判斷是否在單元格內(nèi)椭懊,返回truefalse
  • moveCellForward:獲取當前單元格的前一個單元格位置信息
  • inSameTable:判斷當前選區(qū)是否屬于同一個表格
  • findCell:找到給定位置的單元格的尺寸
  • colCount:調(diào)用TableMapcolCount方法诸蚕,返回當前單元格的列數(shù)
  • nextCell:根據(jù)傳入的位置,在給定方向上查找下一個單元格
  • removeColSpan:為指定單元格刪除colspan
  • addColSpan:為指定單元格添加colspan氧猬,根據(jù)傳入的n來設定
  • columnIsHeader:判斷當前單元格是否為header
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末背犯,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子盅抚,更是在濱河造成了極大的恐慌漠魏,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,277評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件泉哈,死亡現(xiàn)場離奇詭異蛉幸,居然都是意外死亡,警方通過查閱死者的電腦和手機丛晦,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,689評論 3 393
  • 文/潘曉璐 我一進店門奕纫,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人烫沙,你說我怎么就攤上這事匹层。” “怎么了锌蓄?”我有些...
    開封第一講書人閱讀 163,624評論 0 353
  • 文/不壞的土叔 我叫張陵升筏,是天一觀的道長。 經(jīng)常有香客問我瘸爽,道長您访,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,356評論 1 293
  • 正文 為了忘掉前任剪决,我火速辦了婚禮灵汪,結(jié)果婚禮上檀训,老公的妹妹穿的比我還像新娘。我一直安慰自己享言,他們只是感情好峻凫,可當我...
    茶點故事閱讀 67,402評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著览露,像睡著了一般荧琼。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上差牛,一...
    開封第一講書人閱讀 51,292評論 1 301
  • 那天命锄,我揣著相機與錄音,去河邊找鬼偏化。 笑死累舷,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的夹孔。 我是一名探鬼主播被盈,決...
    沈念sama閱讀 40,135評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼搭伤!你這毒婦竟也來了只怎?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,992評論 0 275
  • 序言:老撾萬榮一對情侶失蹤怜俐,失蹤者是張志新(化名)和其女友劉穎身堡,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體拍鲤,經(jīng)...
    沈念sama閱讀 45,429評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡贴谎,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,636評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了季稳。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片擅这。...
    茶點故事閱讀 39,785評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖景鼠,靈堂內(nèi)的尸體忽然破棺而出仲翎,到底是詐尸還是另有隱情,我是刑警寧澤铛漓,帶...
    沈念sama閱讀 35,492評論 5 345
  • 正文 年R本政府宣布溯香,位于F島的核電站,受9級特大地震影響浓恶,放射性物質(zhì)發(fā)生泄漏玫坛。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,092評論 3 328
  • 文/蒙蒙 一包晰、第九天 我趴在偏房一處隱蔽的房頂上張望湿镀。 院中可真熱鬧禀梳,春花似錦、人聲如沸肠骆。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,723評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蚀腿。三九已至,卻和暖如春扫外,著一層夾襖步出監(jiān)牢的瞬間莉钙,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,858評論 1 269
  • 我被黑心中介騙來泰國打工筛谚, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留磁玉,地道東北人。 一個月前我還...
    沈念sama閱讀 47,891評論 2 370
  • 正文 我出身青樓驾讲,卻偏偏與公主長得像蚊伞,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子吮铭,可洞房花燭夜當晚...
    茶點故事閱讀 44,713評論 2 354

推薦閱讀更多精彩內(nèi)容