src/content.ts
Rama main · réplica de lectura del repositorio original.
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<SelectionClip> {
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<HTMLMetaElement>(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;
});