20210305のJavaScriptに関する記事は17件です。

【TypeScriptハンズオン①】男もすなるTypeScriptといふものを、女もしてみむとてするなり

この記はなに

TypeScriptを実際に触りながらTypeScriptを説く記です。
TypeScriptが しょしんなる 方や、TypeScriptを いとをかし と思っているひとが対象です。

この記で、こころよしと思った方は、LGTM✨を何卒よしなに願い申し上げ候

続編 --> 【TypeScriptハンズオン②】男もすなるTypeScriptといふものを、女もしてみむとてするなり ~自分だけの型を作ろう!~
@ TypeScriptをインストールしていないひとはこちら?
  【画像で説明】シンプルにTypeScriptを導入して使う方法
@ TypeScriptをいとをかしと思ったひとはこちら?
 JavaScriptを知っている方がTypeScriptをなんとなく理解するための記事

ハンズオン

✅ これ以降、古語は出てきません。ご安心ください
✅ VSCodeを使います

基本的な型の明記(Type Annotations)

TypeScriptは、変数や関数に型を明記することで、開発者がハッピー?になります。
まずは、このハッピー?になれる型の明記にて、以下でハンズオン✋をおこなっていきます。

変数に型を付けるときは次のように、変数の後に: <型>と明記します。

let profile: string = '紀貫之はネカマ';

このように記述すると、VSCode上では、profilestring型となっていることが確認できます。

image.png

これにより、profileには文字列stringしか許容されなくなるので、数値を代入しようとするとエラーが発生します。

let profile: string = '紀貫之はネカマ';
profile = 111; // タイプ「数値」をタイプ「文字列」に割り当てることはできません。

image.png

以上のように、型を明記することで、意図しない型(文字列型, 数値型, 真理値型などなど...)の代入を防ぐことが出来ます。

次は関数の型について、触っていきましょう。

関数では、引数や返り値に型を明記することが出来ます。
引数に型を指定するには、次のように型を指定します。

const 関数名 = (引数名: <>) => {
  ...
}

これは、以下のように使っていきます。

const bookDetails = (bookName: string) => {
  if(bookName === "土佐日記") {
    return "男もすなる日記といふものを、女もしてみむとてするなり。それの年(承平四年)のしはすの二十日あまり一日の、戌の時に門..."
  } else {
    return "この本の詳細はありません。"
  }
}
console.log(bookDetails("土佐日記")); // 男もすなる日記といふものを、女もしてみむとてするなり。それの年(承平四年)のしはすの二十日あまり一日の、戌の時に門...

さきほどと同じように,引数の後に: <型>をつければOKです!
これにより、bookDetails( )の括弧内に入れる値は、文字列stringの型のみ許容されます。

また、関数の返り値にも型を指定することが出来ます。
返り値に型を指定するには、関数の( )の後に: <型>をつければOKです!

const 関数名 = (): <> => {
  ...
}

これは、以下のように利用します。

const bookDetails = (bookName: string): string => {
  if(bookName === "土佐日記") {
    return "男もすなる日記といふものを、女もしてみむとてするなり。それの年(承平四年)のしはすの二十日あまり一日の、戌の時に門..."
  } else {
    return "この本の詳細はありません。"
  }
}
console.log(bookDetails("土佐日記")); // 男もすなる日記といふものを、女もしてみむとてするなり。それの年(承平四年)のしはすの二十日あまり一日の、戌の時に門...

VSCodeを確認すると、返り値がstring型であることがわかります。
image.png

このように、型をバシバシ指定していって、品質の高いソフトウェアを作る手助けをするのがTypeScriptです!

おわりに

今回は、簡単な型定義のハンズオンを行いました。
続編もぜひぜひ

Next: 【TypeScriptハンズオン②】男もすなるTypeScriptといふものを、女もしてみむとてするなり② ~自分だけの型を作ろう!~

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【phina.js】牛を避けるだけのゲームを作りました(?)

webゲーム「牛を避けるだけのゲーム」を作りました

空から降り注ぐ牛を避け続けるゲームです
github pagesにアップしてあるので気軽に遊んでみてください!
https://git-gen.github.io/cow_runner
※ 音が出るので音量注意です!!

スクリーンショット 2021-03-04 22.33.01.png

スクリーンショット 2021-03-04 22.38.47.png

プレイ動画(Twitter)

スクリーンショットを見ればすぐわかると思いますが、クソゲーです
今回はこんな感じのクソゲーの作り方をご紹介します!

ゲームライブラリ

当たり判定など大変そうな事はやりたくないので素直にゲームライブラリを使います
webゲームには有名どころでPhaserなどがありますが、
あまり時間をかけずにサクッとゲームを作りたいのであれば「phina.js」がオススメです!

phina.jsは日本製のjavascriptのwebゲームライブラリで使った感想はこんな感じ
- セットアップが楽
- 仕組みがシンプル
- runstantというサイトに沢山サンプルがあるので参考にできる
とにかくすぐにゲームとして形にできるのでプログラミングを楽しみたい人に良い!

雛形

parcelにphina.jsを入れただけのリポジトリです
phina.jsはパッケージではなくCDNで読み込んでます、
パッケージを使わない理由はnpm・yarnのphina.jsは更新されていないのかバグがある為

セットアップ

$ yarn 

$ yarn dev

ざっくり仕組みを説明

雛形の中のindex.jsが本体になります
まずこれがindex.jsの中身で最低限の設定しかされていない状態です

parcel-phinajs-example/src/assets/index.js

// phina.js をグローバル領域に展開
phina.globalize();

// MainScene クラスを定義
phina.define('MainScene', {
  superClass: 'DisplayScene',
  init: function() {
    this.superInit();
    this.backgroundColor = '#fff';
  },
});

// メイン処理
phina.main(function() {
  // アプリケーション生成
  var app = GameApp({
    startLabel: 'title',
    title: 'ゲーム',
  });
  // アプリケーション実行
  app.run();
});

phina.globalize();はphina.jsで用意されているオブジェクトを呼び出しやすくする宣言です
といっても、これを書かなかった場合は原因不明のエラーが発生してゲームが止まる事が多々あったので、ほぼ必須な気がします

後はapp.runしてあげれば自動でDOMにcanvas要素を作成してくれてゲーム画面が作られます!

MainSceneというクラスを定義しています
これはゲーム画面はシーンごとに分かれており
phina.jsではデフォルトで用意されているシーン(タイトル、メイン、リザルトなどがある)を上書きしているものです!

アセットを用意する

アセットを用意しましょう!
フリー素材を使っても良し・自分で作っても良しです
僕はスマホアプリとPiskelってツールでドット絵描いて
BGMとかはDOVA-SYNDROMEってサイトで用意しました!

自機や障害物にアニメーションを付けたければ
スプライトシートを作れば簡単にアニメーションをつけることもできます
ちなみにスプライトシートの作成はPiskelでできます!(オススメ)

読み込みはこんな感じです!

parcel-phinajs-example/src/assets/index.js

import bgm from './bgm.mp3'
import player from './player.png'

const ASSETS = {
  sound: {
    'bgm': bgm,
  },
  image: {
    'player': player,
  },
}

...省略

