Skip to content

開發網頁編輯器的十年筆記

45 min read

最近重做自己的部落格,又花了不少時間在文字編輯器上。另一邊,客戶網站的後台也有編輯器,寫著寫著,兩邊開始遇到一些相似的問題。

表格插得進去,但找不到加一列的按鈕。圖片正在上傳,繼續打字之後卻可能跑到錯的位置。中文輸入到一半,游標突然跳走。文章短的時候都很正常,寫長一點、開著預覽,就開始感覺每個字慢半拍。

從以前用 Rails 幫人做網站,到現在,已經過了十年。套件比以前完整很多,做出一個有粗體、圖片和表格的編輯器並不難,但要讓人放心地在裡面寫完一篇文章,還是有很多工作要做。

我自己很在意這件事。如果寫文章的時候一直被工具打斷,我就會越來越不想打開它。客戶也一樣,他們不會因為底下用了什麼厲害的套件,就比較能接受剛剛寫的東西不見了。

這篇整理的是我在自己的部落格和客戶專案裡遇到的問題。如果你也要在 Web App 裡放一個編輯器,希望這些經驗能讓你少漏掉一些事情。

這十年,換過哪些工具

最早寫 Rails 的時候,要加編輯器,通常就是找一套現成的。TinyMCE、CKEditor 都是當時常見的名字。我也維護過把整包 JavaScript 和 CSS 放在 public/ 或 vendor/assets/ 裡的專案。

後來有各種 Rails 整合套件,像 bootstrap-wysihtml5-rails、wysiwyg-rails,還有連圖片上傳一起處理的 Bootsy。安裝方便很多,但之後也要留意包裝套件和編輯器本身各自的維護狀況。

我接著用過 Trix、Draft.js 和 Slate。Trix 的整合很方便;Draft.js 讓我開始接觸 React 裡的編輯器狀態管理;Slate 給了很多彈性,也留下比較多需要自己完成的部分。每套工具都有適合的情境,我也都是做下去之後,才比較清楚自己的需求落在哪裡。

現在我用的是 Tiptap,底下是 ProseMirror。它有不少現成的擴充,也留了足夠的空間讓我調整行為,目前用起來很順手。

不過,接下來的問題大多不只會發生在某一套編輯器。就算換了工具,貼上要怎麼處理、存檔失敗怎麼辦、選單出現在哪裡,還是要有人決定。

功能有做,使用者卻用不到

我做過一組表格操作:加列、刪欄,功能都能用,也有測試。

但按鈕藏在工具列裡,只有游標進入表格才會亮起來。編輯表格時,我的注意力明明就在那一格,卻還要抬頭去找哪個 icon 是我要的。後來把操作移到表格旁邊,用起來才比較自然。

這件事也影響了我在客戶專案裡的安排。

選取文字後,旁邊出現粗體、連結、螢光筆等格式操作。點到圖片就能換圖,點到連結可以修改或移除。插入新東西用 / 選單,已經寫好的段落要改成標題、清單或引用,則從區塊旁的把手操作。

做完這些之後,原本那排工具列幾乎每個功能都有別的入口了,我就把它拿掉了。自己的部落格目前還保留工具列,兩邊不一定要長得一樣。

我會注意的還有操作完成之後。打開連結對話框,原本選取的文字要留著;關掉對話框,焦點應該回到合理的位置;選單太長,要在視窗裡捲動,不能把後半段選項丟到畫面外。

快捷鍵也需要入口讓人知道。/ 很快,但第一次使用的人不一定會自己猜到。我在客戶專案裡,讓空白段落的提示文字直接告訴使用者可以打 /,也保留區塊旁的插入入口。

如果要檢查自己的設計,可以試著完成一段完整的操作:寫一段文字、改成標題、插入表格、加一欄,再回去繼續寫。中間每一次需要停下來找按鈕、重新選字或把游標放回去,都值得看看能不能少做一步。

貼上,是我很在意的一個操作

