C.W.K.
Stream
Lesson 05 of 05 · published

ClipDeck Mode Toggle — Per-tab pause / resume

~14 min · clipdeck, mode-toggle, badge, per-tab, storage

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 5 가 작고 정직한 권한으로 끝남: ClipDeck 이 언제 주목하는지 user 가 결정. Tax return 타이핑 중인 page 에서 pause, article 로 돌아가는 순간 un-pause. State 가 storage 에 살아 모든 surface 가 sync 유지."

이유

User 가 항상 ClipDeck active 원하는 거 아님. Privacy 이유, 놀라운-clip 회피, 또는 그냥 민감한 form 채울 동안 extension off 인 거 아는 편안함. Pause toggle 이 trust 의 막대:

  • Per-tab scope 라 다시 enable 기억할 필요 없음.
  • SW eviction 너머 persistent 라 pause 가 silent reset 안 됨.
  • Badge 에 visible 해서 한눈에 알 수 있음.
  • Popup / keyboard shortcut / (선택적) side panel 에서 닿음.

State shape

chrome.storage.local 의 단순 배열:

{ pausedTabs: number[] }

Tab ID 가 browser restart 너머 durable 안 함 (Chrome 이 새 거 할당), 하지만 pause 는 tab 이 존재하는 동안만 의미 있음, 그래서 fine — tab 닫히면 id 사라지고 같은 id 의 fresh tab 이 un-paused 로 시작.

일일 정도 cleanup pass (또는 chrome.runtime.onStartup 의 하나) 가 chrome.tabs.query({}) 와 intersect 해서 stale id 제거. 배열이 영원히 자라는 거 막음.

세 touch point

Pause state 가 중요한 곳마다 같은 storage 값 read:

  • Popup 버튼. 현재 tab 의 pause state read, toggle, write back, storage.onChanged 가 badge 와 content script update.
  • Keyboard shortcut. 같은 logic, command toggle-pausechrome.commands.onCommand 에서 trigger (Lesson 4 에서 wire).
  • Content script gate. Capture flow 전 content script 가 storage 확인; pause 면 no-op 응답, SW 가 아무것도 기록 안 함.

SW 가 active tab 의 pause state 변경마다 badge 도 write — paused 면 setBadgeText({ tabId, text: 'II' }), un-paused 면 empty.

Race-free update

배열 read 후 write 가 두 event 가까이 fire (user 가 popup 버튼 클릭 동시 hotkey 누름) 시 race 부름. Defensive 패턴은 항상 같은 transaction 안에서 re-read 하는 작은 mutation helper:

async function togglePauseForTab(tabId) {
  const { pausedTabs = [] } = await chrome.storage.local.get('pausedTabs');
  const next = pausedTabs.includes(tabId)
    ? pausedTabs.filter((t) => t !== tabId)
    : [...pausedTabs, tabId];
  await chrome.storage.local.set({ pausedTabs: next });
  return next.includes(tabId);
}

엄격히 atomic 아님 — get 과 set 사이 다른 event 가 insert 가능 — 하지만 human-rate toggle 엔 사실상 괜찮음. ClipDeck 이 multi-window sync 자라면 sequence id 가진 적절한 locking 패턴으로 swap.

시각 피드백

Badge 가 정직한 signal. 세 state:

  • Active + 오늘 clip 있음 → brand color 의 숫자 ("3").
  • Active + 오늘 clip 없음 → empty badge.
  • Paused → grey background 의 "II" 나 "PAUSE".

Storage 변경마다 count-mode 와 pause-mode 사이 전환. Popup 도 버튼 label flip: "Pause on this site" ↔ "Resume on this site," side panel 이 user 가 paused tab browsing 중이면 banner 표시 가능 ("Capture paused. Resume?").

Track 5 마무리

이 lesson 으로 ClipDeck 이 full action surface 가짐:

  • 오늘-count + pause indicator 둘 다 하는 badge 가진 icon.
  • Stats, primary action 셋, discoverable settings link 가진 popup.
  • Label 에 실제 선택 텍스트 가진 right-click context menu.
  • User-bindable keyboard shortcut 넷.
  • Omnibox 검색 keyword clip.
  • 모든 곳에 반영된 per-tab pause/resume.

Track 6 가 다음 합리적 step — permission model. ClipDeck 이 왜 그게 요청하는 걸 요청하는지, 필요한 순간에만 요청하는 법, 거절하는 user 위해 design 하는 법.

Pause 는 user 의 trust dial. Per-tab scope, storage 에 persist, badge 와 popup 에 mirror. Surface 셋, state 하나, 놀람 없음.
대안으로서 chrome.storage.session. Chrome 102+ 가 chrome.storage.session ship — browser 닫히면 죽는 in-memory storage area. Browser restart 너머 reset 돼야 하는 (일부 케이스에서 user 기대) pause state 엔 session 이 깔끔한 fit. ClipDeck v1 은 pause 가 restart 살아남게 local 사용, 선택은 자기 거 — 둘 다 틀린 거 없음, 다른 default 인코딩.

Code

background.js — toggle + badge refresh + tab close cleanup·javascript
// background.js — toggle helper + badge refresh + content-script gate response
async function togglePauseForTab(tabId) {
  const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
  const isPaused = pausedTabs.includes(tabId);
  const next = isPaused ? pausedTabs.filter((t) => t !== tabId) : [...pausedTabs, tabId];
  await chrome.storage.local.set({ pausedTabs: next });
  await refreshBadgeForTab(tabId);
  return !isPaused;
}

