Document Picture-in-Picture API 利用時の注意点

## 概要

https://developer.chrome.com/docs/web-platform/document-picture-in-picture?hl=ja

meets的なポップアップを組み込みたいと考えたときに、上記の記事を見つけた。
実際にTimerを作成してみたところ、`「ボタンが押せない」「Tailwind CSS のスタイルが適用されない」`といった問題にぶつかった。

簡単に対処法等をまとめていきたい。

## 動作しなかったコード例 - その１

最初は、メインドキュメントから対象 DOM（タイマー＆ストップウォッチコンテンツ）をそのまま PiP ウィンドウに移動する実装を行いました。

```tsx:動作しなかったコード例
// app/page.tsx
'use client';

import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Timer } from '@/components/Timer';
import { Stopwatch } from '@/components/Stopwatch';
import { Button } from '@/components/ui/button';
import { useState } from 'react';

export default function Home() {
  const [isPiPActive, setIsPiPActive] = useState(false);

  const enterDocumentPiP = async () => {
    // 修正: document ではなく window でチェックする
    if ('documentPictureInPicture' in window) {
      try {
        const content = document.getElementById('pip-container');
        if (content) {
          // PiP ウィンドウをリクエスト（オプションで初期サイズを指定）
          const pipWindow = await (
            window as any
          ).documentPictureInPicture.requestWindow({
            width: content.clientWidth,
            height: content.clientHeight,
          });
          // スタイルシートのコピー（必要に応じて）
          [...document.styleSheets].forEach((styleSheet) => {
            try {
              const cssRules = [...(styleSheet as CSSStyleSheet).cssRules]
                .map((rule) => rule.cssText)
                .join('');
              const style = document.createElement('style');
              style.textContent = cssRules;
              pipWindow.document.head.appendChild(style);
            } catch (e) {
              if (styleSheet.href) {
                const link = document.createElement('link');
                link.rel = 'stylesheet';
                link.href = styleSheet.href;
                pipWindow.document.head.appendChild(link);
              }
            }
          });
          // PiP ウィンドウにコンテンツを移動
          pipWindow.document.body.appendChild(content);
          // ウィンドウが閉じられた際に元の位置へ戻す
          pipWindow.addEventListener('pagehide', () => {
            const mainContainer = document.getElementById('main-container');
            if (content && mainContainer) {
              mainContainer.appendChild(content);
            }
            setIsPiPActive(false);
          });
          setIsPiPActive(true);
        }
      } catch (error) {
        console.error('PiP モードへの切替に失敗しました:', error);
      }
    } else {
      console.warn(
        'Document Picture-in-Picture API はこのブラウザではサポートされていません。'
      );
    }
  };

  const exitDocumentPiP = async () => {
    if (document.pictureInPictureElement) {
      try {
        await document.exitPictureInPicture();
        setIsPiPActive(false);
      } catch (error) {
        console.error('PiP モードの終了に失敗しました:', error);
      }
    }
  };

  return (
    <main
      id="main-container"
      className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-50"
    >
      <div
        id="pip-container"
        className="w-full max-w-md bg-white rounded-lg shadow-md p-6"
      >
        <h1 className="text-2xl font-bold text-center mb-6">
          Timer & Stopwatch
        </h1>
        <Tabs defaultValue="timer" className="w-full">
          <TabsList className="grid w-full grid-cols-2">
            <TabsTrigger value="timer">Timer</TabsTrigger>
            <TabsTrigger value="stopwatch">Stopwatch</TabsTrigger>
          </TabsList>
          <TabsContent value="timer">
            <Timer />
          </TabsContent>
          <TabsContent value="stopwatch">
            <Stopwatch />
          </TabsContent>
        </Tabs>
      </div>
      <div className="mt-4">
        {!isPiPActive ? (
          <Button
            onClick={enterDocumentPiP}
            className="bg-blue-600 hover:bg-blue-700 text-white"
          >
            PiP モードに切替
          </Button>
        ) : (
          <Button
            onClick={exitDocumentPiP}
            className="bg-blue-600 hover:bg-blue-700 text-white"
          >
            PiP モード終了
          </Button>
        )}
      </div>
    </main>
  );
}
```

