React でデジタルパンフレットを作ってみた

## はじめに
業界業種問わず、カタログパンフレットや冊子、その他各種ドキュメントを閲覧、ダウンロードできるようにしているサイトは多いですよね。
コンテンツの表示方法はテキストリンクがポピュラーという印象ですが、テキストリンクに pdf や excel のアイコンが付いていたり、表紙のサムネイル画像 + ドキュメント名で各種一覧表示されていたり、ボタンデザインだったりなど色々あります。

カタログ系はデジタルパンフレットで読めるようになっているところも少なくないです。
ですが、ものによっては多機能すぎて少し使いづらかったりして……。弊社ではわざわざ WordPress を用意してプラグインを使ってデジタルパンフレットを用意していました。
しかしある日、プラグインをアップデートしたところ今まで通りの使い方ができなくなったのです（無料のプラグインでしたし文句なんか一切言えません）。

そこで今回、`React`, `TypeScript`でデジタルパンフレットを制作しました。
使用ツールは下記になります。
- React
- TypeScript
- Vite
- styled-components

## 制作経緯
ちらっと先ほど述べましたがプラグインが従来通り使えなくなったことが制作経緯です。
「もうテキストリンクでもええんとちゃうの？」という悪魔の囁きに身を委ねそうになりながらも、せっかくの機会だし自作してみようと思い立ちました。

## 実装した機能
- ページ送りの挙動とページめくり時の演出
- ページ数を指定してページ移動できる機能
一般的なデジタルパンフレットにあるような、各ページの拡大・縮小機能や自動ページ送り機能、ふせん、目次機能など諸々の多機能は要らないかなぁ〜と思って実装せず ~~（面倒くさいのと単なる技術不足）~~ シンプルな作りを目指しました。

【今回作ったもの】
- 大きいディスプレイver
<img width="75%" alt="大きいディスプレイver" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/105e8d8d-f1b6-557b-bf80-8451ae6d5d09.gif">

- スマートフォンver
<img width="75%" alt="スマートフォンver" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/3b08e5ab-ff67-3593-81e1-49d61d6e1ce7.gif">


### 各種ページコンポーネントを用意
最初（表紙）と最後のページは1枚表示、それ以外は両開き（2ページ表示）にしたかったので以下の3つのコンポーネントを用意しました。
- FirstPage.tsx（最初のページ）
- FinalPage.tsx（最後のページ）
- MultiPages.tsx（両開きページ：2ページ表示）

### ページ番号を管理するStateを用意
```
const [isPageNum, setPageNum] = useState<number>(1);

// thePostsPagination：ページ送りの処理に使用する関数も用意
const thePostsPagination = (pageNum: number) => {
    setPageNum((prevPageNum) => prevPageNum + pageNum);
}
```

### ページ送りの機能
```
...中略
export const usePagination = () => {
    /* 画像データへのパス一部（本環境での使用時にサブディレクトリで機能させるにはディレクトリパスの記述が必要になります）*/
    const imgpath: string = '/public/catalog-img/catalog_all_page_';
    // const imgpath: string = '/サブディレクトリ-XXX/サブディレクトリ-YYY/catalog-img/catalog_all_page_';

    /* 画像データの拡張子 */
    const extendsType: string = 'jpg';

    ...中略

    /* 前ページ */
    const PrevPage = useCallback((pageNum: number) => {
        return `${location.origin}${imgpath}${pageNum}.${extendsType}`;
    }, []);

    /* 次ページ */
    const NextPage = useCallback((pageNum: number) => {
        return `${location.origin}${imgpath}${pageNum + 1}.${extendsType}`;
    }, []);

    return { PrevPage, NextPage }
}
```

画像データは catalog_all_page_`1` のように末尾に番号を振っていて、絶対パスで画像データ（へのパス）を返すようにしています。そして、各種ページコンポーネント内にある`img`（ページ画像）をクリックすることで、ページ画像の`src`属性を書き換えていく仕様です（クリックで`thePostsPagination`が実行されてページ番号が加算 or 減算される）。

```
<img 
    src={NextPage(isPageNum)} 
    alt={`${documentTitle}の画像 - ${isPageNum + 1}ページ目`}
    onClick={() => {
        isPageNum === 0 ? thePostsPagination(2) : thePostsPagination(1)
    }}
/>
```

### ページ送りの演出
ページ送りの演出部分はCSSの `animation` を使いました。
<details>
<summary>ページ送り演出用の CSS（class属性の用意）に関する記述</summary>

