iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
AI 自動化

用 AI Agent 打造你的產品使用手冊產線系列 第 11

[Day 11] 產線實作 4:用 CSS 畫框與標註

  • 分享至 

  • xImage
  •  

前三天分別解決了「找得到元件」、「畫面內容穩定」與「時機和範圍都正確」,到這邊已經可以穩定拿到一張乾淨的截圖了。今天要在這張截圖上動手:畫框、標號,讓讀者一眼就知道該看畫面上的哪個位置。

為什麼一定要標註

使用手冊的靈魂,某種程度上就是「圖上標了 ①②③」。一張沒有標註的截圖,讀者要自己猜「說的是哪個按鈕」;一張標註清楚的截圖,配合旁邊的文字說明,讀者幾乎不用思考就知道該點哪裡。這件事做得好不好,某種程度上就是一份手冊看起來專不專業的分水嶺。

三條路的比較

要在截圖上畫框、標號,大致有三條路可以走:

  1. 影像處理庫 (sharp、canvas 之類)

    直接對截圖的像素資料動手,用程式庫畫矩形、畫文字。問題是中文字型的載入與渲染要自己處理、文字寬度要自己算才不會溢出框外,而且多了一層原生相依 (native binding),在不同作業系統、CI 環境上的安裝與相容性都要額外花力氣。

  2. 後製工具

    用 Photoshop 一類的工具人工後製。這條路直接出局,它不可自動化,跟這整條產線「改完 UI 跑一個指令」的目標完全相反。

  3. 注入 DOM 疊層再截圖 (推薦)

    不直接對圖片的像素動手,而是在按下快門之前,先在頁面上疊一層用 HTML / CSS 畫出來的框與圓標,截圖時連同這層一起拍下去。沒有原生相依、樣式用 CSS 調整比程式化畫圖直觀得多,而且中文字型直接吃瀏覽器本身的渲染引擎,完全不用管字型載入。

三條路裡,DOM 疊層是最務實的選擇。它把「畫框」這件事,從「影像處理問題」轉換成「網頁排版問題」,而網頁排版正好是瀏覽器最擅長的事。

實作

整個流程是:先用 boundingBox() 算出目標元件的位置與尺寸 → 注入一層 position: fixed 加上 pointer-events: none 的 overlay 容器 → 在這層 overlay 裡畫出框與圓標 → 截圖 → 截完之後把疊層移除,還原畫面。

座標在 Node 這邊算好再丟進去,page.evaluate() 裡面就只剩下「照著座標畫方框」這件事:

async function annotate(page: Page, targets: Target[]) {
  // 先在 Node 這邊把每個目標解析成畫面座標
  const items = await resolve(page, targets)

  await page.evaluate((items) => {
    const overlay = document.createElement('div')
    overlay.id = '__manual-overlay'
    overlay.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:999999;'

    for (const t of items) {
      const pad = 4
      const box = document.createElement('div')
      box.style.cssText = `
        position:absolute;
        left:${t.x - pad}px; top:${t.y - pad}px;
        width:${t.width + pad * 2}px; height:${t.height + pad * 2}px;
        border:3px solid #ff3b30; border-radius:6px;
        box-shadow:0 0 0 2px rgba(255,255,255,0.9);
      `
      const badge = document.createElement('div')
      badge.textContent = t.label
      badge.style.cssText = `
        position:absolute; left:-14px; top:-14px;
        width:28px; height:28px; border-radius:50%;
        background:#ff3b30; color:#fff;
        display:flex; align-items:center; justify-content:center;
        font:bold 14px/1 system-ui, sans-serif;
        box-shadow:0 0 0 2px rgba(255,255,255,0.9);
      `
      box.appendChild(badge)
      overlay.appendChild(box)
    }
    document.body.appendChild(overlay)
  }, items)
}

截完之後記得把疊層清掉,不然下一張截圖會連同上一張的框一起拍進去:

await page.screenshot({ path: 'output/day11/steps.png' })
await page.evaluate(() => document.getElementById('__manual-overlay')?.remove())

對著範例 App 跑一次,標上「①搜尋攝影機 → ②雙擊清單裡的 Lobby-01 → ③畫面填入左上角的格子」,產出大概是這樣:

await annotate(page, [
  { selectors: ['[data-testid="camera-search"]'], label: 1 },
  { selectors: ['[data-testid="camera-row_lobby-01"]'], label: 2 },
  { selectors: ['[data-testid="grid-cell_1"]'], label: 3 },
])

整頁截圖,標上 ①②③ 三個操作步驟

框線與圓標的設計

