プライベートで実践しているユーザースクリプト開発の話

業務ではサーバーサイドでJavaをメインに扱っている僕が、プライベートで実践しているユーザースクリプト開発についてお話します。

## ユーザースクリプトとは
**ユーザースクリプト**とは、ユーザーが定義した任意のスクリプトをウェブページ上で実行する機能、およびそのスクリプトのことです。
ウェブブラウザ上で動かすので基本的には**JavaScript**で書きますが、**TypeScript**や**CoffeeScript**などのAltJSで書くこともできます。

## 僕が開発中のユーザースクリプト
<img width="1072" alt="sample.png" src="https://qiita-image-store.s3.amazonaws.com/0/132608/24339880-082c-08a7-1111-7c9a53a8c7f3.png">

ビジネス用チャットツール**[direct](https://direct4b.com/ja/)**に便利な機能を追加するユーザースクリプト、**[direct helper](https://github.com/munierujp/direct_helper)**を開発しています。
当初は純粋なJavaScript（ES2015+）で書いていましたが、標準APIによるDOM操作に限界を感じたため、現在は**jQuery**も使用しています。[^1]

### 実装した機能
direct helperで実装した機能について、ソースコードを交えながらご紹介します。

#### 設定関連処理の自動化
設定項目はソースコード上に定数オブジェクトとして定義しており、一定の仕様に沿って定義することで、設定関連処理（設定画面の描画、設定値の保存、機能の実行）を自動的に行なう設計になっています。

```js
/** 設定データ */
const SETTING_DATA = {
	name: "direct helper設定",
	description: `以下はdirect helperの設定です。設定変更後はページをリロードしてください。<br>
詳しい使用方法は<a href="https://github.com/munierujp/direct_helper/blob/master/README.md" target="_blank">readme</a>を参照してください。`,
	sections: [
		{
			key: "user-dialog-settings",
			name: "ユーザーダイアログ",
			description: "ユーザーダイアログの動作を変更します。",
			items: [
				{
					key: "expand_user_icon",
					name: "ユーザーアイコンの拡大",
					description: "ユーザーアイコンをクリックで拡大表示します。",
					type: FormTypes.CHECKBOX,
					default: true
				}
			]
		}
	]
};
```

```js
/**
* 設定を初期化します。
*/
function initializeSettings(){
	const settings = getSettings();

	//未設定項目にデフォルト値を設定
	SETTING_DATA.sections.forEach(section => {
		section.items
			.filter(item => settings[item.key] === undefined)
			.forEach(item => settings[item.key] = item.default);
	});

	setSettings(settings);
}
```

```js
/**
* 設定画面を描画します。
*/
function drawSettingView(){
	const $settingPage = $('#environment-page');
	$settingPage.append(`<hr>`);
	$settingPage.append(`<h3 class="page-title"><span class="page-title-glyphicon glyphicon glyphicon-cog"></span>  ${SETTING_DATA.name}</h3>`);
	$settingPage.append(`<div>${SETTING_DATA.description}</div>`);
	SETTING_DATA.sections.forEach(section => appendSettingSection($settingPage, section));
}
```

```js
/**
* 各種機能を実行します。
*/
function doActions(){
	const settings = getSettings();

	Object.keys(SETTINGS_KEY_ACTIONS)
		.filter(key => settings[key] === true)
		.map(key => SETTINGS_KEY_ACTIONS[key])
		.forEach(action => action());
}
```

#### メッセージ監視
メッセージを監視してコンソールに出力する機能です。
メッセージのデータは、**MutationObserver**でDOMの変化を監視することで取得しています。[^2]
ユーザースクリプト内ではMutationObserverを直接使わず、自作ライブラリの**[Observer.js](https://github.com/munierujp/Observer.js)**を使用しています。

```js
/** トークエリア */
class TalkArea{
	/**
	* @param {Element} value トークエリア
	*/
	constructor(value){
		this.value = value;
		this.realMessageArea = this.value.querySelector('.real-msgs');
	}

	/**
	* TalkAreaオブジェクトを生成します。
	* @param {Element} value トークエリア
	* @return {TalkArea} TalkAreaオブジェクト
	*/
	static of(value){
		return new this(value);
	}

	/**
	* メッセージエリアの追加を監視します。
	* @param {Function} callback : messageArea => {...}
	*/
	observeAddingMessageArea(callback){
		Observer.of(this.realMessageArea).childList().hasChanged(records => {
			records.forEach(record => {
				Array.from(record.addedNodes)
					.filter(node => node.className == "msg")
					.forEach(messageArea => callback(messageArea));
			});
		}).start();
	}
}
```

```js
//メッセージの追加を監視
TalkArea.of(talkArea).observeAddingMessageArea(messageArea => {
	//メッセージを生成
	const message = MessageArea.of(messageArea).createMessage(settings, talk);

	//メッセージをコンソールに出力
	const messageIsNotPast = message.time > observeStartDate;
	if(messageIsNotPast || settings.show_past_message === true){
		message.log(settings);
	}
});
```

#### マルチビューのレスポンシブ化
デフォルトでは3カラム固定であるマルチビューのカラム数を、選択状態に応じて動的に変更する機能です。
要素の高さをもとにスクロール位置を調整しているのですが、微妙に位置がずれるバグを抱えています。[^3]

```js
//アクティブペインを外側から表示
$activeTalkPanes.each((i, talkPane) => {
    $(talkPane).show();
    const $timelinebody = $(talkPane).find('.timeline-body');
    $timelinebody.show();
    const $timelineHeader = $(talkPane).find('.timeline-header');
    const $timelineFotter = $(talkPane).find('.timeline-footer');
    $timelinebody.height($(talkPane).prop("clientHeight") - $timelineHeader.prop("clientHeight") - $timelineFotter.prop("clientHeight"));
    $timelinebody.scrollTop($timelinebody.prop("scrollHeight"));
});

//非アクティブペインを内側から非表示
$inactiveTalkPanes.each((i, talkPane) => {
	const $timelinebody = $(talkPane).find('.timeline-body');
	$timelinebody.hide();
	$(talkPane).hide();
});

//アクティブペインがない場合は1番目のペインの空ビューを表示
if($activeTalkPanes.length === 0){
	$firstTalkPane.show();
	const $emptyView = $firstTalkPane.find('.empty-view-container-for-timeline');
	$emptyView.removeClass("hide");
	$firstTimelineHeader.css("background-color", "#ffffff");
}else{
	$firstTimelineHeader.css("background-color", firstTalkPaneColor);
}
```

#### 入力文字数の表示
入力文字数をカウントダウン形式またはカウントアップ形式で表示する機能です。
カウント形式によって、カウンターの初期値と計算式を切り替えています。

```js
//カウンターを作成
const count = countDown ? maxLength : 0;
const $counter = $(`<label>${count}</label>`).css("margin-right", "8px");
const $sendButtonGroup = $(sendForm).find('.form-send-button-group');
$sendButtonGroup.prepend($counter);

//文字入力時にカウンターの値を更新
$textArea.on("input.direct_helper_doShowMessageCount", () => {
    const currentLength = $textArea.val().length;
    const count = countDown ? maxLength - currentLength : currentLength;
    $counter.text(count);
});
```

#### ユーザーアイコンの拡大
ユーザーアイコンをクリックで拡大表示する機能です。
クリックイベントとキー入力イベントを検知するため、状態によってイベントリスナーを切り替えています。

```js
//Escapeキー押下時に拡大画像エリアを閉じる
addEscapeKeyupListener(event => {
    if(event.key == KeyTypes.ESCAPE.key){
        closeExpandedImage();
        removeEscapeKeyupListener();
    }
});

//拡大画像エリアクリック時に拡大画像を閉じる
$expandedImageArea.on("click.direct_helper_doExpandUserIcon_onClickExpandedImageArea", () => {
    closeExpandedImage();
    removeEscapeKeyupListener();

    //拡大画像エリアクリック後にEscapeキー押下時にユーザーダイアログを閉じる
    addEscapeKeyupListener(event => {
        if(event.key == KeyTypes.ESCAPE.key){
            const $userModal = $('#user-modal');
            $userModal.click();
        }
    });
});
```

#### 送信ボタンの確認
送信ボタンによるメッセージ送信前に確認する機能です。
本物の送信ボタンの上にダミーの送信ボタンを表示して、挙動をカスタマイズしています。

```js
//ダミー送信ボタンを作成
const $dummySendButton = $sendButton.clone();
$dummySendButton.prop("disabled", true);
const $sendButtonGroup = $(sendForm).find('.form-send-button-group');
$sendButtonGroup.append($dummySendButton);

//送信ボタンを非表示化
$sendButton.hide();

//文字入力時にダミー送信ボタンをクリック可能化
const $textArea = $(sendForm).find('.form-send-text');
$textArea.on("input.direct_helper_doConfirmSendMessageButton", () => {
	const textAreaIsEmpty = $textArea.val() === "";
	$dummySendButton.prop("disabled", textAreaIsEmpty);
});

//添付ファイル追加時にダミー送信ボタンをクリック可能化
const $fileAreas = $(sendForm).find('.staged-files');
$fileAreas.each((i, fileArea) => {
	Observer.of(fileArea).attributes("style").hasChanged(records => {
		records.forEach(record => {
			const fileAreaIsHidden= $(fileArea).is(':hidden');
			$dummySendButton.prop("disabled", fileAreaIsHidden);
		});
	}).start();
});

//ダミー送信ボタンクリック時に確認ダイアログを表示
$dummySendButton.on("click.direct_helper_doConfirmSendMessageButton", () => {
	if(window.confirm(CONFIRM_MESSAGE)){
		$sendButton.click();
	}else{
		//なにもしない
	}
});
```

#### サムネイルサイズの変更
画像のサムネイルサイズを変更する機能です。
サムネイル画像エリアの横幅を変更しているのですが、本文の幅も変更されてしまうバグを抱えています。[^4]

```js
const $thumbnailArea = $(messageArea).find('.msg-text-contained-thumb');
$thumbnailArea.width(settings.thumbnail_size);
```

#### サムネイル画像をぼかす
サムネイル画像にブラー効果をかけてぼかす機能です。
ブラー効果には、CSSのfilterプロパティを使用しています。

```js
const $thumbnailArea = $(messageArea).find('.msg-text-contained-thumb');
const $thumbnails = $thumbnailArea.find('img');
$thumbnails.each((i, thumbnail) => $(thumbnail).css("filter", `blur(${settings.thumbnail_blur_grade}px)`));
```

## ユーザースクリプト開発を通じて得られたこと
### JavaScriptの知識が身についた
JavaScriptで書いているので当然ですが、JavaScriptの知識が身につきました。
当初は純粋なJavaScript（ES2015+）で書いていたため、標準APIに対する理解も深まりました。

### jQueryの知識が身についた
途中でコード全体をjQueryに書き換えるという作業をしたことで、jQueryの知識が身につきました。
**[『You Don't Need jQuery』](https://qiita.com/tatesuke/items/b9548dd484b01b139b74)**と言われて久しいですが、なんだかんだでDOM操作を行なうには便利です。

### GitHubに慣れた
このプロジェクトで初めて本格的にGitHubを使いました。
ぼっち開発ですが、人に見られることを意識してコミット、プルリクエスト等を行なっています。

### JavaScriptライブラリが生まれた
ユーザースクリプトを書く中で生まれたユーティリティクラスをライブラリ化して、GitHubで公開しました。

* **[Optional.js](https://github.com/munierujp/Optional.js)**
* **[Iterator.js](https://github.com/munierujp/Iterator.js)**
* **[Observer.js](https://github.com/munierujp/Observer.js)**
* **[Replacer.js](https://github.com/munierujp/Replacer.js)**
* **[SuperMap.js](https://github.com/munierujp/SuperMap.js)**

## 今後やりたいこと
Issuesにも登録していますが、Chromeアドオン化してChrome ウェブストアで公開しようと考えています。[^5]
また、その作業を通じてChromeアドオン開発のノウハウを身につけ、別のアドオンも開発したいと思います。

[^1]: [jQueryの使用 by munierujp · Pull Request #65 · munierujp/direct_helper](https://github.com/munierujp/direct_helper/pull/65)
[^2]: [JavaScriptのMutationObserverでDOMの変化を監視する方法](https://qiita.com/munieru_jp/items/a6f1433652124a2165e4)
[^3]: [マルチビューのレスポンシブ化機能における縦幅不足問題の修正 · Issue #40 · munierujp/direct_helper](https://github.com/munierujp/direct_helper/issues/40)
[^4]: [サムネイルサイズ変更機能で本文の幅も変更される問題の修正 · Issue #52 · munierujp/direct_helper](https://github.com/munierujp/direct_helper/issues/52)
[^5]: [Chromeアドオン化 · Issue #60 · munierujp/direct_helper](https://github.com/munierujp/direct_helper/issues/60)