"Track 3이 쓰기, Track 4가 읽기였어. Track 7은 고치기랑 지우기 — CRUD의 마지막 두 글자야. 그리고 '방금 clip 날렸다' 를 '5 초 안에 되돌릴 수 있다' 로 바꿔 주는 Undo toast로 닫아. 여기까지 오면 ClipDeck이 한 살이를 다 갖춰."
Inline edit이 뭐냐면
side panel에서 clip의 텍스트를 누르면 그 자리에서 고칠 수 있게 돼. 타이핑하고 Enter 치면 저장, Escape 면 되돌리기. modal도 없고, tab으로 옮겨 다닐 필요도 없고, 따로 form을 띄우지도 않아. clip의 본문이랑 제목 둘 다 이렇게 고칠 수 있어. URL 이랑 시각은 못 고쳐 — 그건 출처 기록이지 user가 손댈 데이터가 아니거든.
방법이 둘 있어:
contenteditable — 텍스트 요소에 contenteditable="true"를 걸어. 브라우저가 알아서 편집기를 만들어 줘. 좋은 점은 서식을 마음대로 줄 수 있고 (Cmd+B 같은 거), 키보드 처리도 매끄러워. 나쁜 점은 HTML을 다루게 되니까 저장할 때 씻어 내야 해.
textarea로 바꿔치기 — 누르는 순간 렌더된 텍스트를 <textarea>로 갈아. 좋은 점은 언제나 순수 텍스트라 예측이 쉬워. 나쁜 점은 코드가 좀 늘고, 서식이 없고, focus를 직접 챙겨야 해.
ClipDeck v1은 textarea로 갈 거야. 순수 텍스트가 schema 니까 단순함이 제값을 하거든. 서식 있는 clip이 필요해지면 v2에서 contenteditable을 얹으면 돼.
고치는 흐름
user가 clip의 텍스트를 누른다.
click handler가 <p>를 지금 텍스트가 담긴 <textarea>로 갈고, focus를 주고, 전체 선택해 둔다.
확정되면 SW가 고친 clip을 storage에 다시 써 (clips 배열에서 같은 id를 갈아끼우는 거야). 그럼 storage.onChanged가 울리고 그 줄이 새 텍스트로 다시 그려져.
제목도 모양이 같아. 누르면 입력창, Enter 면 확정, Escape 면 취소. 제목은 여러 줄이 필요 없으니 textarea 대신 한 줄짜리 input을 쓰고.
지우기 흐름 — Undo를 달고
줄마다 작은 휴지통 아이콘을 둬. 누르면 그 줄이 스르륵 사라지고, 잠깐 Undo toast ("Clip deleted. Undo")가 그 자리를 대신해. toast는 5 초 버텨. 그 안에 Undo를 누르면 clip이 돌아오고, 시간이 지나면 진짜로 사라져 (아니면 나중에 치우도록 예약해 두거나 — 아래를 봐).
5 초짜리 창을 만드는 방법은 둘이야:
Soft delete — 실제로 빼는 대신 clip에 deletedAt: timestamp를 찍어. 화면은 그런 걸 걸러서 안 보여 주고. 5 초가 지나면 그때 진짜로 뺀다. Undo는 deletedAt을 다시 null로 돌리기만 하면 돼.
빼놨다가 되돌리기 — clips에서 바로 빼고, 뺀 clip을 메모리에 들고 있어. Undo를 누르면 도로 밀어 넣고, 시간이 지나면 메모리에서 버려.
SW가 evict 되는 걸 생각하면 soft delete가 더 튼튼해. 메모리에 들고 있는 방식은 worker가 죽을 때 같이 죽어 버리거든. 그래서 ClipDeck v1은 soft delete를 쓰고, deletedAt < now - 5s 인 것들을 주기적으로 치우는 alarm을 하나 돌려.
여러 개 고르기
고치기랑 지우기가 생기면 다음으로 당연히 나오는 게 여러 개 고르기야. "이 clip 12 개 한꺼번에 지워" 같은 거. side panel에 이런 게 붙어야 해:
줄마다 checkbox 칸. 평소엔 숨겨 두고 선택 모드에 들어가야 보여.
선택 모드를 켜는 'Select' 버튼. 켜지면 줄에 checkbox가 뜨고, 줄을 누르면 checkbox가 토글돼.
Undo는 똑같이 붙어. 일괄 삭제는 고른 clip 전부에 같은 deletedAt을 찍고, Undo 버튼 하나가 그걸 전부 되돌려. ClipDeck v1은 한 줄 삭제까지만 내보내고, 여러 개 고르기는 v1.1로 미룰 거야.
편집 중에 끼어드는 경우
미묘한 버그가 하나 있어. user가 clip 하나를 편집 모드로 열어 놨는데, 다른 창 (또는 clip을 추가하는 다른 tab)에서 storage.onChanged가 울려. 화면을 다시 그리면서 편집 중이던 textarea가 통째로 날아가는 거야. 막는 법은 이래. panel.js 메모리에 editingId를 들고 있다가, onChanged 때문에 다시 그릴 때 editingId === clip.id 인 줄은 건너뛰는 거지.
이건 inline edit 이랑 실시간 갱신이 같이 있는 목록이라면 어디서나 만나는 함정이야. ClipDeck의 storage 층은 popup 이랑 panel 이랑 content script가 같이 쓰거든. 그중 아무나 언제든 다시 그리기를 부를 수 있어.
Track 7을 닫으며
일곱 track이 채워졌어:
MV3 기초 / service worker / content script / side panel / action+popup / permission / DOM 도구.
부르는 방법: 떠 있는 버튼 / 키보드 / popup / context menu / omnibox.
표면 나눠 쓰기: popup / side panel / options page 자리.
tab 별 일시정지 / 필요할 때 권한 요청 / Readability / screenshot / React 친화적 입력 / preview-and-confirm.
Track 8은 packaging 이야. build 시스템, .crx와 unpacked의 차이, Chrome Web Store 제출, 아이콘, 개인정보 처리방침 — 돌아가는 폴더 하나를 남이 설치할 수 있는 물건으로 만드는 모든 것.
Inline edit + soft delete + Undo toast. 이 셋이면 user가 실제로 원하는 한 살이는 거의 다 채워져. 고친 건 바로 확정되고, 지운 건 5 초 동안 되돌릴 수 있고, side panel이 깨질까 봐 조심조심 다루는 박물관처럼 느껴지지 않아.
기록을 남기고 싶다면. 뭘 언제 지웠는지 알고 싶어 하는 user도 있어. clip을 모으는 작업이라면 5 초를 한참 넘겨서 되찾고 싶은 순간이 가끔 생기거든. v2에서 30 일 보관하는 휴지통을 만들고 Settings 탭에 붙여도 좋아. v1은 단순하게 가자 — 5 초, 그다음엔 사라짐.
Compound storage update는 원자적이지 않아.get → modify → set을 surface 둘이 동시에 돌리면 한쪽 변경이 사라질 수 있어. Service worker를 sole writer로 두고 mutation queue 하나로 직렬화하거나 record를 독립 key로 저장해. 한 번의 set은 그 안의 key를 함께 적용하지만, 앞뒤 read-modify-write 전체가 atomic인 건 아니야.
Code
panel.js — inline edit handler·javascript
// panel.js — inline edit + soft delete + Undo toast
let editingId = null;
const editingState = new WeakMap(); // textarea -> {originalText}
function onClipClick(event) {
const row = event.target.closest(".row");
if (!row) return;
const id = row.dataset.id;
if (event.target.classList.contains("copyBtn")) return; // Track 4 에서 처리
if (event.target.classList.contains("deleteBtn")) return softDelete(id, row);
if (event.target.matches(".text")) return startEdit(id, event.target);
}
function startEdit(id, p) {
editingId = id;
const ta = document.createElement("textarea");
ta.value = p.textContent;
ta.style.width = "100%";
editingState.set(ta, { originalText: p.textContent });
p.replaceWith(ta);
ta.focus(); ta.select();
ta.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(id, ta); }
if (e.key === "Escape") { e.preventDefault(); cancelEdit(ta); }
});
ta.addEventListener("blur", () => cancelEdit(ta));
}
async function commitEdit(id, ta) {
const newText = ta.value.trim();
const { clips = [] } = await chrome.storage.local.get("clips");
const next = clips.map((c) => c.id === id ? { ...c, text: newText, updatedAt: Date.now() } : c);
editingId = null;
await chrome.storage.local.set({ clips: next });
// render 가 onChanged 통해 fire
}
function cancelEdit(ta) {
const state = editingState.get(ta);
editingId = null;
if (!state) return;
const p = document.createElement("p");
p.className = "text";
p.textContent = state.originalText;
ta.replaceWith(p);
}
첫 번째랑 두 번째 code block을 clipdeck/panel.js에 넣어. document.addEventListener('click', onClipClick) 을 걸어서 dispatcher가 관련 click을 다 받게 해. 기존 render()에서 clip 줄마다 작은 휴지통 버튼 (<button class="deleteBtn">🗑️</button>)을 붙이고, soft-deleted clip은 걸러 내도록 고쳐: clips.filter((c) => !c.deletedAt). 세 번째 code block (purge alarm)은 background.js에 넣어. Reload 하고 clip을 몇 개 저장해. 아무 clip의 텍스트를 눌러 봐 — textarea로 바뀌고 focus가 잡혀야 해. 고치고 Enter를 치면 storage에 반영되고 줄이 다시 그려져. Escape도 눌러 봐 — 되돌아가야 해. 다른 clip의 휴지통을 눌러 — 줄이 사라지고 Undo toast가 5 초 떠. 5 초 안에 Undo를 누르면 clip이 돌아오고, 그냥 두면 toast가 사라지고 다음 1 분 안에 alarm이 돌면서 진짜로 지워져.
Hint
다른 창에서 clip을 저장할 때 편집 중이던 textarea가 날아가면 editingId 로 건너뛰는 걸 안 걸어 준 거야. render에서 줄을 갱신하는 부분을 감싸서 id가 editingId 랑 같은 줄은 그냥 지나가게 해. Undo 버튼을 눌렀는데 아무 일도 안 일어나면 click handler가 돌기 전에 toast 뿌리가 비워졌을 수 있어 (timer 랑 경합한 거야). click handler가 돈 뒤에 timer를 null 로 만들어서 두 번 정리되지 않게 해. 삭제가 뚝뚝 끊겨 보이면 줄이 사라지기 전에 200ms 정도 opacity를 주면 훨씬 부드러워.
Progress
Progress is local-only — sign in to sync across devices.