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

ClipDeck Save Clip — CRUD-C 도착

~15 min · clipdeck, crud, selection, messaging, storage, hotkey

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 3 가 CRUD 의 첫 글자 찍히면서 끝나. Content script 가 user 의 selection 잡고, service worker 로 ship, SW 가 storage 에 write, popup 이 re-render. 한 loop, 실제 work 의 한 조각. ClipDeck 의 나머지는 이 template 의 변형들."

Clip schema

코드 전에 shape 합의. Clip 은:

  • id — string. 저장 시점 생성, 가장 단순하고 올바른 generator 는 crypto.randomUUID() (Chrome 92+). Id 가 Track 7 의 U / D 작업 primary key.
  • text — string. 선택 텍스트, trim.
  • url — string. 저장 시점 location.href.
  • title — string. 저장 시점 document.title. Popup 이 raw URL 대신 "From Wikipedia — Service Worker" 같이 표시 가능.
  • savedAt — number. Date.now() 밀리초. Newest-first 정렬과 상대 시간 표시에 사용.

chrome.storage.localclips 아래 plain array 로 저장. 다섯 field, nested shape 없음, 다 JSON-clonable. Track 7 의 update flow 가 text mutate; delete 는 id 로 제거. Track 4 의 side panel 이 같은 배열 sort/filter.

세 trigger

저장 시작 합리적 방법 세 가지, 각자 나중 track 에서 추진력 얻어:

  • Floating button — Lesson 4 가 이미 wire. 매 page 에서 visible; 발견 저렴.
  • Keyboard shortcut — 이 lesson 의 primary trigger. Manifest 의 commands 아래 선언; SW 가 command 받아, active tab 의 content script 로 메시지, content script 가 selection read 하고 ship.
  • Context menu — Track 5 가 다룸. Selection 위 right-click → "Save to ClipDeck."

실제 ClipDeck user 들은 아마 keyboard shortcut (default Ctrl+Shift+K; user-rebindable) primary 로 유지하고 context menu 는 discoverability fallback. Floating button 은 나중 per-site opt-in toggle 로 졸업.

End-to-end flow

  1. User 가 page 에서 텍스트 select 하고 Ctrl+Shift+K.
  2. Chrome 이 SW 에서 chrome.commands.onCommand 를 command 이름 save-clip 으로 fire.
  3. SW 가 active tab 의 content script 에 질문: chrome.tabs.sendMessage(activeTab.id, { type: 'captureSelection' }).
  4. Content script 가 window.getSelection().toString() read, URL 과 title 모음, payload 로 응답.
  5. SW 가 응답 받아, clip object 만들고, 기존 clips 를 storage 에서 read, append, write back.
  6. chrome.storage.onChanged fire; popup (열려 있으면) 과 side panel (Track 4) 이 re-render.

각 hop 이 작고 테스트 가능. Popup 에 clip 안 보이면 chain 거꾸로 walk: DevTools 에서 storage 확인, 다음 SW 로그, 다음 content script 로그.

뭐가 잘못돼

  • Toolbar 상호작용 시 selection 손실. Toolbar click 이 popup 접고 가끔 selection clear. Hotkey + content-script capture 는 이걸 피해; popup-driven "Save current selection" 버튼은 popup 열리기 에 content script 가 capture 해야 함.
  • Restricted page. chrome://, Chrome Web Store, view-source: URL 은 content-script injection 거부. SW 의 send-to-content-script 호출을 try/catch 로 감싸고 친절한 "이 page 는 ClipDeck 허용 안 함" 메시지 표면화.
  • Empty selection. Content script 는 명확한 empty payload ({ ok: false, reason: 'no-selection' }) 로 응답하고 SW 가 toast 띄울지 silent drop 할지 결정하게.
한 clip, 한 loop. Content script 에서 selection, SW 로 메시지, storage 에 write, onChanged 통해 re-render. 이후 모든 ClipDeck feature 가 이 loop 에 다른 동사.
Wikipedia 에서 먼저 테스트. Wikipedia 가 가장 친절한 content-script 대상 — SPA reshuffle 없음, 공격적 CSP 없음, selection-steal handler 없음. SPA (Gmail, Twitter/X) 나 민감 content (banking, password manager — 그건 exclude 유지) 만지기 전에 Wikipedia article 에서 전체 Ctrl+Shift+K → popup-에-clip-표시 loop 동작 확인.

