LoginSignup
5
6

storybook で競プロの解説画像をまとめて GitHub Pages で公開してみた

Posted at

はじめに

AtCoder コンテストの問題を解いていると、ふと解説画像を描きたくなることがあります。2023-11-04 に ABC327-F の解説画像を描いて、X (Twitter) に投稿していました。

abc327-f-640.png

画像作成後に、解説やコード例を付けて Qiita の記事を仕上げることもあります。その手間をかけず、絵を作るところまでで満足することも多かったです。そうするともう画像が目に留まることがなくなります。

記事を書かなくても、画像を簡単にまとめられる方法がないかということで、UI のカタログ集として人気の Storybook を使ってみようと思いました。

これがけっこう良い感じでした。ツリーで画像を切り替えられる、MDX で問題文と解答例へのリンクを気軽に追加できるなど。

abc327-all-640.png

storybook を UI カタログやテストフレームワークとしては使っていません。やりたいことに対して大仰な道具を振り回している感じもしますが、試してみたという記事にしてみます。

デモ

  • ツリーで、問題に対応するページを選べます
  • 各ページを開くと、以下を見られます
    • 解説画像: 1600 * 900px, 1枚以上
    • AtCoder 問題文へのリンク
    • AtCoder 解答例へのリンク、または Qiita 過去に投稿した解説記事へのリンク
  • 解説画像をクリックすると拡大表示できます

本記事で扱うこと

  • Storybook 7 + React 18 + TypeScript + Vite でプロジェクトを作成する
  • Storybook でスライド画像を表示する
    • 個々のスライドに対応する Docs (MDX) を作成する
    • スライドを共通で扱う UI コンポーネントを作成し、使用する
  • Storybook でその他調整
    • ツリー表示順
    • Open Graph protocol 設定
  • GitHub Pages に GitHub Actions でデプロイする

ソースコードはこちらです:

想定読者

  • React 18 + TypeScript が分かる方
  • Storybook のプロジェクト作成・公開方法を知りたい方

プロジェクトを作成する

開発環境

事前に用意します。

Storybook インストール方法確認

Storybook 公式ドキュメントとにらめっこしながら進めていきます。多くのライブラリーなら npm install (package) で入りそうです。

Use the Storybook CLI to install it in a single command. Run this inside your existing project’s root directory:

Storybook はそうではなく、すでに作られているプロジェクトに追加インストールの形で組み込みます。ふつうは UI コンポーネントなどを先に開発しているためでしょう。たとえば React プロジェクトと認識して、React 向けの Storybook おすすめ設定でインストールしてくれる、というのはありがたいです。

というわけで、Storybook をインストールするための仮プロジェクトを作成します。適当な UI コンポーネントを React で作成します。

このページ内の "Create React App" リンク先へ移動します。

React インストール方法確認

React 公式ページに移りました。

React のインストール方法が Next.js フレームワークなどいろいろ紹介されています。昔は create-react-app 一択でした。時代が変わっています。

さて、どのフレームワークに乗って React をインストールするか、ですが……。 今回行いたいのは、Storybook フレームワークをインストールするための準備です。「フレームワークいらない、いちばんコンパクトな構成にしたい」 というのが正直なところです。

インストール方法の下の方に「フレームワークなしで React を使うことはできますか?」 という質問があります。

npm から reactreact-dom を入手し、ViteParcel のようなバンドラを使ってカスタムビルドプロセスをセットアップし、

あまりおすすめではないそうですが、Vite ではできるとのこと。リンク先へ移動します。

Vite + React インストール

Storybook 公式から React 公式を経て、Vite 公式サイトにたどり着きました。インストールコマンドが書かれています。

npm create vite@latest my-app -- --template react-ts

これこそ欲しかったものです。 :tada:

コマンドを実行すると、reactreact-dom ともに 18.2 系のインストールが行われました。

