5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pleasanterの「テーブルの管理 > スクリプト」とWebコンポーネントでウィジェット的なものを作る。

Last updated at Posted at 2025-05-29

image (1).png

はじめに

プリザンターのコメント欄で議論が煮詰まってる仲間を、可愛いネコちゃん画像で癒してあげたい!と思った経験、誰しも一度はありますよね。

今回はそんな、あるあるな要望にお答えするプリザンターのカスタム方法をご紹介。

テーブル管理の「スクリプト」のみを使って、JS/HTML/CSS をカプセル化したウィジェット型のミニアプリを作ってみたいと思います!

標準JavaScriptだけでWebコンポーネント

以前、コードエディターをプリザンターに実装するという記事でWebコンポーネントを扱った時は Svelte というフレームワークを使っていたのですが、今回はライブラリやフレームワークを使わない純粋なJavaScriptのみを使った実装方法です。

Webコンポーネントのおさらい

Webコンポーネントとは、追加のライブラリを使用せずに実装可能な、ブラウザ標準のフロントエンド技術です。再利用可能なUI部品を作成するために、3つの主要な機能を組み合わせて構成されます。

  • ⚙️カスタム要素
    JavaScriptのAPIを用いて、独自のHTML要素とその動作を定義できます。
  • 🔒シャドウDOM
    コンポーネントの内部構造やスタイルをカプセル化し、外部からの影響を遮断します。
    これにより、スタイルの衝突を防ぎ、再利用性が高まります
  • 🌐HTMLテンプレート
    <template><slot> 要素を使用することで、ページにすぐには表示されないマークアップのテンプレートを記述できます。これにより、柔軟なレイアウトや動的なコンテンツ挿入が可能になります。(今回は使わない)

なんちゃってスタンプウィジェット

はじめは無難に計算機でも作ろうと思っていたのですが、少し面白みに欠けるので何かいいネタが無いかなと悩んでいたところ、興味本位でTenorのAPIキーを取得したまま放置していたことを思い出したので、こちらを使って簡易スタンプ風のウィジェットを作ることにしました。

Tenorとは?

TenorはユーザーがGIFアニメーションを検索し共有できるサービス・プラットフォームで、スマホアプリ、ウェブ、キーボード拡張などを通じて利用されます。
もとは独立した企業でしたが、現在はGoogleが運営しています。
WhatsApp、Facebook Messenger、Gboard(Googleキーボード)、Slack、Telegram、iOSキーボードなど数多くのサービスで利用されています。

機能要件を考える

  1. ウィジェットとして、常に表示されつつ画面の邪魔にならない位置に配置すること。
  2. Tenor APIを利用して、キーワード検索によりGIF画像を取得できるようにする。
  3. 検索結果から画像を選択しマークダウン形式でクリップボードにコピーできるように。
  4. 値や状態が変化した際には、HTMLを一旦クリアして再描画を行う。
  5. 再描画によるイベントハンドラの喪失を防ぐため、イベントデリゲーションを活用する。

こんなところでしょうか。
では、さっそく作っていきましょう!


すべてのコードを説明すると長くなってしまうので主要な機能だけ解説します。
スタイルも書き出すと冗長になってしまうので今回は割愛。
最後の完成品でご確認ください。

Webコンポーネント全体の枠組み

まずはWebコンポーネント全体の枠組みから。

class StampWidget extends HTMLElement {
    constructor() {
        super();
        //1️⃣シャドウDOMを有効に
        this.attachShadow({ mode: 'open' });

        //2️⃣パラメータ
        this.params = {
            api: {},
            results: []
        };

        //3️⃣スタイルを定義・適用
        this.styleSheet = new CSSStyleSheet();
        this.styleSheet.replaceSync(this.styles());
        this.shadowRoot.adoptedStyleSheets = [this.styleSheet];
    }

    //4️⃣ カスタム要素がDOMに追加されたとき自動実行
    connectedCallback() {
        this.render();
    }

    //5️⃣ HTMLレンダリング
    render() {
        this.shadowRoot.innerHTML = `
            <div class="stamp-widget">
                <!-- 検索フォームや検索結果一覧など -->
            </div>
        `;
    }

    //6️⃣ スタイル定義
    styles() {
        return `
            :host {
                color: #333;
            }
            * {
                box-sizing: border-box;
            }
        `;
    }

    //7️⃣ DOMから削除時に実行(後でイベント解除などに使う)
    disconnectedCallback() {
        // 例: this.shadowRoot.removeEventListener(...)
    }
}

//8️⃣ カスタムHTML要素として定義
window.customElements.define('stamp-widget', StampWidget);

//9️⃣ body直下に要素を追加
document.body.appendChild(document.createElement('stamp-widget'));


説明

1️⃣シャドウDOMの有効

ここでシャドウDOMを有効にしています。
これによってスタイルやイベントリスナーなどがカプセル化し、再利用可能なコンポーネントとして利用できます。

2️⃣パラメータ

Webコンポーネント内で参照する値を格納します。

3️⃣スタイルを定義・適用

this.styleSheet = new CSSStyleSheet();                  // 1.CSSStyleSheet オブジェクトを生成
this.styleSheet.replaceSync(this.styles());             // 2.定義したスタイルを同期的に適用
this.shadowRoot.adoptedStyleSheets = [this.styleSheet]; // 3.スタイルをシャドウDOMに適用