phina.main(function () {
  // アプリケーションを生成
  const app = GameApp({
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    assets: ASSETS,
  app.run()
})

ゲームを作ってみる

例として敵にぶつかるとゲームオーバーになるだけのゲームを作ってみました
MailSceneで定義しているplayerはクリックした位置に移動して動かないenemyに接触するとゲームオーバーとなります
コード内にコメントを記載しているので参考にしてみてください!
基礎としてはこんな感じでガンガンコーディングしてクオリティをあげていく感じです!

import bgm from './bgm.mp3'
import effect from './effect.mp3'
import player from './player.png'

// phina.js をグローバル領域に展開
phina.globalize();

const SCREEN_WIDTH = 640
const SCREEN_HEIGHT = 960
const OBJECT_POSITION = 800
const ASSETS = {
  sound: {
    'bgm': bgm,
    'effect': effect,
  },
  image: {
    'player': player,
  },
}

// MainScene クラスを定義
phina.define('MainScene', {
  superClass: 'DisplayScene',
  init: function() {
    this.superInit();
    this.backgroundColor = '#fff';
    SoundManager.playMusic('bgm');

    // labelを表示する
    Label('敵ぶつかるとゲームオーバー').addChildTo(this).setPosition(this.gridX.center(), this.gridY.center());

    // 背景の設定方法(backgroundという背景用の画像をassetsに読み込む)
    // Sprite('background').addChildTo(this).setPosition(this.gridX.center(), this.gridY.center());

    // 自機
    this.player = Sprite('player', 64, 64).addChildTo(this)
    this.player.setPosition(200, OBJECT_POSITION)

    // 敵
    this.enemy = Sprite('player', 64, 64).addChildTo(this)
    this.enemy.setPosition(440, OBJECT_POSITION)
  },

  // 毎フレーム更新処理
  // 1フレームごとにこの処理が実行されます
  // デフォルトは30fpsなので1秒間に30回実行されるという事です
  update(app) {
    const p = app.pointer
    // クリックがあったところにplayerを移動させる
    if (p.getPointing()) {
      this.player.setPosition(p.x, p.y)
    }

    // playerがenemyに接触していたらgemeover()を呼び出す
    if (this.player.hitTestElement(this.enemy)) {
      this.gameover()
    }
  },

  // ゲームオーバー
  gameover() {
    // 効果音を再生する
    SoundManager.play('effect');

    // 次のシーンに遷移する
    this.exit()
  },
});

// メイン処理
phina.main(function() {
  // アプリケーション生成
  var app = GameApp({
    startLabel: 'title',
    title: 'ゲーム',
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    assets: ASSETS,
  });
  // アプリケーション実行
  app.run();
});

ゲームをビルドしてアップロードする

ゲームができたらビルドしてgithub pagesにでもアップして遊んでもらいましょう〜

$ yarn build
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【GAS】Googleカレンダーの変更をLINEに通知する

イントロダクション

この記事の概要

Googleカレンダーにで予定が登録/更新/削除されたことをLINEに通知する方法をまとめる。

背景

Googleカレンダーを家族で共有して、お互いの予定を確認できるようにしている。
ただ、Googleカレンダーは通知機能が非常にしょぼく、予定が新規登録されたとき、カレンダーのオーナー宛にメール通知するくらいしかできない(オーナーが登録した予定はメンバーには通知されない)。IFTTTと連携することでLINEに通知することはできるが、予定が「登録」されたときのみで更新や削除は通知されないので、物足りない。

Google Calendar APIを叩いてJavaでエンドポイントをつくろうと思ったが、たかだかカレンダーの変更通知のために重厚長大になりすぎる。。。と思ってたら、カレンダーの変更をトリガーにGASを起動できると知ったので挑戦する。

ざっくりアーキテクチャ

無題のプレゼンテーション.png

0. コード全文

いきなり完成形。細かい解説は後述する。

application.gs
// ScriptPropertiesのキー定数
const PROPERTY_KEY_SYNC_TOKEN = 'SYNC_TOKEN';
const PROPERTY_KEY_LINE_TOKEN = 'LINE_TOKEN';
const PROPERTY_KEY_ENDPOINT_SLACK_WEBHOOK = 'ENDPOINT_SLACK_WEBHOOK';

// Google Driveに保存されている予定全件リストのファイルID定数
const FILE_ID_EVENTS = '1PVVkZUjD6wSw-kIGp1BpnLm_TsaYCWEtUEYEwE8QAaI';

// LINE Notify APIエンドポイント定数
const ENDPOINT_LINE_NOTIFY_API = 'https://notify-api.line.me/api/notify';

// ScriptProperties
var properties = PropertiesService.getScriptProperties();

var nextSyncToken = '';

/**
 * メイン関数
 * カレンダー変更にトリガーされて起動する
 *
 * @param event カレンダー変更イベント情報
 */
function calendarUpdated(event) {
    console.time('----- calendarUpdated -----');

    try {
        // 変更されたカレンダーのIDを取得する
        var calendarId = event.calendarId;

        // syncTokenを用いて予定の差分リストを取得する
        var options = {
            syncToken: getSyncToken(calendarId)
        };
        var recentlyUpdatedEvents = getRecentlyUpdatedEvents(calendarId, options);

        // 予定の差分リストからLINE通知用のメッセージを生成する
        var message = generateMessage(recentlyUpdatedEvents);

        // LINEへ通知する
        notifyLINE(message);

        // スプレッドシートに保存した予定の全件リストを最新化する
        refleshStoredEvents(calendarId);

        // 次回起動用にsyncTokenを保持する
        properties.setProperty(PROPERTY_KEY_SYNC_TOKEN, nextSyncToken);
    } catch (e) {
        console.error(e);

        // エラー情報をSlackへ通知する
        var error = 'カレンダー変更イベントの処理中にエラーが発生しました。\n\n calendarId=' + calendarId + '\nerror=' + e.message;
        notifySlack(error);
    }
    console.timeEnd('----- calendarUpdated -----');
}

/**
 * syncToken取得関数
 *
 * @param calendarId 対象カレンダーのID
 */
function getSyncToken(calendarId) {
    console.time('----- getSyncToken -----');

    // 前回起動時に保持したsyncTokenを取得する
    var token = properties.getProperty(PROPERTY_KEY_SYNC_TOKEN);

    // 前回起動時に保持したsyncTokenがない場合、予定リストから取得する
    if (!token) {
        token = Calendar.Events.list(calendarId, { 'timeMin': (new Date()).toISOString() }).nextSyncToken;
    }

    console.timeEnd('----- getSyncToken -----');
    return token;
}

/**
 * 予定の差分リスト取得関数
 *
 * @param calendarId 対象カレンダーのID
 * @param options    予定リスト取得リクエストのオプション
 */
function getRecentlyUpdatedEvents(calendarId, options) {
    console.time('----- getRecentlyUpdatedEvents -----');

    // 予定の差分リストを取得する
    var events = Calendar.Events.list(calendarId, options);

    // 次回起動用にsyncTokenを取得する
    nextSyncToken = events.nextSyncToken;

    console.timeEnd('----- getRecentlyUpdatedEvents -----');
    return events.items;
}

/**
 * LINE通知用メッセージ生成関数
 *
 * @param events 予定の差分リスト
 */
function generateMessage(events) {
    console.time('----- generateNotifyMessages -----');

    var message = '';
    var messages = [];
    for (var i = 0; i < events.length; i++) {
        // 予定差分のステータスを取得する
        var status = events[i].status;

        // ファイルに保存した予定の全件リストをIDで検索する
        var storedEvent = searchStoredEventById(events[i].id);

        if (status == 'cancelled') { // 予定が削除された場合
            if (storedEvent) { // 予定の全件リストにIDが一致する予定が存在した場合
                messages.push('Googleカレンダーの予定が削除されました。\n\nタイトル:' + storedEvent.summary + '\n開始:' + dateToString(storedEvent.start) + '\n終了:' + dateToString(storedEvent.end));
            } else { // 予定の全件リストにIDが一致する予定が存在しない場合
                messages.push('Googleカレンダーの予定が削除されました。');
            }
        } else { // 予定が登録or更新された場合
            if (storedEvent) { // 予定が更新された場合
                messages.push('Googleカレンダーの予定が更新されました。\n\nタイトル:' + storedEvent.summary + '\n開始:' + dateToString(storedEvent.start) + '\n終了:' + dateToString(storedEvent.end));
            } else { // 予定が登録された場合
                // 予定のdateもしくはdateTimeから予定の開始日時/終了日時を取得する
                var start = (events[i].start.dateTime) ? events[i].start.dateTime : events[i].start.date;
                var end = (events[i].end.dateTime) ? events[i].end.dateTime : events[i].end.date;

                messages.push('Googleカレンダーに予定が登録されました。\n\nタイトル:' + events[i].summary + '\n開始:' + dateToString(start) + '\n終了:' + dateToString(end));
            }
        }
    }

    // メッセージ配列を結合してひとつにする
    message = messages.join('\n----------\n');

    console.timeEnd('----- generateNotifyMessages -----');
    return message;
}

/**
 * ファイルに保存した予定検索関数(主キー検索用)
 *
 * @param id ID
 */
function searchStoredEventById(id) {
    console.time('----- searchStoredEventById -----');

    // ファイルに保存した予定を検索する
    var event = searchStoredEvents('id = ' + id)[0];

    console.timeEnd('----- searchStoredEventById -----');
    return event;
}

/**
 * ファイルに保存した予定検索関数(複数結果取得用)
 *
 * @param filter 検索条件
 */
function searchStoredEvents(filter) {
    console.time('----- searchStoredEvents -----');

    // ファイルに保存した予定を検索する
    var events = [];
    var result = SpreadSheetsSQL.open(FILE_ID_EVENTS, 'DATA').select(['id', 'summary', 'start', 'end']).filter(filter).result();
    for (var i = 0; i < result.length; i++) {
        // 検索結果からStoredEventインスタンスを生成し、配列に格納する
        events.push(new StoredEvent(result[i].id, result[i].summary, result[i].start, result[i].end));
    }

    console.timeEnd('----- searchStoredEvents -----');
    return events;
}

/**
 * 日時->文字列変換関数
 *
 * @param source 日時
 */
function dateToString(source) {
    console.time('----- dateToString -----');

    var stringFormat = '';
    var yyyyMMdd = String(source).split('T')[0];
    var hhmm = String(source).split('T')[1];
    var yyyy = String(yyyyMMdd).split('-')[0];
    var MM = String(yyyyMMdd).split('-')[1];
    var dd = String(yyyyMMdd).split('-')[2];

    // 終日予定の場合、時分は未定義となる。その場合は「00:00」とする
    var hh = (hhmm) ? String(hhmm).split(':')[0] : '00';
    var mm = (hhmm) ? String(hhmm).split(':')[1] : '00';

    stringFormat = yyyy + '-' + MM + '-' + dd + ' ' + hh + ':' + mm;

    console.timeEnd('----- dateToString -----');
    return stringFormat;
}

/**
 * LINE通知関数
 *
 * @param message 通知メッセージ
 */
function notifyLINE(message) {
    console.time('----- notifyLINE -----');

    // ScriptPropertiesからLINEのアクセストークンを取得する
    var token = properties.getProperty(PROPERTY_KEY_LINE_TOKEN);

    // LINE Notify APIを呼び出し、通知を行なう
    var options = {
        method: 'post',
        payload: 'message=' + message,
        headers: { 'Authorization': 'Bearer ' + token },
        muteHttpExceptions: true
    };
    UrlFetchApp.fetch(ENDPOINT_LINE_NOTIFY_API, options);

    console.timeEnd('----- notifyLINE -----');
}

/**
 * スプレッドシートに保存した予定の全件リスト最新化関数
 *
 * @param calendarId 対象カレンダーのID
 */
function refleshStoredEvents(calendarId) {
    console.time('----- refleshStoredEvents -----');

    // 既存の全件リストを削除する
    SpreadSheetsSQL.open(FILE_ID_EVENTS, 'DATA').deleteRows();

    // カレンダーから最新の予定の全件リストを取得する
    var events = Calendar.Events.list(calendarId, { 'timeMin': (new Date()).toISOString() }).items;

    var storedEvents = [];
    for (var i = 0; i < events.length; i++) {
        // 予定のdateもしくはdateTimeから予定の開始日時/終了日時を取得する
        var start = (events[i].start.dateTime) ? events[i].start.dateTime : events[i].start.date;
        var end = (events[i].end.dateTime) ? events[i].end.dateTime : events[i].end.date;

        // 検索結果からStoredEventインスタンスを生成し、配列に格納する
        storedEvents.push(new StoredEvent(events[i].id, events[i].summary, start, end));
    }

    // 最新の予定の全件リストをスプレッドシートに保存する
    SpreadSheetsSQL.open(FILE_ID_EVENTS, 'DATA').insertRows(storedEvents);

    // スプレッドシートの全セルの書式設定を「書式なし」に設定する
    SpreadsheetApp.openById(FILE_ID_EVENTS).getSheetByName('DATA').getDataRange().setNumberFormat('@');

    console.timeEnd('----- refleshStoredEvents -----');
}

/**
 * Slack通知関数
 *
 * @param message 通知メッセージ
 */
function notifySlack(message) {
    console.time('----- notifySlack -----');

    // SlackのAPIを呼び出し、通知を行なう
    var options = {
        method: 'post',
        payload: JSON.stringify({ 'text': message }),
        muteHttpExceptions: true
    };
    UrlFetchApp.fetch(properties.getProperty(PROPERTY_KEY_ENDPOINT_SLACK_WEBHOOK), options);

    console.timeEnd('----- notifySlack -----');
}

/**
 * スプレッドシートに保存した予定を表すエンティティクラス
 */
class StoredEvent {
    constructor(id, summary, start, end) {
        this.id = id;
        this.summary = summary;
        this.start = start;
        this.end = end;
    }
}

1. トリガーを設定する

トリガーの設定はこのようにする。

黄色で囲んだ部分には「カレンダーのオーナーのメールアドレス」とあるが、要するに通知対象のカレンダーIDを入力する。カレンダーIDの確認方法は下記を参考にする。

GASでGoogleカレンダーに予定を追加する

これでカレンダーに予定が登録などされると、calendarUpdated関数がキックされるようになる。

2. コードの解説

calendarUpdated関数の引数

function calendarUpdated(event) {
    var calendarId = event.calendarId;
    ...
}

引数eventにはカレンダー変更イベント情報が含まれているが、「どのカレンダーが変更されたか」しかわからない。どの予定が登録/更新/削除されたのかは、eventから取得したカレンダーIDを用いて取得することになる。

SyncToken

function getSyncToken(calendarId) {
    // 前回起動時に保持したsyncTokenを取得する
    var token = properties.getProperty(PROPERTY_KEY_SYNC_TOKEN);

    // 前回起動時に保持したsyncTokenがない場合、予定リストから取得する
    if (!token) {
        token = Calendar.Events.list(calendarId, { 'timeMin': (new Date()).toISOString() }).nextSyncToken;
    }
    ...
}

前述のとおり、カレンダー変更イベントから変更された予定を直接取得することはできないので、カレンダー変更イベントを受けたらGoogle Calendar APIを呼び出して、予定の一覧を取得することになる。
ただ、毎回全予定を取得して更新日時を見るのでは効率が悪いのでSyncTokenを使う。SyncTokenを使うことで、前回一覧を取得して以降に変更が加わった予定、つまり予定の差分一覧を取得できる。

最新のSyncTokenはプロパティから取得するが、初めての起動時などプロパティから取得できないときには予定一覧を取得し、そこからSyncTokenを取り出す。ここで取得する予定一覧はあくまでSyncTokenを取得するためのものなので、timeMinを指定して件数を絞り込むことで性能劣化を避ける。

更新前の予定の全件リスト

SyncTokenによって予定の差分リストはとれたが、「なんらかの更新があった予定」としかわからないので、更新の内容まで特定するには更新前の予定と比較する必要がある。つまり、更新前の予定の全件リストをどこかに持っていないといけない。わざわざDBを立てるのもめんどくさいなと思っていたら、とても便利なライブラリ「SpreadSheetSQL」を見つけたので、Googleドライブ上のスプレッドシートをDBとして更新前の予定リストを保存できるようにする。

GoogleAppsScriptでSQLライクにスプレッドシートを扱えるライブラリを作りました。

スプレッドシートはこのような形で用意する。

  • id : 予定を一意に表すID
  • summary : 予定のタイトル
  • start : 予定の開始日時
  • end : 予定の終了日時

キャプチャ.PNG

列名を指定するのみで、レコードを積む必要はない。GASからスプレッドシートにアクセスするにはシート名を指定する必要があるので、それっぽい名前をつけておく。

予定の登録/更新/削除の判別

function generateMessage(events) {
    ....
    for (var i = 0; i < events.length; i++) {
        // 予定差分のステータスを取得する
        var status = events[i].status;

        // ファイルに保存した予定の全件リストをIDで検索する
        var storedEvent = searchStoredEventById(events[i].id);

        if (status == 'cancelled') { // 予定が削除された場合
            if (storedEvent) { // 予定の全件リストにIDが一致する予定が存在した場合
                messages.push('Googleカレンダーの予定が削除されました。\n\nタイトル:' + storedEvent.summary + '\n開始:' + dateToString(storedEvent.start) + '\n終了:' + dateToString(storedEvent.end));
            } else { // 予定の全件リストにIDが一致する予定が存在しない場合
                messages.push('Googleカレンダーの予定が削除されました。');
            }
        } else { // 予定が登録or更新された場合
            if (storedEvent) { // 予定が更新された場合
                messages.push('Googleカレンダーの予定が更新されました。\n\nタイトル:' + storedEvent.summary + '\n開始:' + dateToString(storedEvent.start) + '\n終了:' + dateToString(storedEvent.end));
            } else { // 予定が登録された場合
                // 予定のdateもしくはdateTimeから予定の開始日時/終了日時を取得する
                var start = (events[i].start.dateTime) ? events[i].start.dateTime : events[i].start.date;
                var end = (events[i].end.dateTime) ? events[i].end.dateTime : events[i].end.date;

                messages.push('Googleカレンダーに予定が登録されました。\n\nタイトル:' + events[i].summary + '\n開始:' + dateToString(start) + '\n終了:' + dateToString(end));
            }
        }
    }
    ....
}

更新後の予定と更新前の予定を比較して、登録/更新/削除のいずれなのか判別するロジックは上記のとおり。

予定の「削除」

更新後の予定のstatuscancelledであれば、予定が「削除」されたとわかる。
削除された時点で予定のタイトルなどは取得できなくなってしまうため、イベントIDで更新前の予定を検索することで削除前の予定情報から削除された予定の情報を取得する。

予定の「更新」

更新後の予定のstatuscancelledでなく、かつ更新前の予定リストに同IDが存在すれば、予定が「更新」されたとわかる。

予定の「登録」

更新後の予定のstatuscancelledでなく、かつ更新前の予定リストに同IDが存在しなければ、予定が「登録」されたとわかる。

LINEに通知する

function notifyLINE(message) {
    ....
    // ScriptPropertiesからLINEのアクセストークンを取得する
    var token = properties.getProperty(PROPERTY_KEY_LINE_TOKEN);

    // LINE Notify APIを呼び出し、通知を行なう
    var options = {
        method: 'post',
        payload: 'message=' + message,
        headers: { 'Authorization': 'Bearer ' + token },
        muteHttpExceptions: true
    };
    UrlFetchApp.fetch(ENDPOINT_LINE_NOTIFY_API, options);
    ....
}

通知メッセージを組み立てたら、LINE Notify APIを使ってLINEへ通知を行なう。LINE Notify APIの使い方は下記記事を参考にした。

Google Apps ScriptからLINE NotifyでLINEにメッセージを送る

更新前の予定リストを最新化

function refleshStoredEvents(calendarId) {
    ....
    // 既存の全件リストを削除する
    SpreadSheetsSQL.open(FILE_ID_EVENTS, 'DATA').deleteRows();

    // カレンダーから最新の予定の全件リストを取得する
    var events = Calendar.Events.list(calendarId, { 'timeMin': (new Date()).toISOString() }).items;

    var storedEvents = [];
    for (var i = 0; i < events.length; i++) {
        // 予定のdateもしくはdateTimeから予定の開始日時/終了日時を取得する
        var start = (events[i].start.dateTime) ? events[i].start.dateTime : events[i].start.date;
        var end = (events[i].end.dateTime) ? events[i].end.dateTime : events[i].end.date;

        // 検索結果からStoredEventインスタンスを生成し、配列に格納する
        storedEvents.push(new StoredEvent(events[i].id, events[i].summary, start, end));
    }

    // 最新の予定の全件リストをスプレッドシートに保存する
    SpreadSheetsSQL.open(FILE_ID_EVENTS, 'DATA').insertRows(storedEvents);

    // スプレッドシートの全セルの書式設定を「書式なし」に設定する
    SpreadsheetApp.openById(FILE_ID_EVENTS).getSheetByName('DATA').getDataRange().setNumberFormat('@');
    ....
}

LINEへの通知まで完了したら、次回起動に向けて最新の予定リストをスプレッドシートに保存する。
まず、スプレッドシートの予定レコードをすべて削除する。そのあと、カレンダーから最新の予定リストを取得し、スプレッドシートに書き込む。

※全件といいつつ、timeMinを指定して未来の予定のみ取得している。そのため、過去の予定1が更新/削除された場合は正しく通知できない(更新でも「登録」として通知される など)。timeMinを指定しなければ過去の予定も含めて保存できるが、どんどんリストが肥大化することになる。性能やユースケースの兼ね合いでチューニングすること。

また細かいポイントだが、スプレッドシートに書き込んだあとに書式設定を「なし」にしている。これをしないと、自動書式が適用され、スプレッドシートからレコードを取り出したときに意図した形式にならない場合がある(とくに日時)。

まとめ

GASを使うことでサーバレス/省コードで実現できた。
開始/終了日時の取得の仕方がカッコ悪かったりタイムゾーンの問題で通知メッセージの日時が実際とズレてたり、細かいところでイマイチだけど、追々直すということでヨシとする。

syake-salmon/google-calendar-watchdog - GitHub

参考文献


  1. 「過去の予定」ってなんじゃ 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ページ離脱時や、遷移した時に出る画面の上下中央に出るバナーのCSS

どうも7noteです。最近よく見かける離脱防止用のバナーの作り方

たとえばこんなやつですね。

image.png

これと同じようなものを作ってみます。

どんっ!!

image.png

画面いっぱいを暗くして、バナーを表示する方法

index.html
<!-- body直下に設置 -->
<div id="overview">
  <div class="bnr"><img src="sample.png" alt=""><span class="close">×</span></div>
</div>
style.css
#overview {
  width: 100%;       /* 幅いっぱい */
  height: 100vh;     /* 高さいっぱい */
  background: rgba(0, 0, 0, 0.5); /* 背景を薄暗くする */
  display: none;     /* デフォルトは非表示 */
  position: fixed;   /* 表示位置を絶対指定して固定 */
  top: 0;            /* 表示位置を上から0pxに指定 */
  left: 0;           /* 表示位置を左から0pxに指定 */
}

#overview .bnr {
  position: absolute; /* 相対位置とする、かつcloseの基準値になる。 */
  top: 50%;           /* 表示位置を上から50%に指定 */
  left: 50%;          /* 表示位置を左から50%に指定 */
  transform: translate(-50%, -50%); /* 上下中央にするため要素の半分分だけ左上に戻す */
}
#overview .bnr .close {
  font-weight: bold;  /* 太字にする */
  padding: 0 6px;     /* 左右の余白を適当に調整 */
  background: #fff;   /* 背景色を白に指定 */
  position: absolute; /* 相対位置とする */
  top: -28px;         /* いい感じの位置に指定 */
  right: 0px;         /* 同様 */
  cursor: pointer;    /* hover時にカーソルを指の形にする */
}
script.js
/* ページ読み込み完了時にバナーのブロックを表示 */
window.onload = function(){
  $("#overview").show();
}

