本文書では電子カタログを作成するためのクラウドサービスや作成用のアプリケーションを利用することなくTypeScriptを利用してスクラッチから作成する手順について説明を行っています。ネット上で見かけるおすすめのクラウドサービスやアプリケーションを利用して電子カタログを作成するといった記事ではなく電子カタログを自作する手順です。

PCにしか対応していないので主にJavaScript(中でもcanvas)を学び何かアプリケーションを構築したみたいという学習者向けの内容となっていますがPC対応に限定すれば本番環境でも十分に利用できる内容になっています。TypeScriptを利用していますがTypeScriptの知識はほとんど必要がありません。

本文書で作成する電子カタログの実際の動作はこちらのURL(https://digital-catalog-example.vercel.app/)から確認できます。

電子カタログとは

電子カタログ(デジタルカタログ)は主に紙媒体のカタログを作成する際に作成したPDFファイルを利用してホームページ上にカタログを電子データとして掲載する際に利用されています。

PDFファイルの場合はファイルをダウンロードしてAdobeのAcrobatなどのPDF Readerを利用して閲覧することになりますが電子カタログの場合はブラウザを利用して紙のカタログのように見開きページで閲覧でき、実際に紙のカタログをめくっているような感覚でページをめくれるようにアニメーションが設定されています。PDFファイルでは一括でダウンロードする必要があるためカタログのように画像が多い場合にはファイルサイズが非常に大きく環境によってはダウンロードするだけでも時間がかかります。電子カタログではページ毎に画像をわけているため閲覧している画像のみダウンロードすることになるのでPDFファイルに比べてページを開くまでのダウンロード時間を減らすことができます。

事前準備

電子カタログを作成するために電子カタログの元となる画像を準備する必要があります。通常は紙のカタログを作成する際に作成したPDFファイルを利用してPDFファイルから画像を作成します。PDFファイルが手元にある場合の作成方法の一例としてPythonを利用することでも簡単にPDFファイルから画像を作成することができます。

本環境では電子カタログで利用するファイル名は表紙の画像データを0.jpgとして1.jpg, 2.jpgと順番に名前をつけていきます。

環境の構築

プロジェクトの作成(Vite)

Viteを利用してTypeScriptの開発環境を構築します。プロジェクトの作成は”npm create vite@latest”コマンドを実行します。プロジェクトは任意の名前をつけることができここではdigital-catalogとしています。コマンドを実行して、プロジェクト名を設定した後にframeworkの選択で”Vanilla”を選択、variantでTypeScriptを選択します。


 % npm create vite@latest
Need to install the following packages:
create-vite@5.2.3
Ok to proceed? (y) y
✔ Project name: … digital-catalog
✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript

Scaffolding project in /Users/mac/Desktop/digital-catalog...

Done. Now run:

  cd digital-catalog
  npm install
  npm run dev

プロジェクトディレクトリに移動してnpm installを実行してJavaScript関係のパッケージのインストールを行います。


% npm install

Tailwind CSSの設定

必須ではありませんがCSSによるスタイリングの設定にTailwind CSSを利用します。ViteでTailwind CSSを利用するためにはTailwind CSSのインストールを行い初期設定が必要となります。

tailwindcss, postcss, autoprefixerのパッケージのインストールを行います。


 % npm install -D tailwindcss postcss autoprefixer

Tailwind CSSの設定ファイルtailwind.config.jsファイルを作成するために以下のコマンドを実行します。


 % npx tailwindcss init

Created Tailwind CSS config file: tailwind.config.js

コマンド実行後プロジェクトディレクトリにtailwind.config.jsファイルが作成されます。tailwind.config.jsファイルのcontentにindex.htmlとTypeScriptのファイル(拡張子ts)を認識できるように設定を追加します。index.htmlファイルのみの設定ではこれから作成するmain.tsファイルに記述されたclassを認識してくれないので忘れないで設定してください。


/** @type {import('tailwindcss').Config} */
export default {
  content: ['index.html', './src/**/*.{html,ts}'],
  theme: {
    extend: {},
  },
  plugins: [],
};

PostCSSを利用するためにプロジェクトディレクトリにpostcss.config.jsファイルを作成して以下のコードを記述します。


export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

srcディレクトリのstyle.cssファイルにtailwindのディレクティブの設定を追加します。


@tailwind base;
@tailwind components;
@tailwind utilities;

srcディレクトリのmain.tsファイルでstyle.cssのimport文のみを残して残りのコードを削除します。


import './style.css';

またsrcディレクトリにデフォルトで存在するcounter.ts, typescript.svgファイルを削除します。

プロジェクトディレクトリの直下にあるindex.htmlファイルでTailwind CSSが提供するUtility Classを設定してTailwind CSSが正常に設定が完了しているか確認します。


<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>電子カタログ</title>
  </head>
  <body>
    <div class="font-bold text-xl">Hello World</div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

index.htmlファイルを更新後”npm run dev”コマンドを実行して開発サーバを起動してブラウザからアクセスすると”Hello World”の文字が太文字になっていることが確認できればTailwind CSSの設定は正常に完了しています。

Tailwind CSSの動作確認
Tailwind CSSの動作確認

動作確認したJavaScriptのパッケージのバージョンもpackage.jsonファイルで確認しておきます。


{
  "name": "digital-catalog",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "autoprefixer": "^10.4.19",
    "postcss": "^8.4.38",
    "tailwindcss": "^3.4.3",
    "typescript": "^5.2.2",
    "vite": "^5.2.0"
  }
}

電子カタログの作成

環境の構築が完了したので電子カタログの作成を行っていきます。本文書で作成する電子カタログはPCからの閲覧を想定しています。モバイルから閲覧を考えていません。

フリックやピンチイン、ピンチアウトに対応するためのイベント処理が少し複雑になるためモバイル対応は省略しています。
fukidashi

canvas要素の作成

電子カタログの作成は主にHTML5のcanvasを利用して行います。canvasではJavaScriptを利用して操作を行なうのでJavaScriptの知識が必要となります。

canvasについての基本はこちらでも公開しています。

canvasを利用する際はcanvasタグが必要になるのでcanvasタグを追加して画像を描写できる環境を設定します。

index.htmlファイルにid属性にappを持つdiv要素を追加します。


<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>電子カタログ</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

main.tsファイルでcanvas要素の作成を行います。


import './style.css';

const app = document.getElementById('app') as HTMLDivElement;

const canvas = document.createElement('canvas');
canvas.id = 'canvas';

app.appendChild(canvas);

上記のコードではdocument.getElementByIdメソッドでindex.htmlファイルのid属性にappを持つdiv要素を取得して変数appに保存します。document.createElementメソッドでcanvas要素を作成し、appが持つappendChildメソッドで作成したcanvas要素をdiv要素に挿入します。その結果、id属性にappを持つdiv要素の中にid属性にcanvasを持つcanvas要素が作成されます。

canvas要素が持つgetContextの引数に”2d”を指定してCanvasのRendering Contextを取得してctx変数に保存します。canvas上に図形を描写するためにはRendering Contextが必要となります。


//略
app.appendChild(canvas);

const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;

if (!ctx) {
  throw new Error('Failed to get 2d context from canvas');
}

console.log(ctx);

上記のコードではcanvasの描写領域を設定しただけなのでブラウザ上にも何も表示されませんがconsole.logによりブラウザのデベロッパーツールのコンソールにはCanvasRenderingContext2Dオブジェクトの情報が表示されます。

画像の描写

canvasの作成が完了したのでcanvas上に画像の描写を行います。

canvas上に画像を描写するために画像をロードする処理が必要になります。画像をロードするためのloadImg関数を追加します。


const loadImg = (img: HTMLImageElement): Promise<HTMLImageElement> => {
  return new Promise((resolve, reject) => {
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error('Failed to load image'));
  });
};

loadImg関数の引数にしているHTMLImageElementのオブジェクトを作成します。

Image Classでインスタンスを作成してsrcプロパティに画像のパスを指定します。動作確認のためデフォルトで存在するpublicディレクトリに保存されているvite.svgファイルを利用します。画像のロードが完了するとctx.drawImageメソッドを利用して画像を描写することができます。ctx.drawImageメソッドの第一引数にはImage Classをインスタンス化した際に戻されるimageオブジェクト、第二引数は画像を描写する際のx座標と第三引数にはy座標を指定しています。画像の左上の座標が指定したx座標とy座標になります。x座標とy座標の原点はcanvasの描写領域の左上になります。画像を描写する際には左上の座標が重要になっていきます。


const image = new Image();
image.src = '/vite.svg';
loadImg(image).then(() => {
  ctx.drawImage(image, 15, 15);
});

ブラウザで確認すると指定した位置にvite.svgファイルの画像が描写されます。

画像の描写
画像の描写

canvas上での画像の描写方法を確認することができました。

ローディングの表示

開発環境ではローカルに画像を一緒に保存するためネットワーク上の別の場所から画像をダウンロードする必要がないためすぐに画像がロードされ描写されます。しかしネット上に公開した場合はユーザのネットワークの通信環境によっては画像のダウンロードに時間がかかります。画像の描写が完了するまでの間処理が進行中であることをユーザに示すため、ローディングを表示させる設定を行います。

index.htmlにid属性にloadingを持つローディングのためのdiv要素を追加します。Tailwind CSSのclassを利用してブラウザの画面中央にLoadingを表示する設定を行っています。


<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>電子カタログ</title>
  </head>
  <body>
    <div id="app"></div>
    <div
      id="loading"
      class="absolute top-0 flex items-center justify-center h-screen w-screen"
    >
      <div
        class="animate-spin h-7 w-7 border-4 border-gray-900 rounded-full border-t-transparent"
      ></div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

動作確認を行うとブラウザの中央にローディングが表示されることが確認できます。

ローディングの表示
ローディングの表示

ローティングが表示されるのは画像のロードが完了するまでの間なのでロードが完了したら非表示となるように設定を行います。

ローティングのdiv要素をdocument.getElementByIdメソッドで取得します。


const app = document.getElementById('app') as HTMLDivElement;
const loading = document.getElementById('loading') as HTMLDivElement;

開発環境では説明した通り即座にLoadが完了するのでローディングが表示から非表示になるのが一瞬なのでわかりずらいです。そのため動作確認用にロードに時間がかかっていることを擬似的に発生させるため待ちのコードをPromiseを利用して設定します。awaitを利用しているのでasyncも忘れずに設定してください。


loadImg(image).then(async () => {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  loading.remove();
  ctx.drawImage(image, 15, 15);
});

動作確認を行うとページを開いて3秒間はローティングが表示されますが3秒経過後はローティングは消えて画像が描写されます。

動作確認が完了したら待ちのコードは削除しておきます。


loadImg(image).then(() => {
  loading.remove();
  ctx.drawImage(image, 15, 15);
});

画像の描写設定

PDFファイルから作成した画像を利用して表紙画面を描写するコードを実装していきます。publicディレクトリには画像ファイルの0.jpg, 1.jpg, …..ファイルが保存されている状態で開始します。

main.tsファイルで先ほどはvite.svgファイルを指定してcanvas上に描写しましたがvite.svgから0.jpgに変更します。


const image = new Image();
image.src = '/0.jpg';
loadImg(image).then(() => {
  loading.remove();
  ctx.drawImage(image, 15, 15);
});

ブラウザで確認すると0.jpgファイルの左部分のみ表示されます。理由はcanvasのwidth, heightのデフォルトサイズが300px, 150pxであるためその領域に入らない部分が表示されないためです。

PDFファイルから作成した画像を描写
PDFファイルから作成した画像を描写

ブラウザのウィンドウサイズを取得するためwindow.innerHeightとwindow.innerWidthを利用します。新たにsetting関数を追加してsetting関数の中でcanvasのwidthとheightの設定を行います。setting関数は画像のロードが完了した後に実行します。後ほど作成するコードでロードした画像の各種情報を利用するためです。


const setting = () => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
};

const image = new Image();
image.src = '/0.jpg';
loadImg(image).then(() => {
  loading.remove();
  setting();
  ctx.drawImage(image, 15, 15);
});

ブラウザで確認すると画像全体が描写されるわけではありませんがブラウザの画面全体に描写されていることがわかります。

ブラウザ全体で描写される画像
ブラウザ全体で描写される画像

画像の一部ではなくブラウザ上に画像の全体が入るように設定を行っていきます。

そのためにはブラウザ上に収まる画像の幅と高さが必要となります。ロードした画像のサイズを利用して画像のアスペクト比を計算します。描写する画像の高さはcanvasの高さ、画像の幅は画像の高さとアスペクト比を利用して計算します。アスペクト比や画像の高さ、幅はブラウザのサイズをリサイズしても再計算できるように変数として定義しています。それらの処理はsetting関数に追加します。


//略
let aspectRatio = 0;
let imgHeight = 0;
let imgWidth = 0;

const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height;
  imgWidth = imgHeight * aspectRatio;
};

const image = new Image();
image.src = '/0.jpg';
loadImg(image).then((img) => {
  loading.remove();
  setting(img);
  ctx.drawImage(image, 0, 0, imgWidth, imgHeight);
});

ctx.drawImageを利用して画像を描写していますが引数から3つから5つになっています。第四引数と第五引数には描写する画像のwidthとheightを設定することができます。画像の位置には0, 0を指定しているのでcanvasの原点に画像の左上の座標が配置されます。

ブラウザで確認するとブラウザ内に収まった画像が描写されます。

ブラウザのウィンドウサイズに収まった画像
ブラウザのウィンドウサイズに収まった画像

利用するPDFファイルは複数のページで構成されています。複数のページの画像の情報を管理する変数imagesを配列で定義します。配列の要素にはインスタンス化したImageオブジェクトの情報を保存するためコード実行直後にページ数分のImageオブジェクトを配列に保存します。そのためにページ数を保存する変数pagesを定義します。本文書で利用するカタログは表紙を含め14ページから構成されているがめpages変数には14を設定しています。