new CSSStyleSheet()はJavaScript から動的にスタイルシートを作成するための構文です。
styleSheet.replaceSync()には6️⃣のstyles()を渡してスタイルを定義しています。
最後に、定義したスタイルをshadowRoot.adoptedStyleSheetsに適用し、シャドウDOMに反映して完了です。

4️⃣connectedCallback関数

カスタム要素がDOMに追加されたとき自動で呼び出されるメソッドです。
render()を実行し、HTMLを流し込んでます。

5️⃣HTMLレンダリング関数

パラメータの値を解釈しShadowDOM内にHTMLを展開する関数です。
再レンダリングしたい際は適宜render()を実行します。

6️⃣styles()

3️⃣で呼び出されるスタイルシートのコードです。

7️⃣disconnectedCallback()関数

カスタム要素がDOMから削除されたときに自動的に呼ばれるメソッドです。
イベントリスナーが残留してメモリリークの原因になるため、ここで確実に解除します。

8️⃣window.customElements.define();

カスタムHTML『stamp-widget』として定義します。
<stamp-widget>がHTML上に現れたらStampWidgetが呼び出され、再利用可能なUIコンポーネントとして実行されます。

9️⃣Body内に<stamp-widget>タグを追加

JavaScript経由で<body>タグの直下に<stamp-widget>を追加します。
これによりHTMLを編集することなく、スクリプトの中だけで完結することができます。


全体的な枠組みはこんな感じです。
次はこちらにロジックやHTML/スタイルを実装していきます。

Pleasanterの「テーブル管理 > スクリプト」に登録

と、その前にウィジェットを使いたいテーブルの管理画面の「スクリプト」に登録します。

image.png

出力先は「編集画面」と、あとはお好みでどうぞ。

9️⃣ document.body.appendChild(document.createElement('stamp-widget'))

によって<body>の末尾に<stamp-widget />要素が追加されることで自動的にWebコンポーネントが起動します。

Webコンポーネント内で使うhtmlcss

  • 5️⃣ render() HTMLレンダリング
  • 6️⃣ styles() スタイル定義

の関数の中でコーディングするので「スクリプト」のみで完結できます。

Pleasanter Code Assist のご紹介
Pleasanter Code Assist はプリザンターの開発者向けに快適な開発体験を提供するVSCodeのプラグインです。
Visual Studio Codeでスクリプトなどのソースコードの保存と同時に指定のテーブルへ登録・更新します。これにより、開発のたびにテーブルの管理を開くわずらわしさから解放され、また強力な機能を備えたVisual Studio Codeの利用で、より快適に開発を行うことができます。

詳しくはこちら:Pleasanter Code Assist

d96af949d8a94e29b0a8506c8c71d90a.gif

レンダリング(画面描画)

このWebコンポーネントでは、状態(変数)をもとにrender()でDOMを再描画し、UIを動的に切り替えています。
render()は状態に応じてHTMLを生成し、既存のDOMを更新します。

まずはパラメータにisOpen:booleanを追加してウィジェットの開閉状態を管理します。

// パラメータ
this.params = {
+   isOpen: false,
    api:{},
    results: []
}

このparams.isOpenの値はテンプレートリテラルを使ってHTML内に埋め込むことができ、動的な表示制御に利用できます。

// data-open 属性には params.isOpen の bool値 がそのまま入る
<div class="stamp-widget" data-open="${this.params.isOpen}">
    <form class="search-form"></form>
    ${
        /* params.isOpen が true で、かつ結果(params.results)が存在する場合のみ、
           <div class='list-wrap'> と <footer> 要素を出力する */
        this.params.isOpen && this.params.results.length
            ? `
                <div class='list-wrap'></div>
                <footer>
                    <button type="button" class="btn-close">❌ CLOSE</button>
                </footer>`
            : ``
    }
</div>

ウィジェット本体の <div class="stamp-widget">data-open属性を追加し、params.isOpenの値に応じて、開閉状態のスタイルを分岐します。

.stamp-widget{
    width: 340px;
    &[data-open="false"]{
        width: 150px;
    }
}

2つ目のテンプレートリテラルの条件分岐では、params.isOpenparams.resultsの内容によってHTML自体の出力を操作しています。

流れを追うとこんな感じです。

/*
 1. ウィジェットの初期状態
   params.isOpenがfalseでレンダリング実行
*/
{
    this.params.isOpen = false;
    this.render();
}

<div class="stamp-widget" data-open="false" style="width: 150px;">
  <form class="search-form"></form>
</div>



/*
 2. 検索キーワード入力状態(開く)
 params.isOpenをtrueにして再レンダリング
*/
{
    this.params.isOpen = true;
    this.render();
}

<div class="stamp-widget" data-open="true" style="width: 340px;">
  <form class="search-form"></form>
</div>



/*
 3. 検索結果の表示
params.resultsに検索結果を格納して再レンダリング
*/
{
    this.params.results = data.results;
    this.render();
}

<div class="stamp-widget" data-open="true" style="width: 340px;">
  <form class="search-form"></form>
  <div class="list-wrap"></div>
  <footer></footer>
</div>



/*
 4. 入力状態の解除(閉じる)
   params.isOpenをfalseに戻してレンダリング実行
*/
{
    this.params.isOpen = false;
    this.render();
}

<div class="stamp-widget" data-open="false" style="width: 150px;">
  <form class="search-form"></form>
</div>

