向所見即所得編輯器添加評論系統

已發表: 2022-03-10
快速總結 ↬在本文中,我們將重新使用第一篇文章中內置的基礎 WYSIWYG 編輯器,為 WYSIWYG 編輯器構建一個評論系統,使用戶能夠選擇文檔中的文本並分享他們的評論。 我們還將引入 RecoilJS 用於 UI 應用程序中的狀態管理。 (我們在此處構建的系統的代碼可在 Github 存儲庫中獲得以供參考。)

近年來,我們已經看到協作滲透到許多行業的許多數字工作流程和用例中。 就在設計和軟件工程社區中,我們看到設計師使用 Figma 等工具在設計工件上進行協作,團隊使用 Mural 等工具進行 Sprint 和項目規劃,以及使用 CoderPad 進行採訪。 所有這些工具一直致力於彌合執行這些工作流程的在線和物理世界體驗之間的差距,並使協作體驗盡可能豐富和無縫。

對於像這樣的大多數協作工具,相互分享意見並就相同內容進行討論的能力是必不可少的。 使協作者能夠註釋文檔的某些部分並就它們進行對話的評論系統是這個概念的核心。 除了在 WYSIWYG 編輯器中構建文本之外,本文還試圖讓讀者參與我們如何嘗試權衡利弊,並在為 WYSIWYG 編輯器或構建功能時嘗試在應用程序複雜性和用戶體驗之間找到平衡點。一般的文字處理器。

在文檔結構中表示註釋

為了找到一種在富文本文檔的數據結構中表示註釋的方法,讓我們看一些可以在編輯器中創建註釋的場景。

  • 在沒有樣式的文本上創建的評論(基本場景);
  • 在可能是粗體/斜體/下劃線等的文本上創建的評論;
  • 以某種方式相互重疊的評論(部分重疊,即兩條評論僅共享幾個詞或完全包含,即一條評論的文本完全包含在另一條評論的文本中);
  • 在鏈接內的文本上創建的評論(特別是因為鏈接本身就是我們文檔結構中的節點);
  • 跨越多個段落的註釋(特別是因為段落是我們文檔結構中的節點,並且註釋應用於作為段落子節點的文本節點)。

查看上述用例,在富文本文檔中出現的註釋似乎與字符樣式(粗體、斜體等)非常相似。 它們可以相互重疊,遍歷其他類型節點(如鍊接)中的文本,甚至跨越多個父節點(如段落)。

出於這個原因,我們使用與字符樣式相同的方法來表示註釋,即“標記”(因為它們在 SlateJS 術語中被稱為)。 標記只是節點上的常規屬性——特別是 Slate 的標記 API( Editor.addMarkEditor.removeMark )處理節點層次結構的變化,因為多個標記應用於相同的文本範圍。 這對我們來說非常有用,因為我們要處理許多不同的重疊評論組合。

評論線程作為標記

每當用戶選擇一個文本範圍並嘗試插入評論時,從技術上講,他們正在為該文本範圍啟動一個新的評論線程。 因為我們將允許他們插入評論並稍後回复該評論,所以我們將此事件視為文檔中的新評論線程插入。

我們將評論線程表示為標記的方式是,每個評論線程都由一個名為commentThread_threadID的標記表示,其中threadID是我們分配給每個評論線程的唯一 ID。 因此,如果相同的文本範圍有兩個評論線程,它將有兩個屬性設置為true —— commentThread_thread1commentThread_thread2 。 這就是註釋線程與字符樣式非常相似的地方,因為如果相同的文本是粗體和斜體,那麼它的兩個屬性都會設置為true bolditalic

在我們深入實際設置這個結構之前,有必要看看文本節點在評論線程被應用到它們時如何變化。 其工作方式(與任何標記一樣)是,當在選定文本上設置標記屬性時,Slate 的 Editor.addMark API 將在需要時拆分文本節點,以便在生成的結構中,文本節點以每個文本節點具有完全相同的標記值的方式設置。

為了更好地理解這一點,請查看以下三個示例,這些示例顯示了在所選文本上插入註釋線程後文本節點的前後狀態:

插圖顯示如何使用基本註釋線程插入拆分文本節點
一個文本節點被分成三個作為註釋線程標記被插入到文本的中間。 (大預覽)
顯示在註釋線程部分重疊的情況下如何拆分文本節點的插圖
在“text has”上添加評論線程會創建兩個新的文本節點。 (大預覽)
插圖顯示在評論線程與鏈接部分重疊的情況下如何拆分文本節點
在“有鏈接”上添加評論線程也會拆分鏈接內的文本節點。 (大預覽)
跳躍後更多! 繼續往下看↓

突出顯示註釋文本

現在我們知道我們將如何在文檔結構中表示註釋,讓我們繼續在第一篇文章的示例文檔中添加一些內容,並將編輯器配置為實際將它們顯示為突出顯示。 由於在本文中我們將有很多實用程序函數來處理評論,因此我們創建了一個EditorCommentUtils模塊來容納所有這些實用程序。 首先,我們創建一個函數,為給定的評論線程 ID 創建一個標記。 然後,我們使用它在ExampleDocument中插入一些評論線程。

 # src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }

下圖用紅色強調了我們在下一個代碼片段中添加的示例註釋線程的文本範圍。 請注意,文本“Richard McClintock”有兩個相互重疊的評論線程。 具體來說,這是一個評論線程完全包含在另一個評論線程中的情況。

圖片顯示文檔中的哪些文本範圍將被評論 - 其中一個完全包含在另一個中。
將被評論的文本範圍以紅色下劃線表示。 (大預覽)
 # src/utils/ExampleDocument.js import { getMarkForCommentThreadID } from "../utils/EditorCommentUtils"; import { v4 as uuid } from "uuid"; const exampleOverlappingCommentThreadID = uuid(); const ExampleDocument = [ ... { text: "Lorem ipsum", [getMarkForCommentThreadID(uuid())]: true, }, ... { text: "Richard McClintock", // note the two comment threads here. [getMarkForCommentThreadID(uuid())]: true, [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, { text: ", a Latin scholar", [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, ... ];

在本文中,我們專注於評論系統的 UI 方面,因此我們在示例文檔中直接使用 npm 包 uuid 為其分配 ID。 在編輯器的生產版本中,這些 ID 很可能是由後端服務創建的。

我們現在專注於調整編輯器以突出顯示這些文本節點。 為了做到這一點,在渲染文本節點時,我們需要一種方法來判斷它是否有評論線程。 我們為此添加了一個 util getCommentThreadsOnTextNode 。 我們建立在第一篇文章中創建的StyledText組件的基礎上,以處理它可能試圖呈現帶有註釋的文本節點的情況。 由於我們有更多功能將在稍後添加到註釋文本節點,因此我們創建了一個組件CommentedText來呈現註釋文本。 StyledText將檢查它試圖呈現的文本節點是否有任何評論。 如果是,它會呈現CommentedText 。 它使用 util getCommentThreadsOnTextNode來推斷。

 # src/utils/EditorCommentUtils.js export function getCommentThreadsOnTextNode(textNode) { return new Set( // Because marks are just properties on nodes, // we can simply use Object.keys() here. Object.keys(textNode) .filter(isCommentThreadIDMark) .map(getCommentThreadIDFromMark) ); } export function getCommentThreadIDFromMark(mark) { if (!isCommentThreadIDMark(mark)) { throw new Error("Expected mark to be of a comment thread"); } return mark.replace(COMMENT_THREAD_PREFIX, ""); } function isCommentThreadIDMark(mayBeCommentThread) { return mayBeCommentThread.indexOf(COMMENT_THREAD_PREFIX) === 0; }