const pages = 14;
const images: HTMLImageElement[] = [];
//略

for (let i = 0; i < pages; i++) {
  images[i] = new Image();
}

配列にImageオブジェクトを保存すると同時にsrcプロパティに画像のパスを設定することもできますが設定した時点で画像のダウンロードが開始されます。電子カタログではすべての画像を閲覧する可能性は少ないため不必要な画像のダウンロードは無駄な帯域の利用と遅延につながります。必要な時に画像をダウンロードさせるように設定を行うためここではsrcのは行いません。

表紙ページの描写

表紙ページの描写設定を行いますが、電子カタログをブラウザ上でどのように表示させるのかを確認しておきます。

見開きページを表示したい場合にはブラウザ上に2つのページ画像を描写させます。

見開きページの表示
見開きページの場合

表紙については右側部分のみに画像を描写させます。裏表紙については右側ではなく左側部分に描写させます。表示させるページによってこの3つの表示方法を切り替える必要があります。

画像の上下にスペースを追加
表紙ページの場合

表示には3つのパターンがあることを説明したので表紙画像の描写を行っていきますが、画像を描写した際に確認した通りcanvas上に画像を描写する場合は左上の座標を指定して描写することになります。表紙の場合は、ブラウザの右側部分に表示させるため画像の左上のx座標はブラウザの中央となります。ブラウザの中央のx座標を変数centerXに保存します。canvas.widthはブラウザのウィンドウサイズの幅を設定しているのでブラウザの中央のx座標はcanvas.width/2となります。変数centerXとは別に変数dxとdyを定義してctx.drawImageのx座標とy座標の設定に利用します。


//略
let aspectRatio = 0;
let imgHeight = 0;
let imgWidth = 0;
let centerX = 0;
let dx = 0;
let dy = 0;

//略

const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  centerX = canvas.width / 2;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height;
  imgWidth = imgHeight * aspectRatio;
  dx = centerX;
  dy = 0;
};

images[0].src = '/0.jpg';
loadImg(images[0]).then((img) => {
  loading?.remove();
  setting(img);
  ctx.drawImage(images[0], dx, dy, imgWidth, imgHeight);
});

ブラウザで確認すると表紙の画像が右側に表示されます。

ブラウザの右側に表示
ブラウザの右側に表示

画像の背景が白の場合、画像の内容によってどこがページ画像なのか分かりにくいのでcanvasの背景色を設定します。


const bgColor = '#999';

const canvas = document.createElement('canvas');
canvas.id = 'canvas';
canvas.style.backgroundColor = bgColor;

canvasの背景色を設定するとページ画像がどの部分なのかが境界線が明確になります。

canvasの背景色の設定
canvasの背景色の設定

画像の上下にスペースを空けるためにいつでも調整が可能なtopMarginとbottomMargin変数を定義します。marginの値を画像の高さとy座標に反映させます。


const topMargin = 10;
const bottomMargin = 20;

let aspectRatio = 0;
let imgHeight = 0;
let imgWidth = 0;
let centerX = 0;
let dx = 0;
let dy = 0;

const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  centerX = canvas.width / 2;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height - topMargin - bottomMargin;
  imgWidth = imgHeight * aspectRatio;
  dx = centerX;
  dy = topMargin;
};

ブラウザで確認すると画像の上下にスペースを確認することができます。

画像の上下にスペースを追加
画像の上下にスペースを追加

表紙以外の画像のロード

表紙以外のページについてはdrawImage関数を追加してページの描写を行います。drawImage関数の中では引数から受け取ったpageNumの画像がImageオブジェクトのsrcプロパティに設定されているのか。またロードが完了しているのかをcompleteプロパティを利用してチェックしロードが完了していない場合にはloadImg関数を利用してロードを行い描写します。


const drawImage = (
  pageNum: number,
  x: number,
  y: number,
  width: number,
  height: number
) => {
  if (!images[pageNum].src) images[pageNum].src = `/${pageNum}.jpg`;
  if (images[pageNum].complete) {
    ctx.drawImage(images[pageNum], x, y, width, height);
  } else {
    loadImg(images[pageNum])
      .then((img) => {
        ctx.drawImage(img, x, y, width, height);
      })
      .catch((err) => console.log(err));
  }
};

見開きページの作成

見開きページの場合は2枚の画像をブラウザの中央を境に左と右に描写させる設定を行います。

表紙の次のページを描写するためのnextPage関数を追加します。nextPage関数では追加したdrawImage関数を利用して画像をcanvas上に描写しています。動作確認なのでnextPage関数は表紙画像のloadImg関数の中で実行しています。


const nextPage = () => {
  drawImage(1, dx - imgWidth, dy, imgWidth, imgHeight);
  drawImage(2, dx, dy, imgWidth, imgHeight);
};

images[0].src = '/0.jpg';
loadImg(images[0]).then((img) => {
  loading?.remove();
  setting(img);
  //   ctx.drawImage(img, dx, dy, imgWidth, imgHeight);
  nextPage();
});

左側のページの描写ではx座標をブラウザの中央である右側のページのx座標のdxから画像の幅(imgWidth)分ほどずらしています。

ブラウザで確認すると見開きでページが表示されていることが確認できます。

見開きページの表示
見開きページの表示

ページの移動ボタンの作成(次へ、戻るボタン)

index.htmlファイルにページの移動ボタンの要素を追加します。id属性にtoolsを持つdiv要素で2つのボタン要素をWrapしてdisplayはhiddenでアクセス直後には非表示に設定しています。2つのボタンのコンテンツには&lt;, &gt;を利用して矢印としています。


//略
<body>
  <div id="tools" class="hidden">
    <button
      id="previous_btn"
      class="absolute left-0 w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 hover:bg-gray-600 text-white text-xl"
    >
      <
    </button>
    <button
      id="next_btn"
      class="absolute right-0 w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 hover:bg-gray-600 text-white text-xl"
    >
      >
    </button>
  </div>
  <div id="app"></div>
//略

div要素、2つのbutton要素に対してdocument.getElemetByIdメソッドで要素を取得しそれぞれを変数に保存します。


const app = document.getElementById('app') as HTMLDivElement;
const loading = document.getElementById('loading') as HTMLDivElement;
const tools = document.getElementById('tools') as HTMLDivElement;
const previous_btn = document.getElementById(
  'previous_btn'
) as HTMLButtonElement;
const next_btn = document.getElementById('next_btn') as HTMLButtonElement;

表紙画像のロード完了後にtools要素を非表示から表示に切り替え、2つのボタンをブラウザの高さの中央とそれぞれ左右から10px空けて表示させるようにsetting関数の中で設定します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  centerX = canvas.width / 2;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height - topMargin - bottomMargin;
  imgWidth = imgHeight * aspectRatio;
  dx = centerX;
  dy = topMargin;
  previous_btn.style.top = `${imgHeight / 2 + topMargin / 2}px`;
  previous_btn.style.left = '10px';
  next_btn.style.top = `${imgHeight / 2 + topMargin / 2}px`;
  next_btn.style.right = '10px';
  tools.className = 'block';
};

ブラウザで確認するとブラウザの左右にボタンが表示されます。

ページ移動ボタンの表示
ページ移動ボタンの表示

各ボタンをクリックすると処理が行えるようにclickイベントを追加します。イベントはregisterEvents関数を作成してその中に追加します。


const registerEvents = () => {
  previous_btn.addEventListener('click', () => {
    console.log('previous');
  });
  next_btn.addEventListener('click', () => {
    console.log('next');
  });
};

registerEvents関数をsetting関数の後に追加します。


images[0].src = '/0.jpg';
loadImg(images[0]).then((img) => {
  loading.remove();
  setting(img);
  ctx.drawImage(img, dx, dy, imgWidth, imgHeight);
  registerEvents();
});

clickイベントの処理を追加後はボタンをクリックするとブラウザのデベロッパーツールのコンソールに”next”または”previous”の文字列が表示されます。

現在描写しているページ番号

現在ブラウザ上に描写させているページがどのページなのかを管理するcurrentPage変数を追加します。最初は表紙が表示されるので値を0とします。


let currentPage = 0;
let aspectRatio = 0;
let imgHeight = 0;
let imgWidth = 0;
let centerX = 0;
let dx = 0;
let dy = 0;

表紙ページをロードする際に0を直接設定していましたがcurrentPageに変更します。


images[currentPage].src = `/${currentPage}.jpg`;
loadImg(images[0]).then((img) => {
  loading.remove();
  setting(img);
  registerEvents();
});

また表紙の描写処理ctx.drawImageはsettting関数の中で行うように更新を行います。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  centerX = canvas.width / 2;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height - topMargin - bottomMargin;
  imgWidth = imgHeight * aspectRatio;
  dx = centerX;
  dy = topMargin;
  ctx.drawImage(images[currentPage], dx, dy, imgWidth, imgHeight);
 //略
};

ページ移動処理

ページの移動処理を実装していきます。next_btnボタンをクリックすると次のページが表示されるようにnext_btnに設定したclickイベントを利用します。nextPage関数では一度ブラウザ上に表示されている画像をクリアするためctx.clearRectメソッドを実行しています。


const registerEvents = () => {
  previous_btn.addEventListener('click', () => {
    console.log('previous');
  });
  next_btn.addEventListener('click', () => {
    nextPage();
  });
};

const nextPage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawImage(1, dx - imgWidth, dy, imgWidth, imgHeight);
  drawImage(2, dx, dy, imgWidth, imgHeight);
};

表紙ページを開いた状態でnext_btnボタンをクリックすると見開きのページが表示されることが確認できます。表紙ページから次のページにあたる1, 2ページの見開きページを開くことができましたそれ以降のページに移動することはできません。他のページへ移動するためにcurrentPageを利用します。

ページを移動する際に表紙は0.jpgで次の見開きページは1.jpg,2jpg、その次のページは3jpg,4jpgとなりcurrentPageは0, 1, 3, 5と変わるように設定していきます。最後のページは裏表紙なので表紙とは異なり左側部分にのみ画像を描写させます。nextPageにその内容を反映させると下記のようなコードになります。


const nextPage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  currentPage === 0 ? (currentPage += 1) : (currentPage += 2);
  drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
  if (currentPage !== pages - 1)
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
};

currentPageが0の場合はcurrentPageに1足しますがそれ以外の場合は2足します。最後のページの一つ前のページ以外には見開きの左右でページを表示するように設定しています。

ブラウザ上で動作確認を行うためにnext_btnボタンをクリックしていくとページを移動することができ、最後のページである裏表紙でのみ画像は左側のみ描写されます。本文書で利用した裏表紙には白紙なので以下のような画面ですが問題はありません。

裏表紙のページの描写
裏表紙のページの描写

next_btnボタンによるページ移動の設定が完了したのでprevious_btnボタンを押すと前のページに戻る設定を追加します。1.jpg, 2.jpgのページが開いている時にprevious_btnをクリックした時のみ0.jpgが右側のみに表示されることを考慮に入れてpreviousPage関数の処理を記述します。


const previousPage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  currentPage === 1 ? (currentPage -= 1) : (currentPage -= 2);
  if (currentPage === 0) {
    drawImage(currentPage, dx, dy, imgWidth, imgHeight);
  } else {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  }
};

作成したpreviousPage関数はprevious_btnのclickイベントのcallback関数の中で実行します。


const registerEvents = () => {
  previous_btn.addEventListener('click', () => {
    previousPage();
  });
  next_btn.addEventListener('click', () => {
    nextPage();
  });
};

設定後は左右のボタンをクリックしてページの移動が行えるようになります。しかし表紙が表示されている状態でprevious_btn, 裏表紙が表示されている状態でnext_btnボタンをクリックすると対応する画像は存在しないのでエラーとなります。表紙が表示されている状態ではprevious_btnボタン、裏表紙ボタンが表示されている状態でnext_btnボタンが表示されないようにsetting関数の処理を追加します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  centerX = canvas.width / 2;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height - topMargin - bottomMargin;
  imgWidth = imgHeight * aspectRatio;
  dx = centerX;
  dy = topMargin;
  ctx.drawImage(images[currentPage], dx, dy, imgWidth, imgHeight);
  if (currentPage === 0) previous_btn.style.display = 'none';
  previous_btn.style.top = `${imgHeight / 2 + topMargin / 2}px`;
  previous_btn.style.left = '10px';
  if (currentPage === pages - 1) next_btn.style.display = 'none';
  next_btn.style.top = `${imgHeight / 2 + topMargin / 2}px`;
  next_btn.style.right = '10px';
  tools.className = 'block';
};

カタログページにアクセスした直後に描写される表紙ではprevious_btnが表示されなくなりますがnext_btnをクリックして次のページに移動しても表示されないままの状態となります。next_btn, previous.btnボタンをクリックした後に各ボタンの表示・非表示をupdateStyle関数を設定して制御します。


const nextPage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  currentPage === 0 ? (currentPage += 1) : (currentPage += 2);
  drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
  if (currentPage !== pages - 1)
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  updateStyle();
};

const previousPage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  currentPage === 1 ? (currentPage -= 1) : (currentPage -= 2);
  if (currentPage === 0) {
    drawImage(currentPage, dx, dy, imgWidth, imgHeight);
  } else {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  }
  updateStyle();
};

const updateStyle = () => {
  if (currentPage === 0) {
    previous_btn.style.display = 'none';
  } else if (currentPage === pages - 1) {
    next_btn.style.display = 'none';
  } else {
    previous_btn.style.display = 'block';
    next_btn.style.display = 'block';
  }
};

設定完了後、表紙ページではnext_btn、裏表紙ページではprevios_btn、それ以外のページでは2つのボタンが表示されページの移動を行うことができます。

リサイズ時の処理

一度ページが表示された後にブラウザのウィンドウサイズを変更するとcanvasのサイズに変化がないため描写されている画像のサイズに変化がありません。拡大した場合と縮小した場合のそれぞれは下記のようになります。