実際には、スタイルの制御はCSSセレクターによって行われますが、インラインスタイルで表現するとこのようなイメージになります。

テンプレートリテラルを使い、HTML内にモデルの状態(変数)を直接バインドすることで、モデルとビューの関係が明確になり、可読性と開発効率が向上します。

また、React や Vue のように、状態(state)を変更するだけで自動的にビューが再描画される仕組みに近いため、UI更新のロジックを明示的に記述する必要がなくなり、実装がよりシンプルになります。

画像検索API機能

次はAPIによる画像取得です。
HTMLにフォーム、JSにイベントリスナーや必要な関数を登録します。

constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.params = {
        isOpen: false,

        // 画像検索APIのパラメータ
        api: {
+            key: '{USER_API_KEY}',
+            q: '',
+            limit: 50,
+            locale: 'ja_JP',
+            country: 'JP',
+            ar_range: 'standard',
+            // searchfilter: 'sticker',
+            media_format: 'gif'
        },

        // APIで取得した結果を格納
        results: []
    };

    this.styleSheet = new CSSStyleSheet();
    this.styleSheet.replaceSync(this.styles());
    this.shadowRoot.adoptedStyleSheets = [this.styleSheet];
}

connectedCallback() {
    // シャドウ内イベント
+   this.shadowRoot.addEventListener('submit', this.onFormSubmit);

    // レンダリング実行
    this.render();
}

+++
// 追加:submitハンドラ
onFormSubmit = (event) =>  {
    if (event.target.matches('form.search-form')) {
        event.preventDefault();
        const formData = new FormData(event.target);
        const formRequest = Object.fromEntries(formData.entries());
        Object.assign(this.params.api, formRequest);
        if (this.params.api.q) {
            this.fetchImages();
        }
    }
}
+++

+++
//追加:画像取得API実行
async fetchImages() {
    const url = `https://tenor.googleapis.com/v2/search?${new URLSearchParams(this.params.api).toString()}`;
    try {
        const res = await fetch(url);
        const data = await res.json();
        this.params.results = data.results || [];
        this.render();
        if (!this.params.results.length) {
            alert('検索結果は0件です');
        }
    } catch (error) {
        this.params.results = [];
        this.render();
        alert('API call failed:');
    }
}
+++ 

render() {
    this.shadowRoot.innerHTML = `
        <div class="stamp-widget" data-open="${this.params.isOpen}">
            <form class="search-form">
                <div class="search-input">
                    <input
                        type="text"
                        name="q"
                        value="${this.params.api.q}"
                        placeholder="画像を検索"
                        autocomplete="off"
                    >
                </div>
                <div class="search-button"><button type="submit">🔍</button></div>
            </form>
            ${
                this.params.isOpen && this.params.results.length 
                /* 以下省略 */
            }
        </div>
    `;
    if (this.params.isOpen) {
        this.shadowRoot.querySelector('.search-input input').focus();
    }
}

disconnectedCallback() {
+   this.shadowRoot.removeEventListener('submit', this.onFormSubmit);
}

画面上にウィジェトのUIが表示されました。

image.png

TenorAPIのパラメータ設定

params.apiにエンドポイントのパラメータから必要な値を初期値で設定します。
コメントアウトしているsearchfilter: 'sticker'は検索される画像に変化があります。
(白い背景が多いのでこっちの方が使いやすいかも?)
無料で使えるので、ぜひAPIKeyを取得してください!

イベントデリゲーションによるsubmitイベントの設定

以下のようにsubmitイベントを直接フォームに登録する方法はよく使われますが、今回のケースではうまく機能しません。

this.shadowRoot.querySelector('.search-form').addEventListener('submit', this.onFormSubmit);

理由はrender()メソッドによってDOMがすべて上書きされるのでthis.shadowRoot.querySelector('.search-form')で取得した要素がイベントリスナーごとDOMから消失してしまい、再描画後の新しい要素にはイベントリスナーが登録されていない状態になるからです。

この問題を回避するために、render()の影響を受けないthis.shadowRootに対して直接イベントリスナーを登録し、そこから発生する子要素のイベントを親側で拾って処理します。

this.shadowRoot.addEventListener('submit', this.onFormSubmit);

onFormSubmit = (event) =>  {
    if (event.target.matches('form.search-form')) {
        ...
    }
}

このように、親要素にイベントリスナーを設定し特定の子要素で発生したイベントを処理する手法をイベントデリゲーション(Event Delegation) と呼びます。

今回のケースでは、shadowRootでキャッチした submitイベントに対して、params.apiの初期値に検索キーワードqを追加してfetchImages() メソッドに渡します。
Tenor の 検索API から画像一覧をリクエストし、結果の取得に成功したらparams.resultsresultsを代入。
最後にrender()を再実行することで検索結果がウィジェット上に表示される仕組みとなっています。

render()実行時にフォーカスを復元

render()が実行されると再描画の関係で入力フォームにfocusが当たっていた状態も解除されてしまいます。
今回のケースでは復元させた方がユーザーフレンドリーだと判断したので、render()関数の最後にisOpen = true の場合にはフォーカスを復元させるように処理を追加しました。

if (this.params.isOpen) {
    this.shadowRoot.querySelector('.search-input input').focus();
}

イベントリスナーの破棄について

カスタム要素がDOMから削除された場合でも、設定されたイベントリスナーがメモリ上に残ったままになることがあります。少数であれば大きな問題にはなりませんが、数が増えるとメモリリークの原因になる可能性があります。