第一篇文章構建了一個組件StyledText ,用於渲染文本節點(處理字符樣式等)。 我們擴展該組件以使用上述實用程序並在節點上有評論時呈現CommentedText組件。

 # src/components/StyledText.js import { getCommentThreadsOnTextNode } from "../utils/EditorCommentUtils"; export default function StyledText({ attributes, children, leaf }) { ... const commentThreads = getCommentThreadsOnTextNode(leaf); if (commentThreads.size > 0) { return ( <CommentedText {...attributes} // We use commentThreads and textNode props later in the article. commentThreads={commentThreads} textNode={leaf} > {children} </CommentedText> ); } return <span {...attributes}>{children}</span>; }

下面是CommentedText的實現,它呈現文本節點並附加將其顯示為突出顯示的 CSS。

 # src/components/CommentedText.js import "./CommentedText.css"; import classNames from "classnames"; export default function CommentedText(props) { const { commentThreads, ...otherProps } = props; return ( <span {...otherProps} className={classNames({ comment: true, })} > {props.children} </span> ); } # src/components/CommentedText.css .comment { background-color: #feeab5; }

將上述所有代碼組合在一起後,我們現在可以在編輯器中看到帶有註釋線程的文本節點。

插入註釋線程後,註釋文本節點顯示為突出顯示
插入註釋線程後,註釋文本節點顯示為突出顯示。 (大預覽)

注意用戶目前無法判斷某些文本是否有重疊評論。 整個突出顯示的文本範圍看起來像一個單獨的評論線程。 我們稍後會在文章中介紹活動評論線程的概念,它允許用戶選擇特定的評論線程並能夠在編輯器中查看其範圍。

用於評論的 UI 存儲

在我們添加允許用戶插入新評論的功能之前,我們首先設置一個 UI 狀態來保存我們的評論線程。 在本文中,我們使用 RecoilJS 作為我們的狀態管理庫來存儲評論線程、線程中包含的評論以及其他元數據,如創建時間、狀態、評論作者等。讓我們將 Recoil 添加到我們的應用程序中:

 > yarn add recoil

我們使用 Recoil atom 來存儲這兩個數據結構。 如果你不熟悉 Recoil,那麼原子就是保持應用程序狀態的東西。 對於不同的應用程序狀態,您通常需要設置不同的原子。 Atom Family 是原子的集合——它可以被認為是從標識原子的唯一鍵到原子本身的Map 。 在這一點上,值得通過 Recoil 的核心概念並熟悉它們。

對於我們的用例,我們將評論線程存儲為 Atom 系列,然後將我們的應用程序包裝在RecoilRoot組件中。 RecoilRoot用於提供將要使用原子值的上下文。 我們創建一個單獨的模塊CommentState來保存我們的 Recoil 原子定義,因為我們將在本文後面添加更多原子定義。

 # src/utils/CommentState.js import { atom, atomFamily } from "recoil"; export const commentThreadsState = atomFamily({ key: "commentThreads", default: [], }); export const commentThreadIDsState = atom({ key: "commentThreadIDs", default: new Set([]), });

值得一提的是關於這些原子定義的一些事情:

  • 每個原子/原子族都由一個key唯一標識,並且可以設置一個默認值。
  • 隨著我們在本文中進一步構建,我們將需要一種方法來迭代所有評論線程,這基本上意味著需要一種方法來迭代commentThreadsState atom 系列。 在撰寫本文時,使用 Recoil 的方法是設置另一個原子來保存原子族的所有 ID。 我們使用上面的commentThreadIDsState做到這一點。 每當我們添加/刪除評論線程時,這兩個原子都必須保持同步。

我們在根App組件中添加了一個RecoilRoot包裝器,以便我們以後可以使用這些原子。 Recoil 的文檔還提供了一個有用的 Debugger 組件,我們將其按原樣放入我們的編輯器中。 當 Recoil atom 實時更新時,該組件會將console.debug日誌留給我們的開發控制台。

 # src/components/App.js import { RecoilRoot } from "recoil"; export default function App() { ... return ( <RecoilRoot> > ... <Editor document={document} onChange={updateDocument} /> </RecoilRoot> ); }
 # src/components/Editor.js export default function Editor({ ... }): JSX.Element { ..... return ( <> <Slate> ..... </Slate> <DebugObserver /> </> ); function DebugObserver(): React.Node { // see API link above for implementation. }

我們還需要添加代碼,使用文檔中已經存在的註釋線程(例如,我們在上一節中添加到示例文檔中的註釋線程)初始化我們的原子。 我們稍後會在構建需要讀取文檔中所有評論線程的評論側邊欄時這樣做。

此時,我們加載我們的應用程序,確保沒有指向我們的 Recoil 設置的錯誤並繼續前進。

添加新評論

在本節中,我們向工具欄添加一個按鈕,允許用戶為選定的文本範圍添加評論(即創建新的評論線程)。 當用戶選擇文本範圍並單擊此按鈕時,我們需要執行以下操作:

  1. 為要插入的新評論線程分配一個唯一 ID。
  2. 使用 ID 向 Slate 文檔結構添加新標記,以便用戶看到突出顯示的文本。
  3. 將新的評論線程添加到我們在上一節中創建的 Recoil atom。

讓我們向EditorCommentUtils添加一個 util 函數來執行 #1 和 #2。

 # src/utils/EditorCommentUtils.js import { Editor } from "slate"; import { v4 as uuidv4 } from "uuid"; export function insertCommentThread(editor, addCommentThreadToState) { const threadID = uuidv4(); const newCommentThread = { // comments as added would be appended to the thread here. comments: [], creationTime: new Date(), // Newly created comment threads are OPEN. We deal with statuses // later in the article. status: "open", }; addCommentThreadToState(threadID, newCommentThread); Editor.addMark(editor, getMarkForCommentThreadID(threadID), true); return threadID; }

通過使用標記的概念將每個評論線程存儲為自己的標記,我們可以簡單地使用Editor.addMark API 在選定的文本範圍上添加一個新的評論線程。 這個調用單獨處理添加評論的所有不同情況——我們在前面的部分中描述了其中一些——部分重疊的評論、內部/重疊鏈接的評論、粗體/斜體文本上的評論、跨段落的評論等等。 此 API 調用調整節點層次結構,以根據需要創建盡可能多的新文本節點來處理這些情況。

addCommentThreadToState是處理第 3 步的回調函數——將新的評論線程添加到 Recoil atom 。 我們接下來將其實現為自定義回調鉤子,以便它可以重用。 這個回調需要將新的評論線程添加到兩個原子commentThreadsStatecommentThreadIDsState 。 為了能夠做到這一點,我們使用了useRecoilCallback鉤子。 這個鉤子可以用來構造一個回調,它可以獲取一些可以用來讀取/設置原子數據的東西。 我們現在感興趣的是set函數,它可用於將 atom 值更新為set(atom, newValueOrUpdaterFunction)

 # src/hooks/useAddCommentThreadToState.js import { commentThreadIDsState, commentThreadsState, } from "../utils/CommentState"; import { useRecoilCallback } from "recoil"; export default function useAddCommentThreadToState() { return useRecoilCallback( ({ set }) => (id, threadData) => { set(commentThreadIDsState, (ids) => new Set([...Array.from(ids), id])); set(commentThreadsState(id), threadData); }, [] ); }