テンプレートとして react-ts 、慣れている React + TypeScript 構成を使用しました。他にも複数のテンプレートが用意されています。

Vite + React 動作確認

npm run dev

動作確認できました。

このプロジェクトはあくまで Storybook の準備用です。以降は使いません。それでも進んでいる感があります。

Storybook インストール

npx storybook@latest init

今度はインストールできました。祝。

Storybook 動作確認

npm run storybook

Install Storybook で紹介されているようなサイトが表示されました。

Storybook でスライド画像を表示する

プロジェクト構成と進め方

storybook インストールまでで次のようなファイルが作られました。

.storybook/
  main.ts
  preview.ts
src/
  stories/
    Button.stories.ts
    Button.tsx
    Configure.mdx
    Header.stories.ts
    Header.tsx
    Page.stories.ts
    Page.tsx
    assets/
      *.png

これから解説画像表示用に差し替えていきます。

.storybook/
  main.ts
  preview.ts
src/
  stories/
    Readme.mdx
    Slide.tsx
    SlideAbc327d.mdx
    SlideAbc327e.mdx
    SlideAbc327f.mdx
    assets/
      abc327-d.png
      abc327-e.png
      abc327-f.png

各ページの MDX を作る

MDX は、Markdown を React コンポーネントを貼り付けられるよう拡張したものです。

ページ作成を MDX 形式で行います。画像を React コンポーネントで表示しつつ、リンクなどの追加情報を気軽に追加できます。

SlideAbc327d.mdx
import { Meta } from "@storybook/blocks";
import { Slide } from "./Slide";
import Png from "./assets/abc327-d.png";

<Meta title="ABC327/ABC327-D" />
<Slide src={Png} layout="fullscreen" label="ABC327 D - Good Tuple Problem" />

- [D \- Good Tuple Problem](https://atcoder.jp/contests/abc327/tasks/abc327_d)
- [提出 \#47233850 \- HHKBプログラミングコンテスト2023\(AtCoder Beginner Contest 327\)](https://atcoder.jp/contests/abc327/submissions/47233850) (Rust)

<Meta /> 要素がページタイトルに対応します。

<Slide> コンポーネントをこれから作ります。

画像表示用のコンポーネントを作成する

コンポーネントで画像表示を行う

<img> を表示します。表示まではできます。

Slide.tsx
interface SlideProps {
  src: string;
  label?: string;
}

export const Slide = ({ src, label }: SlideProps) => {
  return <img src={src} aria-label={label} />;
};

しかし残念ながら、Storybook の MDX 表では、最大横幅が 1000px 程度に制限されました。 1600 * 900 で作っている画像は小さくつぶれます。横 100% 表示や、等倍表示もしたくなります。

横 1000px というのは Storybook 自体のスタイルによるもののようです。このスタイルを外から指定することで、画像を大きく表示できるはずです。しかし、 Storybook のバージョンが上がるとスタイル指定方法が変わってしまうかもしれません。あまり依存したくありません。

コンポーネントで画像サイズ切り替えも行う

そこで、React コンポーネント側でサイズを切り替えられるようにしました。

  • 画像クリックで「初期サイズ (mode: 0)」「横幅100% (1)」「等倍 (2)」を切り替えられる
    • 画像のスタイルを position: fixed にすれば、Storybook 右の <iframe> 範囲内については全画面表示できそう
      • 左のツリー部分は <iframe> の外側ですので表示できません
    • クリックできそうに見せるために、画像スタイルに cursor: zoom-in を付ける
  • [Enter] [Esc] でも拡大縮小できる
    • 画像に tabIndex 属性を付けて、onKeyDown イベントを処理
    • Storybook のグローバルなキー処理に流れないように ev.stopPropagation();

今回書いた React は、この 1つだけです。記事の本題ではないですから折りたたみます。興味のある方はどうぞ。

Slide.tsx
Slide.tsx
import { KeyboardEventHandler, useCallback, useState } from "react";
import "./Slide.css";

interface SlideProps {
  src: string;
  label?: string;
}

export const Slide = ({ src, label }: SlideProps) => {
  const [zoom, setZoom] = useState<0 | 1 | 2>(0);
  const onKeyDown = useCallback<KeyboardEventHandler>(
    (ev) => {
      if (ev?.key === "Enter") {
        setZoom(((zoom + 1) % 3) as 0 | 1 | 2);
        ev.stopPropagation();
      } else if (ev?.key === "Escape" && zoom > 0) {
        setZoom(0);
        ev.stopPropagation();
      }
    },
    [zoom]
  );
  return (
    <>
      <img
        onClick={() => setZoom(1)}
        onKeyDown={onKeyDown}
        src={src}
        aria-label={label}
        tabIndex={0}
      />
      {zoom > 0 && (
        <div onClick={() => setZoom(0)} className="modal-overlay">
          <div className="modal-content">
            {zoom == 1 ? (
              <img
                src={src}
                aria-label={label}
                onClick={(ev) => {
                  setZoom(2);
                  ev.stopPropagation();
                }}
                onKeyDown={onKeyDown}
                tabIndex={0}
              />
            ) : (
              <img
                src={src}
                aria-label={label}
                className="zoom100"
                onKeyDown={onKeyDown}
                tabIndex={0}
              />
            )}
          </div>
        </div>
      )}
    </>
  );
};
Slide.css
Slide.tsx
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 1;
  cursor: zoom-out;
}

.modal-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: auto;
}