「支援貼上」其實有很多種意思。

貼截圖,我希望直接上傳。貼 Markdown 表格,我希望它變成可編輯的表格。貼進程式碼區塊,縮排、引號和符號都要保留。從網頁複製一段文字,有時候我要格式,有時候只想要字。

這些需求不能全部交給同一條轉換規則。

例如把所有純文字都丟進 Markdown parser,看起來省事,但一般段落裡的 * 可能突然變成格式,多行文字也可能被重新解讀。反過來,完全不處理 Markdown,貼進來的表格就只剩一堆直線。

所以我會看來源和貼入的位置,也保留貼上純文字的方式。程式碼區塊尤其需要小心,使用者放進去的內容可能是準備直接執行的指令。

輸入時的自動轉換也有類似問題。我遇過直引號被換成彎引號,打三個 - 想要分隔線,卻被另一條破折號規則搶先處理。還有一次,建立程式碼區塊後繼續打字,第一個單字被當成語言名稱吃掉。

這些功能單獨看都像是在幫忙,但它們彼此不一定合得來。我現在會縮小自動判斷的範圍,例如確認是支援的語言名稱,才把它當成程式碼區塊的語言。

另外,行內程式碼兩側那對刪不掉的反引號,也曾經讓我查了一陣子。最後發現是 CSS 的 ::before、::after 畫的,根本不在文件裡。它不會污染存檔,但在可編輯的畫面上很容易讓人誤會。

試這些行為時,我會連復原一起按。貼上一張表格之後,按一次 undo 會發生什麼?自動換成分隔線,再按 Backspace,會回到什麼狀態?不能只確認轉換成功,也要確認使用者反悔時走得回來。

CJK 輸入,不是把介面翻譯好就結束

我實際踩到的案例主要來自中文輸入,但組字這件事也需要放進日文、韓文的測試範圍。

使用輸入法時,按了鍵,不代表文字已經確定。注音、拼音、日文的假名轉換,以及韓文音節的組合,都不能簡單地當成「每次 keydown 就新增一個完成的字」。瀏覽器有 composition events 來描述組字過程,編輯器外面的搜尋、快捷鍵和表單也要配合。

我在部落格做 @ 搜尋文章時,遇到的第一個問題很小:預設規則要求前面有空白。但中文很可能直接寫「可以參考@另一篇文章」,選單就不出來了。

這裡不能把「沒有空格」當成所有 CJK 語言共同的規則。中文、日文常見的連續文字,以及韓文的分詞空格,使用習慣不同。觸發條件要看實際句子,不能只拿英文範例來驗收。

再來是搜尋時機。輸入法還在組字,查詢可能就帶著注音或拼音的半成品送出去了。Debounce 只代表等一段時間,使用者停下來選字時,計時器一樣可能到期。這部分要另外知道組字是否完成。

Enter 和方向鍵也值得一起測。它們可能正在確認候選字或操作輸入法選單,應用程式如果先攔走,就可能變成送出表單、選取搜尋結果,或插入一個使用者沒要的區塊。

我遇過更麻煩的是內容同步。表單收到更新後,重新設定整份編輯器內容,剛好打斷進行中的組字。看到的症狀是游標跳動、重複輸入,或打字變慢。後來同步時會避開 composition,並處理組字結束後仍需套用的更新。

客戶專案的自動儲存也會先確認組字狀態,避免把尚未確定的內容當成完成的草稿送出去。

這些問題需要用真實輸入法驗證。我會把「在文章中段組字時觸發搜尋、儲存和外部更新」列進操作情境。中文測過了,不等於日文和韓文也通過;不同輸入法、作業系統和瀏覽器的行為,仍然要各自確認。

寫作不能因為一張圖還沒傳完就停下來

我希望貼上截圖之後,可以馬上繼續寫。

但上傳是非同步的。圖片還沒傳完,使用者可能已經在前面多打了兩段字,也可能把原本那段刪掉。如果只記住貼上當下的位置,上傳完成後,圖片就可能插到別的地方。