第一次調用set將新 ID 添加到現有的評論線程 ID 集中並返回新的Set (它成為原子的新值)。

在第二次調用中,我們從原子族中獲取 ID 的原子commentThreadsState作為commentThreadsState(id) ,然後將threadData設置為它的值。 atomFamilyName(atomID)是 Recoil 讓我們使用唯一鍵從其原子族訪問原子的方式。 粗略地說,如果commentThreadsState是一個javascript Map,那麼這個調用基本上就是commentThreadsState.set(id, threadData)

現在我們已經設置了所有這些代碼來處理向文檔和 Recoil atom 插入新的註釋線程,讓我們在工具欄上添加一個按鈕,並將其與對這些函數的調用連接起來。

 # src/components/Toolbar.js import { insertCommentThread } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); ... const addCommentThread = useAddCommentThreadToState(); const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); }, [editor, addCommentThread]); return ( <div className="toolbar"> ... <ToolBarButton isActive={false} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> ); }

注意我們使用onMouseDown而不是onClick會使編輯器失去焦點和選擇變為null 我們已經在第一篇文章的鏈接插入部分更詳細地討論了這一點。

在下面的示例中,我們看到了一個簡單評論線程和一個帶有鏈接的重疊評論線程的插入操作。 請注意我們如何從 Recoil Debugger 獲取更新以確認我們的狀態正在正確更新。 我們還驗證了在將線程添加到文檔時是否創建了新的文本節點。

插入註釋線程會拆分文本節點,使註釋文本成為自己的節點。
當我們添加重疊的評論時,會創建更多的文本節點。

重疊評論

在繼續向評論系統添加更多功能之前,我們需要就如何處理重疊評論及其在編輯器中的不同組合做出一些決定。 為了了解我們為什麼需要它,讓我們先來看看評論彈出框是如何工作的——我們將在本文後面構建一個功能。 當用戶單擊帶有評論線程的特定文本時,我們“選擇”一個評論線程並顯示一個彈出框,用戶可以在其中向該線程添加評論。

當用戶單擊具有重疊評論的文本節點時,編輯器需要決定選擇哪個評論線程。