.modal-content img.zoom100 {
  max-width: unset;
  cursor: zoom-out;
}

img {
  cursor: zoom-in;
}

Storybook でその他調整

画像が表示できましたから、ここまでで目標達成です。

ここからは短く対応できる範囲で、もう少し調整してみよう、というものです。

ソースコード表示 (対応しない)

画像の隣にソースコードを折り畳み表示できるようにすると、見る人にとって絵のイメージと実装がより結びつき、良いのではと思いました。

Markdown のコード埋め込みで "```rust" のように言語指定できることを期待しました。しかし、試してみたところ、書式が反映されませんでした。プレーンテキスト同様の表示でした。

Storybook 7 がサポートしているのは JavaScript など一部の言語だけのようです。

Why aren't my code blocks highlighted with Storybook MDX
Out of the box, Storybook provides syntax highlighting for a set of languages (e.g., Javascript, Markdown, CSS, HTML, Typescript, GraphQL) you can use with your code blocks. Currently, there's a known limitation when you try and register a custom language to get syntax highlighting. We're working on a fix for this And will update this section once it's available.

今回はソースコード表示は対応せず、AtCoder 提出コードへのリンクとしました。

- [提出 \#47233850 \- HHKBプログラミングコンテスト2023\(AtCoder Beginner Contest 327\)](https://atcoder.jp/contests/abc327/submissions/47233850) (Rust)

もし対応するなら、Storybook 6 系を使う、Gist などにコードを置いて埋め込む、といった方法があるようです。

ツリーを並び替える

ツリーの並べ方を指定しない場合、ページ名が単純に昇順に並びます。古いコンテストの方が番号が小さく、上の方に現れます。

直近のコンテストのページが目立つよう、並び替えたいです。

並び替え指定しない コンテスト名で並び替え ソート関数を書いて並び替え
はじめに
ABC308
:page_facing_up:ABC308-A
:page_facing_up:ABC308-B
:page_facing_up:ABC308-C
:page_facing_up:ABC308-D
:page_facing_up:ABC308-E
:page_facing_up:ABC308-Ex
:page_facing_up:ABC308-F
:page_facing_up:ABC308-G
ABC312
:page_facing_up:ABC312-E
ABC327
:page_facing_up:ABC327-D
:page_facing_up:ABC327-E
:page_facing_up:ABC327-F
はじめに
ABC327
:page_facing_up:ABC327-D
:page_facing_up:ABC327-E
:page_facing_up:ABC327-F
ABC312
:page_facing_up:ABC312-E
ABC308
:page_facing_up:ABC308-A
:page_facing_up:ABC308-B
:page_facing_up:ABC308-C
:page_facing_up:ABC308-D
:page_facing_up:ABC308-E
:page_facing_up:ABC308-Ex
:page_facing_up:ABC308-F
:page_facing_up:ABC308-G
はじめに
ABC327
:page_facing_up:ABC327-D
:page_facing_up:ABC327-E
:page_facing_up:ABC327-F
ABC312
:page_facing_up:ABC312-E
ABC308
:page_facing_up:ABC308-A
:page_facing_up:ABC308-B
:page_facing_up:ABC308-C
:page_facing_up:ABC308-D
:page_facing_up:ABC308-E
:page_facing_up:ABC308-F
:page_facing_up:ABC308-G
:page_facing_up:ABC308-Ex

コンテスト名で並び替え

Naming components and hierarchy

並び替え方法が公式に載っています。 preview.ts を次のように書きます。ルールが少なければ簡単です。

.storybook/preview.ts
const preview: Preview = {
  parameters: {
    options: {
      storySort: {
        method: "alphabetical",
        order: [
          "はじめに",
          "ABC327",
          "ABC312",
          "ABC308",
        ],
      },

しかし、コンテストを追加するたびに preview.ts を更新するのは手間がかかります。もっと簡単にしたいです。

ソート関数を書いて並び替え

storySort には関数を指定することもできます。"abc" で始まるストーリー名を特別扱いするような関数を次のように書けば、毎回のメンテナンスは不要になります。

.storybook/preview.ts
storySort: (a, b) => {
  if (a.id === b.id) {
    return 0;
  }
  if (!a.id.startsWith("abc") && !b.id.startsWith("abc")) {
    return a.id.localeCompare(b.id);
  }
  if (!a.id.startsWith("abc") && b.id.startsWith("abc")) {
    return -1;
  }
  if (a.id.startsWith("abc") && !b.id.startsWith("abc")) {
    return 1;
  }
  if (a.id.substring(0, 6) === b.id.substring(0, 6)) {
    if (!a.id.includes("-ex-") && b.id.includes("-ex-")) {
      return -1;
    }
    if (a.id.includes("-ex-") && !b.id.includes("-ex-")) {
      return 1;
    }
    return a.id.localeCompare(b.id);
  }
  return -a.id.localeCompare(b.id);
},

ついでに Ex 問題を難易度順ということで一番最後に表示するようにしました。 1

タイトル差し替え (たぶん行えない)

サイトを公開するなら、タイトル <title> を指定したいと思いました。

ブラウザのウィンドウやタブに 「ABC327 / ABC327-D - Docs ⋅ Storybook」 と表示されます。この「Storybook」 は、 Storybook を使って作られていることは分かるけれど、何をまとめた Storybook か分からないのではと。

しかし残念ながら、調べた限りではタイトルの変更方法が分かりませんでした。

Storybook 作成例として参考にしている Fluent UI v9 ページも タイトルは 「Concepts / Introduction - Page ⋅ Storybook」 のように Fluent UI という言葉が入っていませんでした。こういうものなのかもしれません。

Open Graph protocol 設定

タイトルは差し替えられなくても、Open Graph protocol (OGP) は設定できます。これで十分そうです。

  • SNS などに URL を貼り付けると、タイトルと画像を表示するサービスもある 2
  • 検索エンジンが、何に対する Storybook かということを理解できる

たとえば Qiita に https://hossy3.github.io/atcoder-slides/ と書くと、このように表示されます。

この OGP の設定は、<head></head> 内に OGP 用のタグを埋め込む形で行えます。

Storybook での設定方法

It's also possible to programmatically modify the preview head/body HTML using a preset, similar to the way preview-head.html/preview-body.html can be used to configure story rendering.
(中略)
Similarly, the managerHead can be used to modify the surrounding "manager" UI, analogous to manager-head.html.

全体のヘッダーに好きな情報を追加するには manager-head.html を使いましょう、とのことです。

Fluent UI v9 の設定例を参考に、次のように書きました。

.storybook/manager-head.html
<meta name="title" content="競技プログラミング お絵描き広場" />
<meta
  name="description"
  content="競技プログラミングの解法を、感覚的にイメージでとらえられるようなものを描いています。数式は控えめです。"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hossy3.github.io/atcoder-slides/" />
<meta property="og:title" content="競技プログラミング お絵描き広場" />
<meta
  property="og:description"
  content="競技プログラミングの解法を、感覚的にイメージでとらえられるようなものを描いています。数式は控えめです。"
/>
<meta property="og:locale" content="ja_JP" />
<meta
  property="og:image"
  content="https://hossy3.github.io/atcoder-slides/banner-meta.png"
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="670" />
<meta property="og:image:type" content="image/png" />
<meta
  property="og:image:alt"
  content="競技プログラミング お絵描き広場 バナー"
/>

<meta property="twitter:card" content="summary_large_image" />

Storybook では、ページごとに異なる OGP を表示するのは難しそうです。各スライド画像をそのまま og:image に入れたかったのですけれど。

ここまでで調整系は一区切りとします。お疲れさまでした。

GitHub Pages に GitHub Actions でデプロイする

作成したサイトを公開します。

ビルド

npm run build-storybook

Storybook インストール時に、ビルドコマンドも追加されています。

実行すると、storybook_static フォルダーにビルド成果物が置かれました。

.storybook/
src/
storybook_static/
  assets/
    abc*.png
    Slide*.js
  index.html

storybook_static の中身を、どこかの WEB サーバーに置けば、公開完了です。

gh-pages で手動デプロイ (今回は行わない)

以前サービスを公開したときは、更新頻度が少ないだろうと思い、gh-pages ブランチを経由して手動でデプロイしていました。同じようにも公開できるはずです。

今回はより更新しやすいように、GitHub Actions でデプロイします。

GitHub Actions でデプロイ

Publish Storybook

公式ページにおすすめの設定方法が書いていました。 main ブランチに変更があったときに、storybook-static/ 以下を公開するというように、と 2か所だけ書き換えました。

.github\workflows\deploy-github-pages.yaml
# Workflow name
name: Build and Publish Storybook to GitHub Pages

on:
  # Event for the workflow to run on
  push:
    branches:
      - "main"

permissions:
  contents: read
  pages: write
  id-token: write

# List of jobs
jobs:
  deploy:
    runs-on: ubuntu-latest
    # Job steps
    steps:
      # Manual Checkout
      - uses: actions/checkout@v3

      # Set up Node
      - uses: actions/setup-node@v3
        with:
          node-version: "16.x"

      #👇 Add Storybook build and deploy to GitHub Pages as a step in the workflow
      - uses: bitovi/github-actions-storybook-to-github-pages@v1.0.1
        with:
          path: storybook-static

そして GitHub リポジトリ設定の GitHub Pages から、Source を GitHub Actions (Beta) にすれば、設定終了。今後の公開作業は GitHub Actions にお任せできます。

最後に

Storybook を使って画像をまとめ、GitHub Pages で公開するところまで行いました。お疲れ様でした。

システムも本記事が書けるくらいには一区切りしたところで、また不定期にスライドを描いていきます。今後ともよろしくお願いします。

  1. ABC318 までで Ex はいったん終了しました。あまり頑張るところではないです。

  2. X (Twitter) では以前は og:title などがリンク先情報として表示されていましたが、2023年10月の仕様変更で除かれました。情報を設定したとしても、それをどのように表示する・しないかは、あくまで各サービス側に委ねられます

5
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6