Wstęp#

Ostatnio przyglądałem się implementacji systemu komentarzy opartego na Cusdis. To lekkie i privacy-friendly rozwiązanie, ale ma jedną cechę, która z perspektywy UI jest wyzwaniem: widget renderuje się wewnątrz iframe.

Co to dla nas oznacza? Iframe tworzy odizolowane środowisko. Style CSS, które mamy w naszym projekcie, nie “przeciekają” do środka. W efekcie, jeśli nasz layout ma specyficzny branding, domyślny, minimalistyczny wygląd Cusdis będzie wyglądał jak ciało obce.

Przeanalizowałem ten temat i znalazłem sposób, by to obejść, zachowując czystość kodu.

Wstrzykiwanie stylów#

Zauważyłem, że Cusdis renderuje ramkę w specyficzny sposób. Zamiast używać atrybutu src wskazującego na zewnętrzny adres URL, korzysta z srcdoc. Dzięki temu kod HTML widgetu jest osadzony bezpośrednio w atrybucie jako ciąg znaków.

To kluczowa różnica. Dzięki temu iframe dziedziczy Origin (pochodzenie) naszej strony głównej, zamiast tworzyć własny. W praktyce oznacza to, że nie blokuje nas polityka CORS. Możemy dostać się do obiektu contentDocument wewnątrz ramki i wstrzyknąć tam własne reguły.

Poniżej mechanizm, który opiera się na nasłuchiwaniu, kiedy ramka będzie gotowa.

const iframe = document.querySelector('#cusdis_thread iframe');

iframe.addEventListener('load', () => {
  // Ustawiam opóźnienie, aby dać czas na załadowanie zewnętrznych zasobów Cusdis
  setTimeout(() => {
    try {
      const doc = iframe.contentDocument || iframe.contentWindow?.document;
      if (!doc?.head) return;

      // Zapobiegam duplikowaniu stylów
      if (doc.getElementById('my-custom-styles')) return;

      const style = doc.createElement('style');
      style.id = 'my-custom-styles';
      style.textContent = `
        /* Nadpisujemy style z użyciem !important, by wygrać priorytetem */
        body {
          font-family: inherit !important;
          background: #1a1a1a !important;
        }
      `;
      doc.head.appendChild(style);
    } catch (e) {
      // Złapanie ewentualnych błędów dostępu
      console.error('Nie udało się wstrzyknąć stylów:', e);
    }
  }, 100);
});

Po co setTimeout i zdarzenie load? Cusdis ładuje swoje własne skrypty i style asynchronicznie. Jeśli wstrzykniemy nasz CSS zbyt wcześnie, zostanie on nadpisany przez oryginalne style widgetu, które załadują się ułamek sekundy później.

Wstrzykując style po zdarzeniu load i dodając je na samym końcu sekcji <head>, mamy pewność, że nasze reguły wygrają w kaskadzie stylów.

Obsługa dynamicznego ładowania (SPA)#

W nowoczesnych aplikacjach, zwłaszcza typu SPA (Single Page Application), iframe może nie istnieć w momencie ładowania strony. Może pojawić się później, np. po przejściu na podstronę artykułu.

MutationObserver pozwala nam nasłuchiwać zmian w drzewie DOM i reagować dokładnie w momencie, gdy widget zostanie dołączony do struktury strony.

const container = document.querySelector('#cusdis_thread');

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.tagName === 'IFRAME') {
        // Mamy go! Podpinamy nasz listener
        node.addEventListener('load', () => injectStyles(node));
      }
    }
  }
});

observer.observe(container, { childList: true, subtree: true });

Synchronizacja z motywem strony (Dark Mode)#

Osobiście nie lubię sztywnych wartości (hardcoding). Jeśli zmienimy kolor tła w głównym motywie, nie chcemy pamiętać o ręcznej aktualizacji skryptu komentarzy.

Rozwiązaniem jest pobranie wartości bezpośrednio z CSS Custom Properties (zmiennych CSS) naszej strony i przekazanie ich do iframe’a.

function getThemeColors() {
  const root = getComputedStyle(document.documentElement);
  return {
    background: root.getPropertyValue('--background').trim(),
    foreground: root.getPropertyValue('--foreground').trim(),
    accent: root.getPropertyValue('--accent').trim()
  };
}

function generateCSS(colors) {
  return `
    body {
      background: ${colors.background} !important;
      color: ${colors.foreground} !important;
    }
    a, button {
      color: ${colors.accent} !important;
    }
  `;
}

Dzięki temu widget automatycznie dostosuje się do zmian w Waszym Design Systemie.

Problem z wysokością (Scrollbary)#

