← 部落格

使用 Yoga 配置主控台版面

Text.js 在 WebAssembly 中執行 Yoga,點縮放係數設為 1,讓每個區塊都對齊完整儲存格。文字以 Intl.Segmenter 分段,並以儲存格為單位測量。

Adam

Text.js 的每個影格都由編譯為 WebAssembly 的 Yoga 負責版面配置,Yoga 是 React Native 使用的 flexbox 引擎。方框和文字共用一棵 Yoga 樹。主控台是由字元儲存格組成的網格,因此算出的每個位置與尺寸都必須是整數個儲存格。

一點就是一個儲存格

Yoga 會依照點縮放係數定義的像素網格,將結果四捨五入。係數為 2 時,如同 Retina 螢幕,邊緣可以落在半點的位置。Text.js 只載入 Yoga 一次,並將係數設為 1:

export function initYoga(): Promise<Yoga> {  if (yoga !== null) return Promise.resolve(yoga);  if (yogaPromise === null) {    yogaPromise = loadYoga().then((instance) => {      yoga = instance;      const config = instance.Config.create();      config.setPointScaleFactor(1);      config.setUseWebDefaults(false);      yogaConfig = config;      return instance;    });    yogaPromise.catch(() => {      yogaPromise = null;    });  }  return yogaPromise;}

係數為 1 時,Yoga 會將每個邊緣四捨五入至整數單位,而 Text.js 將一個單位視為一個儲存格。Yoga 會將每個邊緣的絕對位置四捨五入,再根據取整後的邊緣算出尺寸,因此相鄰的方框會共用邊緣。將一個 81 欄的橫列分成兩個 width="50%" 方框,其中一個會取得 40 欄,另一個取得 41 欄,沒有間隙,也不會重疊。

Text.js 自行套用 CSS 預設值:flexDirection 為 row,flexShrink 為 1,與瀏覽器相同。

測量文字

文字節點是帶有測量函式的 Yoga 葉節點。Yoga 呼叫此函式時會傳入可用寬度,Text.js 則回傳以儲存格為單位的文字尺寸:

private measure(width: number, widthMode: MeasureMode, _height: number, _heightMode: MeasureMode): { width: number; height: number } {  const natural = this.naturalSize();  if (widthMode === MeasureMode.Undefined || Number.isNaN(width)) return natural;  const avail = Math.max(0, Math.floor(width));  if (natural.width <= avail) {    return { width: widthMode === MeasureMode.Exactly ? avail : natural.width, height: natural.height };  }  if ((this.props.wrap ?? "wrap") !== "wrap") return { width: avail, height: natural.height };  const lines = this.getLines(avail);  let maxLine = 0;  for (const l of lines) if (l.width > maxLine) maxLine = l.width;  return { width: widthMode === MeasureMode.Exactly ? avail : Math.min(avail, maxLine), height: lines.length };}

放得下的文字會以原始尺寸回傳。放不下的文字會依可用寬度換行,高度則是行數。節點會保留上次的換行結果,因此再次測量相同寬度時,不會重新將文字換行。

測量字素叢集

字串的 length 計算的是 UTF-16 編碼單元,無法充分反映字串占用多少儲存格。文字會先透過 Intl.Segmenter 分割成字素叢集,因此 e 後接一個組合重音符號會形成一個叢集,以零寬連接符連接的 emoji 序列也一樣。接著為每個叢集指定寬度:

文字儲存格數原因
abc3每個字元占一個儲存格
你好4東亞寬字元占兩個儲存格
🚀2emoji 顯示形式占兩個儲存格
é (e + U+0301)1組合標記不占儲存格
─│┌▼←⠋6製表符號、幾何圖形、箭頭與點字固定占一個儲存格

框線、▼ 標記、箭頭與旋轉指示器的影格一律以一個儲存格測量,因此方框的框線不會擠歪內容。

可列印的 ASCII 字元會略過分割,其他寬度則依碼位與叢集分別快取。

換行

<Text> 接受 wrap 模式:

  • wrap(預設值)會在空格處換行,單字寬於一行時則在單字內換行。
  • nowrap 保持單行,由方框裁切內容。
  • truncate-end、truncate-middle 和 truncate-start 保持單行,並以 … 取代放不下的內容。

截斷也會計算儲存格數,因此一行中文會在字元邊界截斷,而刪節號恰好占一個儲存格。

實際應用

這裡有一個 30 欄寬的側邊欄,旁邊的面板占用剩餘寬度:

<Box flexDirection="row" width="100%" height="100%">  <Box width={30} flexDirection="column" border="rounded" title="Sessions" overflow="hidden">    <Text wrap="truncate-end">session-03 · refactor the renderer pipeline</Text>  </Box>  <Box flexGrow={1} flexDirection="column" border="single" title="Conversation" paddingX={1}>    <Text>Message 0: 你好 🚀</Text>  </Box></Box>

在 100 欄寬的主控台中,側邊欄連同框線占 30 欄,對話面板則占其餘 70 欄。框線在兩側各占一個儲存格,因此工作階段標題會在 28 個儲存格處截斷。