Skip to content

隱藏模式原理:為什麼寬度計算如此重要

學完你能做什麼

  • 理解 OpenCode 隱藏模式的工作原理
  • 知道為什麼普通格式化工具在隱藏模式下會對齊錯位
  • 掌握外掛的寬度計算演算法(三步走)
  • 了解 Bun.stringWidth 的作用

你現在的困境

你用 OpenCode 寫程式碼,AI 生成了一個漂亮的表格:

markdown
| 欄位 | 類型 | 說明 |
|--- | --- | ---|
| **name** | string | 使用者名稱 |
| age | number | 年齡 |

在原始碼視圖裡看著挺整齊。但切到預覽模式,表格卻錯位了:

| 欄位     | 類型   | 說明   |
|--- | --- | ---|
| name | string | 使用者名稱 |    ← 怎麼短了?
| age      | number | 年齡   |

問題出在哪?隱藏模式

什麼是隱藏模式

OpenCode 預設開啟隱藏模式(Concealment Mode),它會在渲染時隱藏 Markdown 語法符號:

原始碼隱藏模式下顯示
**粗體**粗體(4 個字元)
*斜體*斜體(4 個字元)
~~刪除線~~刪除線(6 個字元)
`程式碼`程式碼(4 個字元 + 背景色)

隱藏模式的好處

讓你專注於內容本身,而不是被一堆 *** 符號干擾視線。

為什麼普通格式化工具會出問題

普通的表格格式化工具計算寬度時,會把 **name** 當作 8 個字元:

** n a m e ** = 8 字元

但在隱藏模式下,使用者看到的是 name,只有 4 個字元。

結果就是:格式化工具按 8 字元對齊,使用者看到的卻是 4 字元,表格自然就錯位了。

核心思路:計算"顯示寬度"而非"字元長度"

這個外掛的核心思路是:計算使用者實際看到的寬度,而不是原始碼的字元數

演算法分三步:

第 1 步:保護程式碼區塊(程式碼區塊裡的符號不剝離)
第 2 步:剝離 Markdown 符號(**、*、~~ 等)
第 3 步:用 Bun.stringWidth 計算最終寬度

跟我做:理解三步演算法

第 1 步:保護程式碼區塊

為什麼

行內程式碼(用反引號包裹)裡的 Markdown 符號是"字面量",使用者會看到 **bold** 這 8 個字元,而不是 bold 這 4 個字元。

所以在剝離 Markdown 符號之前,要先把程式碼區塊內容"藏起來"。

原始碼實作

typescript
// 第 1 步:提取並保護行內程式碼
const codeBlocks: string[] = []
let textWithPlaceholders = text.replace(/`(.+?)`/g, (match, content) => {
  codeBlocks.push(content)
  return `\x00CODE${codeBlocks.length - 1}\x00`
})

工作原理

輸入處理後codeBlocks 陣列
`**bold**`\x00CODE0\x00["**bold**"]
`a` and `b`\x00CODE0\x00 and \x00CODE1\x00["a", "b"]

\x00CODE0\x00 這種特殊佔位符替換程式碼區塊,後面剝離 Markdown 符號時就不會誤傷它們。

第 2 步:剝離 Markdown 符號

為什麼

隱藏模式下,**粗體** 顯示為 粗體*斜體* 顯示為 斜體。計算寬度時要把這些符號去掉。

原始碼實作

typescript
// 第 2 步:剝離非程式碼部分的 Markdown 符號
let visualText = textWithPlaceholders
let previousText = ""

while (visualText !== previousText) {
  previousText = visualText
  visualText = visualText
    .replace(/\*\*\*(.+?)\*\*\*/g, "$1") // ***粗斜體*** → 文字
    .replace(/\*\*(.+?)\*\*/g, "$1")     // **粗體** → 粗體
    .replace(/\*(.+?)\*/g, "$1")         // *斜體* → 斜體
    .replace(/~~(.+?)~~/g, "$1")         // ~~刪除線~~ → 刪除線
    .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")     // ![alt](url) → alt
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") // [text](url) → text (url)
}