/* ×ボタンが押されたとき、バナーブロックを非表示 */
$("#overview .close").on("click", function(){
  $("#overview").hide();
});

解説

CSSの解説ですが、ポイントがいくつか。overflowの幅は100vwにしてしまうとpcの縦スクロールバー分の計算をしないといけなくなってしまうので、body直下に設置し、width: 100%;を指定しています。

一番後ろのoverflowに半透明の背景を入れるため16進数での背景指定ではなくbackground: rgba(0, 0, 0, 0.5);と書き、色の透明度で調整。opacityで透明度を指定してしまうと、バナー画像も半透明になってしまうので注意。
詳しくは過去の記事「【初心者でもわかる】cssで使われる透明3種類の使い方」をご覧ください。

あとはバナー画像を上下中央に設置したり、×ボタンをいい感じのところに配置したら完成!
×ボタンは押せることがわかるようにcursor: pointer;を入れておくのがベスト!

まとめ

見た目の体裁についての解説になります。記述しているjsはおまけ程度にお考えください、実運用できるようなレベルのjavascriptではありません。
このままだと、1回バナーを見たユーザーに何度も表示することになりますし、ページリロードする度にバナーが出てくる等、使いにくさが出てくきます。

離脱時(戻るボタンを押したとき)にバナーを出すなどは意外と手間がかかるのでjs初心者の方はあまりお勧めしないかも。。。そういうツールもあるので、ちゃんと運用したいのであればそのようなツールを導入することを検討するといかも

おそまつ!

~ Qiitaで毎日投稿中!! ~
【初心者向け】WEB制作のちょいテク詰め合わせ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Google Apps Script練習 (Gmailの新着メールをLINEに転送)

