Featured image of post Firefox 分頁群組自動折疊:自製擴充套件完整教學

Firefox 分頁群組自動折疊:自製擴充套件完整教學

Firefox 原生分頁群組在從 hover 預覽面板點選分頁後會自動展開,本文說明如何自製 WebExtension 擴充套件,實現選完分頁後自動折疊群組,並涵蓋開發、除錯、安裝與上架流程。

前言

Firefox 137 以後內建了原生分頁群組(Tab Groups)功能,可以將多個分頁歸組並手動折疊。折疊後,滑鼠 hover 群組標籤會彈出垂直預覽面板,點選即可切換分頁——這個設計本意是「不展開就能切換」,但實際上點選後 Firefox 仍會自動將群組展開,必須再手動點一次才能折疊回去,造成操作上的不便。

本文說明如何自製擴充套件,讓 Firefox 在切換到群組內分頁後自動折疊該群組,並搭配現有的 Auto Collapse Tab Groups 套件形成完整方案。


環境需求

  • Firefox 149+(建議;tabGroups.update() 最低需求為 Firefox 139)
  • 文字編輯器(Cursor、VS Code 等均可)
  • 基本的 JavaScript 知識

Firefox 原生行為說明

在開始開發前,先理解 Firefox 分頁群組的現有行為:

可以做到的(原生):

  • 點群組標籤手動折疊 / 展開
  • hover 群組標籤顯示垂直預覽面板,點選切換分頁
  • 搭配 Auto Collapse Tab Groups 套件,切換到群組外的分頁時自動折疊其他群組

無法做到的(原生缺口):

  • 從 hover 面板點選分頁後,自動將該群組折疊回去