為什麼用 while 迴圈?

處理巢狀語法。比如 ***粗斜體***

第 1 輪:***粗斜體*** → **粗斜體**(剝離最外層 ***)
第 2 輪:**粗斜體** → *粗斜體*(剝離 **)
第 3 輪:*粗斜體* → 粗斜體(剝離 *)
第 4 輪:粗斜體 = 粗斜體(無變化,退出迴圈)
圖片和連結的處理
  • 圖片 ![alt](url):OpenCode 只顯示 alt 文字,所以替換為 alt
  • 連結 [text](url):顯示為 text (url),保留 URL 資訊

第 3 步:恢復程式碼區塊 + 計算寬度

為什麼

程式碼區塊內容要放回去,然後用 Bun.stringWidth 計算最終的顯示寬度。

原始碼實作

typescript
// 第 3 步:恢復程式碼區塊內容
visualText = visualText.replace(/\x00CODE(\d+)\x00/g, (match, index) => {
  return codeBlocks[parseInt(index)]
})

return Bun.stringWidth(visualText)

為什麼用 Bun.stringWidth?

Bun.stringWidth 能正確計算:

字元類型範例字元數顯示寬度
ASCIIabc33
中文你好24(每個佔 2 格)
Emoji😀12(佔 2 格)
零寬字元a\u200Bb32(零寬字元不佔位)

普通的 text.length 只能數字元個數,無法處理這些特殊情況。

完整範例

假設儲存格內容是:**`code`** and *text*

第 1 步:保護程式碼區塊

輸入:**`code`** and *text*
輸出:**\x00CODE0\x00** and *text*
codeBlocks = ["code"]

第 2 步:剝離 Markdown 符號

第 1 輪:**\x00CODE0\x00** and *text* → \x00CODE0\x00 and text
第 2 輪:無變化,退出

第 3 步:恢復程式碼區塊 + 計算寬度

恢復後:code and text
寬度:Bun.stringWidth("code and text") = 13

最終,外掛按 13 字元的寬度來對齊這個儲存格,而不是原始碼的 22 字元。

檢查點

完成本課後,你應該能回答:

  • [ ] 隱藏模式會隱藏哪些符號?(答:***~~ 等 Markdown 語法符號)
  • [ ] 為什麼要先保護程式碼區塊?(答:程式碼區塊裡的符號是字面量,不應被剝離)
  • [ ] 為什麼用 while 迴圈剝離符號?(答:處理巢狀語法,如 ***粗斜體***
  • [ ] Bun.stringWidthtext.length 好在哪?(答:能正確計算中文、Emoji、零寬字元的顯示寬度)

踩坑提醒

常見誤解

誤解:程式碼區塊裡的 ** 也會被剝離

事實:不會。外掛會先用佔位符保護程式碼區塊內容,剝離完其他部分的符號後再恢復。

所以 `**bold**` 的寬度是 8(**bold**),不是 4(bold)。

本課小結

步驟作用關鍵程式碼
保護程式碼區塊防止程式碼區塊內的符號被誤剝離text.replace(/\(.+?)`/g, ...)`
剝離 Markdown計算隱藏模式下的實際顯示內容多輪正則替換
計算寬度處理中文、Emoji 等特殊字元Bun.stringWidth()

下一課預告

下一課我們學習 表格規範

你會學到:

  • 什麼樣的表格能被格式化
  • 表格驗證的 4 條規則
  • 如何避免"無效表格"錯誤

附錄:原始碼參考

點擊展開查看原始碼位置

更新時間:2026-01-26

功能檔案路徑行號
顯示寬度計算入口index.ts151-159
程式碼區塊保護index.ts168-173
Markdown 符號剝離index.ts175-188
程式碼區塊恢復index.ts190-193
Bun.stringWidth 呼叫index.ts195

關鍵函數

  • calculateDisplayWidth():帶快取的寬度計算入口
  • getStringWidth():核心演算法,剝離 Markdown 符號並計算顯示寬度

關鍵常數

  • \x00CODE{n}\x00:程式碼區塊佔位符格式