はじめに
AtCoder コンテストの問題を解いていると、ふと解説画像を描きたくなることがあります。2023-11-04 に ABC327-F の解説画像を描いて、X (Twitter) に投稿していました。
画像作成後に、解説やコード例を付けて Qiita の記事を仕上げることもあります。その手間をかけず、絵を作るところまでで満足することも多かったです。そうするともう画像が目に留まることがなくなります。
記事を書かなくても、画像を簡単にまとめられる方法がないかということで、UI のカタログ集として人気の Storybook を使ってみようと思いました。
これがけっこう良い感じでした。ツリーで画像を切り替えられる、MDX で問題文と解答例へのリンクを気軽に追加できるなど。
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 のプロジェクト作成・公開方法を知りたい方
プロジェクトを作成する
開発環境
事前に用意します。
- Node.js 18
- Visual Studio Code - Code Editing. Redefined
- GitHub Desktop | Simple collaboration from your desktop
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 から
react
とreact-dom
を入手し、Vite や Parcel のようなバンドラを使ってカスタムビルドプロセスをセットアップし、
あまりおすすめではないそうですが、Vite ではできるとのこと。リンク先へ移動します。
Vite + React インストール
Storybook 公式から React 公式を経て、Vite 公式サイトにたどり着きました。インストールコマンドが書かれています。
npm create vite@latest my-app -- --template react-ts
これこそ欲しかったものです。
コマンドを実行すると、react
と react-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 コンポーネントで表示しつつ、リンクなどの追加情報を気軽に追加できます。
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>
を表示します。表示まではできます。
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
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
.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 ABC308-A ABC308-B ABC308-C ABC308-D ABC308-E ABC308-Ex ABC308-F ABC308-G ABC312 ABC312-E ABC327 ABC327-D ABC327-E ABC327-F |
はじめに ABC327 ABC327-D ABC327-E ABC327-F ABC312 ABC312-E ABC308 ABC308-A ABC308-B ABC308-C ABC308-D ABC308-E ABC308-Ex ABC308-F ABC308-G |
はじめに ABC327 ABC327-D ABC327-E ABC327-F ABC312 ABC312-E ABC308 ABC308-A ABC308-B ABC308-C ABC308-D ABC308-E ABC308-F ABC308-G ABC308-Ex |
コンテスト名で並び替え
Naming components and hierarchy
並び替え方法が公式に載っています。 preview.ts を次のように書きます。ルールが少なければ簡単です。
const preview: Preview = {
parameters: {
options: {
storySort: {
method: "alphabetical",
order: [
"はじめに",
"ABC327",
"ABC312",
"ABC308",
],
},
しかし、コンテストを追加するたびに preview.ts を更新するのは手間がかかります。もっと簡単にしたいです。
ソート関数を書いて並び替え
storySort
には関数を指定することもできます。"abc" で始まるストーリー名を特別扱いするような関数を次のように書けば、毎回のメンテナンスは不要になります。
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 の設定例を参考に、次のように書きました。
<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 でデプロイ
公式ページにおすすめの設定方法が書いていました。 main ブランチに変更があったときに、storybook-static/ 以下を公開するというように、と 2か所だけ書き換えました。
# 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 で公開するところまで行いました。お疲れ様でした。
システムも本記事が書けるくらいには一区切りしたところで、また不定期にスライドを描いていきます。今後ともよろしくお願いします。