use-companion-window.tsx

Nov 23, 2024ยทuse document-in-picture api via react hook
import { useState, useEffect, useRef } from "react";
import { Logger } from "./logger";

const logger = Logger.createChild("useCompanionWindow");

interface CompanionWindow {
  enter: () => Promise<void>;
  exit: () => void;
  isActive: boolean;
  isSupported: boolean;
}

export function useCompanionWindow(ref: React.RefObject<HTMLElement | null>): CompanionWindow {
  const [companionWindow, setCompanionWindow] = useState<Window | null>(null);
  const originalParent = useRef<ParentNode | null>(null);
  const placeholder = useRef<Comment | null>(null);

  // Check if the Document Picture-in-Picture API is supported
  const isSupported = typeof window.documentPictureInPicture !== "undefined";

  const enter = async () => {
    if (!isSupported) {
      // No operation if API is not supported
      return;
    }

    if (!ref.current) {
      logger.error("The provided ref is not attached to an element.");
      return;
    }

    try {
      // get ref dimensions
      const { width, height } = ref.current.getBoundingClientRect();
      // Request a companion window
      const companionWindow = await window.documentPictureInPicture?.requestWindow({ 
        width,
        height,
      });
      
      if (!companionWindow) {
        return
      }

      setCompanionWindow(companionWindow ?? null);

      // Copy stylesheets to the companion window
      [...document.styleSheets].forEach((styleSheet) => {
        try {
          const cssRules = [...styleSheet.cssRules]
            .map((rule) => rule.cssText)
            .join("");
          const style = document.createElement("style");

          style.textContent = cssRules;
          companionWindow.document.head.appendChild(style);
        } catch (e) {
          const link: HTMLLinkElement = document.createElement("link");

          link.rel = "stylesheet";
          link.type = styleSheet.type;
          if (styleSheet.media) {
            link.media = styleSheet.media.mediaText;
          }
          if (styleSheet.href) {
            link.href = styleSheet.href;
          }
          companionWindow.document.head.appendChild(link);
        }
      });

      // Save the original parent and create a placeholder
      originalParent.current = ref.current.parentNode;
      placeholder.current = document.createComment(
        "Companion window placeholder"
      );
      originalParent.current?.replaceChild(placeholder.current, ref.current);

      // Move the element into the companion window
      companionWindow.document.body.appendChild(ref.current);

      // Handle the companion window unload event
      companionWindow.addEventListener("unload", () => {
        if (originalParent.current && ref.current && placeholder.current) {
          originalParent.current.replaceChild(ref.current, placeholder.current);
        }
        setCompanionWindow(null);
      });
    } catch (error) {
      logger.error("Failed to open companion window:", error);
    }
  };

  const exit = () => {
    if (!isSupported) {
      // No operation if API is not supported
      return;
    }
  
    if (companionWindow) {
      // Move the element back to the original document
      if (originalParent.current && ref.current && placeholder.current) {
        originalParent.current.replaceChild(ref.current, placeholder.current);
      }

      companionWindow.close();
      setCompanionWindow(null);
    }
  };

  useEffect(() => {
    // Clean up when the component unmounts
    return () => {
      if (companionWindow) {
        exit();
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [companionWindow]);

  if (!ref) {
    return {
      enter: () => Promise.resolve(),
      exit: () => {},
      isActive: false,
      isSupported: false,
    };
  }

  return {
    enter,
    exit,
    isActive: !!companionWindow,
    isSupported,
  };
}