### その１の問題点

- **ボタンが押せない:**  
  上記のコードでは、対象コンテンツを単純に PiP ウィンドウに移動した影響でReactの管理下から外れてしまう問題がある。
  直接DOM操作をしていることが問題である。

## 問題のあるコード - その２
```tsx
// app/page.tsx
'use client';

import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Timer } from '@/components/Timer';
import { Stopwatch } from '@/components/Stopwatch';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { createPortal } from 'react-dom';

export default function Home() {
  const [isPiPActive, setIsPiPActive] = useState(false);
  const [pipWindow, setPipWindow] = useState<Window | null>(null);

  // ここで再レンダリングする内容を変数にしておく
  const pipContent = (
    <div
      id="pip-container"
      className="w-full max-w-md bg-white rounded-lg shadow-md p-6"
    >
      <h1 className="text-2xl font-bold text-center mb-6">Timer & Stopwatch</h1>
      <Tabs defaultValue="timer" className="w-full">
        <TabsList className="grid w-full grid-cols-2">
          <TabsTrigger value="timer">Timer</TabsTrigger>
          <TabsTrigger value="stopwatch">Stopwatch</TabsTrigger>
        </TabsList>
        <TabsContent value="timer">
          <Timer />
        </TabsContent>
        <TabsContent value="stopwatch">
          <Stopwatch />
        </TabsContent>
      </Tabs>
    </div>
  );

  const enterDocumentPiP = async () => {
    // サポート判定は window で行う
    if ('documentPictureInPicture' in window) {
      try {
        const container = document.getElementById('pip-container');
        if (container) {
          // PiP ウィンドウをリクエスト（コンテンツのサイズを指定）
          const pipWin = await (
            window as any
          ).documentPictureInPicture.requestWindow({
            width: container.clientWidth,
            height: container.clientHeight,
          });
          setPipWindow(pipWin);
          setIsPiPActive(true);
          // ウィンドウが閉じられたら、状態をリセットする
          pipWin.addEventListener('pagehide', () => {
            setIsPiPActive(false);
            setPipWindow(null);
          });
        }
      } catch (error) {
        console.error('PiP モードへの切替に失敗しました:', error);
      }
    } else {
      console.warn(
        'Document Picture-in-Picture API はこのブラウザではサポートされていません。'
      );
    }
  };

  const exitDocumentPiP = async () => {
    if (document.pictureInPictureElement) {
      try {
        await document.exitPictureInPicture();
        setIsPiPActive(false);
        setPipWindow(null);
      } catch (error) {
        console.error('PiP モードの終了に失敗しました:', error);
      }
    }
  };

  return (
    <main
      id="main-container"
      className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-50"
    >
      {isPiPActive && pipWindow ? (
        // PiP モード時は Portal を使って新しいウィンドウ内に再レンダリングする
        createPortal(
          <>
            {pipContent}
            <div className="mt-4">
              <Button
                onClick={exitDocumentPiP}
                className="bg-blue-600 hover:bg-blue-700 text-white"
              >
                PiP モード終了
              </Button>
            </div>
          </>,
          pipWindow.document.body
        )
      ) : (
        <>
          {pipContent}
          <div className="mt-4">
            <Button
              onClick={enterDocumentPiP}
              className="bg-blue-600 hover:bg-blue-700 text-white"
            >
              PiP モードに切替
            </Button>
          </div>
        </>
      )}
    </main>
  );
}

```

### その２の問題点

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125890/56045232-d89e-4773-b617-db5c5359bce5.png)


- **Tailwind CSS のスタイルが適用されない:**  
  PiP ウィンドウは別ドキュメントであるため、メインドキュメントで読み込まれているスタイルシートが自動では適用されなかった。
  先ほどは、DOMでスタイル事持って行ったため、問題なく表示されていた。

## 解決策と最終コード

### 解決策

1. **React Portal の利用:**  
   対象コンテンツを単に移動するのではなく、Reactの仕組みを利用して PiP ウィンドウ内に再レンダリングすることで、React のイベントシステムを保持した

