記事をPicture-in-Pictureで読めるようにしてみた
Document Picture-in-Picture APIというWeb APIがあります。まだブラウザの実装が限定的ですが、Chromeなら116から使えるようです。
Picture in Picture(以下PiP)と言えば、動画を再生しながら別のタブを開いたり別のアプリケーションを開いたりできる機能ですが、"Document" Picture-in-PictureはそれをHTML要素でも可能にするAPIです。最近Google MeetがこのAPIを使い始めて、別タブに移動したタイミングで勝手にPiPを表示するようになりました。なんか賛否あった気がしたけど僕は便利に使っています。決してミーティングに集中していないわけではないです、えぇ。
このブログサイトでも、Document Picture-in-Picture APIを使って記事をPiPで読めるようにしてみました。APIをサポートしているブラウザで記事詳細ページを開いているなら、記事タイトルの右下あたりにPiPに切り替えるボタンが表示されています。ボタンをクリックすると、記事だけを表示したPiPウィンドウが開きます。
この記事ではこの機能の実装方法について紹介します。
実装
documentPictureInPicture
の型定義
執筆時点で、Document Picture-in-Picture APIはまだTypeScriptの標準型定義ファイルに含まれていないようでした。そのため、適当に型を誤魔化すか型定義を自力で用意する必要があります。書く量は多くなかったので自分で用意しました。
型はちゃんと仕様を参考にしました。Document Picture-in-Picture APIの仕様はこちら。
グローバルに生えているJavaScript APIに対して型定義する記述する方法は、次の記事が非常に参考になります。
上の仕様と記事を参考に、次のように型定義を書きました。
declare global {
interface DocumentPictureInPictureOptions {
width?: number;
height?: number;
disallowReturnToOpener?: boolean;
preferInitialWindowPlacement?: boolean;
}
interface DocumentPictureInPicture {
requestWindow: (options?: DocumentPictureInPictureOptions) => Promise<Window>;
}
// eslint-disable-next-line no-var
var documentPictureInPicture: DocumentPictureInPicture;
}
ポイントはdeclare global
の内側にvar
です。
サポートのチェック
Document Picture-in-Picture APIはまだ一部のブラウザだけで利用可能です。Webページを読み込んだブラウザがサポートしているかどうかは、documentPictureInPicture
が存在するかどうかで判定できます。
if ("documentPictureInPicture" in window) {
// サポートしている
} else {
// サポートしていない
}
さらにサーバーサイドレンダリングによるハイドレーションミスマッチを避けるために、useSyncExternalStore
を使ってステート管理することにしました。
const noop = () => () => {};
export const ArticlePicureInPicture: React.FC = () => {
const isDocumentPipSupported = useSyncExternalStore(
noop,
() => "documentPictureInPicture" in window,
() => false,
);
return <button hidden={!isDocumentPipSupported} />;
};
useSyncExternalStore
は本来、React外のステートをReactの世界のステートとして読み取るためのReactフックですが、今回はサーバーサイドレンダリング時とクライアントサイドレンダリング時が区別できるツールとして使っています。よって、第一引数は何も購読しない関数を渡しています。第二引数はクライアントサイドレンダリング時のステート取得なのでwindow
オブジェクトが参照可能で、第三引数はサーバーサイドレンダリング時のステート取得なのでfalse
固定にしています。
useSyncExternalStore
で計算できたisDocumentPipSupported
をボタンのhidden
属性に指定しました。
あんまり良い使い方ではないと思いつつ、なんか有名なライブラリもisServer
の判定にuseSyncExternalStore
を使っていた気がする。多分。
Picture-in-Picture Windowの生成
Document Picture-in-Pictureはiframe
などと同様にひとつのWindowオブジェクトとして扱います。documentPictureInPicture.requestWindow
メソッドを使ってPiPのWindowを生成します。
const pipWindow = await documentPictureInPicture.requestWindow({
width: 320,
height: 480,
});
サイズは適当です。
DOMのAPIにしては珍しく(?)非同期処理なのでawait
で待機しています。
記事の読み込み
PiPのWindowは生成直後は空っぽで、自由にDOMを挿入できます。元ページで記事を表示している要素をそのままPiPのWindowでも表示すればよいと考えました。
記事の要素にはもともとIDを振っていたので、それを使ってDOMを取得し、PiPに挿入します。
const articleElement = document.querySelector("#markdown-renderer")?.cloneNode(true);
if (!articleElement) return;
pipWindow.document.body.appendChild(articleElement);
ここでcloneNode
を使わないと、元ページから要素を削除した上でPiPに挿入することになります。単純に移動しているだけなので、PiPを閉じても元には戻りません。cloneNode
を使うことで元ページも維持しつつPiPにも表示できました。cloneNode
の引数は「子孫要素を含めるかどうか」のフラグです。
スタイルのコピー
PiPは元ページとは分離されたWindowなので、JSもCSSも分かれています。記事要素をコピーするだけでは、それに付与されたclassを色付けするCSSは存在しないということです。
次の記事を見ると、過去にはcopyStyleSheets
オプションがあったようですが、仕様策定が進む間に廃止されてしまいました。
ということで、ちょっと力技なのですが、元ページに存在するスタイルシートに関係する要素をまるっとコピーすることにしました。
const styleElements = document.querySelectorAll(`link[rel="stylesheet"], style`);
const clonedStyleElements = Array.from(styleElements).map((s) => s.cloneNode(true));
pipWindow.document.head.append(...clonedStyleElements);
pipWindow.document.documentElement.dataset.colorMode =
document.documentElement.dataset.colorMode;
querySelectorAll
で元ページにあるlink
要素またはstyle
要素を取得し、それをcloneNode
ですべてクローンします。クローンした要素をpipWindow
のdocument.head
に追加しました。また、サイトのテーマカラーを継承するためにdocument.documentElement.dataset.colorMode
もコピーしました。
これでコピーしたDOM要素に対して付与されていたスタイルがPiPの中でも有効になります。link
要素のappendはpipWindow
内でCSSファイルの再fetchになりますが、普通は強いHTTPキャッシュが効いているはずなので問題ないでしょう。
使っているときのイメージ
ボタンをクリックすると次の画像のようにPiPとして小さいウインドウが開きます。PC Chromeで閲覧中の人はぜひご自身で開いてみてください。
感想
ボタンをクリックしたタイミングでDOM要素をコピーしているだけなので、その瞬間の画面の状態をスナップショットのようにPiP固定します。元のタブで画面遷移をしてもPiPの中のDOMは変更されません。
元の画面の状態からPiPの状態を書き換える方法もあるようです(PiPもただのWindowなのでpostMessage
を使うことになるかな?)
PiPに対してDOM操作ができるので、react-domでReactアプリをマウントしてやることも考えましたが、CSSやJSって元ページの所有物(バンドル)になるのでできなくね…?と思ってやめました。PiP内の画面をReactでいい感じに構築する方法を模索したいですなぁ。
まとめ
Document Picture-in-Picture APIを使って記事をPiPで読めるようにしてみました。
開いたPiPに対してDOM要素をコピーすることで、ページを再現する方法を取りました。スタイルシートのコピーはちょっと力技でしたが、なんかいい感じに表示できました。
これでみなさんが別タブに移動しても僕の記事を表示し続けられてハッピーですね(?)
それでは良いWebライフを!