Reactで、要素を全画面表示に切り替える方法

​​React​createPortal,Fullscreen API,Playwright,TypeScript,useSyncExternalStore

要素を全画面で表示するには、ブラウザの Fullscreen API の element.requestFullscreen() を、ボタンのクリックの中で呼びます。全画面かどうかを自分の state で持つのではなく、document.fullscreenElement を useSyncExternalStore で読むようにすると、Esc キーで抜けたときもボタンの表示がずれません。

ここでは、全画面への出入りを1つのボタンで切り替えるフック useFullscreen を作り、グラフの入った枠を全画面にしました。あわせて、うまくいかない3つの書き方(state を自分で持つ、ページを開いたときに全画面にする、document.body にポータルを出す)が、どう失敗するかも確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。Esc キーで抜ける動きは、仮想ディスプレイ(Xvfb)の上で画面ありの Chromium を動かし、xdotool でキーを送って確かめました。Firefox と Safari、スマートフォンのブラウザは試していません。

「全画面で表示」を押すと枠が画面いっぱいになり、お知らせを出したあと「全画面を終了」で元の大きさに戻る
3回の操作で、全画面に入り、お知らせを出し、全画面を抜ける様子。document.fullscreenElement と「状態」の表示が変わった瞬間と、出たお知らせだけピンクにしている(カーソル、押した瞬間の輪、操作の表示、fullscreenElement の表示は GIF 用に重ねたもの。お知らせの位置も GIF 用に上へずらしている)

全画面の状態を useSyncExternalStore で読むフック

全画面に入るのは requestFullscreen()、抜けるのは document.exitFullscreen() です。どちらも Promise を返します。今どの要素が全画面かは document.fullscreenElement で分かり、全画面でなければ null です。入ったときと抜けたときには、fullscreenchange イベントが document まで届きます。

全画面は、Esc キーのように React の外の操作でも終わります。そこで、document.fullscreenElement をブラウザが持つ値として扱い、useSyncExternalStore でブラウザの API を購読する書き方で読みます。

ts
import { useCallback, useState, useSyncExternalStore } from 'react';

// 全画面の出入りは Esc キーなど React の外でも起きるので、
// document.fullscreenElement を外部のストアとして読む
function subscribe(onChange: () => void) {
  document.addEventListener('fullscreenchange', onChange);
  return () => document.removeEventListener('fullscreenchange', onChange);
}
const getSnapshot = () => document.fullscreenElement;
const getServerSnapshot = () => null;

export function useFullscreen<T extends HTMLElement>() {
  // ref.current をレンダー中に読まないよう、要素は state に持つ
  const [element, setElement] = useState<T | null>(null);
  const fullscreenElement = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  const isFullscreen = element !== null && fullscreenElement === element;

  const enter = useCallback(async () => {
    if (!element) return;
    await element.requestFullscreen();
  }, [element]);

  const exit = useCallback(async () => {
    if (document.fullscreenElement) await document.exitFullscreen();
  }, []);

  const toggle = useCallback(
    () => (isFullscreen ? exit() : enter()),
    [isFullscreen, enter, exit],
  );

  return { ref: setElement, isFullscreen, fullscreenElement, enter, exit, toggle };
}

subscribe と getSnapshot はコンポーネントの外に置いています。レンダーのたびに違う subscribe を渡すと、React が購読し直すためです。getServerSnapshot は、サーバーでレンダーするときに getSnapshot の代わりに呼ばれる関数です。サーバーには全画面がないので null を返すようにしました。

「この要素が全画面かどうか」は、fullscreenElement と自分の要素を比べて決めます。要素を useRef で持つと、比べるためにレンダー中に ref.current を読むことになります。公式リファレンスでは、レンダー中に ref.current を読み書きしないよう書かれています。そこで、ref には state の set 関数を渡し、要素を state に持たせました。

ボタンで全画面に切り替える

コンポーネントでは、フックが返す ref を全画面にしたい要素に付け、ボタンのクリックで toggle を呼びます。requestFullscreen() が拒否されると Promise が reject されるので、try / catch で受けて画面に出します。次のコードは、検証に使ったコンポーネントから、全画面の切り替えに関わる部分を抜き出したものです。

tsx
export function App() {
  const { ref, isFullscreen, fullscreenElement, toggle } = useFullscreen<HTMLDivElement>();
  const [error, setError] = useState('');

  async function handleClick() {
    setError('');
    try {
      await toggle();
    } catch (e) {
      // requestFullscreen() が拒否されると、Promise が reject される
      setError(e instanceof Error ? `${e.name}: ${e.message}` : String(e));
    }
  }

  return (
    <main>
      <div ref={ref} className="viewer">
        {/* グラフなど、全画面で見せたい中身 */}
        <button type="button" onClick={handleClick}>
          {isFullscreen ? '全画面を終了' : '全画面で表示'}
        </button>
      </div>
      {error && <p role="alert">{error}</p>}
    </main>
  );
}

全画面のときだけの見た目は、CSS の :fullscreen 疑似クラスで書けます。全画面の要素の後ろに敷かれる面は、::backdrop で色を変えられます。