そのため、カスタム要素がDOMから削除された際に自動的に呼び出されるdisconnectedCallback()メソッド内で、removeEventListener()を使ってイベントリスナーを明示的に解除することをおすすめします。


APIから画像一覧データの取得を確認できました。

image.png

次は検索結果一覧をウィジェット上に展開します。

検索結果画面の実装

テンプレートリテラルを使って、検索結果のリストを表示するHTMLを、
<div class='list-wrap'></div>の内部に追加していきましょう。

テンプレートリテラルで配列のループ処理

${
    this.params.isOpen && this.params.results.length
        ? `
        <div class='list-wrap'>
+           <ul class="list-inner">
+           ${
+               this.params.results.map(({ media_formats }) => {
+                   const url = media_formats?.tinygif?.url;
+                   return url ? `<li><img src="${url}" /></li>` : '';
+               }).join('')
+           }
+           </ul>
        </div>
        <footer>
            <button type="button" class="btn-close">❌ CLOSE</button>
        </footer>`
        : ``
}

このコードでは<div class='list-wrap'>の中に<ul class="list-inner">を用意し、
map関数で配列をループして<img>要素を内包する<li>要素を生成しています。

こちらのmap関数が返す<li>の配列は以下のような形になります。

[
    "<li><img src=\"https://media.tenor.com/x/ahhh-scream.gif\" /></li>",
    "<li><img src=\"https://media.tenor.com/4C6T_JOFf3AAAAAM/nerd-emoji.gif\" /></li>",
    "<li><img src=\"https://media.tenor.com/318ro09CmCoAAAAM/haha-lol.gif\" /></li>",
    ...
    "<li><img src=\"https://media.tenor.com/NLp57LxPlJMAAAAM/omg-gif.gif\" /></li>",
    "<li><img src=\"https://media.tenor.com/PvFgHoHLBlEAAAAM/haha-lol.gif\" /></li>",
    "<li><img src=\"https://media.tenor.com/70wfjVTIO1QAAAAM/deah-deahter.gif\" /></li>"
]

この配列をjoin()して連結することで、<ul>の中に複数の<li>要素が展開されます。

テンプレートリテラルを使うことで、HTMLの中で簡単にループ処理ができ、ロジックとHTML構造が一体となって見えるため、出力内容の把握やデバッグ、HTML設計が効率的になります。

結果をブラウザで確認

検索結果が表示されました。

image.png

CSSは割愛してますが、画像の並びはcolumn-countを使い、Masonryレイアウトを採用しています。

メイソンリーレイアウト(Masonry Layout)とは、WebデザインやUIでよく使われるグリッドレイアウトの一種で、ブロック(要素)を隙間なく、縦方向に詰めて配置するレイアウト手法です。Pinterestのデザインが代表的です。

興味のある方は最後のコードまとめをご確認ください。
昔はJSで自動化していましたが。今では普通にCSSだけで実装できるんですよね。
本当にCSSの進化はすごい!

イベントリスナーの実装

データ処理まわりはほぼ完了なので、次は必要なイベント処理を実装したいと思います。
(ブラウザ確認の時は params.isOpenを手動で更新してました)

想定しているイベントは以下になります。

  • <input>をクリック(フォーカス)でウィジェット展開 params.isOpen = true
  • <footer>の閉じるボタンでウィジェット最小化 params.isOpen = false
  • ウィジェット枠外をクリックでウィジェット最小化 params.isOpen = false
  • 一覧の<img>をクリックで画像URLをマークダウン形式でコピー

params.isOpenを操作するイベントが多いので、あらかじめ関数を定義しておきましょう


// ウィジェットを展開
onOpen() {
    if (!this.params.isOpen) {
        this.params.isOpen = true;
        this.render();
    }
}

 // ウィジェットを最小化
 onClose() {
    if (this.params.isOpen) {
        this.params.isOpen = false;
        this.render();
    }
}

では、イベント処理を追加していきます。

<input>をクリック(フォーカス)でウィジェット展開

onFormSubmit()と同様、こちらもイベントデリゲーションで実装します。


connectedCallback() {
    // シャドウ内イベント
    this.shadowRoot.addEventListener('submit', this.onFormSubmit);
+   this.shadowRoot.addEventListener('click', this.onShadowClick);

    // レンダリング実行
    this.render();
};

+++
// 追加:clickイベントハンドラ
onShadowClick = event => {
    if (event.target.tagName === 'INPUT') {
        // ウィジェットをOpen
        this.onOpen();
    }
};
+++

disconnectedCallback() {
    // シャドウ内イベント削除
    this.shadowRoot.removeEventListener('submit', this.onFormSubmit);
+   this.shadowRoot.removeEventListener('click', this.onShadowClick);
}

イベントのタイプがclickになっただけで、onFormSubmit()と変わらないですね。
onShadowClick()の内部でevent.target.を使い条件分岐させ、onOpen()へ流しています。

<footer>の閉じるボタンでウィジェット最小化

続いて、『閉じる(最小化)』処理です。
クリックのイベントリスナーは ↑ で登録してたので、onShadowClick()に条件を付け加えます。

onShadowClick = event => {
    if (event.target.tagName === 'INPUT') {
        // ウィジェットをOpen
        this.onOpen();
+   } else if (event.target.classList.contains('btn-close')) {
+       // ウィジェットをClose
+       this.onClose();
    }
};

