import tokens from "@usememos/ui/tokens.css?inline"; import browser from "webextension-polyfill"; import { selectionToHtml } from "@/lib/capture"; import type { Request, SelectionClip } from "@/lib/messages"; // The popup's capture doesn't go through this script — it injects its own function via // scripting.executeScript so it works even when this script is stale (pre-update tabs). // This script serves the context-menu flow: selection→markdown, clearing, and the save toast. /** * Renders the current selection for a context-menu save. Images are pulled out (as absolute URLs, * for the background to upload as attachments) and removed from the HTML so they aren't also emitted * as inline remote-image markdown — the memo carries them as real attachments instead. */ async function clipSelection(): Promise { const html = selectionToHtml(window.getSelection()); if (!html) return { markdown: "", images: [] }; // Parse into an inert document instead of assigning page-controlled markup to innerHTML in // the live page. This also keeps executable nodes out of the extension's conversion path. const parsed = new DOMParser().parseFromString(html, "text/html"); const container = parsed.body; for (const executable of container.querySelectorAll("script,style,template,noscript")) executable.remove(); const imgs = Array.from(container.querySelectorAll("img")); // Inert documents have no page base URL, so resolve the raw attribute against the live page. const images = imgs .map((img) => { try { return new URL(img.getAttribute("src") ?? "", document.baseURI).href; } catch { return ""; } }) .filter((src) => /^(https?|data):/i.test(src)); for (const img of imgs) img.remove(); // Turndown is loaded lazily: this script runs on every page, but markdown conversion is only // needed on the rare context-menu save — keep the per-page cost to this thin shell. const { htmlToMarkdown } = await import("@/lib/format"); // Same {{description}} source as the popup capture, so both paths compose identically. const normalized = (value: string | null | undefined) => value?.replace(/\s+/g, " ").trim() || undefined; const meta = (s: string) => normalized(document.querySelector(s)?.content); const firstReadableParagraph = () => { for (const paragraph of document.querySelectorAll("p")) { if ( paragraph.hidden || paragraph.getAttribute("aria-hidden") === "true" || paragraph.closest('nav,header,footer,aside,menu,form,dialog,[role="navigation"],[aria-hidden="true"]') ) { continue; } const style = getComputedStyle(paragraph); if (style.display === "none" || style.visibility === "hidden") continue; const text = normalized(paragraph.textContent); if (text && text.length >= 40) return text; } return undefined; }; const description = meta('meta[property="og:description"]') ?? meta('meta[name="description"]') ?? firstReadableParagraph(); return { markdown: htmlToMarkdown(container.innerHTML), images, description }; } let toastHost: HTMLElement | null = null; /** * In-page save feedback for the context-menu flow (the toolbar badge alone is too easy to miss). * Rendered in a shadow root so page CSS can't restyle it and ours can't leak out, and styled * with @usememos/ui tokens (tokens.css sets them on :host). It follows the system light/dark * preference. The theme attribute sits on the toast inside the shadow root, not on the host: * tokens.css can only match `[data-theme=dark]` there, and on the host the page's own * `[data-theme]` rules would reach it. */ function showSaveToast({ ok, title, webUrl, openLabel, direction, }: { ok: boolean; title: string; webUrl?: string; openLabel?: string; direction?: "ltr" | "rtl"; }): void { toastHost?.remove(); const host = document.createElement("div"); toastHost = host; host.dir = direction ?? (browser.i18n.getMessage("@@bidi_dir") === "rtl" ? "rtl" : "ltr"); host.style.cssText = "position:fixed;bottom:20px;inset-inline-end:20px;z-index:2147483647;"; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = `${tokens} .toast{display:flex;align-items:center;gap:var(--mu-space-2);padding:var(--mu-space-3) var(--mu-space-4); background:var(--mu-surface);color:var(--mu-ink);border-radius:var(--mu-radius-xl);box-shadow:var(--mu-shadow-pop); font:500 13px/1.4 var(--mu-font-sans);-webkit-font-smoothing:antialiased; opacity:0;transform:translateY(var(--mu-space-2)); transition:opacity var(--mu-duration-settle) var(--mu-ease-out),transform var(--mu-duration-settle) var(--mu-ease-settle)} .toast.show{opacity:1;transform:none} .dot{display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:var(--mu-radius-full); background:var(--mu-success);color:var(--mu-surface);font-size:11px;flex:none} .err .dot{background:var(--mu-danger)} a{color:var(--mu-accent);font-weight:500;text-decoration:none;margin-inline-start:var(--mu-inset);border-radius:var(--mu-radius-xs)} a:hover{text-decoration:underline} a:focus-visible{outline:var(--mu-focus-width) solid var(--mu-focus);outline-offset:var(--mu-focus-width)} @media (prefers-reduced-motion:reduce){.toast{transform:none}}`; const toast = document.createElement("div"); toast.className = ok ? "toast" : "toast err"; toast.dataset.theme = window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; toast.setAttribute("role", "status"); const dot = document.createElement("span"); dot.className = "dot"; dot.textContent = ok ? "✓" : "!"; const text = document.createElement("span"); text.textContent = title; toast.append(dot, text); if (webUrl) { const open = document.createElement("a"); open.href = webUrl; open.target = "_blank"; open.rel = "noreferrer"; open.textContent = (openLabel ?? browser.i18n.getMessage("commonOpen")) || "Open"; toast.append(open); } shadow.append(style, toast); document.documentElement.appendChild(host); requestAnimationFrame(() => toast.classList.add("show")); setTimeout( () => { toast.classList.remove("show"); setTimeout(() => host.remove(), 200); }, ok ? 3500 : 6000, ); } browser.runtime.onMessage.addListener((message: unknown) => { const req = message as Request; if (req?.type === "GET_SELECTION") { return clipSelection(); } if (req?.type === "SHOW_SAVE_RESULT") { showSaveToast(req); return undefined; } if (req?.type === "CLEAR_SELECTION") { // Drop the highlight and unfocus after a save so the page returns to a clean state. window.getSelection()?.removeAllRanges(); (document.activeElement as HTMLElement | null)?.blur?.(); return undefined; } return undefined; });