ブラウザのウィンドウサイズを拡大した場合
ブラウザのウィンドウサイズを拡大した場合
ブラウザのウィンドウサイズを縮小した場合
ブラウザのウィンドウサイズを縮小した場合

ブラウザのウィンドウサイズに合わせてcanvasとページ画像のサイズが更新されるようにブラウザのresizeイベントを利用します。resizeイベントのcallback関数ではsetting関数を設定します。


const registerEvents = () => {
  previous_btn.addEventListener('click', () => {
    previousPage();
  });
  next_btn.addEventListener('click', () => {
    nextPage();
  });
  window.addEventListener('resize', () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    setting(images[currentPage]);
  });
};

設定完了後、表紙を描写した状態でブラウザウィンドウのサイズの変更を行うとブラウザウィンドウのサイズに合わせて画像のサイズも変更されます。しかし、見開くページで実行すると1つのページしか描写されず、これまでに左側に表示されていた画像が右側に表示されます。

setting関数ではcurrentPageのページ数によって表紙の右側のみの表示、両方のページの表示、裏表紙の左側のみの表示に対応できるように更新します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  centerX = canvas.width / 2;
  aspectRatio = img.width / img.height;
  imgHeight = canvas.height - topMargin - bottomMargin;
  imgWidth = imgHeight * aspectRatio;
  dx = centerX;
  dy = topMargin;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    ctx.drawImage(images[currentPage], dx, dy, imgWidth, imgHeight);
  } else if (currentPage === pages - 1) {
    ctx.drawImage(images[currentPage], dx - imgWidth, dy, imgWidth, imgHeight);
  } else {
    ctx.drawImage(images[currentPage], dx - imgWidth, dy, imgWidth, imgHeight);
    ctx.drawImage(images[currentPage + 1], dx, dy, imgWidth, imgHeight);
  }
  previous_btn.style.top = `${imgHeight / 2 + topMargin / 2}px`;
  previous_btn.style.left = '10px';
  if (currentPage === pages - 1) next_btn.style.display = 'none';
  next_btn.style.top = `${imgHeight / 2 + topMargin / 2}px`;
  next_btn.style.right = '10px';
  tools.className = 'block';
};

設定後は、どのページでウィンドウサイズの変更を行ってもそれに合わせてcanvasサイズと画像のサイズが変更されるようになります。

現在のページ番号の表示

現在どのページを閲覧しているページ番号の表示からわかるようにindex.htmlファイルにfooter要素を追加します。footer要素の中には現在表示されているページ数と全ページ数を表示する要素を設定します。index.htmlを開いた直後はhidden classで非表示にしています。


<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>電子カタログ</title>
  </head>
  <body>
 //略
    <footer id="footer" class="hidden absolute bottom-0 h-16 bg-black/30 w-full">
      <div id="page_area" class="absolute">
        <span id="page_number">0</span>/<span id="page_total"></span>
      </div>
    </footer>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

footer要素を含めid属性を利用して要素を取得できるようにdocument.getElementByIdメソッドで属性を指定して要素を取得して変数に保存します。


//略
const next_btn = document.getElementById('next_btn') as HTMLButtonElement;
const footer = document.getElementById('footer') as HTMLDivElement;
const page_area = document.getElementById('page_area') as HTMLDivElement;
const page_number = document.getElementById('page_number') as HTMLSpanElement;
const page_total = document.getElementById('page_total') as HTMLSpanElement;

setting関数の中でfooter要素に設定されたhidden classを削除することでfooterを非表示から表示に切り替えpage_areaの表示位置をブラウザの中央に設定して全ページ数をpage_totalに表示させています。


const setting = (img: HTMLImageElement) => {
//略
  tools.className = 'block';
  if (footer.classList.contains('hidden')) footer.classList.remove('hidden');
  page_area.style.left = `${dx - page_area.offsetWidth / 2}px`;
  page_total.textContent = `${pages - 1}`;
};

ページ数の表示は移動する度に更新されるのでupdateStyle関数の中で更新処理を行います。


const updateStyle = () => {
  if (currentPage === 0) {
    previous_btn.style.display = 'none';
  } else if (currentPage === pages - 1) {
    next_btn.style.display = 'none';
  } else {
    previous_btn.style.display = 'block';
    next_btn.style.display = 'block';
  }
  page_number.textContent = `${currentPage}`;
};

next_btn, previous_btnボタンをクリックしてページを移動するとブラウザの下部に表示されたページ数が更新されます。

ページ数の表示
ページ数の表示

変数bottomMarginの値を大きく設定することでページ画像との重なりをなくすことができます。


const pages = 14;
const images: HTMLImageElement[] = [];
const topMargin = 20;
const bottomMargin = 80;
ページ画像との重なりをなくす場合はbottomMarginを変更
ページ画像との重なりをなくす場合はbottomMarginを変更

ページめくり機能の設定 (アニメーション)

電子カタログの特徴の一つであるページめくりのアニメーションを設定します。

次のページへめくり

次のページめくりのアニメーションのコードが完了すると以下のようにページをめくることができます。

ページめくりのアニメーション
ページめくりのアニメーション

ページのアニメーションは下記のようにページ画像の左下の先端が点線で示された線の上をアニメーションで移動していくことで実現していきます。グレーで表示されている部分がcanvas上に表示されている次のページの一部です。グレーの部分はクリッピングマスクの機能を利用して表示させるようにします。

ページめくりで表示される部分
ページめくりで表示される部分

点線の上を動くページ画像の左下の先端の点のx座標とy座標をflippingX, flippingYとします。flippingX, flippingYの開始点をstartX, startYとしてその座標はdx + imgWidthとdy+imgHeightとなります。dxはcanvasの中央のx座標、imgWidthはページ画像の幅、imgHeightはページ画像の高さです。

flippingPosXとflippingPosXが移動する線
flippingPosXとflippingPosXが移動する線

中間点のmiddleXとmiddleYの座標は任意の高さを設定することができるのでここではmiddleXはdx, middleYはimgHeightの2/3の高さ((dy + imgHeight) * 2 ) /3とします。終点のendXとendYの座標はdx – imgWidth, dy + imgHeightとなります。

flippingPosXの値がdx(ブラウザの中心のx座標で点線の三角の頂点)より大きいか小さいかで分岐を行い、開始点、中間点、終点の座標を利用して角度を計算してflippingPosXとflipiingPosYの位置の変化を計算します。flipSpeedの値によってflippingPosXとflippingPosYの移動の距離の調整することができます。


const flippingPosX = dx + imgWidth;
const flippingPosY = dy + imgHeight;

const startX = dx + imgWidth;
const startY = dy + imgHeight;

const middleX = dx;
const middleY = ((dy + imgHeight) * 2) / 3;

const endX = dx - imgWidth;
const endY = dy + imgHeight;

let angle = 0;
if (flippingPosX < dx) {
  angle = Math.atan2(endY - middleY, endX - middleX);
} else if (flippingPosX > dx) {
  angle = Math.atan2(middleY - startY, middleX - startX);
}

flippingPosX += Math.cos(angle) * flipSpeed;
flippingPosY += Math.sin(angle) * flipSpeed;

flippingPosXとflippingPosXの座標の計算方法を設定できたので次はcanvas上に表示させる画像の位置の設定を行っていきます。canvas上で画像を表示させる場合には画像の左上の座標が必要となり、そのx座標とy座標をrotateX, rotateYとします。

flippingXとflippingYの値がわかれば簡単な数学を利用してrotateX, rotateYの値を計算することができます。rotateX, rotateYを計算するためにlengthX, lengthY, x, y, X, Yの変数を定義して利用します。 

座標を計算するために利用する変数
座標を計算するために利用する変数

lengthXとlengthYの長さはflippingPosXとflippingPosYの座標を利用して下記のように計算することができます。


const lengthX = dx + imgWidth - flippingPosX
const lengthY = dy + imgHeight - flippingPosY

roateXとrotateYの座標はXとy, Yを利用して下記のように記述することができます。


const rotateX = dx + imgWidth + X
const rotateY = dy + imgHeight - y - Y
XとYを計算するための図

rotateYの座標を計算するためにグレーで色付けされた相似の図形の比を利用してYの値を計算します。

XとYの値は2つの相似形の図の比を利用することで下記のように計算できます。


y : imgHeight -y = lengthX : X
X = (imgHeight - y ) * lengthX / y

y : imgHeight - y = y - lengthY : Y
Y = ((imgHeight -y ) * (y - lengthY)) / y

rotateXとroateYは図形の比から出したXとYで下記のように更新することができます。


const rotateX = dx + imgWidth + (lengthX * (imgHeight - y)) / y;
const rotateY = dy + imgHeight - y - ((imgHeight - y) * (y - lengthY)) / y;

上記の式で分かる通り、rotateXとrotateYを計算するためにはyの値が必要となります。

yの値は別の2つの相似形の図形を利用して計算します。

yの値を計算するための図形
yの値を計算するための図形

y : sqrt(lengthX**2 + lengthY**2) = sqrt(lengthX**2 + lengthY**2)/2 : lengthY
const y = (lengthX**2 + lengthY**2)/(2 * lengthY)

xの値はクリッピングマスクを設定する時に利用するため別の2つの相似形の図形を利用して計算します。

xの値を計算するための図形
xの値を計算するための図形

x: sqrt(lengthX**2 + lengthY**2) = sqrt(lengthX**2 + lengthY**2) /2: lengthX
const x = (lengthX**2 + lengthY**2)/(2 * lengthX)

回転角度

ページ画像の傾きは2*θであることが図から確認できます。θの値は三角関数を利用して計算します。

ページ画像の傾き
ページ画像の傾き

クリッピングマスクの設定

クリッピングマスクは2箇所で利用します。一つ目は下記の図のグレーの部分です。

クリッピングマスクを利用する場所1
クリッピングマスクを利用する場所1

クリッピングマスクで利用する三角形はmoveTo, lineToメソッドを利用して作成することができます。moveToで指定した座標を原点にx, yの座標に対して線を引きます。


ctx.beginPath();
ctx.moveTo(dx + imgWidth, dy + imgHeight);
ctx.lineTo(dx + imgWidth - x, dy + imgHeight);
ctx.lineTo(dx + imgWidth, dy + imgHeight - y);
ctx.closePath();

もう一つのクリッピングマスクは下記の図のグレーの部分です。

クリッピングマスクを利用する場所2
クリッピングマスクを利用する場所2

クリッピングマスクの三角形はmoveTo, lineToメソッドを利用して作成することができます。moveToで指定した座標であるflippingX, flippinYを原点にx, yの座標に対して線を引きます。


ctx.beginPath();
ctx.moveTo(flippingPosX, flippingPosY);
ctx.lineTo(dx + imgWidth - x, dy + imgHeight);
ctx.lineTo(dx + imgWidth, dy + imgHeight - y);
ctx.closePath();

これまでに説明した内容をコードとして記述します。next_btnをクリックするとnextPage関数を実行しますがその前にflippingPosXとflippingPosYのデフォルト値を設定します。


let flippingPosX = 0;
let flippingPosY = 0;
//略
next_btn.addEventListener('click', () => {
  flippingPosX = dx + imgWidth;
  flippingPosY = dy + imgHeight;
  nextPage();
});

nextPage関数ではflippingPosXとflippingPostYの移動設定とrequestAnimationFrameを利用したアニメーションとページめくりアニメーションが完了した後に表示される画像の設定を行います。flipSpeedでページめくりのアニメーションのスピードを制御します。


const bottomMargin = 20;
const flipSpeed = 45;
//略
const nextPage = () => {
  const lengthX = dx + imgWidth - flippingPosX;
  const lengthY = dy + imgHeight - flippingPosY;

  const startX = dx + imgWidth;
  const startY = dy + imgHeight;

  const middleX = dx;
  const middleY = ((dy + imgHeight) * 2) / 3;

  const endX = dx - imgWidth;
  const endY = dy + imgHeight;

  let angle = 0;
  if (flippingPosX < dx) {
    angle = Math.atan2(endY - middleY, endX - middleX);
  } else if (flippingPosX > dx) {
    angle = Math.atan2(middleY - startY, middleX - startX);
  }

  if (flippingPosX < dx - imgWidth) {
    currentPage === 0 ? (currentPage += 1) : (currentPage += 2);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    if (currentPage !== pages - 1)
      drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
    updateStyle();
    return;
  }

  nextPageFlip(lengthX, lengthY);
  flippingPosX += Math.cos(angle) * flipSpeed;
  flippingPosY += Math.sin(angle) * flipSpeed;
  requestAnimationFrame(nextPage);
};

flippingPosXの値がdx-imageWidthの座標よりも小さくなった場合には一度canvasをクリアしてページめくった後のページ画像を表示して処理を完了させています。

nextPageFlip関数でcanvas上に表示させるページ画像の設定を行います。


const nextPageFlip = (lengthX: number, lengthY: number) => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  const square = lengthX ** 2 + lengthY ** 2;
  const x = square / (2 * lengthX);
  const y = square / (2 * lengthY);

  //めくられる前のページ。ページをめくることで消えていくページ。
  if (currentPage === 0) {
    drawImage(currentPage, dx, dy, imgWidth, imgHeight);
  } else {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  }
  //ページをめくることで表示されてくる右側ページ。
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(dx + imgWidth, dy + imgHeight);
  ctx.lineTo(dx + imgWidth - x, dy + imgHeight);
  ctx.lineTo(dx + imgWidth, dy + imgHeight - y);
  ctx.closePath();

  if (currentPage === 0) {
    ctx.clip();
    drawImage(currentPage + 2, dx, dy, imgWidth, imgHeight);
  } else if (currentPage === pages - 3) {
    ctx.fillStyle = bgColor;
    ctx.fill();
  } else {
    ctx.clip();
    drawImage(currentPage + 3, dx, dy, imgWidth, imgHeight);
  }
  ctx.restore();

  ctx.save();
  ctx.beginPath();
  ctx.moveTo(flippingPosX, flippingPosY);
  ctx.lineTo(dx + imgWidth - x, dy + imgHeight);
  ctx.lineTo(dx + imgWidth, dy + imgHeight - y);
  ctx.closePath();
  ctx.clip();

  const rotateX = dx + imgWidth + (lengthX * (imgHeight - y)) / y;
  const rotateY = dy + imgHeight - y - ((imgHeight - y) * (y - lengthY)) / y;
  const angle2 = Math.atan2(lengthY, lengthX);

 //ページをめくることで表示されてくる左側のページはrotateX, rotateY移動して2θ回転
  ctx.moveTo(0, 0);
  ctx.translate(rotateX, rotateY);
  ctx.rotate(2 * angle2);

  //ページをめくることで表示されてくる左側ページ。
  if (currentPage === 0) {
    drawImage(currentPage + 1, 0, 0, imgWidth, imgHeight);
  } else {
    drawImage(currentPage + 2, 0, 0, imgWidth, imgHeight);
  }
  ctx.restore();
};