GASを練習したいと思い、また自分自身Gmailのメールを見逃してしまう事が多いのでそれをなんとか出来ないかと思いスクリプトを作成しました。

gas.js
const LINE_NOTIFY_TOKEN = PropertiesService
  .getScriptProperties()
  .getProperty('LINE_NOTIFY_TOKEN')
const endPoint = 'https://notify-api.line.me/api/notify'

// 1. 転送したいメールの送信元アドレスを指定
const fromAddress = [''].join(' OR ')
// 2. トリガーの設定間隔と合わせる
const minutesInterval = 5

function main() {
  const notices = fetchNotices()

  if (notices.length === 0) {
    return
  }

  for (const notice of notices) {
    send(notice)
  }
}

function fetchNotices() {
  const now = Math.floor(new Date().getTime() / 1000)
  const intervalMinutesAgo = now - (60 * minutesInterval)
   // 3. 検索条件を設定
  const query = `(is:unread from:(${fromAddress}) after:${intervalMinutesAgo})`

  // 4. メールを取得する
  const threads = GmailApp.search(query)

  if (threads.length === 0) {
    return []
  }

  const mails = GmailApp.getMessagesForThreads(threads)
  const notices = []

  for (const messages of mails) {
    const latestMessage = messages.pop()
    const notice = `
--------------------------------------
件名: ${latestMessage.getSubject()}
受信日: ${latestMessage.getDate().toLocaleString()}
From: ${latestMessage.getFrom()}
--------------------------------------

${latestMessage.getPlainBody().slice(0, 350)}
`
    notices.push(notice)
    // 5. メールを既読にする
    latestMessage.markRead()
  }

  return notices
}

function send(notice) {
  if (LINE_NOTIFY_TOKEN === null) {
    Logger.log('LINE_NOTIFY_TOKEN is not set.')
    return
  }

  const options = {
    'method': 'POST',
    'headers': {'Authorization': `Bearer ${LINE_NOTIFY_TOKEN}`},
    'payload': {'message': notice},
  }

  UrlFetchApp.fetch(endPoint, options)
}

GmailにおけるThreadとMessageの違いについて理解に時間がかかりました。
 Thread: あるメールとそのメールに対する一連の返信(配列みたいになる)
 Message: 単体のメール1つ

GASもLINE APIも、本当便利…
GASって、他にもいろんな事できるんですね…Googleスプレッドシートを活用して議事録をいじったり…
まだまだ知らない事ばかりなので、一度ガッツリ時間を取って勉強したい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails + JavaScript】投稿画面に画像プレビュー機能を実装しよう!

画像を投稿する際に選択した画像がプレビューできる機能を実装していきます。
今回も初心者向けにレシピ投稿アプリを例に作成していきます。

JavaScript初心者にもわかりやすいようにメソッドやイベントについては外部リンクを参照しながら解説していきます。

画像投稿機能実装については前回記事にしておりますので、そちらを参照してください。
【超かんたん】Active Storageで画像投稿機能を実装しよう!

完成イメージ

5c93c9251ef0f4ecbbbe1eb8ccc91142.gif

事前準備

Javascriptファイルの作成

まずは、プレビュー機能を実装するためのpreview.jsを作成します。

ターミナル
touch app/javascript/packs/preview.js

ファイルが作成できたらpreview.jsを読み込むための記述をapplication.jsにしていきます。
また、turbolinksはコメントアウトします。

app/javascript/packs/application.js
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.

require("@rails/ujs").start()
// require("turbolinks").start()  // コメントアウト
require("@rails/activestorage").start()
require("channels")
require('./preview')  // 追記

//以下略

画像を表示するスペースの作成

画像を表示する場所をビューファイルに指定します。

app/views/recipes/new.html.erb
<%= form_with model: @recipe, local: true do |f| %>

#中略
    <div class="form-group">
      <label class="text-secondary">画像</label><br>
      <%= f.file_field :image %>
    </div>

    <div id="new-image"></div>  #追記

#以下略

<% end %>

Javascriptファイルの編集

preview.jsを新規投稿ページでしか発火しないようにif文を作成します。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){

}

次にHTMLが最初に読み込まれたときに作動する関数を定義していきます。
addEventListenerメソッドとDOMContentLoadedイベントを使います。

addEventListenerの使い方は下記の通りです。

要素.addEventListener(イベント, 関数, オプション);

それでは処理を記述していきます。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {

  });
}

続いて、検証ツールを用いて投稿フォームのファイル選択ボックスのidを確認しましょう。
e4ef3687bbb1149610a8aa9301d91f83.gif

今回のレシピアプリの場合はid="recipe_image"だったのでこのrecipe_imagegetElementByIdメソッドで取得していきます。
そして、投稿フォームのファイル選択ボックスに変化(change)が起こったときに行われる処理を記述していきます。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    document.getElementById('recipe_image').addEventListener('change', (e) =>{
      console.log(e);
    });
  });
}

アロー関数の「e」はgetElementByIdで取得した投稿フォームのファイル選択ボックスの中身になります。(eはeventの略)

では、本当に中身が取得できたいるか確かめて見ましょう。
以下のようにコンソールに出力されていれば成功です。
event.gif

では、取得した情報を定数に格納します。

e.target.files[0]で取得したファイルの情報を定数fileに格納し、URL.createObjectURL(file)で取得した情報を文字列に変換し、定数blobに格納します。

そして、blobを引数にcreateImageHTML( )という関数を呼び出します。(createImageHTML( )はこのあと作成します。)

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    document.getElementById('recipe_image').addEventListener('change', (e) => {

      console.log(e);  //削除

//ここから追記
      const file = e.target.files[0];  
      const blob = window.URL.createObjectURL(file); 
      createImageHTML(blob); 
//ここまで追記
    });
  });
}

それでは、createImageHTML( )を作成しましょう。
まずは、getElementByIdでnew.html.erbに先ほど追加したdiv要素のidのnew-imageを取得します。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
//ここから追記
    const createImageHTML = (blob) => {  
      const imageElement = document.getElementById('new-image'); 
    }; 
