作品の概要
はじめに作品を見てもらいましょう。
ドラッグでのカレンダー追加
ドラッグでのカレンダー移動
ドラッグでのカレンダー時刻拡張
ダイアログによるカレンダー編集
カスタマイズ可能なカレンダー表示
一度に表示する量を変更できます。
Googleカレンダー連携
マイカレンダー一覧で、表示・非表示を選択できます。言い忘れましたが、Googleアカウント連携機能もあり、アカウントのカレンダーが自動取得されます。
カレンダー再現の動機づけ
インターンでシフト入力をより便利なものにしよう!というのが始まりでした。
現状のシフト入力は、スプレッドシートを用いており、自身の列に時刻を入力する方式です。しかし、問題点が複数あります。
- 自身の確認不足で自分の予定と重なってしまう
- 誤って他人の列に入力してしまう
- 自身のカレンダーに再度入力が必要
- スプレッドシートだけではシフトがあるか否かをパット見で判断できない
どうしてもヒューマンエラーによるものが多く、情報技術の力でこれを減らしたいのです。
作成途中でありますが、こちらがインターンで作成してるシフト提出画面です。こちらも同様にドラッグ操作が可能で、かつ、自身のGoogleアカウントに基づいてGoogleカレンダーの予定を表示しています。
また、将来的にはシフトに記述したカレンダーを同期する処理も作成する予定なので、ヒューマンエラーの解消に大きく貢献するでしょう。
設定でアカウントの追加、マイカレンダーの表示オプションを選択できます。
インターンのシステムはバックエンド付きなので、複数アカウント対応です。
その他、シフト入力だけなのでドラッグ操作の一部省略などをしています。
機能一覧
- カレンダー追加機能
- カレンダー編集機能
- カレンダー削除機能
- 設定機能
- カレンダー同期の項目(アカウント連携で一覧表示)
- ドラッグ時の1時間あたりの分割数
- 1時間あたりの高さ
- 1画面の週の表示数
- カレンダーの開始タイミング
いずれもローカルストレージに保存され、同デバイス内で永続的に保存されます(カレンダーの追加・編集・削除はアカウント連携時のみ対応)。
技術スタック
- フロントエンド:
React
+Vite
+TypeScript
- API:
Google Cloud
(アカウント認証),Google Calendar API
- デプロイ:レンタルサーバー(CI/CD GitHub)
- その他
- ローカルストレージを使用
今回は独立したバックエンドストレージを保有していないため、設定項目のデータはローカルストレージを使用してデータを格納する方式にしています。
今回のメインはドラッグ操作
今回のメインと言っても過言でないのが、ドラッグ操作だと思います。既にあるものを再現したわけですが、構造としてはシンプルです。
MouseDown, Move, Upイベントで取得
-
MouseDown
: マウスが要素内で押された -
MouseMove
: マウスが要素内で動いた -
MouseUp
: マウスが要素内で放された
この3つのイベントを取得して、ドラッグした範囲を取得しています。今回はReactを使用しているので、useStateでその情報を保持します。
import React from 'react';
export const DragTest: React.FC = () => {
// ドラッグ中かどうかの状態
const [isDragging, setIsDragging] = React.useState<boolean>(false);
// ドラッグ中の要素の位置
const [position, setPosition] = React.useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
// マウスダウン時の処理
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
setPosition({ x: e.clientX, y: e.clientY });
};
// マウスムーブ時の処理
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging) {
setPosition({ x: e.clientX, y: e.clientY });
}
};
// マウスアップ時の処理
const handleMouseUp = () => setIsDragging(false);
return (
<div>
<div
style={{
width: '100%',
height: '100svh',
border: '1px solid black',
position: 'relative',
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<div
style={{
width: 100,
height: 100,
backgroundColor: 'blue',
position: 'absolute',
top: position.y,
left: position.x,
}}
onMouseDown={handleMouseDown}
/>
</div>
</div>
);
};
これだけでオブジェクトを動かすことは可能です。ただ実際は日付・時刻ごとに予定追加できるように実装するため、より複雑な処理・計算をする必要があります。
ドラッグ位置をindex値として計算
私が開発した処理は、左上を基準としてindex値でマウス位置を取得しています。
export const getMouseSelectedCalendar = (
e: React.MouseEvent<HTMLElement>, // カレンダー本体
scrollBase: HTMLElement,
topHeight: number // カレンダーの日付上部の高さ
) => {
const rect = e.currentTarget.getBoundingClientRect();
const nowLeftPosition = e.clientX - rect.left - LEFT_WIDTH; // 現在の左位置
const xIndex = Math.floor((nowLeftPosition / (rect.width - LEFT_WIDTH)) * 7);
if (e.clientY < rect.top + topHeight) {
// 終日予定はyIndexを -1 とする
return { xIndex, yIndex: -1 };
}
const nowTopPosition = e.clientY - rect.top - topHeight + scrollBase.scrollTop; // 現在の上位置
// 高さの index を取得(0~23)
const yIndex = Math.floor(nowTopPosition / 40); // 40は1時間あたりの高さ
return { xIndex, yIndex };
};
const { xIndex, yIndex } = getMouseSelectedCalendar(e, scrollRef.current!, topHeight);
マウスイベントはブラウザ左上からの位置
e.clientX
, e.clientY
が、画面上のマウスの位置です。これはブラウザの左上からの座標を示しているので、実際のカレンダーの位置は、topHeight
, LEFT_WIDTH
などを考慮して計算する必要があります。
さらに、カレンダーの0:00以降の部分は内部でスクロール可能となっています。マウスの座標がそのまま、でも要素の位置は動きます。それも考慮してscrollBase.scrollTop
を引き算しています。
ちなみに上部の日付・曜日が記載の部分もドラッグ範囲であり、終日のイベントもドラッグしてイベントの追加が可能です。
時間はbaseからindexを足すことで求められる
index情報から時間に変換するときも、baseの日付(画像だと2024/09/22
)からindexの分を足すことで求められます。
私が今回利用したのは、dayjs
というライブラリです。通常のDate関数よりもformat系の変換が初期で備わっていて便利です。
const baseDate = dayjs('2024-09-22');
console.log(baseDate.add(1, 'day')); // 2024-09-23(実際はオブジェクト)
console.log(baseDate.startOf('day')); // 2024-09-22 00:00(実際はオブジェクト)
console.log(baseDate.endOf('day')); // 2024-09-22 23:59(実際はオブジェクト)
onMouseDownの要素によって処理を変更
今回はカレンダーの追加だけでなく、既存のカレンダー編集、移動、拡張なども対応する必要があります。
onMouseDownはonClickより早く応答する
仮に既存のカレンダーにonClick
属性を付与しても、対象がonMouseDown
の範囲だと、onMouseDown
が優先的に動作します。そのため、onMouseDown
にクリック時の処理を記述する必要があります。
子要素にonMouseDown={(e)=> e.stopPagination()}
を付与することで、コンテナ側のonMouseDown
を動作させないことも可能です。私の場合は既存イベントの移動、拡張も考慮しているためこの方法は使用していません。
既存イベントか否かはidで判別
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// イベントの上でマウスダウンした場合の処理
if (e.target.id.includes('calendarEvent__')) {
const eventId = e.target.id.replace('calendarEvent__', '');
const event = calendarEvents.find((event) => event.id === eventId);
// その後処理が続く…
}
// 通常のイベント追加処理
}
// ボタンのコンポーネント
<button id={`calendarEvent__${id}`}>
このようにすることで、既存のイベントか否かを判断することができます。
ドラッグ中の表示
マウスでドラッグを実施すると、青背景の表示がされます。useState
で定義したドラッグ中のstart
, end
によって表示しています。
先ほどindex
で管理しているとは言いましたが、実際は時刻のオブジェクトをそのまま入れています。
みんな大好きposition: absolute
画像のように、時刻に合わせてきちんと位置が定められていると思います。これは、CSSのposition
の概念を使用することで定められた位置に設置することができます。
また、サイズもきちんと計算して求めています。
複数日にまたぐ場合の処理
画像のように複数日の表示に対応するにはさらにひと手間必要です。オブジェクトとしては1つですが、3日にまたぐ場合は一旦3つに分割して、それぞれで表示されるようにしています。
Googleカレンダー連携
Google Cloudで、Google Calendar APIが提供されています。これを利用してGoogleカレンダーのデータを取得しています。認証はGoogle Cloudの2Authですね。
詳しくはドキュメントをご覧ください。フロントから叩く場合は、ログイン処理以外はトークン、APIキーなどの小難しいこと書く必要がないので比較的楽です。
感想・こだわった点
ここからは感想ですね。元々ここまで作る予定なかったのですが、結果的に楽しくなったため自身で独立のリポジトリ作って作成していました。
可能な限り共通ロジック化
まずは共通ロジック化ですね。ただでさえonMouseDown
, Move
, Up
のロジックが大きくなりやすいですが、まずはこのイベント取得を1箇所のみにしたことで、マウスイベントがごちゃごちゃせずにメンテナンス性が高いコードに仕上げました。
その他、既存イベントの表示とドラッグ中イベントの表示についてもできるだけ共通ロジック化したり、マウス位置の取得を様々な場所で使用できるようにindex化したりしました。
徹底的なファイル分割
ファイル数は結構増えてしまいますが、ロジック、表示ごとにファイルが分かれているので、探すためのスクロール量がかなり削減できます。
1ファイルあたり100行程度に抑えており、かつ内容も、基本的にファイル名に記載のことだけ実施するような形で実装しました。
デザインはNotion, ChatGPTを参考に
デザインは基本白、黒だけで実装しています。どのようにすればおしゃれになるかを考えながら実装しました。
また、できるだけカレンダーのエリアが広くなるように、ヘッダ部分の高さを最適化、随時様々な大きさでプレビューをして表示が隠れないかなどをテストしました。
まとめ
今後はもう少しコピーペーストの機能などもつけて、現状のカレンダーよりスムーズに予定が追加できるようになればいいと感じています。既存のカレンダーをもっとこうしてほしい、という要望ありましたら、いつでも受け付けています!