めくられる前に表示されているページ(めくることで消えていくページ)は下記の図のグレー部分です。

ページをめくる前のページ
ページをめくる前のページ

ページをめくることで表示される右側ページは以下のグレーの部分です。クリッピングマスクを利用することでグレーの部分のみを表示させています。

クリッピングマスクを利用する場所1
右側ページ

ページをめくることで表示されてくる左側ページは以下のグレーの部分です。クリッピングマスクを利用することでグレーの部分のみを表示させています。

クリッピングマスクを利用する場所2
左側ページ

動作確認を行うと下記のようにアニメーションが設定されページをめくりことができます。影等を追加設定することができればさらにページめくりぽさがでてくるかと思います。

ページめくりのアニメーション
ページめくりのアニメーション

前のページへめくり

次はprevious_btnボタンをクリックした後のページめくりのアニメーションの設定を行います。

前のページめくりのアニメーションのコードが完了すると以下のようにページをめくることができます。

ページのめくりのアニメーション
ページのめくりのアニメーション

rotateX, rotateYの計算が複雑なので次のページめくりのアニメーションと同様に説明を行うと長くなるのでコードのみ記述します。

previous_btnボタンをクリックするとflippingPosXとflippingPosYの初期値の設定を行い、previousPage関数が実行されます。開始地点は左側ページの左下となるためdx-imgWidthとdy+imgHeightとなります。


previous_btn.addEventListener('click', () => {
  flippingPosX = dx - imgWidth;
  flippingPosY = dy + imgHeight;
  previousPage();
});

previousPage関数は以下のコードに更新します。


const previousPage = () => {
  const lengthX = imgWidth - (dx - flippingPosX);
  const lengthY = dy + imgHeight - flippingPosY;

  const startX = dx - imgWidth;
  const startY = dy + imgHeight;

  const middleX = dx;
  const middleY = ((dy + imgHeight) * 2) / 3;

  const endX = dx + imgWidth;
  const endY = dy + imgHeight;

  let angle = 0;

  if (flippingPosX < dx) {
    angle = Math.atan2(middleY - startY, middleX - startX);
  } else if (flippingPosX > dx) {
    angle = Math.atan2(endY - middleY, endX - middleX);
  }

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  if (flippingPosX > dx + imgWidth) {
    if (currentPage === 1) {
      currentPage = 0;
      drawImage(currentPage, dx, dy, imgWidth, imgHeight);
    } else {
      currentPage -= 2;
      drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
      drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
    }
    updateStyle();
    return;
  }
  previousPageFlip(lengthX, lengthY);
  flippingPosX += Math.cos(angle) * flipSpeed;
  flippingPosY += Math.sin(angle) * flipSpeed;
  requestAnimationFrame(previousPage);
};

peviousPageFlip関数を追加します。


const previousPageFlip = (lengthX: number, lengthY: number) => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  const square = lengthX ** 2 + lengthY ** 2;
  const x = square / (2 * lengthX);
  const y = square / (2 * lengthY);

  //ページをめくっている際に一番下に表示されるページ
  if (currentPage !== pages - 1) {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  } else {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
  }
  //ページをめくることで表示されるページ
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(dx - imgWidth, dy + imgHeight);
  ctx.lineTo(dx - imgWidth + x, dy + imgHeight);
  ctx.lineTo(dx - imgWidth, dy + imgHeight - y);
  ctx.closePath();

  //ページをめくっている際に左側に表示されるページ(中間ページ)
  if (currentPage == 1) {
    ctx.fillStyle = bgColor;
    ctx.fill();
  } else {
    ctx.clip();
    drawImage(currentPage - 2, dx - imgWidth, dy, imgWidth, imgHeight);
  }
  ctx.restore();

  //めくっているページの裏側。これが次に開くページ。
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(flippingPosX, flippingPosY);
  ctx.lineTo(dx - imgWidth + x, dy + imgHeight);
  ctx.lineTo(dx - imgWidth, dy + imgHeight - y);
  ctx.closePath();
  ctx.clip();

  const a = ((imgWidth - x) * lengthY) / (lengthX - x);
  const b = ((imgWidth - x) * lengthY) / x;
  const c = ((imgHeight - a) * b) / a;
  const e = (x * a) / lengthY;
  const d = (a * (imgHeight - a)) / e;

  const rotateX = dx - imgWidth - (e + d - x);
  const rotateY = dy + imgHeight - c;
  const angle2 = Math.atan2(lengthY, lengthX);

  ctx.moveTo(0, 0);
  ctx.translate(rotateX, rotateY);
  ctx.rotate(-2 * angle2);
  drawImage(currentPage - 1, 0, 0, imgWidth, imgHeight);

  ctx.restore();
};

next_btn, previous_btnのボタンの制御

ページめくりを実行している時は何度もnext_btnボタン、previous_btnをクリックできないようにisFlipping変数を追加して制御します。


let flippingPosX = 0;
let flippingPosY = 0;
let isFlipping = false;

isFlipping変数の値がfalseの場合のみ実行できるようにします。


const registerEvents = () => {
  previous_btn.addEventListener('click', () => {
    if (!isFlipping) {
      isFlipping = true;
      flippingPosX = dx - imgWidth;
      flippingPosY = dy + imgHeight;
      previousPage();
    }
  });
  next_btn.addEventListener('click', () => {
    if (!isFlipping) {
      isFlipping = true;
      flippingPosX = dx + imgWidth;
      flippingPosY = dy + imgHeight;
      nextPage();
    }
  });
//略

previousPage, nextPage関数でページめくりが完了した後にisFlippingの値をtrueからfalseに更新します。


const nextPage = () => {
//略
  if (flippingPosX < dx - imgWidth) {
    currentPage === 0 ? (currentPage += 1) : (currentPage += 2);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    if (currentPage !== pages - 1)
      drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
    isFlipping = false;
    updateStyle();
    return;
  }

  nextPageFlip(lengthX, lengthY);
  flippingPosX += Math.cos(angle) * flipSpeed;
  flippingPosY += Math.sin(angle) * flipSpeed;
  requestAnimationFrame(nextPage);
};

const previousPage = () => {
//略
  if (flippingPosX > dx + imgWidth) {
    if (currentPage === 1) {
      currentPage = 0;
      drawImage(currentPage, dx, dy, imgWidth, imgHeight);
    } else {
      currentPage -= 2;
      drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
      drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
    }
    isFlipping = false;
    updateStyle();
    return;
  }
  previousPageFlip(lengthX, lengthY);
  flippingPosX += Math.cos(angle) * flipSpeed;
  flippingPosY += Math.sin(angle) * flipSpeed;
  requestAnimationFrame(previousPage);
};

設定後はページめくり中はボタンをクリックしても何も処理が行われなくなります。

スライダーによるページ移動

ここまでの設定ではページの移動はnext_btn, previous_btnボタンのどちらかをクリックすることでしか行うことができません。スライダーを追加することでスライダーを利用してページ移動が行えるように設定を行います。

スライダーはindex.htmlファイルのfooter要素の中に追加します。id属性にsliderを設定したdiv要素を追加し、その要素の中にはそれぞれprogress, slider_ballのid属性を持つdiv要素を追加します。


<footer
  id="footer"
  class="hidden absolute bottom-0 h-16 bg-black/30 w-full"
>
  <div
    id="slider"
    class="absolute top-4 h-2 bg-gray-300 rounded-full cursor-pointer"
  >
    <div
      id="progress"
      class="absolute left-0 h-2 bg-gray-900 rounded-full"
    ></div>
    <div
      id="slider_ball"
      class="w-5 h-5 absolute flex items-center justify-center border-gray-600 rounded-full border-2 bg-white"
      style="top: -6px"
    ></div>
  </div>
  <div id="page_area" class="absolute top-8 font-bold text-white">
    <span id="page_number">0</span>/<span id="page_total"></span>
  </div>
</footer>
ページ番号の表示のスタイルと位置も変更しています。
fukidashi

画像とスライダーのfooter要素が被らないようにtopMarginとbottomMarginの値を更新しておきます。


const topMargin = 20;
const bottomMargin = 80;

main.tsファイルではdocumentのgetElementByIdメソッドでslider要素を取得してslider変数に保存します。


//略
const page_total = document.getElementById('page_total') as HTMLSpanElement;
const slider = document.getElementById('slider') as HTMLDivElement;
//略

取得した要素を利用してsetting関数の中でスライダーの幅と位置の設定を行っています。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略

  if (footer.classList.contains('hidden')) footer.classList.remove('hidden');
  slider.style.width = `${imgWidth * 2}px`;
  slider.style.left = `${dx - imgWidth}px`;
  page_area.style.left = `${dx - page_area.offsetWidth / 2}px`;
  page_total.textContent = `${pages - 1}`;
};

ブラウザで確認するとページ番号の上にスライダーが表示されます。スライダーの左端にはスライダーボールが表示されます。

footerへのスライダーの表示
footerへのスライダーの表示

スライダーボールをDragすることでページが移動できるように設定します。

id属性にslider_ballを持つdiv要素をdocument.getElementByIdメソッドで取得してslider_ball変数に保存します。


const page_total = document.getElementById('page_total') as HTMLSpanElement;
const slider = document.getElementById('slider') as HTMLDivElement;
const slider_ball = document.getElementById('slider_ball') as HTMLDivElement;

スライダーボールをDragしているかどうか識別するためにisSliding変数を定義します。デフォルトの値はfalseに設定します。


let flippingPosX = 0;
let flippingPosY = 0;
let isFlipping = false;
let isSliding = false;

slider_ballにmousedownイベントを設定してスライダーボール上でマウスを押すとisSlidingの値をtrueにします。mousedownイベントの処理はregisterEvents関数に追加します。


const registerEvents = () => {
 //略
  slider_ball.addEventListener('mousedown', (e) => {
    e.preventDefault();
    isSliding = true;
  });
};

次にmouseupイベントをwindowオブジェクトに設定することでマウスをアップするとisSlidingの値をfalseに戻します。


const registerEvents = () => {
 //略
  slider_ball?.addEventListener('mousedown', (e) => {
    e.preventDefault();
    isSliding = true;
  });
  window.addEventListener('mouseup', () => {
    isSliding = false;
  });
};

最後にmousemoveイベントを設定、mousedown後にマウスを動かしisSlidingがtrueの場合のみDrag処理を行えるようにslidingBall関数を設定します。引数にはスライダーボールがX軸方向のみの移動に制限するためeventのclientXを渡します。


const registerEvents = () => {
 //略
  slider_ball.addEventListener('mousedown', (e) => {
    e.preventDefault();
    isSliding = true;
  });
  window.addEventListener("mousemove", (e) => {
    if (isSliding) slidingBall(e.clientX);
  });
  window.addEventListener('mouseup', () => {
    isSliding = false;
  });
};

slidingBall関数ではe.clientXの値によってstyleのleftの値を更新することでマウスに合わせてスライダーボールがDragできるようになります。


const slidingBall = (clientX: number) => {
    slider_ball.style.left =
      clientX - (dx - imgWidth + slider_ball.offsetWidth / 2) + 'px';
};

ブラウザで動作確認を行うとスライダーボールをDragすることができます。

スライダーボールのDrag
スライダーボールのDrag

スライダーボールをDragすることができるようになりましたがスライダーの領域を超えてもDragを行うことができます。if文による条件を追加することでスライダーボールの移動をスライダーの領域に制限させます。slider_ball.offsetWidth / 2はslider_ballの幅の半分を意味します。


const slidingBall = (clientX: number) => {
    if (
      clientX <= dx - imgWidth + slider_ball.offsetWidth / 2 ||
      dx + imgWidth - slider_ball.offsetWidth / 2 <= clientX
    )
      return;

    slider_ball.style.left =
      clientX - (dx - imgWidth + slider_ball.offsetWidth / 2) + 'px';
};

スライダーボールの移動だけではどの場所にいるのかわかりづらいのでスライダーボールが移動した部分の色を変更します。色を変更するためにid属性にprogressを設定したdiv要素を利用します。

document.getElementByIdメソッドでprogress要素を取得してprogress変数に保存します。


const page_total = document.getElementById('page_total') as HTMLSpanElement;
const slider = document.getElementById('slider') as HTMLDivElement;
const slider_ball = document.getElementById('slider_ball') as HTMLDivElement;
const progress = document.getElementById('progress') as HTMLDivElement;

slidingBall関数の中でclientXの値を利用してprogressの要素のwidthを設定します。


const slidingBall = (clientX: number) => {
  if (
    clientX <= dx - imgWidth + slider_ball.offsetWidth / 2 ||
    dx + imgWidth - slider_ball.offsetWidth / 2 <= clientX
  )
    return;

  slider_ball.style.left =
    clientX - (dx - imgWidth + slider_ball.offsetWidth / 2) + 'px';
  progress.style.width =
    clientX + slider_ball.offsetWidth / 2 - (dx - imgWidth) + 'px';
};

ブラウザで確認するとスライダーボールの位置が先ほどよりも分かりやすくなりました。

スライダーボールの移動
スライダーボールの移動