2. **スタイルシートのコピー処理:**  
   メインドキュメント内の `<link>` および `<style>` タグをクローンし、PiP ウィンドウの `<head>` に追加することで、Tailwind CSS のスタイルを適用した

### 最終的な動作するコード例

```tsx
// app/page.tsx
'use client';

import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Timer } from '@/components/Timer';
import { Stopwatch } from '@/components/Stopwatch';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { createPortal } from 'react-dom';

export default function Home() {
  const [isPiPActive, setIsPiPActive] = useState(false);
  const [pipWindow, setPipWindow] = useState<Window | null>(null);

  // PiP ウィンドウ内で再レンダリングするコンテンツ
  const pipContent = (
    <div
      id="pip-container"
      className="w-full max-w-md bg-white rounded-lg shadow-md p-6"
    >
      <h1 className="text-2xl font-bold text-center mb-6">
        Timer & Stopwatch
      </h1>
      <Tabs defaultValue="timer" className="w-full">
        <TabsList className="grid w-full grid-cols-2">
          <TabsTrigger value="timer">Timer</TabsTrigger>
          <TabsTrigger value="stopwatch">Stopwatch</TabsTrigger>
        </TabsList>
        <TabsContent value="timer">
          <Timer />
        </TabsContent>
        <TabsContent value="stopwatch">
          <Stopwatch />
        </TabsContent>
      </Tabs>
    </div>
  );

  // メインドキュメントからスタイルシートをコピーする関数
  const copyStylesToPip = (pipWin: Window) => {
    const head = document.querySelector('head');
    if (head) {
      const nodes = head.querySelectorAll('link[rel="stylesheet"], style');
      nodes.forEach((node) => {
        pipWin.document.head.appendChild(node.cloneNode(true));
      });
    }
  };

  const enterDocumentPiP = async () => {
    // API 存在チェックは window で行う
    if ('documentPictureInPicture' in window) {
      try {
        const container = document.getElementById('pip-container');
        if (container) {
          // PiP ウィンドウをリクエスト（対象コンテンツのサイズ指定）
          const pipWin = await (window as any).documentPictureInPicture.requestWindow({
            width: container.clientWidth,
            height: container.clientHeight,
          });
          // メインドキュメントからスタイルシートをコピー
          copyStylesToPip(pipWin);
          setPipWindow(pipWin);
          setIsPiPActive(true);
          // ウィンドウが閉じられた際の処理
          pipWin.addEventListener('pagehide', () => {
            setIsPiPActive(false);
            setPipWindow(null);
          });
        }
      } catch (error) {
        console.error('PiP モードへの切替に失敗しました:', error);
      }
    } else {
      console.warn(
        'Document Picture-in-Picture API はこのブラウザではサポートされていません。'
      );
    }
  };

  const exitDocumentPiP = async () => {
    if (document.pictureInPictureElement) {
      try {
        await document.exitPictureInPicture();
        setIsPiPActive(false);
        setPipWindow(null);
      } catch (error) {
        console.error('PiP モードの終了に失敗しました:', error);
      }
    }
  };

  return (
    <main
      id="main-container"
      className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-50"
    >
      {isPiPActive && pipWindow ? (
        // PiP モード時は Portal を利用して、PiP ウィンドウ内に再レンダリング
        createPortal(
          <>
            {pipContent}
            <div className="mt-4">
              <Button
                onClick={exitDocumentPiP}
                className="bg-blue-600 hover:bg-blue-700 text-white"
              >
                PiP モード終了
              </Button>
            </div>
          </>,
          pipWindow.document.body
        )
      ) : (
        <>
          {pipContent}
          <div className="mt-4">
            <Button
              onClick={enterDocumentPiP}
              className="bg-blue-600 hover:bg-blue-700 text-white"
            >
              PiP モードに切替
            </Button>
          </div>
        </>
      )}
    </main>
  );
}
```

---

## 結論

いろいろやってみて感じたこととしては、ほとんどポップアップウィンドウと変わらないことができるのに最前面で固定できる等規制が緩いので、悪用されそうだと感じた、、