```
&.paginateNext{
    transform-origin: left center;
    animation: paginateNext .25s linear;

    @keyframes paginateNext {
        0%{
          transform: rotateY(-8deg);
          filter: brightness(1);
        }

        25%{
            transform: rotateY(-28deg);
            filter: brightness(1);
        }

        50%{
            transform: rotateY(-48deg);
            filter: brightness(1);
        }

        75%{
            transform: rotateY(-68deg);
            filter: brightness(.5);
        }

        100%{
            transform: rotateY(-88deg);
            filter: brightness(.25);
        }
    }
}
```

上記は次ページに移る際の挙動で、前ページの場合は`transform-origin: right center;`に変更、rotateY を`rotateY（8deg）`のように正の数値に変更しているだけです。
</details>

次に、演出用の class を付与する機能をカスタムフックで用意しました

```
export const useToggleClass = () => {
    const paginateAnimationTime:number = 250;

    const ToggleClass = (
        targetEl: HTMLElement,
        targetClassName: string
    ) => {
        targetEl.classList.add(targetClassName);

        /* 指定時間経過後に初期化処理（targetClassName を remove）を行う */
        setTimeout(() => {
            targetEl.classList.remove(targetClassName);
        }, paginateAnimationTime);
    }

    return { ToggleClass }
}
```

これをページ送り時（画像クリック時）の処理に追加します（FinalPage.tsx の画像と、MultiPages.tsx の前ページ画像では、前ページにめくる挙動の class を付与）。ページをめくった後は class を都度削除してリセットします。

```
<img 
onClick={(elm) => {
    ToggleClass(elm.currentTarget, 'paginateNext');
    isPageNum === 0 ? thePostsPagination(2) : thePostsPagination(1)
/>
```

これでページ送りの演出もできました。ですが、JavaScript は即座に処理するので ページ送りの演出を見る間もなく処理されてしまいました。これは`setTimeout`で遅延処理を行って期待する挙動にできました。 

```
onClick={(elm) => {
    ToggleClass(elm.currentTarget, 'paginateNext');
    {isPageNum === 0 ?
        setTimeout(() => { thePostsPagination(2) }, 遅延させる時間) :
        setTimeout(() => { thePostsPagination(1) }, 遅延させる時間)
    }
```

### ページ数を指定してページ移動できる機能
まずは、ページ数を入力する`input`と、入力内容を管理するStateを用意しました。
```
const [isInputTxt, setInputTxt] = useState<string | number>('');
```

次に、ページ移動する機能をカスタムフック（useSetInputPagerNumber.ts）で用意しました。
<details>
<summary>useSetInputPagerNumber.ts の中身</summary>

```
export const useSetInputPagerNumber = () => {
    const _setPageNumImgSrc = (
        isInputTxt: string | number,
        setPageNum: (isPageNumEl: number) => void,
        setPageAction: (isPageNumEl: number) => string,
        imgEl: HTMLImageElement
    ) => {
        if (Number(isInputTxt) % 2 === 0) {
            setPageNum(Number(isInputTxt));
            imgEl.setAttribute('src', setPageAction(Number(isInputTxt)));
        } else {
            setPageNum(Number(isInputTxt) - 1);
            imgEl.setAttribute('src', setPageAction(Number(isInputTxt) - 1));
        }
    }

    const SetInputPagerNumber = (
        isInputTxt: string | number,
        lastPageNum: number,
        setPageNum: (isPageNumEl: number) => void,
        PrevPage: (isPageNumEl: number) => string,
        NextPage: (isPageNumEl: number) => string,
    ) => {
        const multiPageWrapperEl: HTMLDivElement | null = document.querySelector('.multiPageWrapper');

        const imgEls: NodeListOf<HTMLImageElement> | undefined = multiPageWrapperEl?.querySelectorAll('.imgEls');

        imgEls?.forEach(imgEl => {
            /* スマートフォンでの閲覧：1ページver */
            if (multiPageWrapperEl?.querySelector('.singlePageImg')) {
                setPageNum(Number(isInputTxt));
                imgEl.setAttribute('src', PrevPage(Number(isInputTxt)));
            }

            /* タブレット・PCでの閲覧：両開き仕様ver */
            else {
                /* 最後のページでの処理は PrevPage */
                if (Number(isInputTxt) === lastPageNum) {
                    _setPageNumImgSrc(isInputTxt, setPageNum, PrevPage, imgEl);
                }

                /* 最初のページでの処理は NextPage */
                else if (Number(isInputTxt) === 1) {
                    _setPageNumImgSrc(isInputTxt, setPageNum, NextPage, imgEl);
                }

                /* 両開き（複数）ページではページ別の処理を指定（prevPage：左ページ）*/
                else {
                    if (imgEl.classList.contains('prevPage')) {
                        _setPageNumImgSrc(isInputTxt, setPageNum, PrevPage, imgEl);
                    } else {
                        _setPageNumImgSrc(isInputTxt, setPageNum, NextPage, imgEl);
                    }
                }
            }
        });
    }

    return { SetInputPagerNumber }
}
```
</details>