スライダーボールを移動している際に下部に表示されているページ番号が更新できるように設定を行います。スライダーボールの移動した距離からページ数を計算する必要があります。

スライダーの幅をページ数で割ることで1ページあたりの幅を計算し、移動した距離をその幅で割ることでページ数を計算します。


const slidingBall = (clientX: number) => {
  let range = 0;
  const slider_width = slider.offsetWidth - slider_ball.offsetWidth;
  range = slider_width / (pages - 1);
  if (
    clientX <= dx - imgWidth + slider_ball.offsetWidth / 2 ||
    dx + imgWidth - slider_ball.offsetWidth / 2 <= clientX
  )
    return;

  slider_ball.style.left =
    clientX - (dx - imgWidth + slider_ball.offsetWidth / 2) + 'px';
  progress.style.width =
    clientX + slider_ball.offsetWidth / 2 - (dx - imgWidth) + 'px';
  const slider_page = Math.round(
    (clientX - (dx - imgWidth + slider_ball.offsetWidth / 2)) / range
  );
  console.log(slider_page);
};

ブラウザのデベロッパーツールを利用してスライダーボールを移動することでスライダーの範囲内で0ページから全ページ数が表示されるか確認してください。本文書ではスライダーボールを移動させることで0から13までの数字が表示されます。

表紙が0ページのみ表示されますが次のページでは1-2ページ、3-4ページが表示されるのでページ数もそれに合わせて表示させる必要があります。slider_pageが表紙か裏表紙の場合と偶数か奇数かによって表示を変更しています。処理が終わった後はslider_pageの値をcurrentPageに保存します。


const slidingBall = (clientX: number) => {
  if (
    clientX <= dx - imgWidth + slider_ball.offsetWidth / 2 ||
    dx + imgWidth - slider_ball.offsetWidth / 2 <= clientX
  )
    return;

  let range = 0;
  const slider_width = slider.offsetWidth - slider_ball.offsetWidth;
  range = slider_width / (pages - 1);
  slider_ball.style.left =
    clientX - (dx - imgWidth + slider_ball.offsetWidth / 2) + 'px';
  progress.style.width =
    clientX + slider_ball.offsetWidth / 2 - (dx - imgWidth) + 'px';
  let slider_page = Math.round(
    (clientX - (dx - imgWidth + slider_ball.offsetWidth / 2)) / range
  );
  if (slider_page === 0) {
    page_number.textContent = '0';
  } else if (slider_page === pages - 1) {
    page_number.textContent = `${pages - 1}`;
  } else if (slider_page % 2 === 0) {
    slider_page -= 1;
    page_number.textContent = `${slider_page}-${slider_page + 1}`;
  } else {
    page_number.textContent = `${slider_page}-${slider_page + 1}`;
  }
  currentPage = slider_page;
};

ブラウザ確認するとスライドボールを移動することでページ数が移動距離に合わせて変わります。

スライドボールの移動によるページ数の更新
スライドボールの移動によるページ数の更新

スライドボールを離すと(mouseup)すると離した位置のページが開くように設定を行います。mouseupイベントでisSlidingがtrueの場合のみchangePage関数を実行します。changePage関数はこれから追加します。


window.addEventListener('mouseup', () => {
  if (isSliding) changePage();
  isSliding = false;
});

changePage関数ではcurrentPageの値を利用してページ画像を表示させます。


const changePage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    drawImage(currentPage, dx, dy, imgWidth, imgHeight);
  } else if (currentPage === pages - 1) {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
  } else {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  }
};

ここまでの設定が完了するとスライディングボールを移動させてマウスをアップするとスライディングボールの移動で更新されるページ数のページに移動することができるようになります。表示されるページによってnext_btn, previous_btnボタンの表示・非表示が変わるのでupdateStyle関数も実行します。


const changePage = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    drawImage(currentPage, dx, dy, imgWidth, imgHeight);
  } else if (currentPage === pages - 1) {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
  } else {
    drawImage(currentPage, dx - imgWidth, dy, imgWidth, imgHeight);
    drawImage(currentPage + 1, dx, dy, imgWidth, imgHeight);
  }
  updateStyle();
};

updateStyle関数の中で行なっていたページ番号の更新のコードを1-2ページ、3-4ページといったページ数で表示されるようにupdatePageNum関数を追加して実行します。


const updateStyle = () => {
  if (currentPage === 0) {
    previous_btn.style.display = 'none';
  } else if (currentPage === pages - 1) {
    next_btn.style.display = 'none';
  } else {
    previous_btn.style.display = 'block';
    next_btn.style.display = 'block';
  }
  updatePageNum();
};

const updatePageNum = () => {
  if (currentPage === 0) {
    page_number.textContent = '0';
  } else if (currentPage === pages - 1) {
    page_number.textContent = `${pages - 1}`;
  } else if (currentPage % 2 === 0) {
    currentPage -= 1;
    page_number.textContent = `${currentPage}-${currentPage + 1}`;
  } else {
    page_number.textContent = `${currentPage}-${currentPage + 1}`;
  }
};

slideingBall関数もページ数更新の処理をupdatePageNum関数で行います。


const slidingBall = (clientX: number) => {
  if (
    clientX <= dx - imgWidth + slider_ball.offsetWidth / 2 ||
    dx + imgWidth - slider_ball.offsetWidth / 2 <= clientX
  )
    return;

  let range = 0;
  const slider_width = slider.offsetWidth - slider_ball.offsetWidth;
  range = slider_width / (pages - 1);
  slider_ball.style.left =
    clientX - (dx - imgWidth + slider_ball.offsetWidth / 2) + 'px';
  progress.style.width =
    clientX + slider_ball.offsetWidth / 2 - (dx - imgWidth) + 'px';
  currentPage = Math.round(
    (clientX - (dx - imgWidth + slider_ball.offsetWidth / 2)) / range
  );
  updatePageNum();
};

スライディングボールをDragするとprogressバーがスラインディングボールと一緒に移動しますがnext_btn, previous_btnでページを移動した場合にはスライディングボールもprogressバーも更新されません。progressバーも一緒に更新されるようにupdateProgress関数を追加します。


const updateProgress = () => {
  const slider_width = slider.offsetWidth - slider_ball.offsetWidth;
  const range = slider_width / (pages - 1);
  slider_ball.style.left = `${currentPage * range}px`;
  progress.style.width = `${
    currentPage * range + slider_ball.offsetWidth / 2
  }px`;
};

updateProgress関数をupdateStyle関数の中で実行します。


const updateStyle = () => {
  if (currentPage === 0) {
    previous_btn.style.display = 'none';
  } else if (currentPage === pages - 1) {
    next_btn.style.display = 'none';
  } else {
    previous_btn.style.display = 'block';
    next_btn.style.display = 'block';
  }
  updatePageNum();
  updateProgress();
};

next_btn, progress_btnによるページ移動、スライダーボールでのページ移動でもページ番号の更新、progressバーの更新が行われるようになりました。

目次からのページ移動

next_btn, previos_btnボタン, スライダーからページの移動を行うことができるようになりましたがさらにカタログの目次を作成して目次からもページが移動できるように設定を行います。

index.htmlのid属性にtoolsを持つdiv要素に目次を表示させるためにid属性にindexを持つdiv要素を追加します。さらにその中にid属性にindex_listを持つdiv要素も含まれています。index_listの要素に後ほど索引を挿入します。


  <body>
    <div id="tools" class="hidden">
      <div
        id="index"
        class="z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
      >
        <div class="m-4">
          <div class="flex items-center justify-between">
            <div class="flex items-center gap-2">
              <h2 class="text-xl font-bold">INDEX</h2>
              <span class="font-bold text-gray-600">目次</span>
            </div>
          </div>
          <div id="index_list" class="mt-2"></div>
        </div>
      </div>
      <button
        id="previous_btn"
        class="absolute left-0 w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 hover:bg-gray-600 text-white text-xl"
      >
        <
      </button>
//略

div要素を追加後、ブラウザの左側にINDEX(目次)が表示されます。

索引要素の表示
目次要素の表示

目次要素の幅をブラウザの半分まで広げるためにdocument.getElementByIdメソッドの引数にindexを指定してindex要素を取得しindex変数に保存します。


//略
const slider_ball = document.getElementById('slider_ball') as HTMLDivElement;
const progress = document.getElementById('progress') as HTMLDivElement;
const index = document.getElementById('index') as HTMLDivElement;
//略

setting関数の中でindex要素の幅を設定します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略
  tools.className = 'block';
  index.style.width = `${canvas.width / 2}px`;
  if (footer.classList.contains('hidden')) footer.classList.remove('hidden');
  slider.style.width = `${imgWidth * 2}px`;
  slider.style.left = `${dx - imgWidth}px`;
  page_area.style.left = `${dx - page_area.offsetWidth / 2}px`;
  page_total.textContent = `${pages - 1}`;
};

設定後は、ブラウザの半分まで目次要素が広がります。

索引領域の幅の設定
目次要素の幅の設定

目次要素の中に目次の項目を表示するためにindex.jsonファイルをsrcディレクトリに作成します。index.jsonファイルの中では作成するカタログのpage番号とtitleを記述します。


[
  {
    "page": 0,
    "title": "表紙"
  },
  {
    "page": 1,
    "title": "pypdfを利用した場合"
  },
  {
    "page": 5,
    "title": "tabulaを利用した場合"
  },
  {
    "page": 6,
    "title": "Javaのインストール"
  },
  {
    "page": 8,
    "title": "JAVA_HOMEの設定"
  },
  {
    "page": 9,
    "title": "EXCELファイルへの保存"
  }
]

作成したindex.jsonファイルはmain.tsファイルの先頭でimport文から読み込むことができます。


import './style.css';
import indexes from './index.json';
//略

index.htmlファイルに追加したid属性にindex_listを持つdiv要素を追加します。


//略
const slider_ball = document.getElementById('slider_ball');
const progress = document.getElementById('progress');
const index = document.getElementById('index');
const index_list = document.getElementById('index_list');
//略

index.jsonファイルに保存されている情報から目次を作成します。createIndexList関数を追加し、その関数の中で目次に関連するHTMLを作成します。createIndexList関数では目次の項目の階層構造に対応できるようにするためimportしたitemsと階層のレベルを表すlevelを引数にとります。引数のitemsの型IndexLIst[]も定義します。


type IndexList = {
  page: number;
  title: string;
  children?: IndexList[];
};

const createIndexList = (items: IndexList[], level: number = 0) => {
  const ul = document.createElement('ul');
  ul.className = `pl-${level * 2}`;
  items.forEach((item) => {
    const li = document.createElement('li');
    li.innerHTML = `
    <div class="flex items-center justify-between hover:cursor-pointer hover:bg-gray-100 px-2 py-1 rounded-md">
      <div>
        <div class="leading-3 text-xs font-bold">${item.title}</div>
      </div>
      <span class="font-bold text-xs">${item.page}</span>
    </div>
    `;
    if (item.children && item.children.length > 0) {
      li.appendChild(createIndexList(item.children, level + 1));
    }
    ul.appendChild(li);
  });
  return ul;
};

createIndexList関数で作成した目次はappendChildメソッドでindex_listの要素に挿入します。


//略
images[currentPage].src = `/${currentPage}.jpg`;
ndex_list.appendChild(createIndexList(indexes));
loadImg(images[0]).then((img) => {
  loading.remove();
  setting(img);
  registerEvents();
});

ブラウザで確認するとindex.jsonからimportした目次とその項目が目次要素の中に表示されます。

index.jsonからimportした索引情報を表示
index.jsonからimportした目次を表示

目次の階層構造にも対応しているので動作確認のためchildrenプロパティを追加します。


[
  {
    "page": 0,
    "title": "表紙"
  },
  {
    "page": 1,
    "title": "pypdfを利用した場合",
    "children": [{ "page": 2, "title": "階層構造への対応" }]
  },
//略
]

階層構造にした場合はpadding leftをcreateIndexListの中で動的に設定(ul.className = `pl-${level *2 }`)を行っているのでビルド時にTailwind CSSのclassとして認識してもらえません。そのためtailwind.config.jsファイルの中でsafelistとして追加しておきます。pl-2をindex.htmlからmain.tsの中で既に利用している場合にはこの設定は必要ありません。


/** @type {import('tailwindcss').Config} */
export default {
  content: ['index.html', './src/**/*.{html,ts}'],
  safelist: ['pl-2'],
  theme: {
    extend: {},
  },
  plugins: [],
};

ブラウザで確認すると階層構造して設定されていることが確認できます。

索引が階層構造を持つ場合
目次が階層構造を持つ場合

目次の項目をクリックするとページに移動できるように設定を行います。項目毎にクリックイベントを設定するためにli要素のclassのindex_itemを追加し、どの項目がクリックされたか識別するためのdata-id属性を追加します。data-id属性にはpage番号を設定します。


const createIndexList = (items: IndexList[], level: number = 0) => {
  const ul = document.createElement('ul');
  ul.className = `pl-${level * 2}`;
  items.forEach((item) => {
    const li = document.createElement('li');
    li.innerHTML = `
    <div class="index_item flex items-center justify-between hover:cursor-pointer hover:bg-gray-100 px-2 py-1 rounded-md" data-id="${item.page}">
      <div>
        <div class="leading-3 text-xs font-bold">${item.title}</div>
      </div>
      <span class="font-bold text-xs">${item.page}</span>
    </div>
    `;
    if (item.children && item.children.length > 0) {
      li.appendChild(createIndexList(item.children, level + 1));
    }
    ul.appendChild(li);
  });
  return ul;
};

li要素のclassに追加したindex_itemの要素を取得して保存するため変数index_itemsを定義します。


//略
const index = document.getElementById('index');
const index_list = document.getElementById('index_list');
let index_items: NodeListOf<HTMLLIElement>;
//略

index_item要素の取得はsetting関数の中で行い、複数の要素が存在するのでdocument.querySelectorAllメソッドを利用します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略
  tools.className = 'block';
  index.style.width = `${canvas.width / 2}px`;
  index_items = document.querySelectorAll('.index_item');