css
.viewer:fullscreen { display: flex; flex-direction: column; justify-content: center; padding: 24px; border: 0; border-radius: 0; }
.viewer:fullscreen .picture { flex: 1; }
.viewer::backdrop { background: #1f2a30; }

Playwright でボタンを押した結果です。全画面に入ると、枠の大きさが画面と同じ 1280×720 になり、ボタンの文字も変わりました。ログの [ページ] の行は、確かめるために別に付けたリスナーが、fullscreenchange と keydown のたびに書き出したものです。

text
最初: 通常 / fullscreenElement: null
  [ページ] fullscreenchange fullscreenElement=viewer
「全画面で表示」を押した: 全画面 / fullscreenElement: div.viewer / ボタン: 全画面を終了
全画面の要素の大きさ: 1280x720(画面 1280x720)
  [ページ] fullscreenchange fullscreenElement=null
「全画面を終了」を押した: 通常 / fullscreenElement: null

Esc キーで抜けたときも、ボタンの表示が合う

全画面のときに Esc キーを押すと、ブラウザが全画面を終わらせます。このとき fullscreenchange が起きるので、useFullscreen を使ったボタンは「全画面で表示」に戻りました。

text
「全画面で表示」を押した: 全画面
  [ページ] keydown key=Escape
Playwright で Escape を送った: 全画面 / fullscreenElement: div.viewer
  [ページ] fullscreenchange fullscreenElement=null
Esc キーを押した: 通常 / fullscreenElement: null / ボタン: 全画面で表示

上の出力では、比べるために、先に Playwright のキー入力で Escape を送っています。こちらはページに keydown として届いただけで、全画面は終わりませんでした。そのあと xdotool で X サーバーを通した実際のキー入力として Esc を送ると、全画面が終わりました。このとき、ページの keydown のリスナーには何も届いていません。検証環境の Chromium では、Esc キーで抜けたことを keydown で知ることはできず、fullscreenchange を見る必要があります。

自分で state を切り替えるだけの書き方

requestFullscreen() のあとに setIsFullscreen(true) とするだけの書き方では、Esc キーで抜けても state が変わりません。

tsx
async function handleClick() {
  if (isFullscreen) {
    await document.exitFullscreen();
    setIsFullscreen(false);
  } else {
    await ref.current?.requestFullscreen();
    setIsFullscreen(true);
  }
}

Esc キーで抜けたあとも、表示は「全画面」のままでした。そのままボタンを押すと、全画面ではないのに exitFullscreen() を呼ぶことになり、エラーになりました。エラーのあとも、state は「全画面」のままでした。

text
「全画面で表示」を押した: 全画面 / fullscreenElement: div.viewer
  [ページ] fullscreenchange fullscreenElement=null
Esc キーを押した: 全画面 / fullscreenElement: null / ボタン: 全画面を終了
「全画面を終了」を押した: 全画面 / 捕まえていないエラー: TypeError: Failed to execute 'exitFullscreen' on 'Document': Document not active

ボタンを押したときにしか全画面にできない

requestFullscreen() を使えるのは、ユーザーがクリックなどの操作をした直後だけです。MDN の User activation のページには、この「直後」の状態(transient activation)は時間がたつと切れること、requestFullscreen() がこれを必要とする API の1つであることが書かれています。

ページを開いたときに useEffect で全画面にしようとすると、拒否されました。

tsx
useEffect(() => {
  ref.current?.requestFullscreen().then(
    () => setResult('全画面になった'),
    (e: Error) => setResult(`${e.name}: ${e.message}`),
  );
}, []);
text
  [ページ] Failed to execute 'requestFullscreen' on 'Element': API can only be initiated by a user gesture.
結果: TypeError: Permissions check failed / fullscreenElement: null

クリックのあとに待ち時間をはさんでから requestFullscreen() を呼ぶと、どうなるかも試しました。データを読み込んでから全画面にするような流れを想定しています。検証環境の Chromium では、1秒後と4秒後は全画面になり、6秒後は同じエラーで拒否されました。

text
1000ミリ秒後: 全画面になった
4000ミリ秒後: 全画面になった
  [ページ] Failed to execute 'requestFullscreen' on 'Element': API can only be initiated by a user gesture.
6000ミリ秒後: TypeError: Permissions check failed

何秒で切れるかは、ほかのブラウザでは試していません。クリックの処理の中では、時間のかかる処理より先に requestFullscreen() を呼んでおくほうが確実です。

補足

MDN のブラウザー互換性の表では、iOS の Safari の requestFullscreen() は iPad だけで使え、iPhone では使えないとされています(iPhone では試していません)。全画面にできなくても困らないように作り、使えるかどうかは document.fullscreenEnabled で確かめます。

全画面の中では、body に出したポータルが見えない

全画面の要素は、ページのほかの部分より上の「最上位レイヤー」に置かれます。そのため、全画面の要素の外にあるものは、画面に出ません。

React でトーストやモーダルを createPortal で document.body に出していると、全画面の間はそれが見えなくなります。全画面の枠の中から「お知らせを出す」を押し、お知らせの真ん中の位置に何があるかを document.elementFromPoint で調べました。

tsx
createPortal(<div role="status" className="toast">保存しました</div>, document.body)
text
お知らせの親: body / お知らせの位置にある要素: viewer

お知らせは body の中にできていますが、その位置にあるのは全画面の枠(viewer)でした。お知らせは枠の下に隠れています。

createPortal の2つ目の引数には、出す先の DOM ノードを渡します。useFullscreen は fullscreenElement も返すので、全画面の間は全画面の要素の中に出すようにしました。

tsx
{toast &&
  createPortal(
    <div role="status" className="toast">保存しました</div>,
    // 全画面の間は、全画面の要素の中に出す
    fullscreenElement ?? document.body,
  )}

今度は、お知らせの位置にあるのがお知らせ自身になり、画面に表示されました。Esc キーで抜けると、お知らせは body の中に移りました。

text
お知らせの親: div.viewer / お知らせの位置にある要素: toast
  [ページ] fullscreenchange fullscreenElement=null
Esc キーで抜けたあとのお知らせの親: body
ポイント

公式リファレンスによると、出す先の DOM ノードが変わると、ポータルの中身は作り直されます。この書き方では、全画面の出入りのたびにお知らせが作り直されます。中に入力欄のような、入力途中の値を持つものを出す場合は、この点に気をつけてください。

​Posted by 86