かいつまんで説明しますと、
入力された内容が偶数の場合と、奇数の場合で処理を分けるプライベートメソッドを用意しており、
```
const _setPageNumImgSrc = (...中略) => {
    if (Number(isInputTxt) % 2 === 0) {
        setPageNum(Number(isInputTxt));
        imgEl.setAttribute('src', setPageAction(Number(isInputTxt)));
    } else {
        setPageNum(Number(isInputTxt) - 1);
        imgEl.setAttribute('src', setPageAction(Number(isInputTxt) - 1));
    }
}
```
最初のページ、最後のページ、両開きページの各種ページごとに処理を切り分けています。
```
...中略

// 各ページコンポーネント内の画像
const imgEls: NodeListOf<HTMLImageElement> | undefined = multiPageWrapperEl?.querySelectorAll('.imgEls');

imgEls?.forEach(imgEl => {
    /* 最後のページでの処理は PrevPage */
    if (Number(isInputTxt) === lastPageNum) {
        _setPageNumImgSrc(isInputTxt, setPageNum, PrevPage, imgEl);
    }
    
    /* 最初のページでの処理は NextPage */
    else if (Number(isInputTxt) === 1) {
        _setPageNumImgSrc(isInputTxt, setPageNum, NextPage, imgEl);
    }
    
    /* 両開き（複数）ページではページ別の処理を指定（prevPage：左ページ）*/
    else {
        if (imgEl.classList.contains('prevPage')) {
            _setPageNumImgSrc(isInputTxt, setPageNum, PrevPage, imgEl);
        } else {
            _setPageNumImgSrc(isInputTxt, setPageNum, NextPage, imgEl);
        }
    }
});
```

## 縦書き（左開き）仕様も欲しいなぁ〜
こういう作る側が考えるあったらいいかも的な機能ってユーザーに求められていないことも少なくないのですが（泣）、「後でやろ〜と思うと絶対しないやつ」と自分で思ったので追加実装することにしました。
追加実装といっても大掛かりな変更などはしておらず、縦書き仕様モードの変数（`const verticalWritingMode: boolean`）を用意。

```
// 縦書き仕様にするなら false を true に変更
const verticalWritingMode: boolean = false;
```

それを各種ページコンポーネントやカスタムフックに渡して三項演算子で処理を分ける、といった程度です。
- 例1：ページ画像の クリック時に演出用の class を付与する機能
```
ToggleClass(elm.currentTarget, `${verticalWritingMode ? 'paginatePrev' : 'paginateNext'}`);
```

- 例2：現在表示中のページ数と最終ページ数を表示する部分（縦書き仕様は左開きなので左側のページ数が常に+1ページ）
```
<div className={`pagerEls ${isEdit ? 'isEditTrue' : 'isEditFalse'}`} onClick={editMode}>
{verticalWritingMode ?
    <><span>{isPageNum + 1}</span> - <span>{isPageNum}</span> / {lastPageNum}</> :
    <><span>{isPageNum}</span> - <span>{isPageNum + 1}</span> / {lastPageNum}</>
}
</div>
```

## スマホで両開きは見づらい……
こういう作る側が考えるあったらいいかも…（以下略）。
スマートフォンなど幅が狭いデバイスでは1ページ表示にすることにしました。
とりあえずコード管理のしやすさを考慮して別途スマートフォン用のコンポーネント（SinglePage.tsx）を用意。
<details>
<summary>SinglePage.tsx の一部</summary>

```
...中略
    /* useLayoutEffect でレンダリング前に要素（targetImg：.singlePageImg）を認知させておくことで最初のページにも class付与（ページめくり）の処理を反映 */
    let targetImg: HTMLImageElement | null = null;
    useLayoutEffect(() => {
        targetImg = document.querySelector('.singlePageImg');
    }, [isPageNum]);
    
    return (
        <SinglePageWrapper>
            {/* 前ページボタン（verticalWritingMode true の場合は 次ページボタン）*/}
            <button type="button" className="prevSide" onClick={() => {
                verticalWritingMode ?
                    isPageNum < lastPageNum &&
                        {targetImg && ToggleClass(targetImg, 'paginatePrev')}
                        {setTimeout(() => thePostsPagination(1), pagerSpeed)}
                ...中略
            }}>&nbsp;</button>

            {/* ページ画像 */}
            <img className="imgEls singlePageImg" src={
                isPageNum === 0 ?
                    firstPageImgSrc :
                    PrevPage(isPageNum)
            } alt=...中略 />

            {/* 次ページボタン（verticalWritingMode true の場合は 前ページボタン）*/}
            <button type="button" className="nextSide" onClick={() => {
                verticalWritingMode ?
                    isPageNum > 1 &&
                ...中略
            }}>&nbsp;</button>
        </SinglePageWrapper>
    );
});
```