關鍵技術原因: Firefox 的事件觸發順序如下:

  1. 使用者點擊 hover 預覽面板中的某個分頁
  2. tabs.onActivated 觸發(此時群組仍是 collapsed = true
  3. Firefox 內部才將群組展開(collapsed 變為 false

這導致在 onActivated 時提前偵測 collapsed 狀態會誤判,必須等待 Firefox 完成展開後再執行折疊。


被排除的方案

方案排除原因
Firefox 原生設定無此設定,這不是內建功能
切換到垂直側欄分頁使用者需求是水平分頁列,非側欄模式
只用 Auto Collapse Tab Groups只處理切換到群組外時折疊其他群組,不處理切換回同群組後的折疊
onActivated 中直接判斷 collapsed 狀態事件觸發時群組尚未展開,會誤判為「已折疊」而跳過
Firefox 138 以下的 APItabGroups.update() 於 Firefox 139 才加入

擴充套件開發

檔案結構

auto-collapse-on-select/
├── manifest.json
├── background.js
├── popup.html
├── popup.js
└── icons/
    └── icon48.png

manifest.json

{
  "manifest_version": 2,
  "name": "Auto Collapse Tab Group After Select",
  "version": "1.2.0",
  "description": "切換到群組內的分頁後,自動將該群組折疊,保持分頁列整潔。",
  "permissions": [
    "tabs",
    "tabGroups",
    "storage"
  ],
  "background": {
    "scripts": ["background.js"],
    "persistent": true
  },
  "browser_action": {
    "default_icon": {
      "48": "icons/icon48.png"
    },
    "default_title": "Auto Collapse Tab Group After Select",
    "default_popup": "popup.html"
  },
  "icons": {
    "48": "icons/icon48.png"
  }
}

注意事項:

  • tabGroups permission 是操作分頁群組的必要權限
  • storage permission 用於儲存使用者設定(開關狀態、延遲時間)
  • 三個 permission 缺一不可,漏掉任一個會導致對應功能靜默失敗或拋出例外

background.js(核心邏輯)

let enabled = true;
let collapseDelay = 300; // 預設延遲 ms

browser.storage.local.get(["enabled", "collapseDelay"]).then((result) => {
  if (typeof result.enabled !== "undefined") enabled = result.enabled;
  if (typeof result.collapseDelay !== "undefined") collapseDelay = result.collapseDelay;
  console.log("[AutoCollapse] 初始化完成,enabled =", enabled, "delay =", collapseDelay);
}).catch((e) => {
  console.error("[AutoCollapse] storage 讀取失敗:", e);
});

browser.runtime.onMessage.addListener((message) => {
  if (message.type === "setEnabled") {
    enabled = message.value;
    browser.storage.local.set({ enabled }).catch(() => {});
  }
  if (message.type === "setDelay") {
    collapseDelay = message.value;
    browser.storage.local.set({ collapseDelay }).catch(() => {});
  }
  if (message.type === "getSettings") {
    return Promise.resolve({ enabled, collapseDelay });
  }
});

browser.tabs.onActivated.addListener(async ({ tabId, windowId }) => {
  if (!enabled) return;

  try {
    const tab = await browser.tabs.get(tabId);
    const gid = tab.groupId;

    // groupId 為 -1 表示不屬於任何群組
    if (gid === undefined || gid === -1) return;

    // 關鍵:不在這裡判斷 collapsed 狀態
    // 因為 hover 點選時 onActivated 觸發的當下,Firefox 尚未展開群組
    // 若在此判斷 collapsed === true 會誤判為「已折疊」而跳過
    await delay(collapseDelay);

    // 確認仍是 active(避免快速切換時重複觸發)
    const [cur] = await browser.tabs.query({ active: true, windowId });
    if (!cur || cur.id !== tabId) return;

    // 延遲後再查狀態,若群組已是折疊就不重複操作
    const group = await browser.tabGroups.get(gid);
    if (group.collapsed) return;

    await browser.tabGroups.update(gid, { collapsed: true });
    console.log("[AutoCollapse] 折疊完成 groupId=", gid);

  } catch (e) {
    console.error("[AutoCollapse] 例外:", String(e));
  }
});

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

設計重點說明:

onActivated 觸發時不做 collapsed 狀態的前置判斷,直接進入延遲等待。等待結束後才查詢群組狀態,此時 Firefox 已完成展開動作,查詢結果才是真實狀態。延遲時間預設 300 ms,可由使用者自行調整。

popup.html

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; }
    body {
      font-family: system-ui, sans-serif;
      width: 240px;
      padding: 14px 16px;
      margin: 0;
      background: #1c1b22;
      color: #fbfbfe;
    }
    h3 { margin: 0 0 12px 0; font-size: 13px; color: #cfcfda; font-weight: 600; }
    .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
    .label { font-size: 13px; flex: 1; }
    .sublabel { font-size: 11px; color: #9f9fad; margin-top: 1px; }
    .switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
    .switch input { opacity: 0; width: 0; height: 0; }
    .slider { position: absolute; inset: 0; background: #52525e; border-radius: 20px; cursor: pointer; transition: 0.2s; }
    .slider::before { content: ""; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: white; border-radius: 50%; transition: 0.2s; }
    input:checked + .slider { background: #9059ff; }
    input:checked + .slider::before { transform: translateX(16px); }
    .delay-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
    .delay-val { font-size: 12px; color: #9059ff; font-weight: 600; min-width: 48px; text-align: right; }
    input[type=range] { width: 100%; accent-color: #9059ff; cursor: pointer; height: 4px; }
    .delay-ticks { display: flex; justify-content: space-between; font-size: 10px; color: #6b6b7b; margin-top: 3px; padding: 0 1px; }
    .divider { border: none; border-top: 1px solid #3a3944; margin: 12px 0; }
    .status { font-size: 11px; color: #9f9fad; text-align: center; margin-top: 10px; }
    .status.on { color: #9059ff; }
  </style>
</head>
<body>
  <h3>選分頁後自動折疊群組</h3>
  <div class="row">
    <div class="label">啟用自動折疊</div>
    <label class="switch">
      <input type="checkbox" id="toggle">
      <span class="slider"></span>
    </label>
  </div>
  <hr class="divider">
  <div class="delay-section">
    <div class="delay-header">
      <div class="label">折疊延遲</div>
      <div class="delay-val" id="delay-val">300 ms</div>
    </div>
    <input type="range" id="delay-slider" min="100" max="1000" step="50" value="300">
    <div class="delay-ticks">
      <span>100</span><span>300</span><span>500</span><span>750</span><span>1000</span>
    </div>
    <div class="sublabel" style="margin-top:6px;">數值越小越快折疊;建議 200–400 ms</div>
  </div>
  <div class="status" id="status">讀取中…</div>
  <script src="popup.js"></script>
</body>
</html>

popup.js

const toggle = document.getElementById("toggle");
const status = document.getElementById("status");
const slider = document.getElementById("delay-slider");
const delayVal = document.getElementById("delay-val");

function updateStatus(enabled) {
  status.textContent = enabled ? "✓ 已啟用 — 切換分頁後自動折疊" : "已停用";
  status.className = enabled ? "status on" : "status";
}

browser.runtime.sendMessage({ type: "getSettings" }).then(({ enabled, collapseDelay }) => {
  toggle.checked = enabled;
  updateStatus(enabled);
  slider.value = collapseDelay;
  delayVal.textContent = collapseDelay + " ms";
});

toggle.addEventListener("change", () => {
  const enabled = toggle.checked;
  browser.runtime.sendMessage({ type: "setEnabled", value: enabled });
  updateStatus(enabled);
});

slider.addEventListener("input", () => {
  delayVal.textContent = slider.value + " ms";
});

slider.addEventListener("change", () => {
  browser.runtime.sendMessage({ type: "setDelay", value: parseInt(slider.value) });
});

遇到的問題與解決方式

問題一:載入時出現「does not contain a valid manifest」

原因:about:debugging 載入時選擇了 .zip 壓縮檔,而非解壓縮後的 manifest.json 檔案。

解決: 先解壓縮 zip,進入資料夾,選擇 manifest.json 檔案本身載入。

問題二:browser.storage is undefined

原因: manifest.jsonpermissions 陣列缺少 "storage",導致 browser.storage API 不可用,background.js 在初始化時就拋出例外,後續的事件監聽器根本沒有掛載。

解決:permissions 加入 "storage"

問題三:從 hover 面板點選後不會折疊

原因: 這是最關鍵的 bug。原本的邏輯在 onActivated 觸發後立即查詢群組的 collapsed 狀態,因為 Firefox 此時還沒展開群組,查到的值是 true,程式誤判為「群組已折疊,不需動作」而跳過。

解決: 移除 onActivated 觸發時的前置 collapsed 狀態判斷,改為直接進入延遲等待,等 Firefox 完成展開後再查詢狀態並執行折疊。

// 錯誤做法:在 onActivated 觸發時就判斷 collapsed
const group = await browser.tabGroups.get(gid);
if (group.collapsed) return; // hover 點選時這裡會誤判為 true

// 正確做法:先等待,讓 Firefox 完成展開後再判斷
await delay(collapseDelay);
const group = await browser.tabGroups.get(gid);
if (group.collapsed) return; // 此時查到的才是真實狀態

暫時性安裝(開發測試用)

  1. 開啟 about:debugging
  2. 點左側「此 Firefox」
  3. 點「載入暫時性附加元件」
  4. 選擇資料夾內的 manifest.json

注意: 暫時性安裝在 Firefox 重啟後會消失,修改程式碼後點擴充套件旁的「重新整理」按鈕即可套用,不需重新打包。


正式安裝與上架

打包成 .xpi

Firefox 的擴充套件格式是 .xpi,需從資料夾內部打包,確保 zip 根目錄直接是 manifest.json

cd auto-collapse-on-select
zip -r ../auto-collapse-on-select.xpi *

上架到 Firefox Add-ons (AMO)

  1. addons.mozilla.org/developers 登入或建立開發者帳號
  2. 點「Submit a New Add-on」
  3. 選擇發佈方式:
    • On this site:公開上架,需通過 Mozilla 程式碼審查(數天至數週)
    • On your own:自行散佈,仍需提交讓 Mozilla 自動簽署,取回已簽署的 .xpi 後才能讓他人安裝
  4. 上傳 .xpi,填寫名稱、描述、截圖等資訊
  5. 提交審查

Mozilla 簽署說明: 未經簽署的擴充套件只能在 Firefox Developer Edition 或 Nightly 中搭配 xpinstall.signatures.required = false 安裝,一般 Firefox 無法使用。

上架前建議補充

  • 替換 icon48.png 為正式設計的圖示(建議同時提供 icon96.png
  • manifest.json 補上 authorhomepage_url 欄位
  • 準備 1–2 張截圖供 AMO 頁面展示
  • 撰寫英文版說明文字(AMO 主要以英文為主)

完整方案組合

搭配使用以下兩個套件,可達到最完整的分頁群組整潔體驗:

套件功能
Auto Collapse Tab Groups切換到群組外的分頁時,自動折疊其他群組
本文自製套件切換到群組內的分頁後,自動折疊該群組自身

兩者互補,合在一起就能讓所有群組在任何情況下保持折疊狀態。


結論

Firefox 原生的 hover 預覽面板雖然設計上是為了「不展開就能切換」,但實際上點選後仍會觸發展開,這個行為是 Firefox 內部的設計缺陷,目前沒有原生設定可以改變。

透過監聽 tabs.onActivated 並在適當延遲後呼叫 tabGroups.update() 強制折疊,可以繞過這個限制。關鍵在於延遲的時機:必須等 Firefox 完成展開動作後再執行折疊,而非在 onActivated 觸發時就判斷狀態。

這個方案需要 Firefox 139 以上版本,並且在 Firefox 重啟後暫時安裝會消失,若要永久使用需透過 AMO 正式簽署安裝。

不過不管怎麼用,群組分頁不能用快速鍵展開還是不好用,即便我把摺疊時間修改到 10S 也一樣,設計上本身就有點問題,最後還是只能當雞肋