ここではbtn-closeクラスを持つ要素がクリックされるとonClose()が実行されます。

ウィジェット枠外をクリックでウィジェット最小化

次は少し特殊なケースです。ウィジェットの外側をクリックしたときに onClose() を実行します。

connectedCallback() {
+   // 外部クリック検知
+   document.addEventListener('click', this.onOuterClick);

    // シャドウ内イベント
    this.shadowRoot.addEventListener('submit', this.onFormSubmit);
    this.shadowRoot.addEventListener('click', this.onShadowClick);

    // レンダリング実行
    this.render();
}

+++
// 追加:アウターclickハンドラ
onOuterClick = event => {
    if (
        !this.contains(event.target) &&
        this.shadowRoot.activeElement !== this.shadowRoot.querySelector('.search-input input')
    ) {
        this.onClose();
    }
};
+++

disconnectedCallback() {
+   // 外部クリック検知イベント削除
+   document.removeEventListener('click', this.onOuterClick);
    // シャドウ内イベント削除
    this.shadowRoot.removeEventListener('submit', this.onFormSubmit);
    this.shadowRoot.removeEventListener('click', this.onShadowClick);
}

クリックイベントはカスタム要素外のdocumentに登録しています。
コンソールなどで onOuterClickevent.target を出力したら分かりますが、通常はクリックされた要素自身が代入されているのに対し、ウィジェット内のフォームや画像などパーツをクリックしてもすべて

<stamp-widget>​…​</stamp-widget>​

となり、カスタム要素自身が呼ばれているのが確認できます。
これはWebコンポーネントの正常な動きであり、this.attachShadow({ mode: 'open' })による、Webコンポーネントのカプセル化が働いている証拠です。

今回はその仕様を条件分岐に組み込み、枠外クリックの判定としています。

!this.contains(event.target)

なお、

this.shadowRoot.activeElement !== this.shadowRoot.querySelector('.search-input input')

こちらは、検索フォームのテキストをマウスで範囲選択したときに、ウィジェット外で指を離すと意図せずonClose()が実行されてしまったので、テキスト選択中の誤動作防止のため『フォーカス中の場合は処理をキャンセル』するように追加してあります。

一覧の<img>をクリックで画像URLをマークダウン形式でコピー

では最後に、ウィジェットのゴールである「マークダウン形式で画像URLをクリップボードにコピー」を実装してみましょう。


onShadowClick = event => {
    if (event.target.tagName === 'INPUT') {
        // ウィジェットをOpen
        this.onOpen();
    } else if (event.target.classList.contains('btn-close')) {
        // ウィジェットをClose
        this.onClose();
+   } else if (event.target.tagName === 'IMG') {
+       // 画像のURLをマークダウン形式でコピー
+       this.onCopy(event.target.src);    
    }
};

+++
// 追加:コピー実行
onCopy = async imageUrl => {
    const markdown = `![image](${imageUrl})`;
    try {
        await navigator.clipboard.writeText(markdown);
        alert('クリップボードにコピーしました');
    } catch (err) {
        alert('コピーに失敗しました');
    }
};
+++

まずはonShadowClick()に条件分岐を追加します。
画像をクリックした場合なので、条件はevent.target.tagName === 'IMG'になります。
条件に該当した場合、onCopy()関数に画像のURLevent.target.srcを渡します。

続いてonCopy()の処理です。
クリップボードにコピーする navigator.clipboard.writeText()関数 はクリップボードというプライバシー領域にアクセスするため、必ず非同期処理(async/await)で扱う必要があります。

クリップボードAPIはHTTPS環境またはローカルファイル(localhost)でのみ動作します。

tryの中でPromiseが完了するまで処理をawaitで待機し、コールバックによってalert()を出し分けしています。

これで、想定していたすべての機能を実装できました!

動作検証

image.png

クリップボードにコピー済みなので、このようにコメント欄にペーストするだけで簡単に画像が投稿できちゃいます!

image.png
みなさま待望のネコ画像が、今ここに。


想定した機能はこれで終了なのですが… やはり、最後がalert()なのは少し勿体ないので、最後に通知メッセージを実装したいと思います!

追加要件:通知メッセージの実装

さっそく、onCopy()のアラートを修正し、通知メッセージ用のコードを追加していきます。


constructor() {
    // パラメータ
    this.params = {
        isOpen: false,
        api: {...},
        results: [],
+       copyTimer: undefined //timeoutID
    };
}

onCopy = async imageUrl => {
    const markdown = `![image](${imageUrl})`;
    try {
        await navigator.clipboard.writeText(markdown);
-       //alert('クリップボードにコピーしました');
+       this.showMessage(true, 'クリップボードにコピーしました');
    } catch (err) {
-       //alert('コピーに失敗しました');
+       this.showMessage(false, 'コピーに失敗しました');
    }
};

showMessage(isSuccess, text) {
    const messageElem = this.shadowRoot.querySelector('.message');

    // 現在の処理キャンセル
    clearTimeout(this.params.copyTimer);
    messageElem.removeAttribute('success');

    // メッセージ文言更新
    messageElem.innerText = text;

    // キャンセル処理が確実に終わるまで少し遅延させる
    this.params.copyTimer = setTimeout(() => {
        //メッセージの表示
        messageElem.setAttribute('success', isSuccess);
        
        //メッセージ削除
        this.params.copyTimer = setTimeout(() => {
            messageElem.removeAttribute('success');
        }, 1500);
    }, 100);
}