//略

取得した要素すべてにclickイベントを設定するのでaddClickListenerToItems関数を追加します。addClickListenerToItemsでは引数から受け取ったitemsをforEachで展開して各item要素にclickイベントを設定しています。clickイベントの中では要素のdata-id属性からページ番号を取得してcurrentPageに保存してchangePage関数を実行します。


const addClickListenerToItems = (items: NodeListOf<HTMLLIElement>) => {
  items.forEach((item) => {
    item.addEventListener('click', (event) => {
      const target = event.currentTarget as HTMLElement;
      const data = target.getAttribute('data-id') ?? '';
      if (data == '') {
        return;
      } else {
        currentPage = parseInt(data);
      }
      changePage();
    });
  });
};

イベントの登録はregisterEvents関数の中で行います。


const registerEvents = () => {
  previous_btn.addEventListener('click', () => {
    if (!isFlipping) {
      isFlipping = true;
      flippingPosX = dx - imgWidth;
      flippingPosY = dy + imgHeight;
      previousPage();
    }
  });
//略
  window.addEventListener('mouseup', () => {
    if (isSliding) changePage();
    isSliding = false;
  });
  addClickListenerToItems(index_items);
};

設定後は目次の項目をクリックするとクリックした項目のページに移動してプラウザ上に表示されます。

目次の表示・非表示設定

現在の設定では目次が表示されたままなので表示・非表示を切り替えられるように設定を行います。

カタログにアクセスした直後は非表示にするためindex要素がブラウザの外側に移動するようにindex要素に対してstyle.leftの値を設定します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略
  tools.className = 'block';
  index.style.width = `${canvas.width / 2}px`;
  index.style.left = `-${canvas.width / 2}px`;
  index_items = document.querySelectorAll('.index_item');
//略

表示されていた目次要素がブラウザ上に表示されなくなります。

スライドボールの移動によるページ数の更新
目次要素をブラウザの外側に

index.htmlファイルの中に目次を表示するためのボタンを追加します。id属性にmenu_barを持つdiv要素を追加してその中にid属性にindex_open_btnを持つbutton要素を追加しています。


<body>
  <div id="tools" class="hidden">
    <div
      id="index"
      class="z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
    >
      <div class="m-4">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-2">
            <h2 class="text-xl font-bold">INDEX</h2>
            <span class="font-bold text-gray-600">目次</span>
          </div>
        </div>
        <div id="index_list" class="mt-2"></div>
      </div>
    </div>
    <div id="menu_bar" class="absolute top-4">
      <button
        id="index_open_btn"
        class="p-2 bg-gray-900 text-sm rounded-lg z-10"
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-5 h-5 text-white"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"
          />
        </svg>
      </button>
    </div>

ブラウザで確認するブラウザの左上に目次ボタンが表示されます。

索引要素を表示するためのボタン
索引要素を表示するためのボタン

目次ボタンの要素を取得して変数index_open_btnに保存してclickイベントを設定します。


//略
const index = document.getElementById('index') as HTMLDivElement;
const index_list = document.getElementById('index_list') as HTMLDivElement;
let index_items: NodeListOf<HTMLLIElement>;
const index_open_btn = document.getElementById(
  'index_open_btn'
) as HTMLButtonElement;
//略

clickイベントはregisterEvents関数の中に設定します。clickイベントの中ではindex要素のstyle.leftの値を0pxにしてブラウザの外側から内側に表示させるようにしています。


const registerEvents = () => {
 //略
  addClickListenerToItems(index_items);
  index_open_btn.addEventListener('click', () => {
    index.style.left = '0px';
  });
};

設定後は目次ボタンをクリックすると目次要素が表示されます。表示された目次要素を非表示にするためにid属性にindexを持つdiv要素の中にid属性にindex_close_btnを持つbutton要素を追加します。


<body>
  <div id="tools" class="hidden">
    <div
      id="index"
      class="z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
    >
      <div class="m-4">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-2">
            <h2 class="text-xl font-bold">INDEX</h2>
            <span class="font-bold text-gray-600">目次</span>
          </div>
          <button
            id="index_close_btn"
            class="w-8 h-8 rounded-full bg-gray-900 flex items-center justify-center"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="3"
              stroke="currentColor"
              class="w-5 h-5 text-white"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M6 18 18 6M6 6l12 12"
              />
            </svg>
          </button>
        </div>
        <div id="index_list" class="mt-2"></div>

追加後は目次要素の中にcloseボタンが表示されます。

Closeボタンの表示
Closeボタンの表示

Closeボタンの要素を取得して変数index_close_btnに保存してclickイベントを設定します。


//略
const index = document.getElementById('index');
const index_list = document.getElementById('index_list');
let index_items: NodeListOf<HTMLLIElement>;
const index_open_btn = document.getElementById('index_open_btn');
const index_close_btn = document.getElementById('index_close_btn');
//略

registerEvents関数の中にclickイベントを追加します。


const registerEvents = () => {
//略
  index_open_btn.addEventListener('click', () => {
    index.style.left = '0px';
  });
  index_close_btn.addEventListener('click', () => {
    index.style.left = -canvas.width / 2 + 'px';
  });
};

設定完了後、目次ボタンをクリックすると目次要素が表示され、Closeボタンをクリックすると目次要素を非表示にすることができます。

表示・非表示をアニメーションで切り替えれるようにid属性にindexを持つdiv要素のclassにtransitionを設定します。


<div id="tools" class="hidden">
  <div
    id="index"
    class="transition-all duration-700 ease-in-out z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
  >

設定後は表示する場合はゆっくと左側から目次要素が表示され、非表示になる場合は左側にゆっくりとブラウザ外に移動して非表示となります。

Overlayの設定

目次要素が表示された状態で目次要素以外の場所をクリックすると目次が非表示になるようにindex.htmlファイルにOverlayの要素の設定を追加します。


<body>
  <div id="tools" class="hidden">
    <div
      id="overlay"
      class="absolute top-0 left-0 z-30 h-screen w-screen bg-black opacity-30 hidden"
    ></div>
    <div
      id="index"
      class="transition-all duration-700 ease-in-out z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
    >
//略

デフォルトではhidden(非表示)に設定して目次要素が表示されるとそれに合わせてOverlayの要素が表示されるように設定を行います。

id属性にoverlayを持つdiv要素を取得して変数overlayに保存します。


let index_items: NodeListOf<HTMLLIElement>;
const index_open_btn = document.getElementById(
  'index_open_btn'
) as HTMLButtonElement;
const index_close_btn = document.getElementById(
  'index_close_btn'
) as HTMLButtonElement;
const overlay = document.getElementById('overlay') as HTMLDivElement;

index_open_btn, index_close_btnのclickイベントでoverlayの表示・非表示を切り替えます。


const registerEvents = () => {
//略
  index_open_btn.addEventListener('click', () => {
    overlay.style.display = 'block';
    index.style.left = '0px';
  });
  index_close_btn.addEventListener('click', () => {
    overlay.style.display = 'none';
    index.style.left = -canvas.width / 2 + 'px';
  });
};

目次ボタンをクリックすると目次要素と同時にOverlayも表示されます。

Overlayの表示
Overlayの表示

Overlayをクリックしても目次要素が非表示になるようにOverlayにclickイベントを設定します。


const registerEvents = () => {
//略
  index_open_btn.addEventListener('click', () => {
    overlay.style.display = 'block';
    if (index) index.style.left = '0px';
  });
  index_close_btn.addEventListener('click', () => {
    overlay.style.display = 'none';
    index.style.left = -canvas.width / 2 + 'px';
  });
  overlay.addEventListener('click', () => {
    overlay.style.display = 'none';
    index_close_btn.click();
  });
};

設定後はCloseボタンだけではなくOverlay要素をクリックしても目次要素が非表示になります。

サムネイル画像からのページ移動

目次からページに移動することができたのでページのサムネイルを作成してサムネイル一覧からもページの移動できるように設定を行います。

サムネイル画像の作成方法はPythonを利用することで簡単に作成することができます。

作成したサムネイル画像はpublicディレクトリのthumbnailsディレクトリを作成しその下に保存します。

index.htmlのid属性にtoolsを持つdiv要素の中にサムネイル画像を表示する要素を追加するため, id属性にthumbnailを持つdiv要素を追加します。さらにその中にid属性にthumbnail_listを持つdiv要素も含まれています。index_listの要素に後ほど索引を挿入します。追加する要素は追加したindex要素と同じ構成をしています。


<body>
  <div id="tools" class="hidden">
    <div
      id="overlay"
      class="absolute top-0 left-0 z-30 h-screen w-screen bg-black opacity-30 hidden"
    ></div>
    <div
      id="index"
      class="transition-all duration-700 ease-in-out z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
    >
//略
    </div>
    <div
      id="thumbnail"
      class="transition-all duration-700 ease-in-out z-50 absolute top-0 bg-white h-screen overflow-y-scroll"
    >
      <div class="m-4">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-2">
            <h2 class="text-xl font-bold">THUMBNAIL</h2>
            <span class="text-xs font-bold text-gray-600"
              >サムネイル画像</span
            >
          </div>
          <button
            id="thumbnail_close_btn"
            class="w-8 h-8 rounded-full bg-gray-900 flex items-center justify-center"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="3"
              stroke="currentColor"
              class="w-5 h-5 text-white"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M6 18 18 6M6 6l12 12"
              />
            </svg>
          </button>
        </div>
        <div id="thumbnail_list" class="mt-2 text-sm"></div>
      </div>
    </div>

ブラウザで確認すると追加したサムネイル要素が表示されます。

サムネイル要素の表示
サムネイル要素の表示

サムネイル要素の幅をブラウザの半分まで広げるためにdocument.getElementByIdメソッドの引数にthumbnailを指定してthumbnail要素を取得しthumbnail変数に保存します。


const index_close_btn = document.getElementById(
  'index_close_btn'
) as HTMLButtonElement;
const thumbnail = document.getElementById('thumbnail') as HTMLDivElement;
const overlay = document.getElementById('overlay') as HTMLDivElement;

setting関数の中でthumbnail要素の幅を設定します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略
  tools.className = 'block';
  index.style.width = `${canvas.width / 2}px`;
  index.style.left = `-${canvas.width / 2}px`;
  index_items = document.querySelectorAll('.index_item');
  thumbnail.style.width = `${canvas.width / 2}px`;
//略

設定によりブラウザの半分までサムネイル要素の幅が広がります。

サムネイル要素の幅の設定
サムネイル要素の幅の設定

サムネイル要素に中にサムネイル画像一覧を表示するため, id属性にthumbnail_listを持つ要素を取得して変数thumbnail_listに保存します。


const index_close_btn = document.getElementById(
  'index_close_btn'
) as HTMLButtonElement;
const thumbnail = document.getElementById('thumbnail') as HTMLDivElement;
const thumbnail_list = document.getElementById('thumbnail_list') as HTMLDivElement;
const overlay = document.getElementById('overlay') as HTMLDivElement;

サムネイル画像をブラウザ上に表示するためのcreateThumbnailList関数を追加します。


const createThumbnailList = () => {
  const ul = document.createElement('ul');
  ul.className = 'grid grid-cols-3 md:grid-cols-5 gap-2';
  for (let i = 0; i < pages; i++) {
    const li = document.createElement('li');
    li.innerHTML = `<img class="hover:opacity-80 hover:cursor-pointer thumbnail_item" src="/thumbnails/${i}.jpg" alt="thumbnail" data-id="${i}"
    }>`;
    ul.appendChild(li);
  }
  return ul;
};

作成したサムネイルの要素はappendChildメソッドでthumbnail_listの要素に挿入します。


images[currentPage].src = `/${currentPage}.jpg`;
index_list.appendChild(createIndexList(indexes));
thumbnail_list.appendChild(createThumbnailList());
loadImg(images[0]).then((img) => {
  loading?.remove();
  setting(img);
  registerEvents();
});

サムネイル要素の中にサムネイル画像一覧が表示されます。

サムネイル画像の表示
サムネイル画像の表示

画像が色が白いと背景との境界が分かりにくいのでサムネイル要素の背景色をbg-whiteからbg-gray-100に変更します。


<div
  id="thumbnail"
  class="transition-all duration-700 ease-in-out z-50 absolute top-0 bg-gray-100 h-screen overflow-y-scroll"
>

背景色を設定することによりサムネイル画像同士の境界がはっきりとわかるようになりました。

サムネイル要素の背景色の設定
サムネイル要素の背景色の設定

サムネイル画像をクリックするとページに移動できるように設定を行います。サムネイル画像にクリックイベントを設定するためにimg要素に設定済みthumbnail_item class、どのサムネイル画像がクリックされたか識別するためのdata-id属性を利用します。data-id属性にはi変数を設定します。ページ番号は0から始まるのでfor loopのiと画像に対応するページ番号が一致します。


const createThumbnailList = () => {
  const ul = document.createElement('ul');
  ul.className = 'grid grid-cols-3 md:grid-cols-5 gap-2';
  for (let i = 0; i < pages; i++) {
    const li = document.createElement('li');
    li.innerHTML = `<img class="hover:opacity-80 hover:cursor-pointer thumbnail_item" src="/thumbnails/${i}.jpg" alt="thumbnail" data-id="${i}"
    }>`;
    ul.appendChild(li);
  }
  return ul;
};

img要素のclassに追加したthumbnail_itemの要素を取得して保存するため変数thumbnail_itemsを定義します。


const thumbnail = document.getElementById('thumbnail') as HTMLDivElement;
const thumbnail_list = document.getElementById(
  'thumbnail_list'
) as HTMLDivElement;
let thumbnail_items: NodeListOf<HTMLLIElement>;
const overlay = document.getElementById('overlay') as HTMLDivElement;