我現在用 ProseMirror 的 decoration 表示上傳中的位置,讓它隨著文件變動一起移動,等成功後才插入圖片節點。上傳中的提示也因此不會被當成正文存下來。

使用者如果刪掉那個位置,也要尊重這個操作。不能等請求完成後,又把圖片放回一個他已經不要的地方。

這裡還有失敗的情況。請求要有逾時處理,失敗要看得見,也要能再試。不能讓「上傳中」一直留著,最後只能重整頁面,而重整又可能丟掉還沒存的文字。

測試時可以故意把網路調慢:貼圖、繼續輸入、移動段落、刪掉佔位位置,再讓上傳成功或失敗。這比正常網路下連貼十張圖,更容易看出位置追蹤有沒有問題。

客戶專案還有裁切和媒體管理。裁切後保留原圖,之後想換比例就不用重新找檔案;替代文字和標籤也需要有地方維護。圖片從插入文章到日後修改,整段流程都要能接起來。

自動儲存之後,使用者更需要知道存好了沒

客戶專案的草稿會定期自動儲存。我原本也可以只放一個計時器,但真正需要處理的是請求前後發生了什麼。

假設這次送出的是 A,請求還在路上,使用者又繼續寫成 B。A 儲存成功,只能代表 A 存好了,不能把畫面上最新的 B 也一起標成「已儲存」。失敗時,待儲存的狀態也要保留,後面才能重試。

我會讓畫面區分有變更、儲存中、已儲存和失敗,並記錄上次成功的時間。組字中的內容則延後處理。

這些狀態對使用者很重要。準備關掉分頁時,他需要知道能不能走;網路出問題時,他需要知道這份內容還只在眼前的畫面裡。

至於關閉分頁前補送一次請求,只能當作盡力而為。瀏覽器結束頁面時,不保證非同步請求一定完成。如果產品要求離線也能寫、意外關閉後還能恢復,就要再做本機草稿和重新同步,不能把責任全放在最後那一次儲存上。

另一個讓我印象很深的問題,是按送出之後什麼都沒發生。

客戶的編輯頁把文章設定放在側邊抽屜,抽屜關閉時設為 inert。裡面有個原生 required 欄位沒填,瀏覽器擋下送出,卻又不能把焦點移到那個欄位。使用者看不到原因,只知道按鈕沒反應。

後來這些表單改用 noValidate,由伺服器端的 Zod 驗證,錯誤回來時再打開抽屜、顯示對應欄位的問題。驗證失敗後,也要保留剛剛填的內容。

所以我現在會故意送出一份不完整的表單,確認錯誤在哪裡出現、看不看得懂,以及修完之後能不能直接繼續。成功儲存只是其中一條路。

畫面上看到的,最後有沒有留下來

我的部落格選擇存 Markdown,主要是方便搬遷、搜尋和比對。這是我的需求,不是所有編輯器都必須採用的答案。

不管存什麼,使用者都需要相信自己剛剛寫的內容會被保留下來。

我遇過行內數學的自訂節點缺少 Markdown mapping。編輯畫面上是公式,存下去卻成了一段 HTML,裡面的跳脫字元又讓前台的數學渲染失敗。後來使用官方的 Tiptap Markdown 擴充,自訂節點的讀入和輸出也一起確認。

這種問題不能只看編輯畫面。要存檔、重新載入,再到公開頁面看一次。包含表格、巢狀清單、公式和程式碼的內容,都值得走完這個流程。

我也會重複存幾次。第一次轉換時,格式可能被正規化,例如換一種清單符號;但不能每存一次就繼續變,更不能掉字或改變意思。

程式碼高亮也曾經兩邊不一致。有時候是 CSS 沒套到編輯器,有時候是編輯器和前台使用不同的語言清單,讓沒標語言的區塊被判成不同語言。後來能共用的設定就共用,預覽也沿用公開頁面的渲染方式。