::: note warn
~~useLayoutEffect でレンダリング前にページ画像を認知してもらうことでページ送りの演出部分(CSS)を実装しているのですが、1ページ目（最初のページ）では初期状態の null で演出効果が反映されません。要検証していきたいと思います。~~

```
// 機能しなかった古い記述
let targetImg: HTMLImageElement | null = null;
useLayoutEffect(() => {
    targetImg = document.querySelector('.singlePageImg');
}, [isPageNum]);

// 変更後の記述：Stateとして扱うことで解決
const [isImgEl, setImgEl] = useState<HTMLImageElement | null>(null);
useLayoutEffect(() => {
        const targetImgEl: HTMLImageElement | null = document.querySelector('.singlePageImg');
        setImgEl(targetImgEl);
}, [isPageNum]);
```

2023/09/15追記：単なる変数ではなく、**State変数として扱う** ことで解決しました。
:::

</details>

他のページコンポーネントと違うのは、`button`要素を用意してクリックイベントにページ送りなどの諸機能を指定している部分です。そのため`img`（ページ画像）要素にはクリックイベントは指定されていません。両開き（2ページ）でもないので、`img`は1つかしかありませんし、最初のページ以外は「現在表示中のページ数に +1 or -1 されたページに応じた画像」が反映される仕組みになっています。
（例：catalog_all_page_`現在表示中のページ数 +1 or -1 された数値`.jpg）

その他、useSetInputPagerNumber.ts では、スマートフォン用の`img`要素（.singlePageImg）限定の処理を追加。
```
...中略
imgEls?.forEach(imgEl => {
    /* スマートフォンでの閲覧：1ページver */
    if (multiPageWrapperEl?.querySelector('.singlePageImg')) {
        setPageNum(Number(isInputTxt));
        imgEl.setAttribute('src', PrevPage(Number(isInputTxt)));
    }
...中略
```

全体の親コンポーネント（PageComponents.tsx）では、ブラウザ幅を取得するStateを用意して、三項演算子でページコンポーネントの取得を操作しました。
```
// ブラウザ幅を取得するState（レンダリング前に取得）
const [isViewPortWidth, setViewPortWidth] = useState<number>(0);
useLayoutEffect(() => {
    setViewPortWidth(window.innerWidth);
}, []);

...中略
{isViewPortWidth >= 700 ?
...中略（ブラウザ幅が 700 以上の場合に呼ばれるコンポーネント）　:
<SinglePage ...Propsなど中略 />
```
必要箇所には`isViewPortWidth`をpropsとして渡して、上記のような`isViewPortWidth >= 700 ?`の処理を行っています。

## デメリットというか個人的にイケてないところ
- ただの画像なので拡大すると pdf みたいに滑らかな表示にならない（画質が荒い）
- 高画質な画像を用意すると読み込みが遅くなってページ送り後に表示されるまで少し時間がかかる
~~- 画像のクリック処理とかアクセシビリティをあまり考慮できていない~~（`button`でページ画像をネストすることで`Tab`操作に対応しました）
- 最終ページ数をいちいち入力する必要がある（サーバーにアップした画像数から取得とかはできていない）
```
...中略
export const PageComponents = memo(() => {
    // 最終ページ数を入力
    const lastPageNum: number = 100;
...中略
```

## 個人的に頑張ったと思えるところ
- 縦書き文章（左開き）にも対応した仕様
- スマートフォンでの閲覧を考慮した仕様

## まとめ
長々と自分語りのような記事をここまで読んでいただきましてありがとうございました。
個人的にはJSXでの三項演算子やAND/OR演算子での複数行の記述の仕方をはじめ、コンポーネントや機能の切り分け、拡張性を加味した設計（まだまだですが）など作っていて学ぶ点はたくさんありました。
あと何より、作っていて楽しかっというのが大きかったです。これからも楽しくスキルアップしていきたいと思います！

今回作ったものは GitHub で公開しているのでご興味のある方は自由に使ってください。

https://github.com/Benjuwan/bookReader

## おまけ（Macでのデータ名一括リネームの方法）
1：リネームしたいデータを全選択
2：右クリックして表示される項目から「名称変更...」を選択
![0002-min.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/fc57a0cf-4051-108a-5fde-78fbc88607a9.png)

完全なリネームだけでなく、文字列の一部置換とかもできます。すごい便利！
![0003-min.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/3a8c366c-5cbd-6ae0-00e7-7f80b792d4f0.png)
変更後のデータ名がサンプルとして表示されているところとか、痒いところに手が届く感じだなぁ〜と感心しました。

以上、おしまい。