thumbnail_item要素の取得はsetting関数の中で行い、複数の要素が存在するのでdocument.querySelectorAllメソッドを利用します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略
  tools.className = 'block';
  index.style.width = `${canvas.width / 2}px`;
  index.style.left = `-${canvas.width / 2}px`;
  index_items = document.querySelectorAll('.index_item');
  thumbnail.style.width = `${canvas.width / 2}px`;
  thumbnail_items = document.querySelectorAll('.thumbnail_item');
//略

取得した要素すべてにclickイベントを設定するのでaddClickListenerToItems関数を利用します。設定はregisterEvents関数の中で行い、引数にはthumnail_itemsを設定します。


const registerEvents = () => {
//略
  addClickListenerToItems(index_items);
  index_open_btn.addEventListener('click', () => {
    overlay.style.display = 'block';
    if (index) index.style.left = '0px';
  });
  index_close_btn.addEventListener('click', () => {
    overlay.style.display = 'none';
    index.style.left = -canvas.width / 2 + 'px';
  });
  addClickListenerToItems(thumbnail_items);
  overlay.addEventListener('click', () => {
    overlay.style.display = 'none';
    index_close_btn.click();
  });
};

設定が完了するとサムネイル画像をクリックすると対応する画像がブラウザ上に表示され、ページを移動することができます。

サムネイルの表示・非表示設定

現在の設定ではサムネイルが表示されたままなので表示・非表示を切り替えられるように目次と同じ方法で行います。

カタログにアクセスした直後は非表示にするためthumbnail要素がブラウザの外側に移動するようにstyle.leftの値を設定します。


const setting = (img: HTMLImageElement) => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
//略
  tools.className = 'block';
  index.style.width = `${canvas.width / 2}px`;
  index.style.left = `-${canvas.width / 2}px`;
  index_items = document.querySelectorAll('.index_item');
  thumbnail.style.width = `${canvas.width / 2}px`;
  thumbnail.style.left = `-${canvas.width / 2}px`;
  thumbnail_items = document.querySelectorAll('.thumbnail_item');
//略

表示されていたサムネイル要素がブラウザ上に表示されなくなります。

サムネイル要素を表示するためのボタンを追加します。id属性にmenu_barを持つdiv要素の中にid属性にthumbnail_open_btnを持つbutton要素を追加しています。ボタンが縦並びに表示させるようにmenu_barを持つ要素にflex classを追加しています。


<div
  id="menu_bar"
  class="absolute top-4 flex flex-col gap-1 justify-between"
>
  <button
    id="index_open_btn"
    class="p-2 bg-gray-900 text-sm rounded-lg z-10"
  >
//略
  </button>
  <button
    id="thumbnail_open_btn"
    class="p-2 bg-gray-900 text-sm rounded-lg z-10"
  >
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      stroke-width="1.5"
      stroke="currentColor"
      class="w-5 h-5 text-white"
    >
      <path
        stroke-linecap="round"
        stroke-linejoin="round"
        d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
      />
    </svg>
  </button>
</div>

ブラウザで確認するとブラウザの左上の目次ボタンの下にサムネイルボタンが表示されます。

サムネイル用画像の表示
サムネイル用画像の表示

サムネイルボタンの要素を取得して変数thumbnail_open_btnに保存してclickイベントを設定します。サムネイル要素の中に存在するthumnail_close_btnの設定も一緒に行います。


const thumbnail = document.getElementById('thumbnail') as HTMLDivElement;
const thumbnail_list = document.getElementById(
  'thumbnail_list'
) as HTMLDivElement;
let thumbnail_items: NodeListOf<HTMLLIElement>;
const thumbnail_open_btn = document.getElementById(
  'thumbnail_open_btn'
) as HTMLButtonElement;
const thumbnail_close_btn = document.getElementById(
  'thumbnail_close_btn'
) as HTMLButtonElement;
const overlay = document.getElementById('overlay') as HTMLDivElement;

registerEvents関数の中にthumbnail_open_btnとthumnail_close_btnのclickイベントを追加します。


const registerEvents = () => {
//略
  addClickListenerToItems(thumbnail_items);
  thumbnail_open_btn.addEventListener('click', () => {
    overlay.style.display = 'block';
    thumbnail.style.left = '0px';
  });
  thumbnail_close_btn.addEventListener('click', () => {
    overlay.style.display = 'none';
    thumbnail.style.left = -canvas.width / 2 + 'px';
  });
  overlay.addEventListener('click', () => {
    overlay.style.display = 'none';
    index_close_btn.click();
  });
};

サムネイル要素には追加した時にtransitionによるアニメーションの設定も行っているのでサムネイルボタンをクリックするとサムネイル要素が左側からゆっくりと表示され、Closeボタンをクリックするとサムネイル領域が左に移動しながらブラウザの外側に移動して非表示となります。

Overlayの表示と非表示も設定を行っていますがOverlayをクリックしてもサムネイル要素は非表示になりません。クリックすると非表示になるようにoverlayの要素のclickイベントの処理の中にthumbnailの非表示処理も追加します。


overlay.addEventListener('click', () => {
  overlay.style.display = 'none';
  index_close_btn.click();
  thumbnail_close_btn.click();
});

設定後はOverlayをクリックするとサムネイル要素が非表示になります。

id属性にmenu_barを持つdiv要素を利用して目次ボタンとサムネイルボタンを表示する位置を調整しておきます。


const menu_bar = document.getElementById('menu_bar') as HTMLDivElement;

style.leftの値を10pxに設定しています。


const setting = (img: HTMLImageElement) => {
//略
  tools.className = 'block';
  menu_bar.style.left = '10px';
  index.style.width = `${canvas.width / 2}px`;
  index.style.left = `-${canvas.width / 2}px`;
  index_items = document.querySelectorAll('.index_item');
  thumbnail.style.width = `${canvas.width / 2}px`;
  thumbnail.style.left = `-${canvas.width / 2}px`;
  thumbnail_items = document.querySelectorAll('.thumbnail_item');
//略

2つのボタンの左側にはスペースができました。

ボタンの位置の調整
ボタンの位置の調整

目次またはサムネイル画像からページを移動した後には目次要素、サムネイル要素が非表示になるようにaddClickListenerToItems関数にindex_close_btn, thumbnail_close_btnのクリックメソッドを追加しておきます。


const addClickListenerToItems = (items: NodeListOf<HTMLLIElement>) => {
  items.forEach((item) => {
    item.addEventListener('click', (event) => {
      const target = event.currentTarget as HTMLElement;
      const data = target.getAttribute('data-id') ?? '';
      if (data == '') {
        return;
      } else {
        currentPage = parseInt(data);
      }
      changePage();
      index_close_btn.click();
      thumbnail_close_btn.click();
    });
  });
};

画像拡大、縮小機能の追加

アニメーションのページめぐりと同様に電子カタログの重要な機能の一つである画像の拡大、縮小機能の追加を行なっていきます。

拡大にはダブルクリックイベントを利用します。2段階の拡大を可能にし、1回目、2回目のダブルクリックで拡大を行い、3回目のダブルクリックで拡大が解除され通常の画像サイズに戻るように設定を行っていきます。

ダブルクリックで画像の拡大を行うためcanvasにdblclickイベントを設定してanimateZoomIn関数を追加します。後ほど拡大にもアニメーションを設定するのでanimateという名前を関数につけています。animateZoomIn関数ではclearRectメソッドでcanvas上をクリアしてdrawImageメソッドのwidthとheightで2倍の設定を行っています。


const animateZoomIn = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(images[currentPage], dx, dy, 2 * imgWidth, 2 * imgHeight);
};

const registerEvents = () => {
//略
  canvas.addEventListener('dblclick', () => {
    animateZoomIn();
  });
};

設定後にブラウザ上でダブルクリックを実行するとページ画像が拡大されます。

画像の拡大
画像の拡大

拡大は2段階行えるように設定をしていくため変数zoomLevelと拡大しているかどうかを識別するisZooming変数を追加します。


//略
let isSliding = false;
let zoomLevel = 0;
let isZooming = false;

2段階拡大が行えるようにdblclickイベントに条件分岐を追加します。


canvas.addEventListener('dblclick', () => {
  if (!isZooming) {
    zoomLevel = 1;
    isZooming = true;
    animateZoomIn();
  } else if (isZooming && zoomLevel === 1) {
    zoomLevel = 2;
    animateZoomIn();
  }
});

animateZoomIn関数の中で実行するとctx.drawImageの引数でzoomLevelを利用して画像のサイズを変更します。


const animateZoomIn = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(
    images[currentPage],
    dx,
    dy,
    imgWidth + imgWidth * zoomLevel,
    imgHeight + imgHeight * zoomLevel
  );
};

ブラウザで2回ダブルクリックを行うと1回目のダブルクリックよりもさらに拡大できるようになります。

2回目のダブルクリックによる拡大
2回目のダブルクリックによる拡大

3回目のダブルクリックでは元のサイズに戻るように条件を追加し、animateZoomOut関数を追加します。


canvas.addEventListener('dblclick', () => {
  if (!isZooming) {
    zoomLevel = 1;
    isZooming = true;
    animateZoomIn();
  } else if (isZooming && zoomLevel === 1) {
    zoomLevel = 2;
    animateZoomIn();
  } else {
    isZooming = false;
    animateZoomOut();
  }
});

animateZoomOut関数ではcanvasをクリアした後、元のサイズでページ画像を描写しています。


const animateZoomOut = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(images[currentPage], dx, dy, imgWidth, imgHeight);
};

設定後は3回目のダブルクリックを実行すると画像が元のサイズに戻ります。

アニメーションによる拡大

ダブルクリックを行うと即座に画像が拡大されます。ここではアニメーションを利用してゆっくりと拡大が行われるように設定します。アニメーションの速度の調整を行うzoomIncrement変数とzoom変数を定義します。


//略
const flipSpeed = 45;
const zoomIncrement = 0.05;
let zoom = 0;

requestAnimationFrameを利用してzoomの値をzoomIncrementずつ増やしてアニメーションを設定します。zoomの値が1より小さい場合はアニメーションを繰り返し実行し、画像サイズにzoomをかけることで少しずつ画像を拡大させます。


const animateZoomIn = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(
    images[currentPage],
    dx,
    dy,
    imgWidth * zoomLevel + imgWidth * zoom,
    imgHeight * zoomLevel + imgHeight * zoom
  );
  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomIn());
  } else {
    zoom = 0;
  }
};

設定後はゆっくりと画像の拡大が行われるようになります。

animateZoomOut関数でもrequestAnimationFrameを利用してアニメーションを設定して画像を縮小させます。


const animateZoomOut = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(
    images[currentPage],
    dx,
    dy,
    imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
    imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
  );
  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomOut());
  } else {
    zoom = 0;
  }
};

設定後は拡大、縮小ともにアニメーションで行われます。

ページ毎の拡大、縮小設定

現在の設定では見開きページでダブルクリックするとanimateZoomIn関数の中でclearRectを実行しているので左側のページが右側に移動して右側ページのみ拡大、縮小が行われます。

表示しているページで分岐を行い、ページ毎に拡大の設定を行います。


const animateZoomIn = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    ctx.drawImage(
      images[currentPage],
      dx,
      dy,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
  } else if (currentPage === pages - 1) {
    ctx.drawImage(
      images[currentPage],
      dx - imgWidth * zoomLevel - imgWidth * zoom,
      dy,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
  } else {
    ctx.drawImage(
      images[currentPage],
      dx - imgWidth * zoomLevel - imgWidth * zoom,
      dy,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
    ctx.drawImage(
      images[currentPage + 1],
      dx,
      dy,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
  }

  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomIn());
  } else {
    zoom = 0;
  }
};

animateZoomOut関数でも表示しているページによって縮小の設定を変更します。


const animateZoomOut = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    ctx.drawImage(
      images[currentPage],
      dx,
      dy,
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
  } else if (currentPage === pages - 1) {
    ctx.drawImage(
      images[currentPage],
      dx - imgWidth * (zoomLevel + 1) + imgWidth * zoomLevel * zoom,
      dy,
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
  } else {
    ctx.drawImage(
      images[currentPage],
      dx - imgWidth * (zoomLevel + 1) + imgWidth * zoomLevel * zoom,
      dy,
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
    ctx.drawImage(
      images[currentPage],
      dx,
      dy,
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
  }

  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomOut());
  } else {
    zoom = 0;
  }
};

拡大時の拡大場所の設定

現在の設定では表紙のページ画像を開き、canvas上でダブルクリックを行うと下記のように画像の拡大(グレー部分)が行われブラウザ上にはグレーの濃い部分のみ表示されるため画像の左上のみ拡大が行われているように見え、それ以外の場所の拡大した画像を確認することができません。理由は画像の左端の座標dx, dyがいつも同じ場所であるためブラウザ上に収まる部分しか表示されないためです。

拡大時の図
拡大時の図
白と濃いグレーの部分がブラウザで表示されている画像です。
fukidashi

拡大したい箇所で画像の拡大が行えるように設定を行います。例えば画像の中央をクリックした場合にクリックした場所で拡大を行うためには画像の左上の座標を同じ位置(dx,dy)に固定するのではなくクリックした場所(e.clientX, e.clientY)からdx, dy分の距離をずらす必要があります。画像の左上の座標は拡大前にはA時点にあり、拡大後にはB時点に移動することになります。薄いグレーが拡大後の画像で濃いグレーの部分がブラウザ上に表示されている画像の一部となります。

ページ画像の中心でクリックした場合
ページ画像の中心でクリックした場合

クリックした場所の位置、画像の左上の座標とクリックした場所との距離などを保存するためにdblClickPosXなどの変数を追加します。dblclickX, dblclickYはダブルクリックを行った座標を保存します。startDx, startDyはctx.drawImageメソッドの第二引数と第三引数で利用して拡大前の座標を保存します。shiftXとshiftYはダブクリックした位置と拡大前の座標とのずれを保存します。