目錄則直接從渲染後的標題取得文字和 anchor。自己再掃一遍 Markdown、重算 ID,碰到重複標題或標題裡有格式時,就容易跟實際頁面對不上。

現在文章網址加上 .md,可以取得這篇文章的 Markdown。我原本想讓模型和 agent 讀取時方便一些,後來自己也常用來跟草稿比對,檢查轉換有沒有出問題。

嵌入影片和貼入 HTML 則還多了內容安全的問題。編輯器允許插入什麼、伺服器接受什麼、前台最後能輸出什麼,要一起確認。光是把工具列上的按鈕藏起來,沒有辦法限制實際送進來的內容。

打字卡頓時,我先猜錯了原因

有一陣子,部落格的編輯器每打一個字都會頓。

我第一個懷疑的是 Markdown 序列化,因為每次更新都可能碰到整份文件。我甚至先開了 issue,覺得問題應該就在這裡。

後來量了一下,短文件和一萬八千字的文件,每次按鍵的耗時卻差不多。這讓我開始懷疑自己找錯方向。

繼續看才發現,每個字元都讓整個編輯器介面重新渲染。工具列、選單,連沒有打開的對話框也一起參與。

但又不能直接把重繪全部擋掉。粗體、斜體等按鈕狀態,當時也靠著這些更新維持正確。如果只做 memoization,打字可能變快了,按鈕卻不再跟著游標變。

後來我先調整狀態訂閱,讓控制項只接收自己需要的更新,再減少不必要的渲染。預覽的更新也一起處理。

下面是當時留下的量測,主要用來比較同一個測試環境裡的修改前後,不是不同編輯器之間的效能排名:

情境修正前 p50 / p90修正後 p50 / p90
短文件,關閉預覽21.0 / 29.6 ms10.0 / 16.0 ms
約 18k 字,關閉預覽21.6 / 27.8 ms12.5 / 16.5 ms
約 18k 字,開啟預覽37.3 / 61.6 ms11.8 / 16.2 ms

我會建議至少準備一份接近真實使用量的長文件,把圖片、表格和預覽都放進去。先看輸入成本是否隨文件長度成長,再用 profile 找出時間花在哪裡。不能只用空白文件打幾個字,就認為效能沒有問題。

不急著更新的預覽可以稍微晚一點,游標和正在輸入的文字則不能一起等。使用者對這兩種延遲的感受很不一樣。

最後還是要真的打開瀏覽器寫一篇

我遇過建立程式碼區塊後,第一個字跑到區塊外面。

編輯器的文件和 selection 看起來都是對的,測試也通過。但 React 渲染的內容容器還沒準備好,瀏覽器游標沒有落在預期的位置。只檢查編輯器狀態,抓不到這件事。

表格浮動選單也曾經慢一拍。我拿 React 裡預先算好的狀態判斷是否顯示,但編輯器呼叫那個判斷時,React 還沒完成下一次渲染。後來改成在判斷當下直接讀取編輯器的狀態。

客戶專案的拖曳把手則是另一種問題。其他工具按鈕會攔住 mousedown,保住文字選取範圍;把手照抄同樣的行為,卻連拖曳的起點一起擋掉了。畫面完全正常,只是怎麼拖都拖不動。

這些經驗讓我比較不敢只靠測試報告判斷編輯器完成了。

我還需要用鍵盤走過選單和對話框,確認焦點回得來;拖動一個區塊後,再用其他操作方式完成同一件事;縮小視窗,看看選單會不會被裁掉。手機的虛擬鍵盤、長按選取、沒有 hover 的操作方式,也需要另外驗證。

程式碼區塊的 Tab 就是一個取捨:寫程式時我希望它縮排,但也要考慮只用鍵盤的人怎麼離開編輯區,不能做成焦點陷阱。把按鈕換成浮動選單,同樣不代表可以省略可存取名稱、焦點提示和螢幕閱讀器的測試。Tiptap 的無障礙指南也列了這些整合時要處理的事情。