從上面的視頻可以看出,“設計師”這個詞現在是三個評論線程的一部分。 所以我們有兩個評論線程,它們在一個詞上相互重疊。 並且這兩個評論線程(#1和#2)都完全包含在更長的評論線程文本範圍(#3)中。 這提出了幾個問題:

  1. 當用戶點擊“設計師”這個詞時,我們應該選擇並顯示哪個評論線程?
  2. 根據我們決定如何解決上述問題,我們是否會遇到重疊的情況,即單擊任何單詞都不會激活某個評論線程並且根本無法訪問該線程?

這意味著在評論重疊的情況下,要考慮的最重要的事情是——一旦用戶插入了評論線程,他們將來是否可以通過單擊其中的一些文本來選擇該評論線程它? 如果沒有,我們可能不想讓他們首先插入它。 為了確保在我們的編輯器中大部分時間都遵守這一原則,我們引入了兩條關於重疊註釋的規則並在我們的編輯器中實施它們。

在我們定義這些規則之前,值得一提的是,不同的編輯器和文字處理器在處理重疊評論時有不同的方法。 為簡單起見,一些編輯器不允許任何重疊評論。 在我們的案例中,我們試圖通過不允許過於復雜的重疊案例但仍然允許重疊評論來找到中間立場,以便用戶可以獲得更豐富的協作和審查體驗。

最短評論範圍規則

此規則幫助我們回答上面的問題 #1,即如果用戶單擊具有多個評論線程的文本節點,則選擇哪個評論線程。 規則是:

“如果用戶點擊有多個評論線程的文本,我們會找到最短文本範圍的評論線程並選擇它。”

直觀地說,這樣做是有意義的,這樣用戶總是有辦法到達完全包含在另一個評論線程中的最裡面的評論線程。 對於其他條件(部分重疊或不重疊),應該有一些文本上只有一個評論線程,因此應該很容易使用該文本來選擇該評論線程。 這是線程完全(或密集)重疊的情況,也是我們需要這條規則的原因。

讓我們看一個相當複雜的重疊案例,它允許我們在選擇評論線程時使用此規則並“做正確的事”。

示例顯示三個評論線程相互重疊,選擇評論線程的唯一方法是使用最短長度規則。
遵循最短評論線程規則,單擊“B”選擇評論線程#1。 (大預覽)

在上面的示例中,用戶按該順序插入以下評論線程:

  1. 在字符“B”(長度 = 1)上註釋線程 #1。
  2. 在“AB”上評論線程 #2(長度 = 2)。
  3. 評論線程#3 over 'BC'(長度 = 2)。

在這些插入結束時,由於 Slate 使用標記分割文本節點的方式,我們將擁有三個文本節點 - 每個字符一個。 現在,如果用戶點擊“B”,按照最短長度規則,我們選擇線程#1,因為它是三個長度中最短的。 如果我們不這樣做,我們將無法選擇評論線程#1,因為它只有一個字符的長度,並且也是另外兩個線程的一部分。

儘管這條規則可以很容易地顯示較短的評論線程,但我們可能會遇到較長的評論線程變得不可訪問的情況,因為其中包含的所有字符都是其他較短評論線程的一部分。 讓我們看一個例子。

假設我們有 100 個字符(例如,字符“A”輸入了 100 次)並且用戶按以下順序插入評論線程:

  1. 評論線程#1,範圍為 20,80
  2. 評論線程#2,範圍為 0,50
  3. 評論線程#3,範圍為 51,100
示例顯示最短長度規則使評論線程不可選擇,因為其所有文本都被較短的評論線程覆蓋。
評論線程 #1 下的所有文本也是比 #1 更短的其他評論線程的一部分。 (大預覽)

正如您在上面的示例中看到的,如果我們遵循我們剛剛在此處描述的規則,單擊#20 和#80 之間的任何字符,將始終選擇線程#2 或#3,因為它們比#1 短,因此是#1將無法選擇。 該規則可能使我們無法決定選擇哪個評論線程的另一種情況是,當文本節點上有多個相同最短長度的評論線程時。

對於這種重疊評論的組合和許多其他這樣的組合,人們可以想到遵循此規則會使某個評論線程無法通過單擊文本來訪問,我們在本文後面構建了一個評論側邊欄,讓用戶可以查看所有評論線程出現在文檔中,以便他們可以單擊側欄中的這些線程並在編輯器中激活它們以查看評論的範圍。 我們仍然希望擁有這條規則並實施它,因為它應該涵蓋很多重疊場景,除了我們上面引用的不太可能的例子。 我們圍繞這條規則進行了所有這些努力,主要是因為在編輯器中查看突出顯示的文本並單擊它進行評論是一種訪問文本評論的更直觀的方式,而不是僅僅使用側邊欄中的評論列表。

插入規則

規則是:

“如果用戶選擇並嘗試評論的文本已經被評論線程完全覆蓋,則不允許插入。”

之所以如此,是因為如果我們確實允許這種插入,那麼該範圍內的每個字符最終都會有至少兩個評論線程(一個是現有的,另一個是我們剛剛允許的新的),這使得我們很難確定當用戶稍後單擊該字符。

看看這個規則,如果我們已經有了允許我們選擇最小文本範圍的最短評論範圍規則,那麼我們可能想知道為什麼我們首先需要它。 如果我們可以使用第一條規則來推斷要顯示的正確評論線程,為什麼不允許所有重疊組合? 正如我們之前討論過的一些示例,第一條規則適用於很多場景,但不是所有場景。 使用插入規則,我們盡量減少第一條規則無法幫助我們的場景數量,我們不得不退回到側邊欄作為用戶訪問該評論線程的唯一方式。 插入規則還可以防止評論線程的精確重疊。 這條規則通常由許多流行的編輯器實施。

下面是一個示例,如果此規則不存在,我們將允許評論線程#3,然後作為第一條規則的結果,#3 將無法訪問,因為它將成為最長的長度。

插入規則不允許其整個文本範圍被其他兩個評論線程覆蓋的第三個評論線程。

注意擁有這條規則並不意味著我們永遠不會完全包含重疊的評論。 重疊評論的棘手之處在於,儘管有規則,但插入評論的順序仍然會使我們處於我們不希望重疊出現的狀態。回到我們關於“設計師”一詞的評論示例' 早些時候,插入的最長評論線程是最後一個要添加的評論線程,因此插入規則將允許它,我們最終會得到一個完全包含的情況——#1 和 #2 包含在 #3 中。 這很好,因為最短評論範圍規則會幫助我們。

我們將在下一節中實現最短評論範圍規則,我們將在其中實現評論線程的選擇。 由於我們現在有一個工具欄按鈕來插入評論,我們可以通過在用戶選擇了一些文本時檢查規則來立即實施插入規則。 如果不滿足規則,我們將禁用“評論”按鈕,這樣用戶就無法在所選文本上插入新的評論線程。 讓我們開始吧!

 # src/utils/EditorCommentUtils.js export function shouldAllowNewCommentThreadAtSelection(editor, selection) { if (selection == null || Range.isCollapsed(selection)) { return false; } const textNodeIterator = Editor.nodes(editor, { at: selection, mode: "lowest", }); let nextTextNodeEntry = textNodeIterator.next().value; const textNodeEntriesInSelection = []; while (nextTextNodeEntry != null) { textNodeEntriesInSelection.push(nextTextNodeEntry); nextTextNodeEntry = textNodeIterator.next().value; } if (textNodeEntriesInSelection.length === 0) { return false; } return textNodeEntriesInSelection.some( ([textNode]) => getCommentThreadsOnTextNode(textNode).size === 0 ); }

此函數中的邏輯相對簡單。

  • 如果用戶的選擇是一個閃爍的插入符號,我們不允許在那裡插入評論,因為沒有選擇任何文本。
  • 如果用戶的選擇不是折疊的,我們會找到選擇中的所有文本節點。 注意mode: lowest在調用Editor.nodes (SlateJS 的輔助函數)中的最低模式,它幫助我們選擇所有文本節點,因為文本節點實際上是文檔樹的葉子。
  • 如果至少有一個文本節點上沒有評論線程,我們可以允許插入。 我們使用我們之前在這裡編寫的 util getCommentThreadsOnTextNode

我們現在使用工具欄中的這個 util 函數來控制按鈕的禁用狀態。

 # src/components/Toolbar.js export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); .... return ( <div className="toolbar"> .... <ToolBarButton isActive={false} disabled={!shouldAllowNewCommentThreadAtSelection( editor, selection )} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> );

讓我們通過重新創建上面的示例來測試規則的實現。

當用戶嘗試在已被其他評論完全覆蓋的文本範圍內插入評論時,工具欄中的插入按鈕被禁用。

此處需要指出的一個很好的用戶體驗細節是,如果用戶在此處選擇了整行文本,我們會禁用工具欄按鈕,但它並沒有完成用戶的體驗。 用戶可能不完全理解為什麼該按鈕被禁用,並且可能會感到困惑,因為我們沒有響應他們在此處插入評論線程的意圖。 我們稍後會解決這個問題,因為評論彈出框的構建使得即使工具欄按鈕被禁用,其中一個評論線程的彈出框也會出現,並且用戶仍然可以留下評論。

讓我們也測試一個案例,其中有一些未註釋的文本節點並且規則允許插入新的評論線程。

插入規則允許在用戶選擇中存在一些未註釋的文本時插入註釋線程。

選擇評論線程

在本節中,我們啟用用戶單擊評論文本節點的功能,並使用最短評論範圍規則來確定應選擇哪個評論線程。 該過程的步驟是:

  1. 在用戶單擊的評論文本節點上找到最短的評論線程。
  2. 將該評論線程設置為活動評論線程。 (我們創建了一個新的 Recoil 原子,它將成為這個事實的來源。)
  3. 被評論的文本節點會監聽 Recoil 狀態,如果它們是活動評論線程的一部分,它們會以不同的方式突出顯示自己。 這樣,當用戶單擊評論線程時,整個文本範圍都會突出,因為所有文本節點都會更新它們的突出顯示顏色。

第 1 步:實施最短評論範圍規則

讓我們從第 1 步開始,它基本上是實施最短評論範圍規則。 這裡的目標是在用戶點擊的文本節點處找到最短範圍的評論線程。 為了找到最短長度的線程,我們需要計算該文本節點處所有評論線程的長度。 執行此操作的步驟是:

  1. 獲取相關文本節點的所有評論線程。
  2. 從該文本節點沿任一方向遍歷並不斷更新正在跟踪的線程長度。
  3. 當我們到達以下邊緣之一時,停止沿某個方向的遍歷:
    • 一個未註釋的文本節點(意味著我們已經到達了我們正在跟踪的所有評論線程的最遠開始/結束邊緣)。
    • 我們正在跟踪的所有評論線程都已到達邊緣(開始/結束)的文本節點。
    • 沒有更多的文本節點可以在該方向上遍歷(這意味著我們已經到達文檔的開頭或結尾或非文本節點)。

由於正向和反向的遍歷在功能上是相同的,我們將編寫一個輔助函數updateCommentThreadLengthMap ,它基本上採用文本節點迭代器。 它將不斷調用迭代器並不斷更新跟踪線程長度。 我們將調用這個函數兩次——一次用於正向,一次用於反向。 Let's write our main utility function that will use this helper function.

 # src/utils/EditorCommentUtils.js export function getSmallestCommentThreadAtTextNode(editor, textNode) { const commentThreads = getCommentThreadsOnTextNode(textNode); const commentThreadsAsArray = [...commentThreads]; let shortestCommentThreadID = commentThreadsAsArray[0]; const reverseTextNodeIterator = (slateEditor, nodePath) => Editor.previous(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); const forwardTextNodeIterator = (slateEditor, nodePath) => Editor.next(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); if (commentThreads.size > 1) { // The map here tracks the lengths of the comment threads. // We initialize the lengths with length of current text node // since all the comment threads span over the current text node // at the least. const commentThreadsLengthByID = new Map( commentThreadsAsArray.map((id) => [id, textNode.text.length]) ); // traverse in the reverse direction and update the map updateCommentThreadLengthMap( editor, commentThreads, reverseTextNodeIterator, commentThreadsLengthByID ); // traverse in the forward direction and update the map updateCommentThreadLengthMap( editor, commentThreads, forwardTextNodeIterator, commentThreadsLengthByID ); let minLength = Number.POSITIVE_INFINITY; // Find the thread with the shortest length. for (let [threadID, length] of commentThreadsLengthByID) { if (length < minLength) { shortestCommentThreadID = threadID; minLength = length; } } } return shortestCommentThreadID; }

The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.

One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap such that it can call it while it is iterating text node's path and easily get the previous/next text node. To do that, Slate's traversal utilities Editor.previous and Editor.next (defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator and forwardTextNodeIterator call these helpers with two options mode: lowest and the match function Text.isText so we know we're getting a text node from the traversal, if there is one.

Now we implement updateCommentThreadLengthMap which traverses using these iterators and updates the lengths we're tracking.

 # src/utils/EditorCommentUtils.js function updateCommentThreadLengthMap( editor, commentThreads, nodeIterator, map ) { let nextNodeEntry = nodeIterator(editor); while (nextNodeEntry != null) { const nextNode = nextNodeEntry[0]; const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode); const intersection = [...commentThreadsOnNextNode].filter((x) => commentThreads.has(x) ); // All comment threads we're looking for have already ended meaning // reached an uncommented text node OR a commented text node which // has none of the comment threads we care about. if (intersection.length === 0) { break; } // update thread lengths for comment threads we did find on this // text node. for (let i = 0; i < intersection.length; i++) { map.set(intersection[i], map.get(intersection[i]) + nextNode.text.length); } // call the iterator to get the next text node to consider nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]); } return map; }

One might wonder why do we wait until the intersection becomes 0 to stop iterating in a certain direction. Why can't we just stop if we're reached the edge of at least one comment thread — that would imply we've reached the shortest length in that direction, right? The reason we can't do that is that we know that a comment thread can span over multiple text nodes and we wouldn't know which of those text nodes did the user click on and we started our traversal from. We wouldn't know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.

Check out the below example where we have two comment threads 'A' and 'B' overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.

Example of multiple comment threads overlapping on a text node.
Two comment threads overlapping over the word 'text'. (大預覽)

In this example, let's assume we don't wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we'd stop at the start of text node #2 itself since that's the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.

To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.

Slideshow showing iterations in the implementation of Shortest Comment Thread Rule.

Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It

Now that we have the logic for the rule fully implemented, let's update the editor code to use it. For that, we first create a Recoil atom that'll store the active comment thread ID for us. We then update the CommentedText component to use our rule's implementation.

# src/utils/CommentState.js import { atom } from "recoil"; export const activeCommentThreadIDAtom = atom({ key: "activeCommentThreadID", default: null, }); # src/components/CommentedText.js import { activeCommentThreadIDAtom } from "../utils/CommentState"; import classNames from "classnames"; import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils"; import { useRecoilState } from "recoil"; export default function CommentedText(props) { .... const { commentThreads, textNode, ...otherProps } = props; const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = () => { setActiveCommentThreadID( getSmallestCommentThreadAtTextNode(editor, textNode) ); }; return ( <span {...otherProps} className={classNames({ comment: true, // a different background color treatment if this text node's // comment threads do contain the comment thread active on the // document right now. "is-active": commentThreads.has(activeCommentThreadID), })} onClick={onClick} > {props.children} ≷/span> ); }

該組件使用useRecoilState ,它允許組件訂閱並能夠設置 Recoil atom 的值。 我們需要訂閱者知道這個文本節點是否是活動評論線程的一部分,以便它可以以不同的方式設置自己的樣式。 查看下面的屏幕截圖,中間的評論線程處於活動狀態,我們可以清楚地看到它的範圍。

顯示所選評論線程下的文本節點如何跳出的示例。
所選評論線程下的文本節點更改樣式並跳出。 (大預覽)

現在我們已經有了所有代碼來選擇評論線程,讓我們看看它的實際效果。 為了很好地測試我們的遍歷代碼,我們測試了一些簡單的重疊情況和一些邊緣情況,例如:

  • 單擊編輯器開始/結束處的註釋文本節點。
  • 單擊帶有跨越多個段落的註釋線程的註釋文本節點。
  • 單擊圖像節點之前的註釋文本節點。
  • 單擊重疊鏈接的註釋文本節點。
為不同的重疊組合選擇最短的評論線程。

由於我們現在有一個 Recoil atom 來跟踪活動的評論線程 ID,所以需要注意的一個小細節是當用戶使用工具欄按鈕插入新的評論線程時,將新創建的評論線程設置為活動的。 這使我們在下一節中能夠在插入時立即顯示評論線程彈出框,以便用戶可以立即開始添加評論。

 # src/components/Toolbar.js import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; import { useSetRecoilState } from "recoil"; export default function Toolbar({ selection, previousSelection }) { ... const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); ..... const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); setActiveCommentThreadID(newCommentThreadID); }, [editor, addCommentThread, setActiveCommentThreadID]); return <div className='toolbar'> .... </div>; };

注意:這裡使用useSetRecoilState (一個 Recoil 鉤子,它為 atom 公開一個 setter,但不為組件訂閱它的值)是我們在這種情況下工具欄所需要的。

添加評論線程彈出框

在本節中,我們構建了一個評論彈出框,它利用了選定/活動評論線程的概念,並顯示了一個彈出框,允許用戶向該評論線程添加評論。 在我們構建它之前,讓我們快速看看它是如何工作的。

評論彈出框功能的預覽。

當嘗試在活動的評論線程附近呈現評論彈出框時,我們遇到了我們在第一篇文章中使用鏈接編輯器菜單時遇到的一些問題。 在這一點上,鼓勵通讀第一篇文章中構建鏈接編輯器的部分以及我們遇到的選擇問題。

讓我們首先根據活躍的評論線程在正確的位置渲染一個空的彈出框組件。 popover 的工作方式是:

  • 僅當存在活動的評論線程 ID 時才會呈現評論線程彈出框。 為了獲得這些信息,我們聆聽我們在上一節中創建的 Recoil atom。
  • 當它渲染時,我們在編輯器的選擇處找到文本節點,並在它附近渲染彈出框。
  • 當用戶點擊彈出框外的任何位置時,我們將活動評論線程設置為null ,從而停用評論線程並使彈出框消失。
 # src/components/CommentThreadPopover.js import NodePopover from "./NodePopover"; import { getFirstTextNodeAtSelection } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; import { useSetRecoilState} from "recoil"; import {activeCommentThreadIDAtom} from "../utils/CommentState"; export default function CommentThreadPopover({ editorOffsets, selection, threadID }) { const editor = useEditor(); const textNode = getFirstTextNodeAtSelection(editor, selection); const setActiveCommentThreadID = useSetRecoilState( activeCommentThreadIDAtom ); const onClickOutside = useCallback( () => {}, [] ); return ( <NodePopover editorOffsets={editorOffsets} isBodyFullWidth={true} node={textNode} className={"comment-thread-popover"} onClickOutside={onClickOutside} > {`Comment Thread Popover for threadID:${threadID}`} </NodePopover> ); }

對於 popover 組件的這個實現,應該調用幾件事:

  • 它採用editorOffsetsEditor組件中的selection ,它將被渲染。 editorOffsets是 Editor 組件的邊界,因此我們可以計算彈出框的位置,並且selection可以是當前或以前的選擇,以防用戶使用工具欄按鈕導致selection變為null 。 上面鏈接的第一篇文章中的鏈接編輯器部分詳細介紹了這些內容。
  • 由於第一篇文章中的LinkEditor和此處的CommentThreadPopover都在文本節點周圍呈現了一個彈出框,因此我們已將該通用邏輯移動到一個組件NodePopover中,該組件處理與相關文本節點對齊的組件的呈現。 它的實現細節是第一篇文章中的LinkEditor組件。
  • NodePopoveronClickOutside方法作為道具,如果用戶單擊彈出框之外的某個位置,則會調用該方法。 我們通過將mousedown事件偵聽器附加到document來實現這一點——正如這篇關於這個想法的 Smashing 文章中詳細解釋的那樣。
  • getFirstTextNodeAtSelection獲取用戶選擇中的第一個文本節點,我們使用它來呈現彈出窗口。 此函數的實現使用 Slate 的幫助器來查找文本節點。
 # src/utils/EditorUtils.js export function getFirstTextNodeAtSelection(editor, selection) { const selectionForNode = selection ?? editor.selection; if (selectionForNode == null) { return null; } const textNodeEntry = Editor.nodes(editor, { at: selectionForNode, mode: "lowest", match: Text.isText, }).next().value; return textNodeEntry != null ? textNodeEntry[0] : null; }

讓我們實現應該清除活動評論線程的onClickOutside回調。 但是,當評論線程彈出窗口打開並且某個線程處於活動狀態並且用戶碰巧單擊另一個評論線程時,我們必須考慮這種情況。 在這種情況下,我們不希望onClickOutside重置活動評論線程,因為其他CommentedText組件上的單擊事件應該將其他評論線程設置為活動狀態。 我們不想在彈出窗口中乾擾它。

我們這樣做的方法是,我們找到最接近單擊事件發生的 DOM 節點的 Slate 節點。 如果那個 Slate 節點是一個文本節點並且上面有評論,我們跳過重置活動評論線程 Recoil atom。 讓我們實現它!

 # src/components/CommentThreadPopover.js const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); const onClickOutside = useCallback( (event) => { const slateDOMNode = event.target.hasAttribute("data-slate-node") ? event.target : event.target.closest('[data-slate-node]'); // The click event was somewhere outside the Slate hierarchy. if (slateDOMNode == null) { setActiveCommentThreadID(null); return; } const slateNode = ReactEditor.toSlateNode(editor, slateDOMNode); // Click is on another commented text node => do nothing. if ( Text.isText(slateNode) && getCommentThreadsOnTextNode(slateNode).size > 0 ) { return; } setActiveCommentThreadID(null); }, [editor, setActiveCommentThreadID] );

Slate 有一個輔助方法toSlateNode ,如果它不是 Slate 節點,它返回映射到 DOM 節點或其最近祖先的 Slate 節點。 如果此幫助程序的當前實現找不到 Slate 節點而不是返回null ,則會引發錯誤。 我們通過自己檢查null情況來處理上述問題,如果用戶單擊編輯器外部不存在 Slate 節點的某個位置,這很可能發生這種情況。

我們現在可以更新Editor組件以偵聽activeCommentThreadIDAtom並僅在評論線程處於活動狀態時才呈現彈出框。

 # src/components/Editor.js import { useRecoilValue } from "recoil"; import { activeCommentThreadIDAtom } from "../utils/CommentState"; export default function Editor({ document, onChange }): JSX.Element { const activeCommentThreadID = useRecoilValue(activeCommentThreadIDAtom); // This hook is described in detail in the first article const [previousSelection, selection, setSelection] = useSelection(editor); return ( <> ... <div className="editor" ref={editorRef}> ... {activeCommentThreadID != null ? ( <CommentThreadPopover editorOffsets={editorOffsets} selection={selection ?? previousSelection} threadID={activeCommentThreadID} /> ) : null} </div> ... </> ); }

讓我們驗證彈出框是否在正確的評論線程的正確位置加載,並在我們單擊外部時清除活動的評論線程。

為選定的評論線程正確加載評論線程彈出框。

我們現在繼續讓用戶向評論線程添加評論並在彈出窗口中查看該線程的所有評論。 我們將使用 Recoil atom 系列——我們在本文前面為此創建的commentThreadsState

評論線程中的評論存儲在comments數組中。 為了能夠添加新評論,我們呈現一個允許用戶輸入新評論的表單輸入。 當用戶輸入評論時,我們將其保存在本地狀態變量commentText中。 單擊按鈕時,我們將評論文本作為新評論附加到comments數組中。

 # src/components/CommentThreadPopover.js import { commentThreadsState } from "../utils/CommentState"; import { useRecoilState } from "recoil"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); const [commentText, setCommentText] = useState(""); const onClick = useCallback(() => { setCommentThreadData((threadData) => ({ ...threadData, comments: [ ...threadData.comments, // append comment to the comments on the thread. { text: commentText, author: "Jane Doe", creationTime: new Date() }, ], })); // clear the input setCommentText(""); }, [commentText, setCommentThreadData]); const onCommentTextChange = useCallback( (event) => setCommentText(event.target.value), [setCommentText] ); return ( <NodePopover ... > <div className={"comment-input-wrapper"}> <Form.Control bsPrefix={"comment-input form-control"} placeholder={"Type a comment"} type="text" value={commentText} onChange={onCommentTextChange} /> <Button size="sm" variant="primary" disabled={commentText.length === 0} onClick={onClick} > Comment </Button> </div> </NodePopover> ); }

注意雖然我們為用戶呈現一個輸入以輸入評論,但當彈出框安裝時,我們不一定讓它成為焦點。 這是一個用戶體驗決定,可能因編輯而異。 一些編輯器不允許用戶在評論線程彈出框打開時編輯文本。 在我們的例子中,我們希望能夠讓用戶在點擊評論文本時對其進行編輯。

值得一提的是,我們如何從 Recoil 原子家族中訪問特定評論線程的數據——通過將原子調用為commentThreadsState(threadID) 。 這給了我們原子的值和一個只更新族中那個原子的設置器。 如果評論是從服務器延遲加載的,Recoil 還提供了一個useRecoilStateLoadable鉤子,它返回一個 Loadable 對象,告訴我們原子數據的加載狀態。 如果還在加載,我們可以選擇在彈窗中顯示一個加載狀態。

現在,我們訪問threadData並呈現評論列表。 每條評論都由CommentRow組件呈現。

 # src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );

下面是CommentRow的實現,它呈現評論文本和其他元數據,如作者姓名和創建時間。 我們使用date-fns模塊來顯示格式化的創建時間。

 # src/components/CommentRow.js import { format } from "date-fns"; export default function CommentRow({ comment: { author, text, creationTime }, }) { return ( <div className={"comment-row"}> <div className="comment-author-photo"> <i className="bi bi-person-circle comment-author-photo"></i> </div> <div> <span className="comment-author-name">{author}</span> <span className="comment-creation-time"> {format(creationTime, "eee MM/dd H:mm")} </span> <div className="comment-text">{text}</div> </div> </div> ); }

我們已經將其提取為它自己的組件,以便稍後在我們實現評論側邊欄時重新使用它。

至此,我們的評論彈出框已經擁有了允許插入新評論和更新 Recoil 狀態所需的所有代碼。 讓我們驗證一下。 在瀏覽器控制台上,使用我們之前添加的 Recoil Debug Observer,我們能夠驗證評論線程的 Recoil 原子是否在我們向線程添加新評論時正確更新。

評論線程彈出框在選擇評論線程時加載。

添加評論側邊欄

在本文的前面,我們已經指出了為什麼偶爾會發生這樣的情況,即我們實施的規則會阻止某個評論線程無法通過單獨單擊其文本節點來訪問——這取決於重疊的組合。 對於這種情況,我們需要一個評論側邊欄,讓用戶可以訪問文檔中的任何和所有評論線程。

評論側邊欄也是一個很好的補充,它融入了建議和審查工作流程,審閱者可以在其中一個接一個地瀏覽所有評論線程,並能夠在他們認為需要的任何地方留下評論/回复。 在我們開始實現側邊欄之前,我們在下面處理了一項未完成的任務。

初始化評論線程的 Recoil 狀態

當文檔在編輯器中加載時,我們需要掃描文檔以找到所有註釋線程並將它們添加到我們在上面創建的 Recoil atom 作為初始化過程的一部分。 讓我們在EditorCommentUtils中編寫一個實用函數來掃描文本節點,找到所有評論線程並將它們添加到 Recoil atom。

 # src/utils/EditorCommentUtils.js export async function initializeStateWithAllCommentThreads( editor, addCommentThread ) { const textNodesWithComments = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).size > 0, }); const commentThreads = new Set(); let textNodeEntry = textNodesWithComments.next().value; while (textNodeEntry != null) { [...getCommentThreadsOnTextNode(textNodeEntry[0])].forEach((threadID) => { commentThreads.add(threadID); }); textNodeEntry = textNodesWithComments.next().value; } Array.from(commentThreads).forEach((id) => addCommentThread(id, { comments: [ { author: "Jane Doe", text: "Comment Thread Loaded from Server", creationTime: new Date(), }, ], status: "open", }) ); }

與後端存儲同步和性能考慮

對於本文的上下文,由於我們完全專注於 UI 實現,我們只是使用一些數據來初始化它們,這些數據可以讓我們確認初始化代碼正在工作。

在評論系統的實際使用中,評論線程可能與文檔內容本身分開存儲。 在這種情況下,需要更新上述代碼以進行 API 調用,以獲取commentThreads中所有評論線程 ID 上的所有元數據和評論。 一旦評論線程被加載,它們很可能會隨著多個用戶實時向他們添加更多評論、更改他們的狀態等而更新。 評論系統的生產版本需要以一種我們可以保持與服務器同步的方式來構建 Recoil 存儲。 如果您選擇使用 Recoil 進行狀態管理,那麼 Atom Effects API 上的一些示例(在撰寫本文時是實驗性的)可以做類似的事情。

如果一個文檔真的很長,並且有很多用戶在很多評論線程上協作,我們可能需要優化初始化代碼,只加載文檔前幾頁的評論線程。 或者,我們可以選擇只加載所有評論線程的輕量級元數據,而不是加載整個評論列表,這可能是負載中較重的部分。

現在,讓我們繼續在Editor組件與文檔一起安裝時調用此函數,以便正確初始化 Recoil 狀態。

 # src/components/Editor.js import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Editor({ document, onChange }): JSX.Element { ... const addCommentThread = useAddCommentThreadToState(); useEffect(() => { initializeStateWithAllCommentThreads(editor, addCommentThread); }, [editor, addCommentThread]); return ( <> ... </> ); }

我們使用與 Toolbar Comment Button 實現一起使用的相同自定義鉤子 - useAddCommentThreadToState來添加新的評論線程。 由於彈出框工作正常,我們可以單擊文檔中預先存在的評論線程之一,並驗證它是否顯示了我們用於初始化上述線程的數據。

單擊預先存在的評論線程會正確加載帶有評論的彈出框。
單擊預先存在的評論線程會正確加載帶有評論的彈出框。 (大預覽)

現在我們的狀態已經正確初始化,我們可以開始實現側邊欄了。 我們在 UI 中的所有評論線程都存儲在 Recoil 原子家族 - commentThreadsState中。 如前所述,我們遍歷 Recoil atom 系列中所有項目的方式是跟踪另一個 atom 中的 atom 鍵/id。 我們一直在用commentThreadIDsState做到這一點。 讓我們添加CommentSidebar組件,該組件遍歷該 atom 中的一組 id 並為每個組件呈現一個CommentThread組件。

 # src/components/CommentsSidebar.js import "./CommentSidebar.css"; import {commentThreadIDsState,} from "../utils/CommentState"; import { useRecoilValue } from "recoil"; export default function CommentsSidebar(params) { const allCommentThreadIDs = useRecoilValue(commentThreadIDsState); return ( <Card className={"comments-sidebar"}> <Card.Header>Comments</Card.Header> <Card.Body> {Array.from(allCommentThreadIDs).map((id) => ( <Row key={id}> <Col> <CommentThread id={id} /> </Col> </Row> ))} </Card.Body> </Card> ); }

現在,我們實現CommentThread組件,該組件偵聽與它正在呈現的評論線程對應的系列中的 Recoil 原子。 這樣,當用戶在編輯器中對線程添加更多評論或更改任何其他元數據時,我們可以更新側邊欄以反映這一點。

由於側邊欄對於包含大量評論的文檔可能會變得非常大,因此我們在渲染側邊欄時隱藏所有評論,但第一個評論除外。 用戶可以使用“顯示/隱藏回复”按鈕來顯示/隱藏整個評論線程。

 # src/components/CommentSidebar.js function CommentThread({ id }) { const { comments } = useRecoilValue(commentThreadsState(id)); const [shouldShowReplies, setShouldShowReplies] = useState(false); const onBtnClick = useCallback(() => { setShouldShowReplies(!shouldShowReplies); }, [shouldShowReplies, setShouldShowReplies]); if (comments.length === 0) { return null; } const [firstComment, ...otherComments] = comments; return ( <Card body={true} className={classNames({ "comment-thread-container": true, })} > <CommentRow comment={firstComment} showConnector={false} /> {shouldShowReplies ? otherComments.map((comment, index) => ( <CommentRow key={`comment-${index}`} comment={comment} showConnector={true} /> )) : null} {comments.length > 1 ? ( <Button className={"show-replies-btn"} size="sm" variant="outline-primary" onClick={onBtnClick} > {shouldShowReplies ? "Hide Replies" : "Show Replies"} </Button> ) : null} </Card> ); }

儘管我們使用showConnector添加了一個設計處理,但我們已經重用了彈出框中的CommentRow組件,該屬性基本上使所有評論看起來都與側邊欄中的線程相連。

現在,我們在Editor中渲染CommentSidebar並驗證它是否顯示了我們在文檔中的所有線程,並在我們向現有線程添加新線程或新評論時正確更新。

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
帶有文檔中所有評論線程的評論側邊欄。

我們現在繼續實現編輯器中流行的評論側邊欄交互:

單擊邊欄中的評論線程應選擇/激活該評論線程。 如果評論線程在編輯器中處於活動狀態,我們還添加了差異化設計處理以突出顯示側欄中的評論線程。 為此,我們使用了 Recoil 原子activeCommentThreadIDAtom 。 讓我們更新CommentThread組件以支持這一點。

 # src/components/CommentsSidebar.js function CommentThread({ id }) { const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = useCallback(() => { setActiveCommentThreadID(id); }, [id, setActiveCommentThreadID]); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-active": activeCommentThreadID === id, })} onClick={onClick} > .... </Card> );
單擊評論側欄中的評論線程會在編輯器中選擇它並突出顯示其範圍。

如果我們仔細觀察,我們在將活動評論線程與側邊欄同步的實現中存在一個錯誤。 當我們點擊側邊欄中的不同評論線程時,正確的評論線程確實在編輯器中突出顯示。 但是,評論彈出框實際上並沒有移動到已更改的活動評論線程。 它停留在第一次渲染的位置。 如果我們看一下 Comment Popover 的實現,它會根據編輯器選擇的第一個文本節點呈現自己。 在實現中,選擇評論線程的唯一方法是單擊文本節點,因此我們可以方便地依賴編輯器的選擇,因為單擊事件會由 Slate 更新。 在上面的onClick事件中,我們不更新選擇,而只是更新 Recoil atom 值,導致 Slate 的選擇保持不變,因此 Comment Popover 不會移動。

解決此問題的方法是在用戶單擊側邊欄中的評論線程時更新編輯器的選擇以及更新 Recoil atom。 這樣做的步驟是:

  1. 找到所有具有此評論線程的文本節點,我們將其設置為新的活動線程。
  2. 按照它們在文檔中出現的順序對這些文本節點進行排序(我們為此使用 Slate 的Path.compare API)。
  3. 計算從第一個文本節點開始到最後一個文本節點結束的選擇範圍。
  4. 將選擇範圍設置為編輯器的新選擇(使用 Slate 的Transforms.select API)。

如果我們只是想修復錯誤,我們可以在步驟#1 中找到第一個具有註釋線程的文本節點,並將其設置為編輯器的選擇。 但是,選擇整個評論範圍感覺像是一種更簡潔的方法,因為我們實際上是在選擇評論線程。

讓我們更新onClick回調實現以包含上述步驟。

 const onClick = useCallback(() => { const textNodesWithThread = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).has(id), }); let textNodeEntry = textNodesWithThread.next().value; const allTextNodePaths = []; while (textNodeEntry != null) { allTextNodePaths.push(textNodeEntry[1]); textNodeEntry = textNodesWithThread.next().value; } // sort the text nodes allTextNodePaths.sort((p1, p2) => Path.compare(p1, p2)); // set the selection on the editor Transforms.select(editor, { anchor: Editor.point(editor, allTextNodePaths[0], { edge: "start" }), focus: Editor.point( editor, allTextNodePaths[allTextNodePaths.length - 1], { edge: "end" } ), }); // Update the Recoil atom value. setActiveCommentThreadID(id); }, [editor, id, setActiveCommentThreadID]);

注意allTextNodePaths包含所有文本節點的路徑。 我們使用Editor.point API 來獲取該路徑的起點和終點。 第一篇文章介紹了 Slate 的位置概念。 它們在 Slate 的文檔中也有詳細記錄。

讓我們驗證此實現是否修復了錯誤,並且評論彈出框正確移動到活動評論線程。 這一次,我們還測試了一個重疊線程的情況,以確保它不會在那里中斷。

單擊評論側欄中的評論線程將其選中並加載評論線程彈出框。

通過錯誤修復,我們啟用了另一個尚未討論的側邊欄交互。 如果我們有一個非常長的文檔,並且用戶單擊了視口之外的側邊欄中的評論線程,我們希望滾動到文檔的該部分,以便用戶可以專注於編輯器中的評論線程。 通過使用 Slate 的 API 設置上面的選擇,我們可以免費獲得。 讓我們在下面看看它的實際效果。

在評論側欄中單擊時,文檔會正確滾動到評論線程。

有了這個,我們包裝了側邊欄的實現。 在文章的最後,我們列出了一些不錯的功能添加和增強功能,我們可以對評論側邊欄進行改進,以幫助提升編輯器的評論和審閱體驗。

解決和重新打開評論

在本節中,我們專注於使用戶能夠將評論線程標記為“已解決”,或者能夠在需要時重新打開它們進行討論。 從實現細節的角度來看,這是評論線程上的status元數據,當用戶執行此操作時我們會更改它。 從用戶的角度來看,這是一個非常有用的功能,因為它為他們提供了一種方式來確認關於文檔中某些內容的討論已經結束或需要重新打開,因為有一些更新/新觀點等等。

為了啟用切換狀態,我們向CommentPopover添加了一個按鈕,允許用戶在兩種狀態之間切換: open和已resolved

 # src/components/CommentThreadPopover.js export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { … const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); ... const onToggleStatus = useCallback(() => { const currentStatus = threadData.status; setCommentThreadData((threadData) => ({ ...threadData, status: currentStatus === "open" ? "resolved" : "open", })); }, [setCommentThreadData, threadData.status]); return ( <NodePopover ... header={ <Header status={threadData.status} shouldAllowStatusChange={threadData.comments.length > 0} onToggleStatus={onToggleStatus} /> } > <div className={"comment-list"}> ... </div> </NodePopover> ); } function Header({ onToggleStatus, shouldAllowStatusChange, status }) { return ( <div className={"comment-thread-popover-header"}> {shouldAllowStatusChange && status != null ? ( <Button size="sm" variant="primary" onClick={onToggleStatus}> {status === "open" ? "Resolve" : "Re-Open"} </Button> ) : null} </div> ); }

在我們對此進行測試之前,讓我們也為評論側邊欄提供針對已解決評論的差異化設計處理,以便用戶可以輕鬆檢測哪些評論線程未解決或打開,並在他們願意時關注那些。

 # src/components/CommentsSidebar.js function CommentThread({ id }) { ... const { comments, status } = useRecoilValue(commentThreadsState(id)); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-resolved": status === "resolved", "is-active": activeCommentThreadID === id, })} onClick={onClick} > ... </Card> ); }
評論線程狀態從彈出窗口切換並反映在側邊欄中。

結論

在本文中,我們為富文本編輯器上的評論系統構建了核心 UI 基礎架構。 我們在此處添加的一組功能作為在編輯器上構建更豐富的協作體驗的基礎,協作者可以在其中註釋文檔的某些部分並就它們進行對話。 添加評論側邊欄為我們提供了一個空間,可以在產品上啟用更多對話或基於評論的功能。

沿著這些思路,以下是富文本編輯器可以考慮在我們在本文中構建的內容之上添加的一些功能:

  • 支持@提及,以便合作者可以在評論中相互標記;
  • 支持將圖像和視頻等媒體類型添加到評論線程;
  • 文檔級別的建議模式,允許審閱者對顯示為更改建議的文檔進行編輯。 可以參考 Google Docs 中的此功能或 Microsoft Word 中的更改跟踪作為示例;
  • 側邊欄的增強功能可按關鍵字搜索對話,按狀態或評論作者過濾線程等。