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

ClipDeck Edit and Delete — CRUD-U + CRUD-D With Undo

~14 min · clipdeck, crud, edit, delete, undo

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 3 wrote. Track 4 read. Track 7 closes with edit and delete — the last two letters of CRUD — and the Undo toast that turns 'I just nuked my clip' into 'I'll get it back in 5 seconds.' By the end ClipDeck has the full lifecycle."

What Inline Edit Means

Click a clip's text in the side panel; it becomes editable in place. Type. Press Enter to save, Escape to revert. No modal, no tab navigation, no separate form. The clip's text and title both support inline edit; the URL and timestamp are immutable (they're provenance, not user-owned data).

Two competing approaches:

  • contenteditable — set contenteditable="true" on the text element. Native browser editing. Pros: free formatting controls (Cmd+B for bold etc), good keyboard handling. Cons: handles HTML, which means you have to sanitize on save.
  • swap to textarea — replace the rendered text with a <textarea> on click. Pros: plain text always, predictable. Cons: more code, no formatting, focus management is manual.

ClipDeck v1 ships textarea — plain text is the schema, and the simplicity pays for itself. v2 could add contenteditable for rich clips.

The Edit Flow

  1. User clicks the clip's text.
  2. Click handler replaces the <p> with a <textarea> containing the current text, focuses it, selects all.
  3. Enter (without Shift) commits; Escape reverts; click outside also reverts.
  4. On commit, the SW writes the updated clip back to storage (replacing the same id in the clips array). storage.onChanged fires, the row re-renders with the new text.

The same shape for title — click → input → Enter/Escape. Title is a single-line input rather than a textarea.

The Delete Flow With Undo

Inline trash icon on each row. Click → row fades out, gets replaced briefly with an Undo toast ("Clip deleted. Undo"). Toast persists for 5 seconds; if the user clicks Undo, the clip restores. If the timeout elapses, the clip is gone for good (or scheduled for purge — see below).

Two ways to implement the 5-second window:

  • Soft delete — flag the clip as deletedAt: timestamp instead of removing. The render filters them out. After 5 seconds, the actual remove call runs. Undo flips deletedAt back to null.
  • Stash and restore — actually remove from clips, hold the removed clip in memory; on Undo, push it back. If timeout passes, drop it from memory.

Soft delete is more robust against SW eviction (the in-memory stash dies with the worker). ClipDeck v1 uses soft delete and runs a periodic cleanup alarm that prunes anything deletedAt < now - 5s.

The Multi-Select Variant

Once edit and delete exist, the next obvious feature is multi-select: "delete these 12 clips at once." The side panel needs:

  • Checkbox column on each row, hidden until the user enters select mode.
  • A 'Select' button that toggles select mode (rows now show checkboxes; click row toggles checkbox).
  • A bulk-action bar at the top when any are selected ("3 selected | Delete | Export | Tag").

Same Undo pattern: bulk delete soft-flags all selected clips with the same deletedAt; one Undo button restores all. ClipDeck v1 ships single-row delete; multi-select goes to v1.1.

The Edit Race

A subtle bug: user opens edit mode on a clip. Storage.onChanged fires from another window (or another tab adding a clip). The render runs, blowing away the textarea mid-edit. Defense: maintain a editingId in panel.js memory; skip re-rendering rows where editingId === clip.id during the onChanged-triggered render.

This is a common gotcha across any list with inline edit and live updates. ClipDeck's storage layer is shared across popup, panel, content scripts — any of them could trigger a render at any time.

Wrapping Track 7

Seven tracks complete:

  • MV3 foundations, service worker, content scripts, side panel, action+popup, permissions, DOM tools.
  • Full CRUD: Create (Track 3), Read (Track 4), Update + Delete (this lesson).
  • Triggers: floating button, keyboard, popup, context menu, omnibox.
  • Surface allocation: popup, side panel, options page placeholder.
  • Per-tab pause, just-in-time permissions, Readability, screenshots, React-friendly fill, preview-and-confirm.

Track 8 is packaging — the build system, the .crx versus unpacked, the Chrome Web Store submission, the icons and the privacy policy and everything else that turns the working folder into something other people can install.

Inline edit + soft delete + Undo toast = the full lifecycle most users actually want. Edits commit fast, deletes are reversible for five seconds, the side panel never feels like a fragile museum.
Audit log option. Some users want to know what they deleted and when — useful for clip-collection workflows that occasionally need recovery past the 5-second window. v2 could add a trash bucket with a 30-day retention, surfaced as a Settings tab. v1 keeps it simple: 5 seconds, then gone.

Code

panel.js — inline edit handlers·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; // handled in 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 will fire via onChanged
}

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);
}
panel.js — soft delete with Undo affordance·javascript
// panel.js — soft delete + Undo toast
async function softDelete(id, row) {
  const { clips = [] } = await chrome.storage.local.get("clips");
  const target = clips.find((c) => c.id === id);
  if (!target) return;
  const next = clips.map((c) => c.id === id ? { ...c, deletedAt: Date.now() } : c);
  await chrome.storage.local.set({ clips: next });
  showUndoToast(target);
}

function showUndoToast(clip) {
  const root = document.getElementById("toast");
  root.innerHTML = `Clip deleted. <button class="undoBtn">Undo</button>`;
  root.classList.add("show");
  const timer = setTimeout(() => {
    root.classList.remove("show");
    root.innerHTML = "";
  }, 5000);
  root.querySelector(".undoBtn").addEventListener("click", async () => {
    clearTimeout(timer);
    const { clips = [] } = await chrome.storage.local.get("clips");
    const restored = clips.map((c) => c.id === clip.id ? { ...c, deletedAt: undefined } : c);
    await chrome.storage.local.set({ clips: restored });
    root.classList.remove("show");
    root.innerHTML = "";
  });
}
background.js — periodic purge of expired soft-deleted clips·javascript
// background.js — alarm that purges soft-deleted clips past the undo window
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create("clipdeck-purge", { periodInMinutes: 1 });
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== "clipdeck-purge") return;
  const cutoff = Date.now() - 5_500; // 5s + small buffer
  const { clips = [] } = await chrome.storage.local.get("clips");
  const trimmed = clips.filter((c) => !c.deletedAt || c.deletedAt > cutoff);
  if (trimmed.length !== clips.length) {
    await chrome.storage.local.set({ clips: trimmed });
  }
});

// panel.js's render() must filter c.deletedAt:
//   const visible = clips.filter((c) => !c.deletedAt);

External links

Exercise

Add the first and second code blocks to clipdeck/panel.js. Wire document.addEventListener('click', onClipClick) so the dispatcher catches every relevant click. Add a small trash button (<button class="deleteBtn">🗑️</button>) per clip row in the existing render(). Update render() to filter out soft-deleted clips: clips.filter((c) => !c.deletedAt). Add the third code block (purge alarm) to background.js. Reload. Save a few clips. Click on any clip's text — it becomes a textarea, focused. Edit, press Enter — text updates in storage and the row re-renders. Try Escape — reverts. Click the trash on another clip — row disappears, Undo toast shows for 5 seconds. Click Undo within 5 seconds — clip returns. Wait past 5 seconds — toast vanishes, alarm fires within the next minute and purges the clip permanently.
Hint
If the edit textarea blows away mid-edit when another window saves a clip, you didn't wire the editingId skip — wrap the render's row update so it skips any row whose id matches editingId. If the Undo button does nothing, your toast root element might be cleared before the click handler fires (timer races) — set the timer to null after the click handler runs to prevent the second teardown. If trash deletes feel jumpy, animate the row fade-out: a 200ms opacity transition before removal looks far smoother than an instant disappear.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.