Ctrl K
ComunidadGuías…

src/content.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;
});