Kolejna rzecz, którą zauważyłem podczas testów, to problemy z wysokością. Teoretycznie Cusdis używa postMessage do informowania strony-matki o swojej wysokości, ale w praktyce działa to różnie. Często pojawiał się nieestetyczny, wewnętrzny pasek przewijania.

Zdecydowałem się na przejęcie kontroli nad tym procesem. Musimy sami obliczyć wysokość zawartości iframe’a i wymusić ją na elemencie.

function updateHeight(iframe) {
  try {
    const doc = iframe.contentDocument || iframe.contentWindow?.document;
    if (!doc?.body) return;

    // Pobieram maksimum z trzech różnych metryk dla pewności cross-browser
    const height = Math.max(
      doc.body.scrollHeight,
      doc.documentElement.scrollHeight,
      doc.body.offsetHeight
    );

    iframe.style.height = height + 'px';
  } catch (e) {
    // Fallback w razie błędu
  }
}

Obserwowanie zmian w czasie rzeczywistym#

Komentarze to żywy organizm – dochodzą nowe wpisy, użytkownicy klikają “odpowiedz”. Wysokość obliczona raz, na początku, szybko stanie się nieaktualna.

Tutaj z pomocą przychodzi nam ResizeObserver oraz ponownie MutationObserver, zapięte na wnętrze ramki.

function setupHeightObserver(iframe) {
  try {
    const doc = iframe.contentDocument || iframe.contentWindow?.document;
    if (!doc?.body) return;

    let timer;
    const update = () => {
      // Prosty debounce, żeby nie zarżnąć wydajności przy szybkich zmianach
      clearTimeout(timer);
      timer = setTimeout(() => updateHeight(iframe), 100);
    };

    // Nasłuchuj zmian rozmiaru okna i zmian w strukturze DOM (nowe komentarze)
    new ResizeObserver(update).observe(doc.body);
    new MutationObserver(update).observe(doc.body, {
      childList: true,
      subtree: true,
      attributes: true
    });
  } catch (e) {}
}

Gotowe rozwiązanie: Kod produkcyjny#

Zebrałem te wszystkie elementy w jeden, samowystarczalny moduł. Możecie go wkleić do swojego projektu. Skrypt sam wykryje, kiedy i czy widget się załadował, a następnie zaaplikuje style i naprawi wysokość.

(function() {
  const CONTAINER = '#cusdis_thread';

  function injectStyles(iframe) {
    iframe.addEventListener('load', () => {
      setTimeout(() => {
        try {
          const doc = iframe.contentDocument || iframe.contentWindow?.document;
          if (!doc?.head || doc.getElementById('cusdis-custom')) return;

          const style = doc.createElement('style');
          style.id = 'cusdis-custom';
          style.textContent = `
            /* Tutaj wklej swoje style lub logikę pobierania zmiennych */
            body { background: #1a1a1a !important; }
          `;
          doc.head.appendChild(style);

          // Od razu uruchamiamy obserwatora wysokości
          setupHeightObserver(iframe);
        } catch (e) {}
      }, 100);
    });
  }

  function updateHeight(iframe) {
    try {
      const doc = iframe.contentDocument || iframe.contentWindow?.document;
      if (!doc?.body) return;
      const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight);
      iframe.style.height = h + 'px';
    } catch (e) {}
  }

  function setupHeightObserver(iframe) {
    try {
      const doc = iframe.contentDocument || iframe.contentWindow?.document;
      if (!doc?.body) return;

      let timer;
      const update = () => {
        clearTimeout(timer);
        timer = setTimeout(() => updateHeight(iframe), 100);
      };

      new ResizeObserver(update).observe(doc.body);
      new MutationObserver(update).observe(doc.body, {
        childList: true, subtree: true, attributes: true
      });
    } catch (e) {}
  }

  function init() {
    const container = document.querySelector(CONTAINER);
    if (!container) return;

    // Scenariusz 1: Iframe pojawi się dynamicznie
    new MutationObserver((mutations) => {
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (node.tagName === 'IFRAME') injectStyles(node);
        }
      }
    }).observe(container, { childList: true, subtree: true });

    // Scenariusz 2: Iframe już tam jest
    const iframe = container.querySelector('iframe');
    if (iframe) injectStyles(iframe);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();

Wnioski#

Cusdis to świetne narzędzie, ale jego stylowanie wymaga nieco “gimnastyki” z JavaScriptem. Metoda srcdoc daje nam jednak furtkę, którą warto wykorzystać.

Moim zdaniem podejście z wstrzykiwaniem CSS przez JS (JavaScript Injection) daje najlepszy balans. Z jednej strony mamy pełną kontrolę wizualną i synchronizację z motywem strony. Z drugiej — nie musimy hostować własnej instancji Cusdis ani modyfikować kodu źródłowego widgetu, co znacznie ułatwia utrzymanie projektu w przyszłości.