Code

manifest.json — version 0.5.0, commands.save-clip 선언·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.5.0",
  "action": { "default_popup": "popup.html" },
  "background": { "service_worker": "background.js" },
  "permissions": ["storage", "tabs", "scripting", "activeTab"],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "exclude_matches": ["https://*.bank.com/*", "https://accounts.google.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "commands": {
    "save-clip": {
      "suggested_key": { "default": "Ctrl+Shift+K", "mac": "Command+Shift+K" },
      "description": "Save the current text selection to ClipDeck"
    }
  },
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }
}
background.js — hotkey + message handler 둘 다 clip 생성·javascript
// background.js — hotkey bind, content script 에 message, storage write
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;

  let response;
  try {
    response = await chrome.tabs.sendMessage(tab.id, { type: "captureSelection" });
  } catch (err) {
    console.warn("[ClipDeck SW] no content script on this page:", err.message);
    return;
  }
  if (!response?.ok || !response.payload?.text) return;

  const clip = {
    id: crypto.randomUUID(),
    text: response.payload.text.trim(),
    url: response.payload.url,
    title: response.payload.title,
    savedAt: Date.now(),
  };

  const { clips = [] } = await chrome.storage.local.get("clips");
  await chrome.storage.local.set({ clips: [clip, ...clips] });
  console.log("[ClipDeck SW] saved clip", clip.id, "now", clips.length + 1, "total");
});

// 기존 message handler 경로: 버튼이나 popup-driven save 가
// chrome.runtime.onMessage 로 {type:'saveClip', payload:{...}} 받음.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "saveClip") return;
  (async () => {
    const clip = {
      id: crypto.randomUUID(),
      text: message.payload.text.trim(),
      url: message.payload.url,
      title: message.payload.title ?? "",
      savedAt: Date.now(),
    };
    const { clips = [] } = await chrome.storage.local.get("clips");
    await chrome.storage.local.set({ clips: [clip, ...clips] });
    sendResponse({ ok: true, id: clip.id });
  })();
  return true;
});
content.js — captureSelection 응답기, sentinel-shape·javascript
// content.js — captureSelection 에 현재 selection payload 로 응답
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "captureSelection") return;
  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,
    },
  });
});

// (Lesson 4 의 MutationObserver 버튼 코드는 유지 가능 — chrome.runtime.sendMessage
// 통해 SW 를 때리는 visible 대안 trigger 제공.)

External links

Exercise

clipdeck/manifest.json 을 첫 번째 code block (version 0.5.0, commands.save-clip 포함) 으로 bump. clipdeck/background.js 를 두 번째 code block 으로 update. clipdeck/content.js 에 세 번째 code block 의 captureSelection 응답기 추가. Extension reload. Service Worker 관련 Wikipedia article 열기. 문단 select. Ctrl+Shift+K (Mac 는 Cmd+Shift+K) 누르기. SW DevTools console 열기 — [ClipDeck SW] saved clip <uuid> now 1 total 보여야 함. SW DevTools Application 탭 → Extension Storage → 'local' → 'clips' 열어 entry 하나 가진 배열 확인. clipdeck/popup.html / popup.js 에 clips 렌더하는 list 추가 (clip 당 한 줄, 첫 80 자와 title). 두 개 더 저장. Popup 이 수동 refresh 없이 update 되는 거 확인 — Lesson 4 패턴의 chrome.storage.onChanged 가 일하는 거.
Hint
Ctrl+Shift+K 가 아무것도 안 하면 chrome://extensions → Keyboard shortcuts (상단 링크) 확인 — Chrome 이 일부 combo 예약하고 silently drop. 다른 키 골라서 reload. SW 가 no content script on this page 로깅하면 Chrome-internal URL 이나 exclude_matches 패턴 중 하나 — 다른 실제 web page 시도. sendResponse 가 즉시 return 하는데 storage write 안 나타나면 message listener 의 return true 잊은 거 (Track 2 Lesson 5 의 silent-timeout 함정). crypto.randomUUID() 호출은 Chrome 92+ 필요; 옛 Chrome 엔 Date.now() + '-' + Math.random().toString(36).slice(2) 같은 작은 fallback.

Progress

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

댓글 0

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

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