//ここまで追記

    document.getElementById('recipe_image').addEventListener('change', (e) => {
      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次にcreateElementメソッドでHTML要素の「img」を作成し、blobImageに格納します。
そして、setAttributeでclassとsrcをimgに付与します。
classを付与しているのはCSSを当てるためです。

setAttributeの使い方は以下の通りです。

要素.setAttribute("データ名",データ);

以上のことを踏まえて、記述していきましょう。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const createImageHTML = (blob) => {
      const imageElement = document.getElementById('new-image');
//ここから追記
      const blobImage = document.createElement('img'); 
      blobImage.setAttribute('class', 'new-img') 
      blobImage.setAttribute('src', blob); 
//ここまで追記
    };

    document.getElementById('recipe_image').addEventListener('change', (e) => {
      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

最後に、appendChildメソッドを使ってnew.html.erbに追加したdiv要素の中にimg要素を入れます。

appendChildの使い方は以下の通りです。

親要素.appendChild(追加する子要素);

それでは、preview.jsに追記しましょう。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const createImageHTML = (blob) => {
      const imageElement = document.getElementById('new-image');
      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage); //追記
    };

 //以下省略

setAttributeで付与したクラス「new-img」にCSSをあてます。

style.css
.new-img{
  width: 400px;
  object-fit: cover;
}

実際にビューを確認してみましょう。
てり.jpg
画像が表示され以下のようにimg要素にclass属性とsrc属性がセットされていれば成功です。
6007c0be3679ea9ee27b2e55cab74e10.png

既存のプレビューを削除しよう

現状だと画像ファイルを選択し直すとどんどん画像がプレビューされていくという問題点があります。
542452a03055e4b5d01981c40f15b950.gif
この問題を解決していきましょう。

querySelectorメソッドを使ってimg要素を取得し、imageContentに格納します。

そして、if文を使いimageContentに値が入っている場合removeされます。
(img要素がない、つまりimageContentがnullの場合はif文がfalseとなり、実行されません。)

app/javascript/packs/preview.js
//中略
    document.getElementById('recipe_image').addEventListener('change', (e) => {
//ここから追記
      const imageContent = document.querySelector('img'); 
      if (imageContent){ 
        imageContent.remove(); 
      } 
//ここまで追記

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

それでは確認してみましょう。
以下の通り、画像ファイルを選択し直すと再プレビューされれば成功です。
957bdc1ca5a903937315a55e59b92ff5.gif

以下、完成形のコードです。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const createImageHTML = (blob) => {
      const imageElement = document.getElementById('new-image');
      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
    };

    document.getElementById('recipe_image').addEventListener('change', (e) => {
      const imageContent = document.querySelector('img');
      if (imageContent){
        imageContent.remove();
      }

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScript 忘れがちな配列操作テクニック

jsの便利メソッドを使った、覚えておきたいけど忘れがちな配列操作テクニックです.
自分用のメモとして残しておきます.

配列の合計値[reduce]

const c = [1,2,3]
console.log( c.reduce((a,b) => a+=b, 0) )
// => 6

配列のソート[sort]

const c = [3,2,1]
console.log( c.sort((a,b) => a-b) )
// => [1,2,3]

連番の値をもつ配列の作成[map]

console.log( [...Array(5)].map((_,i) => i+1) )
// => [1,2,3,4,5]

配列の等値比較[every]

const [a, b] = [[1,2,3], [1,2,3]]
console.log( a.every((v,i) => v === b[i]) )
// => true

配列の差分取得[filter]

const [a, b] = [[1,2,3], [1,2,3,4,5]]
console.log( b.filter(v => a.indexOf(v) == -1) )
// => [4,5]
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Javascript] MutationObserverを使って動的に追加・削除される要素に対してスクリプトをあてる

はじめに

以前にShopify開発パートナーさんから、ある機能を追加してみたが動作しない、というご相談を受けました。Shopifyの構造自体よくわかっていないということもあり、勉強がてら解析してみました。どうやら目的の機能を追加するには動的に生成された要素に対して既存のスクリプトをあてる必要があることがわかりました。

このような動的に追加されたり削除されたりする要素(例えば、スクリプト中でappendChildしたり、removeChildすることは多々あると思います。私はよく行ないます。)に対して既存のスクリプトをあてる場合には、MutationObserverが有効1 です。今回はその備忘録です。

MutationObserverとは

MutationObserver とは、指定したコールバック関数を DOM の変更時に実行させる API です。この API は、DOM3 Events の仕様で定義されていた Mutation Events を新しく設計し直したものです。

指定した要素の監視役ですね。変更があった場合、条件に合致すれば任意の処理を実行します。今回は機能追加したい要素の親要素の監視をします。

デモ

ボタンクリックで動的に生成される<div id="id3">に対して、以下のスクリプトをあてたいと思います。

const showAlert = () => alert('clicked')

前提となるHTMLとJavascript。

<div id="container">
  <input type="button" id="btn_genDiv" value="click">
  <!-- ここにid0~id9のdivを生成 -->
</div>
window.onload = () => {
  const showAlert = () => alert('clicked')
  const genDiv    = e => {
    const container = document.querySelector('#container')
    if (container.querySelectorAll('[id^=div]').length) return
    for (let i = 0; i < 10; i++) {
      let div = document.createElement('div')
      container.appendChild(div)
      div.id = `div${i}`
      div.innerText = i
    }
  }

  // ボタンクリクでdiv要素を生成
  const btn = document.querySelector('#btn_genDiv')
  btn.addEventListener('click', genDiv, false)

  /* 以降省略 */
}

動作しないコード

window.onload = () => {
  /* 省略 */
  const div3 = document.querySelector('#div3')
  div3.addEventListener('click', showAlert, false)
}

そもそも当該の要素がwindow.onloadの処理時にDOMtreeに含まれていない2 のでUncaught TypeErrorとなります。

動作するコード

window.onload = () => {
  /* 省略 */
  const target = document.querySelector('#container') // スクリプト設定対象の親要素を設定
  const config = {childList: true, subtree: true} // 監視条件の設定

  //親要素に変化があった場合の処理設定
  const observer = new MutationObserver(() => {
    const div3 = document.querySelector('#div3')
    div3.addEventListener('click', showAlert, false)
  })

  // 監視開始
  observer.observe(target, config)
}

ボタンクリックでDIV要素が展開され、特定の要素<div id="id3">がクリックされると意図通りアラートダイアログが表示されます。

configの設定について

プロパティについてはMDNにて確認できますが、以下に引用します。

プロパティ 意味
childList 対象ノードの子ノード(テキストノードも含む)に対する追加・削除を監視する場合は true にします。
attributes 対象ノードの属性に対する変更を監視する場合は true にします。
characterData 対象ノードのデータに対する変更を監視する場合は true にします。
subtree 対象ノードとその子孫ノードに対する変更を監視する場合は true にします。
attributeOldValue 対象ノードの変更前の属性値を記録する場合は true にします(attributes が true の時に有効)。
characterDataOldValue 対象ノードの変更前のデータを記録する場合は true にします(characterData が true の時に有効)。
attributeFilter すべての属性の変更を監視する必要がない場合は、(名前空間を除いた)属性ローカル名の配列を指定します。

注意点としてchildListattributescharacterDataのいずれか1つ、かつ値をtrueに設定する必要があります。

今回は子ノードのみの監視ですが、子孫ノードも含める場合はsubtree: trueを追加すれば良いです。

結果

See the Pen MutationObserver Sample by STSHISHO (@STSHISHO) on CodePen.

最後に

サンプルのため特定の要素<div id="id3">が確実に読み込まれることを前提として記述していますので、追加されることが不確実な場合は存在判定が必要になるでしょう。

JSFiddleでもサンプル公開中です。

[参考]


  1. 実案件では直接、管理画面でのコード編集ができない状況でした。Shopifyに限らず、CMSのプラグイン等で有効かと思いますが、機能追加によって生じる関連部分への影響をしっかりと検証することは必要ですね。 

  2. display: none;visibility: hidden;からの表示・非表示切り替えの場合、window.onloadの処理時にはDOMtreeに含まれているためエラーになりません。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

お名前ドットコムの独自メールアドレスをFirebaseで使用する方法

最近めっちゃ風強いんすよね。

僕雨と風強いの本当に嫌いなんです。

まぁこんな話はさておき、今回はお名前ドットコムで登録した独自ドメインのメールアドレスをFirebaseで使用する方法を書いていきます!

Firebaseでメールを送信する時って送信アドレスがnoreply@project-auth.firebaseapp.comになっていると思うんですけど、これって知らない人からしたらもしかしたら怪しいと感じて信頼されない場合もありますよね。

せっかく会員登録したのにメールアドレスで信頼されなかったら勿体ない!

ってことで独自ドメインで送信しちゃいましょう!

はじめに

まず、下記の条件を満たしている前提で話します。

・お名前ドットコムで独自ドメインのメールアドレスを設定済み

メールアドレスを設定していない方は下記の記事にやり方が書いてあるので是非ご覧ください!

お名前ドットコムで登録したメールサーバーと「nodeemailer」を連携させてみた

以下、説明用のメールアドレスをinfo@example.comとします。

FirebaseでDNSレコードの取得

まず初めに、独自のアドレスを使うにはFirebaseが発行したDNSレコードをお名前ドットコムで登録する必要があります。

では、DNSレコードを発行しましょう!

「Firebase コンソール」→「Authentication」→「Templates」→「テンプレートを編集」→「ドメインをカスタマイズ」

するとこのような画面になります。

無題.png

そしたら、サイトに使うアドレスを入力します。(例:example.com)

すると、このようにDNSレコードが発行されます。

無題.2png.png

DNSレコードの設定

先ほど発行したDNSレコードをお名前ドットコムで設定してあげましょう!

FirebaseのSMTP設定

そしたらFirebaseでSMTP設定をして独自のメールアドレスを使えるようにしましょう!

「Firebase コンソール」→「Authentication」→「Templates」→「SMTP設定」

以下のように設定してください。

SMTP設定
送信者のアドレス info@example.com
SMTPサーバーホスト mail**.onamae.ne.jp
SMTPサーバーポート 465
SMTP アカウントのユーザー名 info@example.com
SMTP アカウントのパスワード 設定したパスワードを入力
SMTPセキュリティモード SSL

SMTPサーバーホストの情報は、まず「レンタルサーバー一覧」から登録したドメインでログインしてください。

「保存」してください。

これで独自のメールアドレスでFirebaseのメールを送信することができます。

お名前ドットコムってGmailみたいにメールのアイコンを設定することができないのかな。。。

やり方を知っている方はぜひ教えて下さると幸いです。

以上、「お名前ドットコムの独自メールアドレスをFirebaseで使用する方法」でした!

また、何か間違っていることがあればご指摘頂けると幸いです。

他にも初心者さん向けに記事を投稿しているので、時間があれば他の記事も見て下さい!!

あと、最近「ココナラ」で環境構築のお手伝いをするサービスを始めました。

気になる方はぜひ一度ご相談ください!

Thank you for reading

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React と Video.js を使って Amazon IVS プレイヤーを実装する

2021 年より streampack チームに join した endo です。
Qiita に投稿するのは初めてですが、よろしくお願いします。

はじめに

Amazon Interactive Video Service (IVS) のプレイヤーを実装する機会があったので書いていこうと思います。

前提

React を使用したプロジェクトにて Video.js が組み込まれている状態を想定しています。
一応ゼロからのセットアップ手順を記載していますが、詳細については各々の公式サイトをご参照ください。

React のセットアップ

create-react-appを使って React の環境を構築します。

shell
npx create-react-app 【アプリ名】

https://ja.reactjs.org/docs/create-a-new-react-app.html

Video.js のインスール

Video.js が組み込まれていない場合 npm or yarn 経由で Video.js をインストールしてください。

shell
$ npm install --save-dev video.js

https://videojs.com/getting-started/#install-via-npm

プレイヤーの実装

AWS のドキュメントを参考に実装していきます。

注意点として気をつけたいのが IVS プレイヤーを React で実装する場合 npm or yarn でインストールすると現時点ではエラーとなるということです。

したがって、エラーを回避するために動的に JS を script タグで読み込みます。

App.js の修正

AmazonIVSPlayer という名前でコンポーネントを作成し import します。

App.js
import React from "react";
import AmazonIVSPlayer from "./components/AmazonIVSPlayer";

const App = () => {
  return <AmazonIVSPlayer />;
}

export default App;

プレイヤー部分のコーディング

まずはVideo.jsを import します。

import videojs from "video.js";
import "video.js/dist/video-js.css";

その後、動的にamazon-ivs-videojs-tech.min.jsを読み込みます。

const script = document.createElement("script");
script.src =
  "https://player.live-video.net/1.2.0/amazon-ivs-videojs-tech.min.js";
document.body.appendChild(script);

registerIVSTech関数を使って Video.js の登録をします。option がある時は第二引数に渡します。

registerIVSTech(videojs, options);

Video.js を使ってプレイヤーの初期化をします。techOrderオプションでAmazonIVSを追加します。

const player = videojs(
  "amazon-ivs-videojs",
  {
    techOrder: ["AmazonIVS"],
  },
  () => {
    // Play stream
    player.src(PLAYBACK_URL);
  }
);

全体的なコードは以下のようになります。

AmazonIVSPlayer.js
/* global registerIVSTech  */
import React, { useEffect, useRef } from "react";
import videojs from "video.js";
import "video.js/dist/video-js.css";

const AmazonIVSPlayer = () => {
  const playerRef = useRef();

  useEffect(() => {
    const script = document.createElement("script");
    script.src =
      "https://player.live-video.net/1.2.0/amazon-ivs-videojs-tech.min.js";
    document.body.appendChild(script);

    script.addEventListener("load", () => {
      const PLAYBACK_URL =
        "【動画URL】";

      registerIVSTech(videojs);

      // Initialize player
      const player = videojs(
        "amazon-ivs-videojs",
        {
          techOrder: ["AmazonIVS"],
        },
        () => {
          console.log("Player is ready to use!");

          player.src(PLAYBACK_URL);
        }
      );

      playerRef.current = player;
    });

    return () => {
      playerRef.current.dispose();
      document.body.removeChild(script);
    };
  }, []);

  return (
    <div>
      <video
        id="amazon-ivs-videojs"
        className="video-js vjs-4-3 vjs-big-play-centered"
        controls
        autoPlay
        playsInline
        muted
      ></video>
    </div>
  );
};

export default AmazonIVSPlayer;

画質選択機能を付けてみる

画質選択機能をつけるにはプラグインを追加します。

amazon-ivs-videojs-tech.min.jsに加えて、amazon-ivs-quality-plugin.min.jsを読み込む必要があります。

動的に複数の JS を読み込むことになるので、全ての JS のロードを待つ必要があります。

const loadScripts = [
  {
    src: "https://player.live-video.net/1.2.0/amazon-ivs-videojs-tech.min.js",
  },
  {
    src: "https://player.live-video.net/1.2.0/amazon-ivs-quality-plugin.min.js",
  },
];

// counterを用意して全てのJSの読み込みが終わったらプレイヤーを初期化する
let counter = 0;
const counterMax = loadScripts.length;

loadScripts.forEach((scr, i) => {
  const script = document.createElement("script");
  script.src = scr.src;
  document.body.appendChild(script);
  loadScripts[i].ref = script;

  script.addEventListener("load", () => {
    counter++;
    if (counter === counterMax) {
      // ここからプレイヤーの初期化処理
      // ...
    }
  });
});

registerIVSQualityPlugin関数を使って Video.js を登録し、

registerIVSQualityPlugin(videojs);

プレイヤーの初期化時にプラグインの有効化をします。

player.enableIVSQualityPlugin();

全体的には以下のようなコードになると思います。

AmazonIVSPlayer.js
/* global registerIVSTech , registerIVSQualityPlugin */
import React, { useEffect, useRef } from "react";
import videojs from "video.js";
import "video.js/dist/video-js.css";

const AmazonIVSPlayer = () => {
  const playerRef = useRef();

  useEffect(() => {
    const loadScripts = [
      {
        src:
          "https://player.live-video.net/1.2.0/amazon-ivs-videojs-tech.min.js",
        ref: null,
      },
      {
        src:
          "https://player.live-video.net/1.2.0/amazon-ivs-quality-plugin.min.js",
        ref: null,
      },
    ];

    let counter = 0;
    const counterMax = loadScripts.length;

    loadScripts.forEach((scr, i) => {
      const script = document.createElement("script");
      script.src = scr.src;
      document.body.appendChild(script);
      loadScripts[i].ref = script;

      script.addEventListener("load", () => {
        counter++;
        if (counter === counterMax) {
          const PLAYBACK_URL =
             "【動画URL】";

          registerIVSTech(videojs);
          registerIVSQualityPlugin(videojs);

          // Initialize player
          const player = videojs(
            "amazon-ivs-videojs",
            {
              techOrder: ["AmazonIVS"],
            },
            () => {
              console.log("Player is ready to use!");

              // Play stream
              player.src(PLAYBACK_URL);

              player.enableIVSQualityPlugin();
            }
          );

          playerRef.current = player;
        }
      });
    });

    return () => {
      playerRef.current.dispose();
      loadScripts.forEach(scr => {
        document.body.removeChild(scr.ref);
      });
    };
  }, []);

  return (
    <div>
      <video
        id="amazon-ivs-videojs"
        className="video-js vjs-4-3 vjs-big-play-centered"
        controls
        autoPlay
        playsInline
        muted
      ></video>
    </div>
  );
};

export default AmazonIVSPlayer;

まとめ

Reactを使ってのIVSプレイヤーの実装でした。
プレイヤーのUIを作るのは結構大変なので、 Video.js に対応しているのは非常にありがたいですね。

参考 URL

https://docs.aws.amazon.com/ivs/latest/userguide/player.html
https://aws.github.io/amazon-ivs-player-docs/1.2.0/web/
https://docs.videojs.com/
https://dev.classmethod.jp/articles/workaround-react-ivs/)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OpenAPI(Swagger)のAPI開発Docker環境を整備した(yaml分割編集、SwaggerUI表示、モックサーバー、静的HTML出力)

はじめに

OpenAPI(Swagger)でのAPI開発をプロジェクトで導入する際、
OpenAPIのyamlの書き方を覚えることよりも、
チーム開発するための最善の環境(ツール)選定に苦労したりします。

WEB上のSwaggerEditorを利用することや、
SwaggerHubというサービスを利用するという手もありますが、
いろいろとネックとなるところがあります。1 2
各自のローカルPCで各種ツールを利用できるような環境を作ってしまうのが便利かなと思い、
一式のツールをまとめたDocker環境を整備しました。

環境概要

完成品の環境コードはこちらに公開しています。
https://github.com/MinatoNaka/OpenApiDocker

READMEに記載されている手順でDocker環境を構築すれば
すぐに利用可能です。

この環境は、下記の機能を備えています。

  • SwaggerUIによるドキュメント表示
  • ReDocによるドキュメント表示
  • yamlのファイル分割記述可能(自動結合)
  • 単一の静的HTMLでドキュメント出力
  • APIモックサーバ

それぞれの機能について簡単に解説します。
※各機能の具体的な使い方は、GitHubのREADMEを確認してください。

SwaggerUIによるドキュメント表示

環境構築し、http://localhost:8011/ にアクセスすると、
自分で記述したAPI定義のyamlファイルを
SwaggerUIでキレイに表示できます。

このUIは見慣れている人も多いと思います。

screencapture-localhost-8011-2021-03-04-18_02_14.png

ReDocによるドキュメント表示

http://localhost:8012/ にアクセスすると、
ReDocでAPIドキュメントを表示できます。

SwaggerUIと同じようにyaml情報をキレイに表示してくれるものですが、
SwaggerUIとは見た目がかなり違います。

SwaggerUIとReDocで好みの方を利用してください。

screencapture-localhost-8012-2021-03-04-18_17_44.png

yamlのファイル分割記述可能(自動結合)

この環境では、 /openapi/index.yaml にAPI仕様を記述していきます。
(環境構築した時点でサンプルのAPIドキュメントがすでに記述されています)

通常は、1つのyamlファイルに全てのAPI仕様を記述していきますが、
APIの量が多い場合は1つのファイルに全て記述すると
ファイル行数が多すぎて単純に見づらいし、
チーム開発での差分管理などもしづらくなります。

この環境では、yamlファイルを分割して記述することが可能です。

例えば、下記のようなyamlファイルのinfoの記述を別ファイルに切り出したい場合。

openapi/index.yaml
info:
  version: 1.0.0
  title: Swagger Petstore
  license:
    name: MIT

info配下の記述を別ファイルに移動します。

openapi/info/index.yaml
version: 1.0.0
title: Swagger Petstore
license:
  name: MIT

そして、元のファイルではこのように $ref で切り出したファイルを参照します。

openapi/index.yaml
info:
  $ref: './info/index.yaml'

この様にyamlファイルを分割して記述することで、
本体のファイルはすっきりし、管理もしやすくなると思います。

分割されたファイルはそのままではSwaggerUIやReDocで表示することはできません。
分割されたファイルを1つのファイルに結合し、
それをSwaggerUIやReDocに読み込ませる必要があります。

ファイルの結合をするために、chokidarとswagger-mergerというツールを利用しています。
chokidarで、ファイルの変更(追加、編集、削除)を常に監視し、
ファイルが変更された場合自動でswagger-mergerによって1つのyamlファイルに結合しています。

この環境ではこの様に自動でファイル結合し、
結合したファイルをSwaggerUIやReDocで読み込む設定をしています。

単一の静的HTMLでドキュメント出力

APIドキュメントを、単一の静的HTMLで出力することができます。

単一の静的HTMLとはつまり、
他のCSSやJSファイルなどに依存していない、
1枚のファイルだけで表示可能なHTMLファイルです。

このDocker環境を構築できない上流工程の人、エンジニアじゃない人、会社外部の人
などにAPI仕様書を提供する必要がある場合、
このHTMLを1枚渡してブラウザで表示してもらえば
キレイなUIで確認してもらうことができます。

このDocker環境では、ReDocCLIというツールを使って、
ReDocのUIの静的HTMLを出力可能にしてあります。

APIモックサーバ

APISproutというツールを使った、APIモックサーバを利用可能です。

http://localhost:8010 でモックAPIにリクエスト可能になっています。
APIにリクエストすると、yamlの定義に従ってjsonレスポンスが返却されます。

このAPIモックサーバを利用することで、
フロントエンド実装担当者は、APIの完成を待たずに、
モックを利用して実装を進めることが可能です。

リクエストヘッダーに Prefer: status=409 のようにレスポンスの種類を指定することで、
特定のレスポンスを返却させることが可能です。
※サンプルAPIドキュメントの場合、Prefer: status=defaultと記述すればエラーレスポンスが返却される

おわりに

今回OpenAPI関連の色々なツールに触れました。
今後より良いツールを発見したら、
このDocker環境もアップデートしていきたいと思います。


  1. SwaggerEditorはWEB上に案件の機密情報(API仕様書)をアップロードする必要がある点などがネックだったり 

  2. SwaggerHubは機能は便利ですが、チーム開発のためには有料プラン(結構高い)を使う必要がある点がネックだったり 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScriptでストップウォッチを作成(改訂版)

以前にも一度ストップウォッチを作成しましたが、ちょっと無駄な部分も多かったので一から作り直してみました。

機能的にはスタート/ストップ/再開/ラップタイムと前回とほとんど変わりませんが、動作状態やストップ時間保持用に独立したパラメータを使わずに済むよう計測方法を少し変更しています。

localStorageに動作状態を保存してリロードでの再開に対応しているのも前回と同様ですが、今回はラップタイム履歴も保持するようにしてみました。
保存したストレージデータは、計測リセット時に削除しています。

動作デモ

stopwatch.html
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
<title>stopwatch</title>
<style>
    html {
        touch-action: manipulation;
        -webkit-touch-callout: none;
    }
    #main {
        position: sticky;
        top: 0px;
        background-color: #fff;
        padding-bottom: 12px;
    }
    #display {
        font-size: 20px;
    }
    button {
        width: 48%;
        height: 50px;
        max-width: 120px;
        font-size: 20px;
        border-radius: 8px;
        border: 2px #888 solid;
        background-color: #fff;
        -webkit-appearance: none;
        -webkit-user-select: none;
        cursor: pointer;
    }
    #lap {
        font-size: 15px;
        margin-top: 20px;
    }
</style>
</head>
<body>
<div id='main'>
    <div id='display'>0:00:00.000 / 0:00:00.000</div>
    <button id='start'>START</button>
    <button id='reset'>RESET</button>
</div>
<div id='lap'></div>
<script>
'use strict';
const
    storageName = 'stopWatch_status',
    startBtn    = document.getElementById('start'),
    resetBtn    = document.getElementById('reset'),
    display     = document.getElementById('display'),
    lap         = document.getElementById('lap'),
    click       = window.ontouchstart !== undefined ? 'touchstart' : 'mousedown';

let
    startTime  = 0,
    lapTime    = 0,
    lapCount   = 0,
    now        = 0,
    lapRecords = [],
    intervalId = 0;

window.addEventListener('DOMContentLoaded', function() {
    // ボタンへのイベントリスナー追加
    startBtn.addEventListener(click, clickStart);
    resetBtn.addEventListener(click, clickReset);

    const storage = getStorage();
    // localStorageにデータがある場合、状態復元
    if(Object.keys(storage).length > 0) {
        startTime  = storage.startTime;
        lapTime    = storage.lapTime;
        lapRecords = storage.lapRecords;
        // 動作中
        if(startTime > 0) {
            startBtn.textContent = 'STOP';
            resetBtn.textContent = 'LAP';
            intervalId = setInterval(countUp);
        }
        // 一時停止中
        else if(startTime < 0) {
            display.textContent = timeFormat(-startTime) + ' / ' + timeFormat(-lapTime);
        }
        // ラップタイム履歴復元
        if(lapRecords.length) {
            for(let i in lapRecords) {
                lap.innerHTML = '[' + (++lapCount) + '] ' + lapRecords[i] + "<br>" + lap.innerHTML;
            }
        }
    }
});

// START/STOPボタン押下
function clickStart() {
    // 停止時
    if(startTime === 0) {
        // 計測スタート
        startTime = lapTime = Date.now();
    }
    // 一時停止時
    else if(startTime < 0) {
        // 計測再開
        const n = Date.now();
        startTime += n;
        lapTime   += n;
    }
    // 動作時
    else {
        // 一時停止
        clearInterval(intervalId);
        startTime -= now;
        lapTime   -= now;
        startBtn.textContent = 'START';
        resetBtn.textContent = 'RESET';
    }

    if(startTime > 0) {
        startBtn.textContent = 'STOP';
        resetBtn.textContent = 'LAP';
        intervalId = setInterval(countUp);
    }
    setStorage();
}

// RESET/LAPボタン押下
function clickReset() {
    // 計測中
    if(startTime > 0) {
        // LAP
        lapTime = now;
        lapTimePrint();
        window.scrollTo(0, 0);
    }
    // 停止中
    else {
        // リセット
        startTime = 0;
        display.textContent = '0:00:00.000 / 0:00:00.000';
        lap.textContent = '';
        lapCount = 0;
        lapRecords = [];
        clearStorage();
    }
}

// 計測タイム表示
function countUp() {
    now = Date.now();
    const
        t = now - startTime,
        l = now - lapTime;
    display.textContent = timeFormat(t) + ' / ' + timeFormat(l);
}

// 時間表示フォーマット
function timeFormat(t) {
    return Math.floor(t / 36e5) + new Date(t).toISOString().slice(13, 23);
}

// ラップタイム表示
function lapTimePrint() {
    const str = display.textContent;
    lapRecords.push(str);
    lap.innerHTML = '[' + (++lapCount) + '] ' + str + '<br>' + lap.innerHTML;
    setStorage();
}

// localStorageデータ保存
function setStorage() {
    localStorage.setItem(storageName, JSON.stringify({
        startTime:  startTime,
        lapTime:    lapTime,
        lapRecords: lapRecords,
    }));
}

// localStorageデータ削除
function clearStorage() {
    localStorage.removeItem(storageName);
}

// localStorageデータ取得
function getStorage() {
    const params = localStorage.getItem(storageName);
    return params ? JSON.parse(params) : {};
}
</script>
</body>
</html>

スタート、ストップ、再開、リセットのみでラップタイムや状態保持のない必要最低限の簡易バージョン。
設置デモ

<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<title>stopwatch lite</title>
</head>
<body>
<input type='text' id='view' value='0:00:00.000' readonly><br>
<button id='start'>START</button>
<button id='reset'>RESET</button>
<script>
'use strict';
const
    view     = document.getElementById('view'),
    startBtn = document.getElementById('start'),
    resetBtn = document.getElementById('reset');

let startTime  = 0,
    intervalId = 0;

window.addEventListener('DOMContentLoaded', function() {
    startBtn.addEventListener('click', clickStart);
    resetBtn.addEventListener('click', clickReset);
});

function clickStart() {
    if(startTime === 0) {
        startTime = Date.now();
    }
    else if(startTime < 0) {
        startTime += Date.now();
    }
    else {
        clearInterval(intervalId);
        startTime -= Date.now();
        startBtn.textContent = 'START';
        resetBtn.disabled = false;
    }

    if(startTime > 0) {
        intervalId = setInterval(timePrint);
        startBtn.textContent = 'STOP';
        resetBtn.disabled = true;
    }
}

function clickReset() {
    if(startTime < 0) {
        startTime = 0;
        view.value = timeFormat(0);
    }
}

function timePrint() {
    view.value = timeFormat(Date.now() - startTime);
}

function timeFormat(t) {
    return Math.floor(t / 36e5) + new Date(t).toISOString().slice(13, 23);
}
</script>
</body>
</html>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Javascript】メニューモーダル開閉の備忘録用記事

【Javascript】ヘッダーの ≡ (ハンバーガーアイコン)を押下すると、メニューがしゅっと出てくるやつをJavaScriptでやってみた

備忘録用のまとめです。

レスポンシブデザインに欠かせないメニューアイコンとメニューモーダルの表現。
jQuery離れが散見されることもあり、jQueryに頼らずJavaScriptでやってみた。

CSSを上手く使うととそんなに難しくない。(はず)
 
使う言語  HTML5、 CSS3、 JavaScript
 
 
まずはHTML。とてもシンプル。

index.html
<header>
  <div class="header-inner">
    <div class="header-title">
      <h1>ヘッダータイトル</h1>
    </div>
    <!-- メニュー開閉アイコン -->
    <div id="humberger-icon" class="open-menu"></div>
  </div>
</header>
<!-- メニューモーダル -->
<div id="header-mordal">
  <nav>
    <ul id="header-mordal-list">
      <li><a class="header-menu-btn" href="#">menu 1</a></li>
      <li><a class="header-menu-btn" href="#">menu 2</a></li>
      <li><a class="header-menu-btn" href="#">menu 3</a></li>
    </ul>
  </nav>
</div>

 
続いて、CSS。ちょっぴり長め。

ポイント
今回メニューモーダルは右から左にスライドしながら表示させたいので
・メニューモーダルを right: -100%; で画面外表示にして初期表示は隠す。
・transition プロパティを使ってアニメーション時間をcssにて設定。
・閉じるボタン用のcssも書いておく。
 ただし、htmlには開くボタンのスタイルのclassだけ書いておく。

master.css
header {
  padding: 15px;
  width: 100%;
  height: auto;
  backgroud-color: #f8f7f5;
  position: fixed;
  z-index: 10000;
}

.header-inner {
  width: 100%;
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  flex-direction: row;
  align-items: center;
  height: auto;
}

.header-title a h1 {
  padding: 0;
  color: #444;
  font-size: 22px;
}

/* メニューモーダルのスタイル */
#header-mordal {
  z-index: 9999;
  width: 100%;
  height: auto;
  background-color: #f8f7f5;
  padding: 80px 30px 50px 30px;
  position: fixed;
  text-align: center;
  right: -100%;     /* 右側へ画面外表示して初期表示ではメニューを隠す */
  transition: .5s; /* アニメーション時間 */
  transition-timing-function: ease; /* アニメーションの速さ具合的な */
}

#header-mordal-list li {
  font-size: 1em;
  font-weight: normal;
  padding: 30px 0;
  color: #7a7a7a;
}

#header-mordal-list li a {
  text-decoration: none;
  padding-bottom: 8px;
}