render() {
    this.shadowRoot.innerHTML = `
        <div class="stamp-widget" data-open="${this.params.isOpen}">
+            <div class="message"></div>
             <form id="AppSearchForm" class="search-form">
                 :
             </form>

            <!- 以下省略 -->
        </div>
    `;
}

styles() {
    return `

     +++
     .message{
          /* 以下抜粋 */
            position: absolute;
            top: -50px;
            width: 100%;
            height: 50px;
            &[success]{
                animation: msgShow 200ms ease-out forwards,
                      msgHide 200ms ease-out 1000ms forwards;
            }
            &[success="true"]{
                background: rgba(0, 159, 124, 0.8);
            }
            &[success="false"]{
                background: rgba(240, 40, 40, 0.8);
            }
        }

        @keyframes msgShow {
            from { top: -50px; }
            to { top: 0; }
        }

        @keyframes msgHide {
            from { top: 0; }
            to { top: -50px; }
        }
        +++

    `
}

思ったより長くなってしまいました…
では、解説していきます。

メッセージ内容の切り替え

アラートの代わりにshowMessage()関数を用意し、引数で成功の真偽とメッセージ文言を受け取ります。
その後、<div class="message">sucess属性に引数のbool値を与えます。

this.showMessage(true, 'クリップボードにコピーしました');

showMessage(isSuccess, text) 
    const messageElem = this.shadowRoot.querySelector('.message');
    // メッセージ文言更新
    messageElem.innerText = text;
    //メッセージの表示
    messageElem.setAttribute('success', isSuccess);
}

<div class="message" success="true">クリップボードへにコピーしました</div>

CSS側ではsuccess属性の値を判定し背景色を切り替えています。

.message{
    /* success="true"の場合 */
    &[success="true"]{
        background: rgba(0, 159, 124, 0.8);
    }
    /* success="false"の場合 */
    &[success="false"]{
        background: rgba(240, 40, 40, 0.8);
    }
}

Toast通知の実装

通知メッセージの表現ですが、ウィジェットの上部からメッセージパネルが下りてくる、逆Toastタイプの通知メッセージにしました。

Toastとは、アプリケーションが何らかのアクションを完了したり、状態が変化したりした際に、画面の下部から一時的に表示される小さな通知のことです。ユーザーに簡単なフィードバックを提供し、作業を邪魔しないように、一定時間後に自動的に消えます。

連続クリック時の対応ですが、今回は通知パネルの複製まではしないので、clearTimeout()で現在の通知をキャンセルし、新しい通知を流す仕様にします。

// 通知タイマーをキャンセル
clearTimeout(this.params.copyTimer);
// success属性も削除
messageElem.removeAttribute('success');

その後のsetTimeout()が通知メッセージの表示処理です。

// キャンセル処理が確実に終わるまで少し遅延させる
this.params.copyTimer = setTimeout(() => {
    //メッセージ表示
    messageElem.setAttribute('success', sucess);
    
    //メッセージ削除
    this.params.copyTimer = setTimeout(() => {
        messageElem.removeAttribute('success');
    }, 1500);
}, 100);

最初のタイマーはキャンセル処理が確実に終わるのを待つために100ミリ秒遅延させています。
その後、メッセージを表示し、1.5秒後に削除タイマーが実行されます。

CSSアニメーションの実装

さて、ここにきて表示の切り替えをrender()を使わずに、showMessage()関数の中でDOM操作を行ったことに気が付きましたか?

理由はrender()での再描画だとCSSからみたHTMLに連続性がなくなってしまい、CSSのアニメーションが機能しないからです。
基本的な画面更新はrender()で良いのですが、CSSで動きを持たせたい場合は従来通りのDOM操作が安全です。

では、先ほどのCSSにアニメーション用のコードを追加しましょう。

.message{
    &[success]{
        animation: msgShow 200ms ease-out forwards,
                   msgHide 200ms ease-out 1000ms forwards;
    }

    @keyframes msgShow {
        from { top: -50px; }
        to { top: 0; }
    }

    @keyframes msgHide {
        from { top: 0; }
        to { top: -50px; }
    }
}

イージングの関係で、キーフレームは表示用と非表示用の二つの動きを用意する必要があります。
また、メッセージが削除されるまで1500ミリ秒(1.5秒)なので、その間にアニメーションを終了させます。

animation: msgShow 200ms ease-out forwards,
           msgHide 200ms ease-out 1000ms forwards;

今回は表示に200ミリ秒、delayが1000ミリ秒、非表示に200msで計1400ミリ秒の演出になりますね。

このように、render()が使えない場合もあるので、適宜判断ただければと思います。
ただ、可読性やデバッグの観点で考えると、やはりrender()内にテンプレートリテラルで埋め込んだ方が安全かなとは思います。

ついでにfetchImages()

画像取得のfetchImages()のメッセージもアラートだったので、showMessageに差し替えましょう。

- // alert('検索結果は0件です');
+ this.showMessage(true, '検索結果は0件です');

- // alert('API call failed:');
+ this.showMessage(false, 'API call failed:');

動作確認

では、最後までの流れを見てみましょう。
動画を撮ってききたので、こちらをご確認くださいっ

Macbook-Air-localhost-41a9ubp1fh_2d8-ezgif.com-video-to-gif-converter.gif