//略
let zoomLevel = 0;
let isZooming = false;
let dblClickPosX = 0;
let dblclickPosY = 0;
let startDx = 0;
let startDy = 0;
let shiftX = 0;
let shiftY = 0;
//略
canvas.addEventListener('dblclick', (e) => {
  if (!isZooming) {
    isZooming = true;
    zoomLevel = 1;
    dblClickPosX = e.clientX;
    dblclickPosY = e.clientY;
    startDx = dx;
    startDy = dy;
    shiftX = dblClickPosX - startDx;
    shiftY = dblclickPosY - startDy;
    animateZoomIn();
  } else if (isZooming && zoomLevel === 1) {
    zoomLevel = 2;
    startDx = dx - shiftX;
    startDy = dy - shiftY;
    shiftX = e.clientX - startDx;
    shiftY = e.clientY - startDy;
    animateZoomIn();
  } else if (isZooming && zoomLevel === 2) {
    isZooming = false;
    animateZoomOut();
  }
});

画像の左上の座標の移動をctx.drawImageメソッドに加えるためにanimateZooIn関数の更新します。コードを更新後にページ画像上の任意の場所でダブルクリックするとその場所を中心に画像が拡大されます。


const animateZoomIn = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    ctx.drawImage(
      images[currentPage],
      startDx - (shiftX / zoomLevel) * zoom,
      startDy - (shiftY / zoomLevel) * zoom,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
  } else if (currentPage === pages - 1) {
    ctx.drawImage(
      images[currentPage],
      startDx -
        imgWidth * zoomLevel -
        ((imgWidth * zoomLevel + shiftX) / zoomLevel) * zoom,
      startDy - (shiftY / zoomLevel) * zoom,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
  } else {
    ctx.drawImage(
      images[currentPage],
      startDx -
        imgWidth * zoomLevel -
        ((imgWidth * zoomLevel + shiftX) / zoomLevel) * zoom,
      startDy - (shiftY / zoomLevel) * zoom,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
    ctx.drawImage(
      images[currentPage + 1],
      startDx - (shiftX / zoomLevel) * zoom,
      startDy - (shiftY / zoomLevel) * zoom,
      imgWidth * zoomLevel + imgWidth * zoom,
      imgHeight * zoomLevel + imgHeight * zoom
    );
  }
  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomIn());
  } else {
    zoom = 0;
  }
};

このままの設定でも画像の縮小は行われますが、拡大後の座標の左上の座標から元の座標dx, dyにアニメーションで戻りながら縮小されるようにanimateZoomOut関数を更新します。


const animateZoomOut = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    ctx.drawImage(
      images[currentPage],
      dx -
        (dblClickPosX - dx + shiftX / zoomLevel) +
        zoom * (dblClickPosX - dx + shiftX / zoomLevel),
      dy -
        (dblclickPosY - dy + shiftY / zoomLevel) +
        zoom * (dblclickPosY - dy + shiftY / zoomLevel),
      (zoomLevel + 1) * imgWidth - zoomLevel * imgWidth * zoom,
      (zoomLevel + 1) * imgHeight - zoomLevel * imgHeight * zoom
    );
  } else if (currentPage === pages - 1) {
    ctx.drawImage(
      images[currentPage],
      dx -
        imgWidth * (zoomLevel + 1) -
        (dblClickPosX - dx + shiftX / zoomLevel ) +
        zoom *
          (2 * imgWidth + dblClickPosX - dx + shiftX / zoomLevel ),
      dy -
        (dblclickPosY - dy + shiftY / 2 ) +
        zoom * (dblclickPosY - dy + shiftY / 2 ),
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
  } else {
    ctx.drawImage(
      images[currentPage],
      dx -
        imgWidth * (zoomLevel + 1) -
        (dblClickPosX - dx + shiftX / zoomLevel ) +
        zoom *
          (2 * imgWidth + dblClickPosX - dx + shiftX / zoomLevel ),
      dy -
        (dblclickPosY - dy + shiftY / 2 - zoomDragY) +
        zoom * (dblclickPosY - dy + shiftY / 2 ),
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
    ctx.drawImage(
      images[currentPage + 1],
      dx -
        (dblClickPosX - dx + shiftX / zoomLevel ) +
        zoom * (dblClickPosX - dx + shiftX / zoomLevel ),
      dy -
        (dblclickPosY - dy + shiftY / zoomLevel ) +
        zoom * (dblclickPosY - dy + shiftY / zoomLevel ),
      (zoomLevel + 1) * imgWidth - zoomLevel * imgWidth * zoom,
      (zoomLevel + 1) * imgHeight - zoomLevel * imgHeight * zoom
    );
  }

  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomOut());
  } else {
    zoom = 0;
  }
};

ここまでの設定でクリックした場所で拡大が行えるようになります。

拡大時、画像Drag機能の追加

拡大はできるようになりましたが拡大後にブラウザの外側に隠れている拡大画像を確認できるように拡大後に画像をDragして移動できる機能を追加します。

画像のDragによる移動にはmousedown, mouseup, mousemoveの3つのイベントを利用します。またDrag用に新たな変数を定義します。isDraggingはDragを行っているかを識別するため、mouseDownPosX, mouseDownPosYはmousedownした位置を保存するため、zoomDragX, zoomDragYは現在のマウスの位置からmousedownした位置(mouseDownPosX, mouseDownPosY)の距離を保存するために利用します。


//略
let shiftY = 0;
let isDragging = false;
let mouseDownPosX = 0;
let mouseDownPosY = 0;
let zoomDragX = 0;
let zoomDragY = 0;
//略

mousedownイベントでDragの基準となる位置をe.clienX, e.clientYから取得します。Dragを行い一度止めて再度Dragを行うといったことを繰り返してもそれまでにDragした距離を反映させるためzoomDragX, zoomDragYを計算に入れています。Dragを開始するのでisDraggingの値をtrueにします。mousedownイベントはregisterEvents関数に追加します。


canvas.addEventListener('mousedown', function (e) {
  if (isZooming) {
    mouseDownPosX = e.clientX - zoomDragX;
    mouseDownPosY = e.clientY - zoomDragY;
    isDragging = true;
  }
});

mousedownイベントはDragをやめたことを表しているのでisDraggingの値をfalseにしています。


canvas.addEventListener('mouseup', function () {
  if (isZooming && isDragging) {
    isDragging = false;
  }
});

mousemoveイベントで基準点からの移動距離を計算して画像のDragを行います。Dragによる画像の移動は画像の左上の位置の座標にDragした距離を加えることで実現します。


canvas.addEventListener('mousemove', function (e) {
  if (isZooming && isDragging) {
    zoomDragX = e.clientX - mouseDownPosX;
    zoomDragY = e.clientY - mouseDownPosY;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    if (currentPage === 0) {
      ctx.drawImage(
        images[currentPage],
        startDx - shiftX / zoomLevel + zoomDragX,
        startDy - shiftY / zoomLevel + zoomDragY,
        imgWidth * (zoomLevel + 1),
        imgHeight * (zoomLevel + 1)
      );
    } else if (currentPage === pages - 1) {
      ctx.drawImage(
        images[currentPage],
        startDx -
          imgWidth * zoomLevel -
          (imgWidth * zoomLevel + shiftX) / zoomLevel +
          zoomDragX,
        startDy - shiftY / zoomLevel + zoomDragY,
        imgWidth * zoomLevel + imgWidth,
        imgHeight * zoomLevel + imgHeight
      );
    } else {
      ctx.drawImage(
        images[currentPage],
        startDx -
          imgWidth * zoomLevel -
          (imgWidth * zoomLevel + shiftX) / zoomLevel +
          zoomDragX,
        startDy - shiftY / zoomLevel + zoomDragY,
        imgWidth * zoomLevel + imgWidth,
        imgHeight * zoomLevel + imgHeight
      );
      ctx.drawImage(
        images[currentPage + 1],
        startDx - shiftX / zoomLevel + zoomDragX,
        startDy - shiftY / zoomLevel + zoomDragY,
        imgWidth * (zoomLevel + 1),
        imgHeight * (zoomLevel + 1)
      );
    }
  }
});

ここまでの設定で拡大後にページ画像をDragした移動できるようになります。しかし移動後にもう1段階拡大する(zoomLevel1からzoomLevel2へ)と拡大時にzoomDragX, zoomDragYの距離が画像の左上の座標に含まれていないためダブルクリックした場所とは異なる場所で拡大が行われます。

画像の拡大を行うdblclickイベントにzoomDragX, zoomDragYの情報を加えます。


canvas.addEventListener('dblclick', (e) => {
  if (!isZooming) {
    isZooming = true;
    zoomLevel = 1;
    dblClickPosX = e.clientX;
    dblclickPosY = e.clientY;
    startDx = dx;
    startDy = dy;
    shiftX = dblClickPosX - startDx;
    shiftY = dblclickPosY - startDy;
    animateZoomIn();
  } else if (isZooming && zoomLevel === 1) {
    zoomLevel = 2;
    dblClickPosX = dblClickPosX - zoomDragX;
    dblclickPosY = dblclickPosY - zoomDragY;
    shiftX -= zoomDragX;
    shiftY -= zoomDragY;
    zoomDragX = 0;
    zoomDragY = 0;
    startDx = dx - shiftX;
    startDy = dy - shiftY;
    shiftX = e.clientX - startDx;
    shiftY = e.clientY - startDy;
    animateZoomIn();
  } else if (isZooming && zoomLevel === 2) {
    isZooming = false;
    animateZoomOut();
  }
});

zoomDragX, zoomDragYの情報を加えてことでDrag後にダブルクリックしてもその場所で画像の拡大が行われるようになります。

縮小時の処理にもzoomDragX, zoomDragyYの情報を加える必要があります。animateZoomOut関数を更新します。


const animateZoomOut = () => {
  zoom += zoomIncrement;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (currentPage === 0) {
    ctx.drawImage(
      images[currentPage],
      dx -
        (dblClickPosX - dx + shiftX / zoomLevel - zoomDragX) +
        zoom * (dblClickPosX - dx + shiftX / zoomLevel - zoomDragX),
      dy -
        (dblclickPosY - dy + shiftY / zoomLevel - zoomDragY) +
        zoom * (dblclickPosY - dy + shiftY / zoomLevel - zoomDragY),
      (zoomLevel + 1) * imgWidth - zoomLevel * imgWidth * zoom,
      (zoomLevel + 1) * imgHeight - zoomLevel * imgHeight * zoom
    );
  } else if (currentPage === pages - 1) {
    ctx.drawImage(
      images[currentPage],
      dx -
        imgWidth * (zoomLevel + 1) -
        (dblClickPosX - dx + shiftX / zoomLevel - zoomDragX) +
        zoom *
          (2 * imgWidth + dblClickPosX - dx + shiftX / zoomLevel - zoomDragX),
      dy -
        (dblclickPosY - dy + shiftY / 2 - zoomDragY) +
        zoom * (dblclickPosY - dy + shiftY / 2 - zoomDragY),
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
  } else {
    ctx.drawImage(
      images[currentPage],
      dx -
        imgWidth * (zoomLevel + 1) -
        (dblClickPosX - dx + shiftX / zoomLevel - zoomDragX) +
        zoom *
          (2 * imgWidth + dblClickPosX - dx + shiftX / zoomLevel - zoomDragX),
      dy -
        (dblclickPosY - dy + shiftY / 2 - zoomDragY) +
        zoom * (dblclickPosY - dy + shiftY / 2 - zoomDragY),
      imgWidth * (zoomLevel + 1) - imgWidth * zoomLevel * zoom,
      imgHeight * (zoomLevel + 1) - imgHeight * zoomLevel * zoom
    );
    ctx.drawImage(
      images[currentPage + 1],
      dx -
        (dblClickPosX - dx + shiftX / zoomLevel - zoomDragX) +
        zoom * (dblClickPosX - dx + shiftX / zoomLevel - zoomDragX),
      dy -
        (dblclickPosY - dy + shiftY / zoomLevel - zoomDragY) +
        zoom * (dblclickPosY - dy + shiftY / zoomLevel - zoomDragY),
      (zoomLevel + 1) * imgWidth - zoomLevel * imgWidth * zoom,
      (zoomLevel + 1) * imgHeight - zoomLevel * imgHeight * zoom
    );
  }

  if (zoom < 1) {
    requestAnimationFrame(() => animateZoomOut());
  } else {
    zoom = 0;
    zoomDragX = 0;
    zoomDragY = 0;
  }
};

animateZoomOut関数を更新後はDragによる移動した距離が加えられた状態で縮小が行われます。

リセットの設定

拡大を行った直後にページの移動を行った場合やブラウザのウィンドウサイズを変更した場合など拡大前の拡大情報を保持しているため正しく動作が行われません。ページ移動などの処理が行われたら拡大、Dragにより情報をリセットできるようにreset関数を追加します。


const reset = () => {
  zoomLevel = 0;
  isZooming = false;
  dblClickPosX = 0;
  dblclickPosY = 0;
  startDx = 0;
  startDy = 0;
  shiftX = 0;
  shiftY = 0;
  isDragging = false;
  mouseDownPosX = 0;
  mouseDownPosY = 0;
  zoomDragX = 0;
  zoomDragY = 0;
};

reset関数はページ移動の処理完了時に実行されるupdateStyle関数とブラウザのリサイズに実行されるsetting関数に追加します。


const updateStyle = () => {
  updatePageNum();
  updateProgress();
  reset();
  if (currentPage === 0) {
    previous_btn.style.display = 'none';
  } else if (currentPage === pages - 1) {
    next_btn.style.display = 'none';
  } else {
    previous_btn.style.display = 'block';
    next_btn.style.display = 'block';
  }
};

const setting = (img: HTMLImageElement) => {
//略
  reset();
};

まとめ

かなり長い記事になりましたがここまでのコードで電子カタログに必要な最低限の機能は組み込むことができました。ここからさらにページのめくりのアニメーションに影をつけたり機能の追加を行いオリジナルの電子カタログを作成してみてください。

本文書で作成した電子カタログの実際の動作はこちらのURL(https://digital-catalog-example.vercel.app/)から確認できます。