/* メニューを開くためのボタンのスタイル(三本線アイコン) */
.open-menu {
   position: relative;
   display: inline-block;
}

.open-menu:before,
.open-menu:after {
   content: '';
   position: absolute;
}

.open-menu:before {bottom: 9px;}
.open-menu:after {top: 9px;}

.open-menu,
.open-menu:before,
.open-menu:after {
   display: block;
   z-index: 10001;
   height: 2px;
   width: 30px;
   border-radius: 2px;
   background-color: #000;
   transition: .3s;
}

/* ここから閉じるボタンのスタイル(✕アイコン) */
.close-menu {
   position: relative;
   display: inline-block;
}

.close-menu:before,
.close-menu:after {
   content: '';
   position: absolute;
}

.close-menu,
.close-menu:before,
.close-menu:after {
   display: block;
   z-index: 10001;
   height: 2px;
   width: 30px;
   border-radius: 2px;
   background-color: #000;
   transition: .3s;
}

.close-menu:before {
   bottom: 0px;
   transform: rotate(-45deg);
}

.close-menu:after {
   top: 0px;
   transform: rotate(45deg);
}

.close-menu {background-color: transparent;}

 
ここから本番。JSの登場。超要約すると、
・メニューボタンを押下したか否かを判定して、
 メニューモーダルのスタイルを画面内・画面外に変更する。