這幾個數字看起來很瑣碎,但它們直接決定產出看起來像不像一回事:

  • 顏色:要在深色與淺色的 UI 上都清楚可見。範例 App 左邊是白底面板、右邊是深色的即時畫面,單純一條紅框放到深色區域就容易糊掉。做法是在紅框外面再加一圈白色描邊 (上面的 box-shadow:0 0 0 2px rgba(255,255,255,0.9)),兩種背景下都看得出來。
  • 粗細:太細印刷後容易糊掉,太粗又顯得粗糙。實務上 2–3px (96 DPI 邏輯像素下) 是常見的折衷值,實際還要搭配 Day 10 算出來的 deviceScaleFactor 一起看。
  • 圓角:比直角柔和,但不是必要,依團隊的視覺風格決定。
  • 往外留白:框線如果緊貼著元件邊界畫,會擋到元件本身的邊框或圖示。往外留幾個像素 (上面的 pad),讓框「圈住」元件而不是「貼著」元件。

三種標註目標

annotate 的輸入設計成三種形式,對應三種實際會遇到的情境:

  1. 單一 selector:標註單一元件,最常用的情況。

    await page.getByTestId('camera-edit_gate-a').click()
    await page.getByTestId('camera-dialog').waitFor({ state: 'visible' })
    
    await annotate(page, [
      { selectors: ['[data-testid="camera-dialog-name"]'], label: 1 },
      { selectors: ['[data-testid="camera-dialog-enabled"]'], label: 2 },
      { selectors: ['[data-testid="camera-dialog-confirm"]'], label: 3 },
    ])
    

    攝影機設定對話框,標上顯示名稱、啟用推論、儲存三個欄位

  2. selector 陣列:把多個目標合併成一個涵蓋全部的大框,適合「這一整排都是同一件事」的情境。範例 App 的版面切換是 grid-layout_1x1_4x4 四顆獨立的按鈕,但在手冊裡它們就是「版面切換」這一個功能,沒必要拆成四個標號:

    await annotate(page, [
      {
        selectors: [
          '[data-testid="grid-layout_1x1"]',
          '[data-testid="grid-layout_2x2"]',
          '[data-testid="grid-layout_3x3"]',
          '[data-testid="grid-layout_4x4"]',
        ],
        label: 1,
      },
    ])
    

    實作上就是取所有 boundingBox() 的聯集 (最小的 x、最小的 y、最大的右界與下界)。

    1×1 到 4×4 四顆按鈕被合併成一個大框

  3. 手動座標:當目標不是一個明確的 DOM 元件時使用,例如要標註 canvas 上自己畫的一塊區域。這種情況下就只能直接給 { x, y, width, height }

另外,建議在產線裡順手保留一份未標註的版本。除錯的時候 (確認框有沒有畫歪)、審稿的時候 (確認標號有沒有對錯元件),兩張圖並排看會省下很多時間。

經驗分享

1. z-index 要設得誇張一點

App 自己的 modal backdrop、Toast 容器這類浮層,通常也會用偏高的 z-index。如果疊層的 z-index 沒設得夠高,畫出來的框反而會被 App 的浮層蓋住,截圖裡完全看不到。

範例 App 的對話框遮罩是 z-index: 100,第二層的確認框是 110,Toast 更高,是 200。把疊層的 z-index 設成 10 再截同一張對話框,結果是這樣:

疊層 z-index 太低,框被對話框遮罩蓋住,看起來跟沒標註一樣

程式沒有報錯、annotate 也確實執行了,但產出看起來就跟沒標註完全一樣,這種問題如果沒有人工審稿很容易一路混進成品裡。解法很單純:把疊層的 z-index 設成一個刻意誇張的值 (例如前面的 999999),確保它一定在最上層。

2. pointer-events: none 不能漏

position: fixed 加上 inset: 0 的疊層,等於用一張透明的布蓋住整個畫面。如果忘了加 pointer-events: none,後續每一個操作都會點在這塊布上,而不是真正的元件。

3. 疊層會被 App 的全域 CSS 影響

有些 App 的全域樣式表 (尤其是寫得比較激進的 CSS reset) 會連帶影響到疊層元素,讓框線、圓標的呈現跟預期不一樣。

上面的寫法全部用內聯樣式,就是為了把特異性 (specificity) 拉高,減少被全域樣式蓋掉的機會。如果連內聯樣式都擋不住 (例如對方用了 !important),就要改用 Shadow DOM,把整個疊層隔離在 App 的樣式作用域之外。

4. 圓標會擋到東西

看一下前面對話框那張圖,③ 這個圓標其實壓到了旁邊的「取消」按鈕。目前的擺放邏輯是死的,一律放在框的左上角外側,所以只要目標旁邊剛好有東西,圓標就會蓋住它。

而這正好是明天的主題,會討論圓標擺放與碰撞避讓。


上一篇
[Day 10] 產線實作 3:截圖時機與範圍
下一篇
[Day 12] 產線實作 5:標號與標號說明
系列文
用 AI Agent 打造你的產品使用手冊產線17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言