いかがでしょう?
これで簡単に画像を投稿することができますね!

最終的なソースコード

ソースコード全体はこちらです。
TenorのAPIキーを取得し、params.api.keyの値を書き換えたらテーブル管理のスクリプトに貼り付けるだけで、すぐにご利用いただけます。

class StampWidget extends HTMLElement {
    constructor() {
        super();
        //1️⃣シャドウDOMを有効に
        this.attachShadow({ mode: 'open' });

        //2️⃣パラメータ
        this.params = {
            // 開閉状態管理
            isOpen: false,
            // 画像検索APIのパラメータ
            api: {
                key: '{USER_API_KEY}',
                q: '',
                limit: 50,
                locale: 'ja_JP',
                country: 'JP',
                ar_range: 'standard',
                contentfilter: 'high',
                // searchfilter: 'sticker',
                media_format: 'gif'
            },
            // APIで取得した結果を格納
            results: [],
            // timeoutID
            copyTimer: undefined
        };

        //3️⃣スタイルを定義・適用
        this.styleSheet = new CSSStyleSheet(); // 1.CSSStyleSheet オブジェクトを生成
        this.styleSheet.replaceSync(this.styles()); // 2.定義したスタイルを同期的に適用
        this.shadowRoot.adoptedStyleSheets = [this.styleSheet]; // 3.スタイルをシャドウDOMに適用
    }

    //4️⃣ カスタム要素がDOMに追加されたとき自動実行
    connectedCallback() {
        // 外部クリック検知
        document.addEventListener('click', this.onOuterClick);

        // シャドウ内イベント
        this.shadowRoot.addEventListener('submit', this.onFormSubmit);
        this.shadowRoot.addEventListener('click', this.onShadowClick);

        // レンダリング実行
        this.render();
    }

    // ウィジェットを展開
    onOpen() {
        if (!this.params.isOpen) {
            this.params.isOpen = true;
            this.render();
        }
    }

    // ウィジェットを最小化
    onClose() {
        if (this.params.isOpen) {
            this.params.isOpen = false;
            this.render();
        }
    }

    // アウターclickハンドラ
    onOuterClick = event => {
        if (
            // カスタム要素以外がクリック
            !this.contains(event.target) &&
            // フォーカス中の場合は処理をキャンセル
            this.shadowRoot.activeElement !== this.shadowRoot.querySelector('.search-input input')
        ) {
            this.onClose();
        }
    };

    // submitハンドラ
    onFormSubmit = event => {
        if (event.target.matches('form.search-form')) {
            event.preventDefault();
            const formData = new FormData(event.target);
            const formRequest = Object.fromEntries(formData.entries());
            Object.assign(this.params.api, formRequest);
            if (this.params.api.q) {
                this.fetchImages();
            }
        }
    };

    // clickイベントハンドラ
    onShadowClick = event => {
        if (event.target.tagName === 'INPUT') {
            // ウィジェットをOpen
            this.onOpen();
        } else if (event.target.classList.contains('btn-close')) {
            // ウィジェットをClose
            this.onClose();
        } else if (event.target.tagName === 'IMG') {
            // 画像のURLをマークダウン形式でコピー
            this.onCopy(event.target.src);
        }
    };

    // コピー実行
    onCopy = async imageUrl => {
        const markdown = `![image](${imageUrl})`;
        try {
            await navigator.clipboard.writeText(markdown);
            this.showMessage(true, 'クリップボードにコピーしました');
        } catch (err) {
            this.showMessage(false, 'コピーに失敗しました');
        }
    };

    // 画像取得API実行
    async fetchImages() {
        const url = `https://tenor.googleapis.com/v2/search?${new URLSearchParams(this.params.api).toString()}`;
        try {
            const res = await fetch(url);
            const data = await res.json();
            this.params.results = data.results || [];
            this.render();
            if (!this.params.results.length) {
                this.showMessage(true, '検索結果は0件です');
            }
        } catch (error) {
            this.params.results = [];
            this.render();
            this.showMessage(false, 'API call failed:');
        }
    }

    showMessage(isSuccess, text) {
        const messageElem = this.shadowRoot.querySelector('.message');

        /* 現在の処理キャンセル */
        // 通知タイマーをキャンセル
        clearTimeout(this.params.copyTimer);
        // success属性も削除
        messageElem.removeAttribute('success');

        // メッセージ文言更新
        messageElem.innerText = text;

        // キャンセル処理が確実に終わるまで少し遅延させる
        this.params.copyTimer = setTimeout(() => {
            //メッセージの表示
            messageElem.setAttribute('success', isSuccess);

            //メッセージ削除
            this.params.copyTimer = setTimeout(() => {
                messageElem.removeAttribute('success');
            }, 1500);
        }, 100);
    }