這一部分我不會說自己已經全部做完。它需要隨著介面和支援裝置持續驗證,尤其不能把「我的滑鼠操作沒問題」當成所有人都能用。

還有哪些是我目前沒做到的

整理這篇時,我也回頭看了 Tiptap 的官方文件。除了目前處理的寫作和發布流程,還有幾個沒有整合進這兩個專案的方向。如果產品需求走到那裡,要思考的事情也會再多一層。

多人編輯,以及回到先前版本

Tiptap 有以 Yjs 為基礎的協作功能,也提供版本歷史和版本比較的整合。

如果要做,我會先想同一篇文章被兩個人、甚至同一個人的兩個分頁打開時,彼此怎麼知道對方正在修改。按 undo 應該退回自己的哪一次操作?還原昨天的版本時,別人剛剛寫的內容要怎麼處理?

這些需要有明確的產品行為。畫面上出現另一個人的游標,只完成了其中一部分。不同版本的編輯器同時在線,也可能對某些內容型別有不同理解,更新和相容性都需要規劃。

文件內批註與修訂

Comments 可以把討論附在文件中的特定位置;Tracked Changes 則讓修改先以建議存在,再接受或拒絕。這和公開文章下方的讀者留言是不同的功能。

當寫作者和審稿者是不同的人,就值得考慮這一層。哪些人能直接修改,哪些人只能提建議?原本被批註的文字刪掉後,討論要留在哪裡?發布時,還沒處理的修訂算不算正文?

目前我沒有這套審稿流程,所以沒有整合。需要時,權限和發布規則也得一起設計。

離線繼續寫,重新連線後再同步

官方的離線協作指南介紹了搭配 Yjs 和 IndexedDB 保存本機文件的方式。這需要另外整合,不是開啟自動儲存就自然具備的能力。

如果我接著做這部分,會先把畫面上的狀態說清楚:存到這台裝置,和已經同步到伺服器,是兩件需要分別告知使用者的事。重新連線後遇到其他裝置的更新,或使用者登出換帳號,也要有對應處理。

對經常在不穩定網路下寫作的人,這可能比多幾種排版功能更值得先做。

Word、PDF 和需要分頁的文件

如果使用者原本就在 Word 裡工作,匯入、匯出和列印可能會直接影響他願不願意使用這個 Web App。Tiptap 的文件轉換功能涵蓋 DOCX 匯入及 DOCX、PDF 等格式的匯出;需要頁首頁尾、頁碼與分頁編輯時,文件也另外介紹了 Pages 的整合。

這時驗收就不能只看檔案有沒有成功下載。我會拿真實文件往返一次,檢查表格、清單編號、圖片和分頁。某些格式無法保留時,也要讓使用者知道,不能等他把檔案交出去才發現。

上面這些功能有些涉及 Tiptap 的付費方案或額外服務,並不全都包含在開源編輯器核心裡。這裡把它們當成延伸閱讀,也提醒自己:目前做好的範圍,是由現在的使用情境決定的。

十年後,我會怎麼驗收一個編輯器

如果現在要開始一個新的編輯器專案,我還是會先確認使用者要寫什麼、在哪裡寫,以及內容最後要去哪裡。接著準備幾份真實文件,從輸入一路走到發布。

我會在裡面放圖片和表格,試著貼入其他工具的內容,用輸入法在中間改一段字。故意讓上傳變慢、讓儲存失敗,看看能不能繼續工作。再重新打開文章,確認剛才做的修改真的留下來了。

這十年工具進步很多,我能少寫不少底層程式,但這些判斷還是得自己做。哪些自動功能值得開,哪些操作應該靠近游標,哪個狀態需要清楚說明,都會影響使用者願不願意把時間花在這個 Web App 裡。

我希望自己的編輯器能讓人順順地寫完一篇文章。中間不用擔心字被改掉,也不用猜剛才到底有沒有存成功。要做到這樣,過了十年,還是有很多細節值得好好處理。

Back to writing