・toggleを使って、メニューの開閉ボタンを交互に切り替える。

master.js
var isOpen = false;
var menu = document.getElementById('header-mordal');
document.getElementById('humberger-icon').addEventListener('click', function() {

    if (isOpen) {
        isOpen = false;
        menu.style.right = "-100%";  //メニュー非表示
    } else {
        isOpen = true;
        menu.style.right = "0";  //メニュー表示
    }

    this.classList.toggle('close-menu');
}, false);

 
メニューを上から下にスライド表示させたい場合は、top: -100% / top: 0; とすればよいです。

開くボタン↔閉じるボタンに変わる時のアニメーションをCSSに頼ることで、
JSは超シンプルな内容で済みました。

ちなみに、inputタグを使えばJSにすら頼らずともCSSだけで完結も可能ですが、
私はJSありきの方がやりやすい。
 
 
おしまい。どろん。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Javascript】ヘッダーの ≡ (ハンバーガーアイコン)を押下すると、メニューがしゅっと出てくるやつをJavaScriptでやってみた

自己備忘録用のまとめです。

レスポンシブデザインに欠かせないメニューアイコンとメニューモーダルの表現。
jQuery離れが散見されることもあり、jQueryに頼らずJavaScriptでやってみた。

CSSを上手く使うととそんなに難しくない。(はず)
 
使う言語  HTML5、 CSS3、 JavaScript
 
 
まずはHTML。とてもシンプル。

index.html
<header>
  <div class="header-inner">
    <div class="header-title">
      <h1>ヘッダータイトル</h1>
    </div>
    <!-- メニュー開閉アイコン -->
    <div id="humberger-icon" class="open-menu"></div>
  </div>
</header>
<!-- メニューモーダル -->
<div id="header-mordal">
  <nav>
    <ul id="header-mordal-list">
      <li><a class="header-menu-btn" href="#">menu 1</a></li>
      <li><a class="header-menu-btn" href="#">menu 2</a></li>
      <li><a class="header-menu-btn" href="#">menu 3</a></li>
    </ul>
  </nav>
</div>

 
続いて、CSS。ちょっぴり長め。

ポイント
今回メニューモーダルは右から左にスライドしながら表示させたいので
・メニューモーダルを right: -100%; で画面外表示にして初期表示は隠す。
・transition プロパティを使ってアニメーション時間をcssにて設定。
・閉じるボタン用のcssも書いておく。
 ただし、htmlには開くボタンのスタイルのclassだけ書いておく。

master.css
header {
  padding: 15px;
  width: 100%;
  height: auto;
  backgroud-color: #f8f7f5;
  position: fixed;
  z-index: 10000;
}

.header-inner {
  width: 100%;
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  flex-direction: row;
  align-items: center;
  height: auto;
}

.header-title a h1 {
  padding: 0;
  color: #444;
  font-size: 22px;
}

/* メニューモーダルのスタイル */
#header-mordal {
  z-index: 9999;
  width: 100%;
  height: auto;
  background-color: #f8f7f5;
  padding: 80px 30px 50px 30px;
  position: fixed;
  text-align: center;
  right: -100%;     /* 右側へ画面外表示して初期表示ではメニューを隠す */
  transition: .5s; /* アニメーション時間 */
  transition-timing-function: ease; /* アニメーションの速さ具合的な */
}

#header-mordal-list li {
  font-size: 1em;
  font-weight: normal;
  padding: 30px 0;
  color: #7a7a7a;
}

#header-mordal-list li a {
  text-decoration: none;
  padding-bottom: 8px;
}

/* メニューを開くためのボタンのスタイル(三本線アイコン) */
.open-menu {
   position: relative;
   display: inline-block;
}

.open-menu:before,
.open-menu:after {
   content: '';
   position: absolute;
}

.open-menu:before {bottom: 9px;}
.open-menu:after {top: 9px;}