    //5️⃣ HTMLレンダリング
    render() {
        this.shadowRoot.innerHTML = `
            <!-- data-open 属性には params.isOpen の bool値 がそのまま入る -->
            <div class="stamp-widget" data-open="${this.params.isOpen}">
                <div class="message"></div>
                <form class="search-form">
                    <div class="search-input">
                        <input
                            type="text"
                            name="q"
                            value="${this.params.api.q}"
                            placeholder="画像を検索"
                            autocomplete="off"
                        >
                    </div>
                    <div class="search-button"><button type="submit">🔍</button></div>
                </form>
                ${
                    /* params.isOpen が true で、かつ結果(params.results)が存在する場合のみ、
                       <div class='list-wrap'> と <footer> 要素を出力する */
                    this.params.isOpen && this.params.results.length
                        ? `
                        <div class='list-wrap'>
                            <ul class="list-inner">
                            <!-- テンプレートリテラルで配列のループ処理 -->
                            ${this.params.results
                                .map(({ media_formats }) => {
                                    const url = media_formats?.tinygif?.url;
                                    return url ? `<li><img src="${url}" /></li>` : '';
                                })
                                .join('')}
                            </ul>
                        </div>
                        <footer>
                            <button type="button" class="btn-close">❌ CLOSE</button>
                        </footer>`
                        : ``
                }
            </div>
        `;
        // render()実行時にフォーカスを復元
        if (this.params.isOpen) {
            this.shadowRoot.querySelector('.search-input input').focus();
        }
    }

    //6️⃣ スタイル定義
    styles() {
        return `
            :host{
                color: #333;
            }
            *{
                box-sizing: border-box;
            }
            button{
                border: 0;
                outline: none;
                background: transparent;
                cursor: pointer;
            }
            .stamp-widget{
                position: fixed;
                flex-direction: column;
                right: 16px;
                bottom: 80px;
                z-index: 99;
                display: flex;
                width: 340px;
                max-height: 400px;
                border-radius: 8px;
                background: #eee;
                overflow: hidden;
                border: 1px solid #ccc;
                transition: width 300ms ease-out;
                &[data-open="false"]{
                    width: 150px;
                }
            }
            .message{
                position: absolute;
                top: -50px;
                width: 100%;
                height: 50px;
                overflow: hidden;
                display: flex;
                align-items: center;
                justify-content: center;
                color: #fff;
                font-size: 16px;
                font-weight: bold;
                &[success]{
                    animation: msgShow 200ms ease-out forwards, msgHide 200ms ease-out 1000ms forwards;
                }
                /* success="true"の場合 */
                &[success="true"]{
                    background: rgba(0, 159, 124, 0.8);
                }
                /* success="false"の場合 */
                &[success="false"]{
                    background: rgba(240, 40, 40, 0.8);
                }
            }
            .search-form{
                display: flex;
                background: #fff;
                .search-input{
                    flex: 1px;
                    input{
                        height: 50px;
                        padding: 8px 0 8px 16px;
                        outline: none;
                        border: 0;
                        width: 100%;
                        font-size: 16px;
                        font-weight: 600;
                        &::placeholder {
                            color: #888;
                        }
                    }
                }
                .search-button{
                    button{
                        height: 100%;
                    }
                }
            }
            .list-wrap{
                border-top: 1px solid #ccc;
                overflow: auto;
                flex: 1;
                &::-webkit-scrollbar {
                    width: 8px;
                    margin: 10px 0;
                }
                &::-webkit-scrollbar-track {
                    margin: 8px 0;
                }
                &::-webkit-scrollbar-thumb {
                    cursor: grab;
                    background-color: #888;
                    border-radius: 8px;
                    &:hover {
                        background-color: #555;
                    }
                }
            }
            .list-inner{
                column-count: 3;
                column-gap: 8px;
                list-style: none;
                padding: 8px;
                margin: 0;
                li{
                    margin: 0;
                    break-inside: avoid;
                }
                img{
                    max-width: 100%;
                    cursor: pointer;
                    border-radius: 4px;
                }
            }
            footer{
                button{
                    display: block;
                    width: 100%;
                    padding: 8px;
                    color: #333;
                    font-size: 16px;
                    font-weight: bold;
                    font-family: Sitka;
                }
            }

            @keyframes msgShow {
                from { top: -50px; }
                to { top: 0; }
            }

            @keyframes msgHide {
                from { top: 0; }
                to { top: -50px; }
            }
        `;
    }

    //7️⃣ DOMから削除時に実行(後でイベント解除などに使う)
    disconnectedCallback() {
        // 外部クリック検知イベント削除
        document.removeEventListener('click', this.onOuterClick);
        // シャドウ内イベント削除
        this.shadowRoot.removeEventListener('submit', this.onFormSubmit);
        this.shadowRoot.removeEventListener('click', this.onShadowClick);
    }
}

//8️⃣ カスタムHTML要素として定義
window.customElements.define('stamp-widget', StampWidget);

//9️⃣ body直下に要素を追加
document.body.appendChild(document.createElement('stamp-widget'));

さいごに

本記事では、ライブラリを使わずWebコンポーネントのシャドウDOMを活用し、TenorのGIF検索APIと連携したスタンプウィジェットを実装しました。

カプセル化されたスタイルとロジックにより、既存のページやアプリケーションへの導入が容易で、影響範囲を限定できます。
APIレスポンスを非同期に取得して動的にレンダリングし、ユーザーが画像をクリックするとマークダウン形式でURLをクリップボードにコピーするなど、直感的に操作できるようUXにも配慮しました。

このコードをそのままテーブル管理のスクリプトに貼り付けて、APIキーを差し替えるだけで、すぐに画像検索&投稿の便利なウィジェットとして活用可能です。
ぜひ、この記事を参考にWebコンポーネントのモダンな設計思想を体験し、ご自身のサービスやプロジェクトに合わせてカスタマイズしてみてください。

Webコンポーネントの力で、UI開発がよりスマートかつ効率的になることを願っています!

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?