---
title: "開發網頁編輯器的十年筆記"
url: "https://laigary.com/posts/ten-years-of-building-web-editors"
type: "post"
date: "2026-08-05"
updated: "2026-08-05"
reading_time: 89
---

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

先講結論好了：目前我自己搭建出了一個我還算滿意的編輯器，過去這十年的經驗慢慢帶我到這裡來的，我會記錄這十年之間，我對於很多事情的取捨。

很多東西其實不是我一開始就想清楚的東西，是一路撞了幾次牆之後才慢慢長出來的。

## 難的從來不是文字那一層

最早我做自己的網站、也幫別人做網站，用 Rails。**處理文字這層一直都很順** —— 一個 textarea、一個 parser，Rails 那套 MVC 對「一段文字進資料庫再吐出來」處理得非常好。

會變複雜的，是文字以外的東西。

**第一件事：互動元件。** 只要需求變成「我想在文章中間放圖」，事情就不一樣了。編輯器負責產生 `<img src="...">`，但 src 從哪來是你的事。於是你要做上傳端點、處理暫存、處理刪文之後的孤兒檔案，還要讓那個 JavaScript 編輯器知道你的後端長什麼樣。文字很好處理，圖片就沒那麼單純。

**第二件事：換編輯器的時候。** 這件事教我最多。換一套編輯器，舊內容不一定能直接用 —— 每一套編輯器吐出來的 HTML 都有自己的習慣：自己的 class、自己的 wrapper、自己的 `<div>` 結構。舊文章在新編輯器裡打開，版面就跑掉了。而新編輯器又帶著自己的一整包 CSS，跟前台的 style 互相覆蓋，你得一條一條去對哪個選擇器蓋掉了哪個。

**第三件事：編輯器停止維護的時候。** 這件事很難避免。套件停更、不再支援新版的 Ruby 或 Rails、或是它依賴的 plugin 先停了。你就卡在一個舊版本上；而要升級 Rails 的那天，整個專案裡最難處理的，往往就是那個已經沒人維護的編輯器 gem。

三件事加起來，讓我看清楚一件事：**真正的問題不是編輯器好不好用，而是內容跟工具綁在一起。**

所以我慢慢除了注意編輯器的體驗，也很重視檔案格式的選擇，最後收斂到「存檔格式用 **Markdown**」這個方向。不是因為我特別喜歡 Markdown 的語法，而是因為它同時滿足三件我很在意的事。

**一、它活得比編輯器久。** 這是最主要的理由。Markdown 是純文字，可以 `grep`、可以 diff、可以用任何一個文字編輯器打開。它不需要當年產生它的那包 JavaScript 還在維護 —— 而前面三件事教我的，正是這個「還在維護」有多不可靠。

**二、搬遷成本低。** 換框架、換資料庫、換部署平台，內容那一欄基本上原封不動搬過去就好。這十年我換過的東西比我想像的多，唯一沒被換掉的就是那些純文字。

**三、對 LLM 友善 —— 這是我這兩年開始才意識到的。** 現在讀我文章的不只有人，還有各種模型和 agent。給它們一份乾淨的 Markdown，遠比讓它們去 parse 一頁充滿 wrapper div 和樣式 class 的 HTML 要準確。

第三點我後來直接做成了功能：**這篇文章的網址後面加上 `.md`，就會回傳它的 Markdown 原始碼**，前面帶一段 YAML front matter，把只存在於頁面裝飾裡的資訊（標題、canonical URL、日期、標籤）補回去。你現在就可以試：