.open-menu,
.open-menu:before,
.open-menu:after {
   display: block;
   z-index: 10001;
   height: 2px;
   width: 30px;
   border-radius: 2px;
   background-color: #000;
   transition: .3s;
}

/* ここから閉じるボタンのスタイル(✕アイコン) */
.close-menu {
   position: relative;
   display: inline-block;
}

.close-menu:before,
.close-menu:after {
   content: '';
   position: absolute;
}

.close-menu,
.close-menu:before,
.close-menu:after {
   display: block;
   z-index: 10001;
   height: 2px;
   width: 30px;
   border-radius: 2px;
   background-color: #000;
   transition: .3s;
}

.close-menu:before {
   bottom: 0px;
   transform: rotate(-45deg);
}

.close-menu:after {
   top: 0px;
   transform: rotate(45deg);
}

.close-menu {background-color: transparent;}

 
ここから本番。JSの登場。超要約すると、
・メニューボタンを押下したか否かを判定して、
 メニューモーダルのスタイルを画面内・画面外に変更する。
・toggleを使って、メニューの開閉ボタンを交互に切り替える。

master.js
var isOpen = false;
var menu = document.getElementById('header-mordal');
document.getElementById('humberger-icon').addEventListener('click', function() {

    if (isOpen) {
        isOpen = false;
        menu.style.right = "-100%";  //メニュー非表示
    } else {
        isOpen = true;
        menu.style.right = "0";  //メニュー表示
    }

    this.classList.toggle('close-menu');
}, false);

 
メニューを上から下にスライド表示させたい場合は、right としているところを top に書き換える。
cssも top: -100%; にするのを忘れなきよう。

開くボタン↔閉じるボタンに変わる時のアニメーションをCSSに頼ることで、
JSは超シンプルな内容で済みました。

ちなみに、inputタグを使えばJSにすら頼らずともCSSだけで完結も可能ですが、
私はJSありきの方がやりやすい。
 
 
おしまい。どろん。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【TypeScript】型付けしてエラーを片付ける

この記事の目的

TypeScriptを使うとJavaScriptで発生する予期せぬバグが減るっていわれてるけど、具体的にどのようなケースでエラーが減るの?という疑問を具体的な例で解決する。

どんなエラーが片付くか?

ケース1: タイプミスが減りバグが抑制される

TypeScriptは、JavaScriptで起こりうるタイプミスや型付けができないことによって発生するバグを抑制してくれます。

以下のJavaScriptのコードを見てみましょう。
このコードは最後のコンソール出力でnoteBook.colorを出力しようとしていますが、noteBook.colorrとなっているためタイプミスが発生していると考えられます。

sample.js
const noteBook = {
  color: "",
  price: 980,
  size: "A4"
}
console.log(noteBook.colorr); // colorをcolorrにしてある

こちらのJavaScriptのコードを実行してみると、undefinedが出力されます。
特にエラーの文言は出力していないため、この変数を使った処理で予期せぬバグが発生しない限り、このエラーには気付けません。

$ node sample.js
undefined

そこで、TypeScriptの登場です。
上記のsample.jssample.tsにコピペしてみます。

sample.ts
const noteBook = {
  color: "",
  price: 980,
  size: "A4"
}
console.log(noteBook.colorr); // Property 'colorr' does not exist on type '{ color: string; price: number; size: string; }'. Did you mean 'color'?

JavaScriptの場合だとタイプミスがあった場合でもundefined が返却されるだけでしたが、TypeScriptの場合はコーディング中に指摘してくれます。
エラーとしては、type '{ color: string; price: number; size: string; }' にはプロパティ 'colorr' が存在しません。colorのことでしょうか?と出力してくれています。
VsCodeでコーディングした場合はこのように指摘してくれます。(ありがたい...!!)
image.png

ケース2: 静的型付けで変数の型を制約させる

JavaScriptの変数は動的型付けとなり、どんな値でも変数に代入することができます。
しかし、複数人でアプリケーションを構築する場合、予期せぬ値を使用されてしまうことがあります。
例えば、「数値型を引数に取りたい関数に文字列を渡してしまう」などです。
TypeScriptでは予め変数の型を定義できるため、他の値が設定されそうになると「この変数(引数)は数値型なので文字列型は代入できませんよ」と伝えてくれる機能があります。

まずは以下のJavaScriptのコードを見てみましょう。

sample2.js
function sum(a, b) {
  console.log(a + b);
}

sum(1, 2);

こちらのコードを実行すると以下のように出力されます。

$ node sample2.js
3

当たり前ですね。笑
次に、sum関数の引数に文字列を入れてsample2.jsを実行してみようと思います。

sample2.js
function sum(a, b) {
  console.log(a + b);
}

sum(1, "2"); //2つ目の引数に文字列"2"を代入

こちらを実行したら、一般的にはエラーを出力してもらいたいところですがJavaScriptの場合は以下のようにエラーを出力せずに実行できてしまうんです。。

$ node sample2.js
12

このようにsum関数に文字列"2"が代入されたことによって、 JavaScriptさんはa + bを文字列の連結と認識してしまい"1" + "2" = "12"という処理をしてしまったようです。
これはsum関数を実装したプログラマーにとっては予期せぬ振る舞いですね?

こういったエラーですが、TypeScriptの型定義で解決できます!!
以下のコードは上記のsampe2.jsのsum関数の引数に対して、型定義を施してあります。
型定義の方法は簡単で、引数(変数)のとなりに:型を書くことで実現できます。
今回は、引数には数値型しか受け付けたくないので、引数の隣に:numberを記述しています。

sample2.js
function sum(a: number, b: number) {
  console.log(a + b)
}

sum(1, "2"); //Argument of type 'string' is not assignable to parameter of type 'number'.

お、TypeScriptさんはコード実行前に最後の行に対して、エラーを指摘しているようです。
string 型の引数は number 型のパラメータには代入できません。と指摘されています。
そのとおりですね。とても親切。

ちなみにVsCode上だとこのようにエラーが表示されます。
image.png

まとめ

以上がTypeScriptを使うことによって、エラーが解決される具体的なケースでした。
TypeScriptを使えば、エラーに迅速に気づけますし、バグを含んだコードをcommitするリスクも低減されるので手戻り工数も削減されます。

それでは、よいTypeScriptライフを!

もし、よろしければLGTMいただけると幸いです!(ブログを書く励みになります!)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フォームの値が変更されたときのみ、誤操作等ページ離脱時にダイアログを出す話

ユーザーが100もあるテキストフォームを入力中に誤操作でページ離脱してしまい、
入力内容全消しになったら萎えて諦めてしまう。
これを解決していく話です。

早速HTMLから書いていく。

※テキストフォーム100は多いので今回は1つのみ、諸々省略

<form name="foo" action="hoge.cgi" method="post">
 <input type="hidden" name="mode" value="foo_edit">

 <div class="question"><span>Q.1</span>好きな食べ物は?</div>
 <div class="answer"><input name="qa1" type="text" onchange="inputChange()"></div>

<input type="submit" value="公開する" id="baseButton" />
<button type="submit" name ="draft" value="qa_draft" id="draftButton">下書き保存する</button>
</form>

続いてJavaScriptを書いていく。

inputタグのフォーム100個addEventListnerで登録しようとしたが、
forEachしたら負けな気がしてHTMLにonchange()を直書きで結局負けました。

formの値の変化をonchangeで検知した状態で、
ページを離脱しようとするとダイアログで警告がされる仕組みです。
※ただし、公開ボタンと、下書き保存をsubmitする場合は除く。

function inputChange(){
    // beforeunloadイベント発火時の処理
    const unloaded = function (event) {
        const confirmMessage = '行った変更が保存されない可能性があります。';
        event.returnValue = confirmMessage;
        return confirmMessage;
    };

    window.addEventListener('beforeunload', unloaded, false);

    // submit時はアラート表示させない
    document.getElementById('baseButton').addEventListener('click', ()=>{
        window.removeEventListener('beforeunload', unloaded, false);
    });
    document.getElementById('draftButton').addEventListener('click', ()=>{
        window.removeEventListener('beforeunload', unloaded, false);
    });
}

これで一旦優勝

かと思いきや、PCでは動いてるけど
自分のスマホ(iPhone SE2)でダイアログが出ない問題に直面。

なんと
beforeunload イベント event.preventDefault() がios Safariが非対応のようです。(JS書くときは最初に確認入れるべきだったと反省。)

代わりにpagehideイベントを使うといいみたいです。でも情報量少なめでなんか難しそう。

UXはQiitaの下書きのように、
自動で保存してくれた方が僕がユーザーだったら安心できると思うけどなあ。

・pagehideイベント実装
・AjaxでPOST送信
色々やってみます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Javascriptのイテレーターについて

JavaScript学んでいる際にイテレーターについて出てきたのですが、いまいちイテレーターがどんなものかがイメージしにくかったので、調べてアウトプットしていこうと思います。

イテレーターとは?

wikipediaさんから引用

イテレータ(英語: iterator)とは、プログラミング言語において配列やそれに類似する集合的データ構造(コレクションあるいはコンテナ)の各要素に対する繰り返し処理の抽象化である。

MDNさんから引用

JavaScript では、イテレーターはシーケンスおよび潜在的には終了時の戻り値を定義するオブジェクトです。

繰り返しの抽象化ということで、for文や、while文の元となっているものだと思います。
あんまりしっくりこないのですが、順々に処理をしていって、あるところまで到達したら終了するみたいな感じだと捉えました。

イテレーターの書き方

MDN イテレーターとジェネレーターを参考にしつつ、書き方をみてみると、

iter.js
function makeIterator() {
    let i = 0;
    const iterator = {
        next: function() {
            if(i > 5){
                return {
                    done: true,
                }
            }else{
                return {
                    done: false,
                    value: i++
                }
            }
        }
    }
    return iterator;
}

const iter = makeIterator();
console.log(iter.next()) // {done: false, value: 0}
console.log(iter.next()) // {done: false, value: 1}
console.log(iter.next()) // {done: false, value: 2}
console.log(iter.next()) // {done: false, value: 3}
console.log(iter.next()) // {done: false, value: 4}
console.log(iter.next()) // {done: false, value: 5}
console.log(iter.next()) // {done: true}
console.log(iter.next()) // {done: true}

というような具合。

  • イテレーターを作る関数(今回だとmakeIterator)があって、その中にイテレーターというオブジェクトを用意する
  • イテレーターはnextをという関数をもち、その関数はオブジェクトを返す。
  • そのオブジェクトの中にはdonevalueをもつ。
  • doneがfalseの時には更新処理をし、trueの時にはそれ以上の更新をしない(値を返さない)

こういったルールをもとに書かないとイテレーターにはならないらしい。

最後に

ちなみに、ジェネレーターというものを使えばもう少し楽にイテレーターを作れる。
イテレーターもジェネレーターも、反復可能オブジェクトの作成に利用される。

イテレーターがループ処理の基盤となるようなもので、普段は触れることがなかったが、だいぶ理解が深まったと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む