概要
meets的なポップアップを組み込みたいと考えたときに、上記の記事を見つけた。
実際にTimerを作成してみたところ、「ボタンが押せない」「Tailwind CSS のスタイルが適用されない」
といった問題にぶつかった。
簡単に対処法等をまとめていきたい。
動作しなかったコード例 - その1
最初は、メインドキュメントから対象 DOM(タイマー&ストップウォッチコンテンツ)をそのまま PiP ウィンドウに移動する実装を行いました。
動作しなかったコード例
// 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>
);
}
その1の問題点
-
ボタンが押せない:
上記のコードでは、対象コンテンツを単純に PiP ウィンドウに移動した影響でReactの管理下から外れてしまう問題がある。
直接DOM操作をしていることが問題である。
問題のあるコード - その2
// 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>
);
}
その2の問題点
-
Tailwind CSS のスタイルが適用されない:
PiP ウィンドウは別ドキュメントであるため、メインドキュメントで読み込まれているスタイルシートが自動では適用されなかった。
先ほどは、DOMでスタイル事持って行ったため、問題なく表示されていた。
解決策と最終コード
解決策
-
React Portal の利用:
対象コンテンツを単に移動するのではなく、Reactの仕組みを利用して PiP ウィンドウ内に再レンダリングすることで、React のイベントシステムを保持した -
スタイルシートのコピー処理:
メインドキュメント内の<link>
および<style>
タグをクローンし、PiP ウィンドウの<head>
に追加することで、Tailwind CSS のスタイルを適用した
最終的な動作するコード例
// 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>
);
}
結論
いろいろやってみて感じたこととしては、ほとんどポップアップウィンドウと変わらないことができるのに最前面で固定できる等規制が緩いので、悪用されそうだと感じた、、