[https://laigary.com/posts/ten-years-of-building-web-editors.md](https://laigary.com/posts/ten-years-of-building-web-editors.md)

這個功能只花了一個下午，而它能成立的唯一原因，就是**我的內容本來就是 Markdown**。如果當初存的是編輯器產生的 HTML，這件事要做就得先寫一個 HTML→Markdown 的轉換器，而那種轉換永遠是有損的。

---

## 但選了 Markdown，是把難題從資料層搬到 UX 層

存檔格式這關過了，接下來全部是新問題，而且沒有一個有標準答案：

**Markdown editing 和 WYSIWYG editing 要怎麼共存？** 同一份內容有兩種編輯方式。切換的時候游標在哪？還沒存檔的狀態怎麼辦？可是只給一種，另一種的使用者就會不太順手 —— 而那兩種使用者，常常是同一個人在不同時刻。

**編輯器上到底要顯示什麼？** 

- 顯示 render 後的結果？對於閱讀友善，但是就要去考慮 markdown 到底支不支援？會不會需要有 embedded HTML 的情況。
- 顯示純 Markdown？對於編輯友善，但是如果我想要編輯 Table 就會崩潰。
- 還是顯示 render 結果但要能看得到 source —— 那 source 要在哪裡看，整份切換、分割畫面、還是只看游標所在的那個區塊？

**要支援哪一種 flavor？** CommonMark？GFM 的表格和刪除線？數學要不要？那是 `$...$` 還是 `\(...\)`？這個決定不只是編輯器的事 —— **編輯器 parse 的語法，必須跟前台 render 的語法是同一套**，不然你會在發布之後才發現它們對同一段文字有不同意見。（這件事後來真的發生了，見坑 1。）

**圖片上傳怎麼支援？** Markdown 的語法只有 `![](url)`，它對「這個 url 是怎麼來的」完全沒有意見。上傳中要顯示什麼？上傳失敗要留下什麼？上傳到一半按存檔會存到什麼？

**那 embed 呢？** 這個是後來才真正咬到我的。我想在文章裡放 YouTube 影片、放一則推文 —— 而 Markdown 對這種東西完全沒有語法。它只有連結。

於是選擇只有三種：把原始 HTML 寫進 Markdown 裡（那你的「純文字」就不再那麼純）、發明一套自己的簡寫語法（那它就不再是標準 Markdown，換工具時第一個壞掉的就是它）、或是把 embed 拉出正文變成獨立欄位（那一篇文章就散在兩個地方）。

三個都有代價，而且這個決定會一路影響到編輯器、渲染器和安全性。後面我會講我怎麼選的。

這五個問題，就是後面那些坑的源頭。

說到底，所見即所得的編輯器天生的資料結構是一棵 document tree（或者是 HTML），而 Markdown 是一種**有損**的線性語法。中間那層轉換 —— 序列化與反序列化 —— 就是這類專案最需要花心思的地方。

自己做一個 blog，比較費工的其實是這裡，不是版面也不是部署。是「我想用得順手，同時希望資料能活得比工具久」這件事 —— 到 2026 年，它還是需要自己做一些取捨。畢竟如果編輯器不順手，我就不會想要繼續寫文章。

---

## 一路上用過的編輯器

### 大禮包時代

我是寫 Rails 出身的。最早我對於開發編輯器的理解就是：把一整包 JavaScript 丟進 public 資料夾。

Rails 對 rich text 曾經沒有任何意見，所以要編輯器就得自己去外面搬 —— [TinyMCE](https://github.com/tinymce/tinymce)、[CKEditor](https://github.com/ckeditor/ckeditor5)。

「整合」的意思是：把 JS 和 CSS 檔案手動複製到 `public/` 底下。沒有套件管理，沒有版本控制，升級就是再複製一次然後祈禱。

我沒有參與到這一段的全盛時期，但我維護過它留下來的東西 —— `vendor/assets` 底下一包已經沒人記得版本的 CKEditor，和一個大家都不太敢動的 upload controller。

那個年代還有另一半故事，我覺得很有意思：不少 Rails 開發者選擇不用 WYSIWYG，改用 Markdown 或 Textile 的 parser，配一個 AJAX 預覽就好。

也就是說，我今天在想的這件事，十年前就有人想過了。他們選擇保住資料、在體驗上讓一步。我想試的是兩邊都要 —— 而多要的那一半，正好就是難的那一半。

### 模組化管理

工程師是一群很喜歡秩序的群體，想當然爾上面的方法一定很快有很多人去改善他的弊端。

Rails 3.1 的 Asset Pipeline 讓前端資產可以打包進 gem，社群整合套件於是大爆發：[bootstrap-wysihtml5-rails](https://github.com/Nerian/bootstrap-wysihtml5-rails)、[wysiwyg-rails](https://github.com/froala/wysiwyg-rails)。還有 [Bootsy](https://github.com/volmer/bootsy) —— 把 wysihtml5 跟一套 Rails 後端綁在一起，終於有人把「圖片上傳」包好了。

我最早接的那批專案就活在這個生態裡。安裝從「複製檔案」變成「加一行 Gemfile」，這是很實在的進步 —— 但編輯器本身沒有變得更好，是變得更好裝。而且中間多了一層：你不只依賴那個 JS 函式庫，還依賴把它包起來的那個 gem 有沒有人繼續維護。

上面那幾個 gem 有些現在還有更新，但是大多已經停在某個版本了，或者是沒有隨著 Ruby or Ruby on Rails 的更新繼續更新了。

#### Trix (Action Text)

再來是 Trix（Basecamp 出的，後來變成 Rails 的 Action Text）。Trix 是第一個讓我覺得「有人真的想清楚了」的編輯器 —— 它刻意不用 `contenteditable` 的預設行為，自己管理 document model，所以行為在各瀏覽器之間是一致的。

Trix 的立場也很明確：**它的資料格式是 HTML**，而且不打算讓你插一個它沒設計過的節點。對 Basecamp 那種「留言框」的場景這很合理，只是跟「寫技術文章」的需求不太一樣。Trix 讓我學到一件事：**選編輯器其實是在選它的資料格式**，而資料格式是最難回頭的那個決定。

### 前後端完全分離

前端玩久了以後，我終於有能力自己在網頁上做編輯器，而不是找一個來裝。

#### Draft.js

[Draft.js (](https://draftjs.org/)Meta 出品)，Draft.js的模型是不可變的 `EditorState`：每一次使用者互動都產生一個新的 state snapshot，裡面包含完整內容和內建的 undo/redo stack。`react-draft-wysiwyg` 這類 wrapper 把它直接攤出來給你用。

概念很漂亮。實務上，只要碰到 Draft.js 沒有內建的東西 —— 一個自訂節點、一個貼上行為 —— 就會進到文件比較少、範例也比較少的區域。

但是發生什麼事情了？後來 Meta 停止維護它了。

這件事剛好印證了前面那個結論：**內容的壽命，不該取決於某個團隊還想不想維護一個函式庫。** 如果我當年存的是 Draft.js 的 raw state JSON，今天要處理的就是一份遷移腳本了。

#### Slate.js

[Slate.js](http://www.slatejs.org/) 換了一套模型，比 Draft.js 的快照模型有彈性太多，也是後來大家都走的路。Slate 很好，代價是它非常「你自己來」—— 它給你一副骨架，表格、程式碼區塊、貼上處理，全部自己寫。

非常的有彈性是他的優點，但也是他的缺點，我還是偏向使用更現成的解決方案，並且在上面做微調，Slate 對我來說又有點太輕量了。

### 現在

#### Tiptap

[Tiptap](https://tiptap.dev/) 建在 [ProseMirror](https://prosemirror.net/) 上。ProseMirror 有嚴格的 schema、有 transaction、有 plugin、有 decoration —— 這些是我用過的東西裡面，第一次讓我覺得**這個 model 本身是對的**。

Markdown 這件事也終於有了官方答案：`@tiptap/markdown` 跟 core 同版本，核心 extension 自帶 `parseMarkdown` / `renderMarkdown` spec。前面講的那道「縫」，第一次有人正式把它當成一等公民處理。

以後我會用什麼？我也不知道。TinyMCE、Trix、Draft.js 我當年也都覺得夠用了。但至少 Tiptap 現在真的順手，而且因為存的是 Markdown，下一次要搬家的時候，需要重寫的是編輯器，不是內容。

---

## BUT

如果到這裡就結束，那就太完美了，但是很可惜到現在沒有完美的答案。

Tiptap 開箱就很好用 —— 如果你主要書寫的內容是英文，我的情況稍微不同，而這兩件事也是我後面大部分工作的來源：

**我是中文使用者。** 中文要經過輸入法，也就是 composition event —— 使用者按了鍵、但字還沒定案的那一段時間。多數編輯器功能（自動完成、觸發字元、debounce 搜尋）的預設值都假設「一個按鍵 ≈ 一個字」。中文使用者按十次鍵可能只產生兩個字，中間任何一次讀取拿到的都是半成品。

其實我在很多 Web App 的文字編輯器也常會有遇到問題，很大一部分通常是產品都是使用拉丁語系的思考去開發，沒有考慮過 [CJK](https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%97%A5%E9%9F%A9%E6%B1%89%E5%AD%97) 的輸入方式。

**我寫程式碼。** 程式碼區塊在 WYSIWYG 編輯器裡的位置比較特別：它是 rich text 裡唯一一塊「不能 rich」的地方。所有自動轉換（智慧引號、Markdown 語法、貼上解析）在這裡都要退場，同時它還要語法高亮、要能標語言、要能按 Tab 縮排。

---

## 我的踩坑

每個坑我盡量寫三件事：**症狀**、**我一開始以為的原因**、**真正的原因**。第二件通常是花我最多時間的那件，所以我把猜錯的過程也留著。

坑按主題分成五組，最通用的放前面，最跟環境有關的放最後。標記：🔧 是寫程式碼的人比較容易遇到的，☁️ 是平台相關（只有最後一個），🀄 那一節是中文輸入相關的。

## 第一組：存進去的東西才是重點

這一組是我認為最通用、也最值得先看的。它們的共同點是：出錯的時候壞掉的不只是畫面，而是**存進資料庫的內容本身** —— 而那份內容，是這整個專案唯一不能重做的東西。

### 坑 1：社群 Markdown bridge 把 LaTeX 變成 HTML entity

**症狀**：含行內數學的筆記，前台渲染直接 ParseError。翻資料庫，Markdown 裡有 `&gt;`。

**原因**：我當時用的 `tiptap-markdown` 0.9 是社群套件，為 Tiptap v2 寫的，後來沒有再更新。它沒有 `inlineMath` 的 mapping，於是 fallback 到 `renderHTML` 的輸出 —— 一段 escape 過的 HTML span。前台把 `&gt;` 餵給 LaTeX renderer，自然就解析失敗了。

**修法**：換成官方 `@tiptap/markdown`，所有 `@tiptap/*` 對齊同一版本。數學節點補上第一級的 markdown mapping：`$...$` / `$$...$$` 的 tokenizer，遵循 remark-math 的慣例 —— **編輯器 parse 的，必須跟前台 render 的是同一套語法**。

順手做了自癒：節點的 `parseHTML` 會撿起舊的髒 span（DOM 會把 entity 解回來），重新序列化就吐出乾淨的 dollar 語法。舊資料自己會好。

這就是開頭講的那道「縫」實際出現的樣子。序列化層出問題的時候，影響的不只是一個畫面，而是**存進資料庫的內容本身** —— 而那份 Markdown，正是我當初選 Markdown 的全部理由。所以這一層我後來補了不少測試，它值得。

> 心得：選套件的時候，「跟核心同版本、由核心團隊維護」這件事，優先於「功能比較多」。序列化層尤其如此。

### 坑 2：上傳中的圖片，不可以是 document 的一部分

貼上截圖會非同步上傳，上傳中要顯示佔位符。

第一直覺是插入一個 placeholder **node**。這是錯的：node 是 document 的一部分，所以上傳中的狀態會被序列化進交給表單的 Markdown —— 上傳到一半按 ⌘S 就存下一坨沒有意義的東西。

**正解是 widget decoration**。它永遠不會被序列化，但仍然會 map 過每一個 transaction —— 所以上傳期間繼續打字，圖片會落在正確的位置，而不是被釘在過期的 offset。作者若在完成前刪掉那個區域，圖片會被丟棄，而不是憑空出現在他沒在看的地方。

兩個細節：`fetch` 沒有預設 timeout，卡住的 PUT 永遠不會 settle，「Uploading...」會留到重整為止 —— 自己加 deadline。還有，從網頁複製圖片時剪貼簿同時帶著 file 和 `<img>`，優先取 file，位元組才會進自己的 R2 而不是熱連別人的伺服器。

> 心得：「暫時的 UI 狀態」和「文件內容」是兩個 layer。放錯 layer，使用者按 ⌘S 的那一刻就會知道。

### 坑 3：貼上是一個 dispatch table，不是一個功能 🔧

換掉社群 bridge 之後（坑 1），貼上 Markdown 原始碼會變成字面文字。補這個轉換的時候才發現「貼上」是一個五岔路口：

1. **剪貼簿是圖片** → 交給上傳外掛
2. **剪貼簿帶 `text/html`** → 交給 ProseMirror 自己 parse（它知道來源結構，比我猜得準）
3. **游標在 code block 或 inline code 裡** → 字元必須逐字存活，什麼都不要做
4. **文字帶有明確的 Markdown 構造** → 轉成 rich content
5. **其他** → 原樣貼上

第 5 條是刻意的。把每一段純文字都丟進 Markdown parser 會更一致，但會改變「貼上普通文字」的行為：Markdown 把單一換行當空格，多行純文字會塌成一段；散文裡的 `*` 或 `_` 會變成強調。

偵測條件寫得很保守。例如表格必須同時有 pipe 列**和**底下那條 dash 分隔線 —— 單獨一行含 `|` 的東西，是 console 輸出的機率遠高於 Markdown。（第 3 條和這一條，都是「寫程式的人」這個身分逼出來的。）

再來還要有逃生口：`Mod-Shift-V` 貼上純文字。這裡有個 API 限制值得記：**快捷鍵本身讀不到剪貼簿**。它只能記下意圖然後 `return false`，讓瀏覽器繼續跑自己的 paste，剪貼簿事件才會到。那個意圖一秒後過期，免得一個平台從未轉成 paste 的按鍵，在幾分鐘後默默改變一次真正的貼上。

### 坑 4：目錄要從 render 後的 HTML 回頭讀，不能掃 Markdown

技術文章需要目錄，而目錄需要兩個東西：標題清單，和每個標題的 anchor id。

直覺的做法是掃 Markdown source，把 `## xxx` 抓出來、自己算一個 slug 當 id。這也是我一開始想做的，但它會在幾個地方跟實際的 anchor 對不上 —— 而且剛好都是技術文章最常出現的標題：

- **標題帶行內程式碼或強調**：一個 h2 的文字裡包著 `range(n)` 和粗體，從 Markdown 算出來的 slug，和 rehype-slug 從 render 後的文字算出來的，不會是同一個。
- **重複標題的流水號**：rehype-slug 的計數器是**跨所有標題層級**遞增的，包含你在 Markdown 掃描時根本不會看到的 h4，以及用原始 HTML 寫的標題。
- 只要有一個 id 對不上，那個目錄項目就是一個點了沒反應的連結。

所以目錄是從**render 後的 HTML** 回頭解析出來的：rehype-slug 產生 id，我就讀它產生的結果，而不是另外算一份。這樣兩邊不可能不同步，因為根本只有一份。

這件事跟這篇文章講的其他事情是同一個道理 —— 只是這一次，我在寫錯之前就先想到了。

（另外兩個小決定：目錄項目用純 `<a>` 錨點，所以 hydration 之前就能用；少於兩個標題就不顯示，一個項目的目錄是雜訊不是導覽。）

> 心得：兩份資料「應該要一致」的時候，與其想辦法讓它們一致，不如乾脆讓其中一份從另一份算出來。前者要一直維護，後者是結構上就不可能不一致 —— 這種偷懶我很願意。

## 第二組：bug 不在你寫的那一層

這一組每一個我都花了不少時間讀自己的程式碼，最後發現問題在別的地方 —— 第三方套件的預設值、CSS、或是一個沒有作用的 class。跟 Tiptap 沒什麼關係，任何前端專案都可能遇到。

### 坑 5：我花了半天 debug 一個不在我程式碼裡的 bug

**症狀**：「打引號會自動變成一對」，看起來像 inline code 的自動配對。

我很有信心地去查 inline code 的 input rule。寫 repro、開瀏覽器、確認 mark 不 sticky、要兩個反引號才會觸發、沒有任何 auto-pairing。查到最後結論是：**inline code 本身完全正確。**

然後我就卡在那裡了。程式碼看起來沒問題，但畫面上那對引號確實在那裡，它總得從什麼地方來。

**真正的原因**：Typography extension 的 smart quotes。直引號被改寫成成對的彎引號，看起來就像自動配對 —— 而且 U+2018/2019/201C/201D 會滲進存下來的 Markdown。關掉那四條規則，保留 em-dash、刪節號、箭頭。

**還沒完。** 同一個 issue 還抱怨 inline code 兩側有反引號，而且**刪不掉**：按 backspace 會從裡面吃掉真正的程式碼，直到整個 chip 一次消失。

那對反引號不是文件裡的字元，是 `@tailwindcss/typography`：

```css
code::before { content: "`"; }
code::after  { content: "`"; }
```

編輯器表面和預覽都掛在 `prose` 底下，所以每一個 inline code 都長這樣，新舊皆然。刪不掉，因為它根本不是內容。

**然後是第三次。** 後來我用 `>` 寫引言，字的兩側又冒出一對 `"`。同一個套件，同一種手法：

```css
blockquote p:first-of-type::before { content: open-quote; }
blockquote p:last-of-type::after  { content: close-quote; }
```

而發布出去的引言是左邊一條線、完全沒有引號 —— 編輯器又一次在顯示一段文章永遠不會有的標點。

被同一個東西咬第三次之後，我才停下來把它整個盤過一遍。結論其實讓人安心：**畫出你沒打的字**那一側總共只有三對 `content`，兩對就是上面這兩個，第三對套件自己就關掉了 —— 這一側到此為止，不會有第四個。**改寫你打的字**那一側有二十二條規則，但 Tiptap 的 input rule 執行器本來就會在程式碼區塊、以及緊鄰 `code` mark 的地方直接放棄，所以我只需要衡量散文。

真正該關掉的，是那些「觸發序列在技術寫作裡另有意思」的：`<<` 和 `>>` 會變成 `«` `»`（中文根本不用這種引號，而這兩個符號在散文裡多半是位移或重導向），還有 `(c)` 會變成 `©` —— 這個最陰險，因為 `(a) (b) (c)` 條列才是打出這三個字元的常見理由，而你多半要到發布之後才會發現第三項變成了版權符號。

> 心得：後來我養成一個習慣 —— 只要出現「我打字，畫面卻多了我沒打的東西」，我第一件事不是讀自己的 extension，而是先問「這個字元到底在不在 document 裡」。一行 `editor.getText()` 就能回答，而答案是「不在」的次數，比我想像的多很多。嫌犯通常就那三個：input rule、CSS 的 pseudo-element、瀏覽器或輸入法。

> 心得二（第三次之後才學會的）：同一個套件連續咬你兩次，就別再一個一個修了。把它所有會插手你內容的規則列出來，一次決定完 —— 這比等它在某篇已經發布的文章裡咬你第三次便宜太多。

### 坑 6：語法高亮「有在跑」，只是沒有人在畫 🔧

**症狀**：編輯器和預覽裡的程式碼是灰色板磚，發布出去的頁面卻有完整高亮。預覽既不像編輯器、也不像網站 —— 一個所見即所得編輯器最沒有資格出的錯。

**原因**：lowlight 一直都有把 `hljs-*` class 塞進編輯器 DOM。但顏色主題被 scope 在 `.tm-prose` 底下，編輯器沒有這個 class。所以每次按鍵都在算那些 class，然後從來沒有被畫出來。

**修法**：scope 放寬到 `.tm-code`，兩邊都掛。`--tm-*` token 定義在 `:root` 跟著 `data-theme` 走，一份定義同時處理兩個表面的亮暗色。

順便把預覽改成用公開頁面同一個 `<Prose>` component，而不是自己寫的 `prose dark:prose-invert` —— **不要對「一篇文章長什麼樣」有第二種意見。**

還有一個相關的：編輯器從 `common`（約 37 種 grammar）建 lowlight，前台 renderer 卻只在一個手寫的 9 種子集裡自動偵測。同一個沒標語言的區塊，寫的時候和發布後可能被判成不同語言。現在兩邊從同一個清單來。

### 坑 7：`z-50` 放在 `position: static` 上，什麼事都不會發生

**症狀**：`/` 選單被 toolbar 切掉一半，在筆電上還會超出畫面。

**原因**：`z-50` 掛在一個 `position: static` 的 div 上 —— z-index 在那裡完全無效。popup 於是 stack 在 `auto`，被 z-10 的 sticky toolbar 蓋過去。class 必須放在 floating-ui 實際定位、實際 append 到 `<body>` 的那個元素上。

第二個問題：11 個項目高 419px，而且 `overflow: hidden` —— 尾巴不只看不到，是**碰不到**。

至於「選單開在錯的位置」這個抱怨，placement 本身沒問題 —— suggestion plugin 的 `flip` 預設就是 true。它缺的是一個「兩側都塞得下」的高度。**症狀描述的位置，跟真正的原因是兩回事。**

> 心得：把沒有作用的 class 刪掉，不要留著讓它看起來是對的。一個 no-op 的 `z-50` 會讓三個月後的你以為這裡處理過了。

## 第三組：兩個時鐘不同步

React 的 render 和 ProseMirror 的 transaction 是兩個獨立的時鐘，瀏覽器的游標位置又是第三個。這一組全部是同一件事的不同面貌 —— 只要你把一個編輯器核心接進 React，換成 Slate、Lexical 也一樣要面對。

### 坑 8：React NodeView 的游標慢了一幀 🔧

**症狀**：新建 code block 之後，第一個按鍵落在區塊**外面**。

**原因**：這個最難查，因為所有測試都通過。建立 code block 時 ProseMirror 立刻給了正確的 selection —— `state.selection` 從頭到尾都對。但這個 code block 是 React NodeView，它的 `contentDOM` 晚一幀才掛上，所以**瀏覽器的游標**留在區塊後面。state 說一套，DOM 做一套。

```ts
requestAnimationFrame(() => {
  if (editor.isDestroyed) return;
  editor.view.focus();   // 不是 chainable 的 focus()
});
```

必須是 `view.focus()`。可鏈式的 `focus()` 在編輯器已 focus 時 no-op，而每一個需要這個修正的路徑，編輯器都已經 focus 了。

> 心得：ProseMirror 的 state 和瀏覽器的 selection 是兩個東西。把 NodeView 交給 React，等於在兩者之間插入一個 render cycle。jsdom 測不到，只能開瀏覽器。

### 坑 9：`shouldShow` 讀 React state，永遠慢一個 transaction

**症狀**：表格的 BubbleMenu 從來不出現。

**原因**：我第一版從一個 `useEditorState` 的 boolean 去算 `shouldShow`。但 plugin 是在 **ProseMirror transaction 進行中**評估它的，那時 React 還沒 re-render。所以那個 boolean 永遠慢一個 transaction —— 包括「建立表格」的那一個。

**修法**：`shouldShow` 在被呼叫的當下直接問 editor，不要捕捉任何 derived value。

這跟坑 10 是同一個病的兩面：**React 的 render cycle 和 ProseMirror 的 transaction 是兩個不同步的時鐘。** 坑 10 是「不小心依賴 React 才正確」，坑 9 是「依賴 React 所以永遠不對」。

順帶一提這個功能的起點：使用者回報「我找不到怎麼加 row 和 col」。**沒有任何東西壞掉。** 那七個 command 全都能用、都有測試在驗真實的行列數。它們在 toolbar 裡、藏在沒有標籤的 icon 後面、而且在游標不在表格裡時是 disabled 的 —— 你會看到它可用的那一刻，早就不看那裡了。修法是把控制項放到**被編輯的那個東西旁邊**。

> 心得：「功能存在且有測試」和「功能能被找到」是兩個獨立的問題。後者不會出現在測試報告裡。

### 坑 10：效能問題不在我以為的地方（兩次）

我開了 issue 說「每個按鍵序列化兩次 document，太慢了」。我很確定。我錯了。

**測量方式**：capture-phase 的 keydown probe 配一個 MessageChannel message —— 後者會在整個 task（含 React flush）排乾之後才送達。

結果：每次按鍵約 21ms，而且**從 664 字到 18,185 字完全持平**。序列化的成本會隨長度成長，這個沒有。所以不是序列化。

CPU profile 直接點名：React element creation，`jsxDEV` 和 `createElement` 是整個 trace 裡最大的兩個 JS frame。

**真正的原因**：document 流經 react-hook-form，每一個字元都 re-render 整個編輯器，重建 15 個 toolbar 按鈕跟 icon、三個選單、三個 dialog（`DialogContent` 的 children 不管有沒有 mount 都會被 eager 建構）。

更難看的真相是：toolbar 那些 inline 的 `editor.isActive(...)` 讀取，**之所以一直正確，正是因為這個意外的 re-render**。

所以修法不是「少 render」，是「讓正確性不再依賴 render」：每個控制項用 `useEditorState` 訂閱自己要畫的那塊 state，然後 memo。先讓正確性獨立，才敢停止渲染。

**第二個效能坑**：修完之後，開著預覽仍然每次按鍵多花 12.7ms 的 Layout。不是 debounce 壞了 —— 計數器顯示 51 次 effect、但只有 1 次 timer fire、1 次 pipeline run。

是預覽本身。React 用**物件識別**比較 `dangerouslySetInnerHTML`，每次 render 給一個新的 `{ __html }` literal，就是一次無條件的 innerHTML 賦值。MutationObserver 抓到 50 次按鍵重建了 25,150 個節點 —— 從**位元組完全相同**的 HTML。


| 情境        | 修正前 (p50/p90) | 修正後           |
| --------- | ------------- | ------------- |
| 短文件，無預覽   | 21.0 / 29.6ms | 10.0 / 16.0ms |
| 18k 字，無預覽 | 21.6 / 27.8ms | 12.5 / 16.5ms |
| 18k 字，開預覽 | 37.3 / 61.6ms | 11.8 / 16.2ms |


開預覽的成本現在等於不開預覽，p90 落在一個 60Hz frame 之內。

> 心得：一、先量，而且要量「成本是否隨規模成長」—— 那條曲線的形狀就足以排除一半嫌犯。二、`dangerouslySetInnerHTML` 吃的是物件識別，不是字串內容。三、發現「這段程式碼一直是對的，但理由是意外的」，那不是效能問題，是正確性問題偽裝成效能問題。

## 第四組：鍵盤與輸入規則 🔧

這一組偏向「會寫程式碼的人才會遇到」：自動轉換規則猜錯了你的意圖，或是某個鍵被別人先接走了。

### 坑 11：三個反引號的 input rule 會吃掉你的下一個字 🔧

**症狀**：打 `` ``` `` 然後接著打 `const x = 1;`，得到一個標成 `` ```const `` 的區塊，而且 `const` 從程式碼裡不見了。

**原因**：預設 input rule 會捕捉 fence 後面第一個 word 當語言，不管那是什麼。那個 info string 會一路傳進儲存的內容和前台 renderer。

**修法**：只有那個 word 真的是**已註冊的 grammar 名稱**時才當語言。否則那是作者正在打的程式碼，規則只吃掉 fence 把字留下。

（一個細節：觸發的空格要手動補回去，因為 ProseMirror 只在「沒有規則處理這次輸入」時才插入它。）

> 心得：只要一條規則在猜使用者想幹嘛，它就該有一份可以驗證的白名單。「後面那個字是語言」是猜的；「後面那個字在我註冊的那幾十個 grammar 裡面」才是可以查證的。差別聽起來很小，但前者會刪掉使用者剛打的字，後者不會。

### 坑 12：兩條 input rule 搶同一串字 🔧

**症狀**：打 `---` 會出現分隔線（這是對的），但游標上方多了一行空白；而且想把這行刪掉的時候，分隔線會變回「兩槓」。

**我以為的原因**：分隔線那條規則寫壞了。

**真正的原因**：有兩條規則在搶同一串字元，而先搶到的那條不是我以為的那條。

Typography 的 em-dash 規則是 `/--$/` —— 它在你打**第二個** hyphen 的時候就觸發了。所以三個 hyphen 的實際歷程是 `-` → `—` → `—-`。

分隔線還是出現了，因為 hr 那條規則的 pattern 裡本來就寫了 `—-` 這個變體：上游早就知道會被 em-dash 搶先，選擇了容忍。但被容忍掉的是**藏在分隔線背後的原文**。Backspace 走的是 `undoInputRule`，它還原的是「你真正打出來的東西」，而那時候你真正打出來的，是一個破折號加一個 hyphen。

**修法**：把 em-dash 縮小到「前面有非 hyphen 字元」才觸發，行首的 `--` 放著不動。這樣 `---` 是以三個貨真價實的 hyphen 抵達 hr 規則的，undo 還原的也是 `---`。

至於那行多出來的空白 —— 它不存在。我把文件結構印出來看，是 `paragraph | horizontalRule | paragraph("")`，游標就在那個唯一的空段落裡，沒有多餘節點。真正的原因又是 CSS：編輯器的 `hr` 吃到 Tailwind Typography 的 `margin: 3em`，而發布頁是 24px，落差四倍，看起來就像上面被插了一行。跟坑 6 是同一件事 —— 編輯器在描述一個文章不會有的版面。

一個症狀，兩個成因，分別住在兩層完全不同的東西裡。

> 心得：input rule 是有順序、而且會互相搶的。一條規則「看起來正確」不代表它拿到的是使用者打的字 —— 它拿到的是前一條規則吐出來的結果。而 undo 還原的永遠是後者。

### 坑 13：Tab 鍵把 focus 送去了 submit 按鈕 🔧

**症狀**：在 code block 裡按 Tab，沒有縮排，focus 跳到表單送出按鈕。

**原因**：兩層。ProseMirror 刻意把 Tab 留給瀏覽器（無障礙 —— 鍵盤使用者要能離開編輯器），而我的編輯器掛在 `<form>` 裡，所以 Tab 就是正常的 focus 移動。寫有縮排的 Python 或 TypeScript 等於永遠不能碰這個鍵。

**修法**：給 code block 自己的 keymap，**只在 selection 在 code block 內時攔截 Tab**，其他地方照舊，無障礙的逃生口留著。

行的數學運算被抽到一個不依賴 ProseMirror 的檔案，所以最麻煩的 off-by-one（選取剛好結束在某行開頭時那行不該被縮排）可以直接測。

> 心得：覆寫瀏覽器預設鍵之前，先問那個預設值是為誰存在的。答案常常是無障礙。攔截範圍要縮到最小。

## 第五組：測試與部署

放在最後，因為這兩個最跟環境有關 —— 尤其最後一個只有 Cloudflare Workers 適用。但兩者背後的道理都不挑平台。

### 坑 14：CI 全綠，然後 exit 1

```
586 passed, 1 unhandled error, exit code 1

TypeError: target.getClientRects is not a function
  at singleRect → coordsAtPos → scrollToSelection → dispatchTransaction
```

每個測試都過，整個 run 卻是失敗的。而且本機跑是綠的，只有 CI 紅 —— 我第一個反應當然是重跑一次，然後它又綠了。第三次才紅回來。

**原因**：jsdom 沒有實作 layout，`Range.getClientRects` 根本不存在（不是 no-op，是 undefined）。而 Tiptap 的 `focus()` 把 `scrollIntoView` 丟進 `requestAnimationFrame` —— 於是那個 throw 落在**造成它的測試已經結束之後**，Vitest 只能報成 unhandled error。時序不同就不一定出現。

**修法**：在全域 setup 裡 shim 那兩個 Range 幾何方法，回傳空的幾何 —— 對一個沒有 layout 的 DOM 來說，這是誠實的答案。（同一類的洞後來又出現一次：jsdom 也沒有 `scrollIntoView`。）

> 心得：`unhandled error` + exit 1 而測試全綠，幾乎一定是有東西被排進了測試生命週期之外的timer / rAF / microtask。往那裡找，不要重跑 CI。

### 坑 15：編輯器的體積，跟著 SSR 一起進了伺服器 ☁️

> ☁️ 3 MiB 這個數字只有 Cloudflare Workers 適用，但「編輯器只有後台會用到，不該進 SSR bundle」在哪個平台都成立 —— 差別只在別的平台不會直接部署失敗，而是安靜地讓每一次 SSR 都多背一份重量。

**症狀**：本機都正常，一部署到 Cloudflare 就失敗，bundle 超過免費方案 3 MiB 的上限。

**原因**：Tiptap 全家桶加上 KaTeX 一起進了 SSR bundle。但編輯器是後台才用得到的東西，訪客永遠看不到它，卻跟著每一個 SSR 請求進 worker。

在 Next.js 這是一行 `next/dynamic({ ssr: false })` 就能解決的事；TanStack Start 目前沒有對應的 API，所以要自己做：

```tsx
const TiptapEditorImpl = lazy(() => import("./TiptapEditorImpl"));

export function TiptapEditor(props) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return fallback;
  return <Suspense fallback={fallback}><TiptapEditorImpl {...props} /></Suspense>;
}
```

這裡有個小細節：只有 `lazy` 還不夠，伺服器端仍然會渲染 Suspense 的內容。要加上 `mounted` 這道 gate，重的 chunk 才會等到 hydrate 之後才在瀏覽器載入。

> 心得：換框架真正的成本，往往不是學新 API，而是舊框架幫你做掉、久到你已經忘記它存在的那些事。搬家的時候值得先列一張「上一個框架幫我處理了什麼」的清單。

---

## 番外：中文輸入要多做的一些功課 🀄

這幾件事我不太想叫它們「坑」，因為它們不是 bug —— 比較像是每個功能都要再多做一次的功課。我把它們放在一起，因為背後是同一件事：**大部分套件的預設值是先為英文設計的。** 這很合理，只是身為中文使用者，需要自己補上這一段。

**觸發字元。** `@` 提及要能在中文字後面直接觸發。`@tiptap/suggestion` 的 `allowedPrefixes` 預設要求前面是空白 —— 英文自然會有空格，中文不會。要設成 `null`。

**輸入法還沒定案的那段時間。** 搜尋要 gate 在 composition 上，否則使用者打「賴」的過程中，`lai`、`ㄌㄞˋ` 這些半成品會被送去查詢。suggestion 的 `items()` 在 composition 進行中要直接 bail。

**Slug。** 這個我覺得蠻有意思：標題自動填 slug，在**第二個字之後就停了**。「十年部落格如一日」存成 `shih`。

守衛條件是 `!formState.dirtyFields.slug`。`setValue` 不帶 `shouldDirty` 不會標記 dirty，但**標題的下一個按鍵會**：react-hook-form 的 `updateTouchAndDirty` 在某欄位的 pristine 狀態和表單的 `isDirty` 不一致時，會重新 diff 整份表單對照 `defaultValues`，而自動填入的 slug 跟 `""` 不同，於是被標回 dirty。一次 render 之後守衛就永久關閉 —— 所以**剛好**是前兩個按鍵能通過。

修法是別追蹤 dirty，追蹤**作者身份**：記住自己上一次寫進去的值，一旦欄位裡不是那個值就退場。

> 心得：`dirtyFields` 回答的是「這份表單跟 defaultValues 不一樣嗎」，不是「使用者碰過這個欄位嗎」。語意差一個字，bug 就只在第 2 個按鍵之後發生。

---

## 回頭看，這些坑其實只有四類

整理的時候我才發現，這十五個坑其實翻來覆去就是四種。如果這篇只能留四段，我會留下面這四段 —— 它們跟 Tiptap 其實沒什麼關係，換個編輯器、甚至換個框架，大概還是一樣的。

**一、Bug 常常不在你寫的那一層。** 彎引號在 Typography extension，反引號在 Tailwind 的 CSS，高亮失效是 CSS scope，`z-50` 失效是因為 `position: static`。四個看起來像「編輯器 bug」的問題，最後都不在編輯器的程式碼裡。所以我現在會先確認症狀屬於哪一層，再開始讀程式碼。

**二、兩個時鐘不同步。** ProseMirror 的 transaction 和 React 的 render 是兩個獨立的時鐘，瀏覽器的 caret 和 `state.selection` 又是第三個。NodeView 慢一幀、`shouldShow` 慢一個 transaction、toolbar 靠意外的 re-render 才正確 —— 都是同一件事的不同面貌。任何跨越這兩者的 derived value，都值得多看一眼。

**三、我對原因的第一直覺，大概錯了一半。** 以為是 inline code（其實是 smart quotes）、以為是序列化（其實是 React element creation）、以為是 debounce（其實是 innerHTML 的物件識別）、以為選單位置算錯（其實是高度沒有上限）。每次救我的都是**先量再改**：一條隨文件長度持平的曲線、一個 MutationObserver 的節點計數、一份 CPU profile。花在測量上的時間，通常比猜錯之後改錯地方要少。

**四、有些事 jsdom 不會告訴你。** caret 的實際位置、`getClientRects`、一個看起來正確但其實只是碰巧正確的 re-render —— 測試套件都會全綠。jsdom 本來就沒有 layout，有些事情還是得開瀏覽器親自看一次。

---

## 那五個問題，我最後怎麼答的

回到開頭那五個沒有標準答案的問題，這是我的選擇：

**Markdown editing 和 WYSIWYG 要怎麼共存？** 我選了**不共存**。編輯面只有 WYSIWYG，Markdown 純粹是存檔格式，使用者從頭到尾看不到它。兩種模式並存聽起來很體貼，實際上是把「切換時游標在哪、哪一邊是真相」這個問題丟給使用者。既然我信任序列化層（並且為它寫了一堆測試），就沒有理由再開一扇後門。

**編輯器上顯示什麼？** 顯示 render 後的結果。另外有一個預設關閉、`⌘⇧P` 開啟的預覽窗格 —— 但它顯示的不是 source，是**這篇文章發布之後長什麼樣**，而且是用公開頁面同一個 component 算出來的（坑 6）。所以那不是「第二種編輯模式」，是「第二個觀點：讀者的觀點」。

至於前面列的第三個選項 —— 「顯示 render 結果但要能看得到 source」—— 我後來也做了，但刻意只做一半：工具列上一個 `</>`，開一個**唯讀**的側欄，裡面就是這份文件現在的 Markdown，附一個複製鈕。

唯讀是重點。你不能在裡面打字，所以「哪一邊是真相」這個問題根本不存在，前面那個「不共存」的決定沒有被推翻。我缺的從來不是第二個可以打字的地方，是「我剛剛那個構造到底被序列化成什麼」這個問題的答案 —— 而寫這篇文章的過程裡，這是我問得最多的一個問題。

**要支援哪一種 flavor？** GFM 那一套（表格、刪除線、任務清單）加上 `$...$` 的數學。決定的方式不是查規格，是**讓編輯器和前台 renderer 共用同一份清單** —— 程式碼語言的清單是同一個檔案，數學的 tokenizer 遵循前台 renderer 的慣例。flavor 的定義不是一份文件，是「兩邊必須同意」這個約束。

**圖片上傳怎麼支援？** 貼上或拖放就上傳，走 presign → PUT → confirm 到自己的 R2。上傳中的佔位符是 decoration 而不是 node（坑 2），所以它永遠不會進到存檔的 Markdown 裡。

**Embed 呢？** 我選了三條路裡的第一條：**允許原始 HTML 進到 Markdown 裡**。前台的渲染管線帶著 `rehype-raw`，Markdown 裡的 HTML 會照樣輸出；編輯器則把 YouTube 當成一個真正的節點，有自己的 NodeView，序列化回去就是那段 HTML。

我知道這讓「純文字」打了折扣，但我接受的理由是：**這條折扣是有邊界的。** 一篇文章裡真正需要 embed 的段落是少數，而且就算換掉整個編輯器，那段 HTML 在任何 Markdown 渲染器裡仍然是有效的 HTML —— 它壞掉的方式，比「自訂簡寫語法」溫和得多。相對地，如果我發明一個 `{{youtube:xxx}}`，那它只在我自己的 renderer 裡有意義，換工具的那天它就變成一串沒有人看得懂的字。

代價要說清楚：開了 raw HTML，等於在渲染端關掉了一層防護。我的情境是單一作者、內容只有我自己寫得進去，所以這個取捨可以接受。**如果內容會來自其他人，這個決定要完全反過來** —— 那時候比較合理的做法是只允許白名單內的 embed，把 URL 轉成受控的元件，而不是讓任意 HTML 直通。

五個答案有一個共同點：**每一次我都是在減少「真相的份數」。** 一種編輯模式、一個 render 管線、一份語言清單、一個不會被序列化的暫時狀態、一份不需要專屬 renderer 才看得懂的 embed。這篇文章裡幾乎每一個坑，追到底都是某個地方存在了第二份真相。

---

## 還沒做，但已經在影響決定的兩件事

有兩個功能我還沒做，不過想清楚它們要怎麼做，反而讓我更確定存檔格式的選擇是對的。

**付費牆。** 這件事的難處不在金流，在於**一篇文章需要一個「免費的部分到這裡為止」的切點**，而 Markdown 沒有這個概念。

可以選的還是那幾種：在正文裡放一個標記（Rails 時代的 `<!-- more -->` 就是這個），或是把文章切成兩個欄位分開存。我會選標記 —— 因為切成兩欄，等於同一篇文章從此活在兩個地方，而這篇文章從頭到尾在講的就是不要有第二份真相。標記則不然：它只是純文字裡的一行，把它拿掉，文章仍然是完整的一篇文章。

**電子報。** 這件事我原本以為是寄信的問題，後來發現是渲染的問題：**email 的 HTML 跟網頁的 HTML 幾乎是兩種語言** —— 很多 client 不吃外部 CSS、不吃現代排版、樣式得寫成 inline。

換句話說，同一篇文章我需要兩種渲染結果。而這正好是存 Markdown 最直接的一次回報：來源只有一份，我只要再寫一條 pipeline 就好。如果我當初存的是編輯器產生的 HTML，那要做的就不是「多一條 pipeline」，而是把一份已經定型的 HTML 想辦法翻譯成另一種 HTML —— 那是完全不同量級的工作。

這兩件事都還沒動工，但它們共同指向同一個結論：**存 source，不要存呈現結果。** 你不會知道三年後這篇文章需要被渲染成什麼樣子 —— 網頁、email、RSS、App、或是某個還沒出現的東西。而只要 source 還在，那些都只是多一條 pipeline 的事。

---

## 值得嗎？

從我接手的第一個 `vendor/assets` 底下那包 CKEditor 到現在，十年，我大概用過六套編輯器。每一套在當下都覺得「這次應該就是了」，然後總會在某個地方遇到限制。

現在這套也一樣。Tiptap 總有一天也會被別的東西取代 —— 這不是壞事，前端本來就是這樣往前走的。

差別在於，這一次我的資料是 Markdown。

編輯器是我的工具，內容是我的東西。這十年我最實在的一個心得，就是這兩件事要能分開 —— 下一次要搬家的時候，需要重寫的是編輯器，不是十年份的文章。

而在那之前，我現在可以貼上截圖、打 `/table` 插入表格、在中文字後面直接按 `@` 連到自己的另一篇文章；而存下去的，仍然是我十年後打得開的純文字。

老實說這些東西加起來花的時間，遠比我一開始估的多。如果重來一次，我大概還是會做，只是會先把「存檔格式」這件事想清楚再開始 —— 因為後面所有的選擇裡，就這一個最難回頭。

其他的，慢慢調就好，反正部落格又不會跑掉。