async function refreshBadgeForTab(tabId) {
  const { pausedTabs = [], clips = [] } = await chrome.storage.local.get([
    "pausedTabs",
    "clips",
  ]);
  if (pausedTabs.includes(tabId)) {
    await chrome.action.setBadgeText({ tabId, text: "II" });
    await chrome.action.setBadgeBackgroundColor({ tabId, color: "#666" });
    await chrome.action.setTitle({ tabId, title: "ClipDeck — paused on this tab" });
    return;
  }
  // Active path — 오늘 clip count 표시 (global, per-tab 아님).
  const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
  const todayCount = clips.filter((c) => c.savedAt >= todayStart.getTime()).length;
  await chrome.action.setBadgeText({
    tabId,
    text: todayCount === 0 ? "" : String(todayCount > 999 ? "999+" : todayCount),
  });
  await chrome.action.setBadgeBackgroundColor({ tabId, color: "#1a6bd6" });
  await chrome.action.setTitle({
    tabId,
    title: todayCount === 0 ? "ClipDeck" : `ClipDeck — ${todayCount} clips today`,
  });
}

chrome.tabs.onActivated.addListener(({ tabId }) => refreshBadgeForTab(tabId));
chrome.tabs.onUpdated.addListener((tabId, info) => {
  if (info.status === "complete") refreshBadgeForTab(tabId);
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
  const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
  if (pausedTabs.includes(tabId)) {
    await chrome.storage.local.set({ pausedTabs: pausedTabs.filter((t) => t !== tabId) });
  }
});

// Lesson 4 의 toggle-pause 키보드 command wire
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "toggle-pause") return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab?.id) await togglePauseForTab(tab.id);
});
popup.js — Pause 버튼 + live label update·javascript
// popup.js — Pause 버튼을 SW-side helper 에 wire
document.getElementById("pauseBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  await chrome.runtime.sendMessage({ type: "togglePause", tabId: tab.id });
});

async function refreshPauseLabel() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
  const paused = tab?.id ? pausedTabs.includes(tab.id) : false;
  document.getElementById("pauseBtn").textContent = paused
    ? "Resume on this tab"
    : "Pause on this tab";
}

chrome.storage.onChanged.addListener((c, a) => {
  if (a === "local" && "pausedTabs" in c) refreshPauseLabel();
});

refreshPauseLabel();

// background.js 에서 메시지 route:
// chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
//   if (message?.type !== 'togglePause') return;
//   (async () => {
//     await togglePauseForTab(message.tabId);
//     sendResponse({ ok: true });
//   })();
//   return true;
// });
content.js + SW gate — save 시도 전에 pause 존중·javascript
// content.js — captureSelection 응답 전 pause state 존중
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "captureSelection") return;
  (async () => {
    // 어느 tab 인지 SW 한테 물어보기; content script 가 자기 tabId 직접 모름.
    // SW 는 chrome.tabs.query({active:true,currentWindow:true}) 에서 앎.
    const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
    // tabId 없이 가장 단순한 gate: SW 가 chrome.tabs.sendMessage 호출 전
    // 이미 paused tab filter. 하지만 세 번째 trigger (floating 버튼) 가
    // SW 우회하면, content script 가 자기 tab id round-trip 통해 query 해서
    // 여전히 확인 가능 — v1 에는 SW-side gate 만으로 충분.
    const selection = window.getSelection();
    const text = selection?.toString() ?? "";
    if (!text.trim()) {
      sendResponse({ ok: false, reason: "no-selection" });
      return;
    }
    sendResponse({
      ok: true,
      payload: { text, url: location.href, title: document.title },
    });
  })();
  return true;
});

// SW-side gate (background.js — 기존 save-clip handler 교체):
// chrome.commands.onCommand.addListener(async (command) => {
//   if (command !== 'save-clip') return;
//   const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
//   if (!tab?.id) return;
//   const { pausedTabs = [] } = await chrome.storage.local.get('pausedTabs');
//   if (pausedTabs.includes(tab.id)) {
//     console.log('[ClipDeck SW] paused on tab', tab.id, 'ignoring save-clip');
//     return;
//   }
//   // ...기존 save flow...
// });

External links

Exercise

첫 번째 code block 을 clipdeck/background.js 에 추가 — togglePauseForTab helper / per-tab badge refresh / command wiring / tab-close cleanup. 두 번째 code block 을 clipdeck/popup.js 에 추가 — Pause 버튼 handler 와 live label. togglePause 메시지 router (두 번째 code block 맨 아래 주석) 를 background.js 에 추가. 기존 save-clip handler 를 세 번째 code block 의 gated 버전으로 교체. Reload. 아무 page 에서 popup 열기 — Pause on this tab. Click — badge 가 grey 의 II 로 변경, label 이 Resume on this tab 으로 flip. Ctrl+Shift+K 누르기 — 아무것도 저장 안 됨. Resume on this tab click — badge 복원, save-clip 다시 동작. Tab 전환 — badge 가 각 tab 의 per-tab state 독립 반영. Paused tab 닫고 같은 URL 재오픈 — state 가 fresh (un-paused), 의도대로.
Hint
Pause 시 badge 가 II 로 안 변경되면 chrome.action.setBadgeText await 잊은 것 — Promise. Popup 에서 togglePauseReceiving end does not exist throw 하면, togglePause message 의 SW listener 등록 안 됨 (두 번째 code block 맨 아래 commented router — 추가). Paused 동안 save-clip 여전히 저장하면, SW-side gate 가 handler 에 없음 — save-clip 의 기존 chrome.commands.onCommand listener 찾아 그 맨 위에 pause 확인 추가.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.