0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Slackを使用していると、同じような文章を定期的に書くことがあると思います。
コピー&ペーストで対応しても良いのですが、以前の投稿を探す手間などもあって少し面倒なので、定型文を保存できる機能をChrome拡張機能として開発してみました。

image.png

image.png

本記事ではプログラムの中身を簡単に紹介したいと思います。
開発した拡張機能はChrome ウェブストアで公開していますので、興味のある方は使ってみてください。

前提

本記事ではChrome拡張機能の開発方法自体の解説は記載しません。
Chrome拡張機能の開発については以下の公式ドキュメントや記事などを参考にしてください。

実装内容

コード全文は以下を展開してご確認ください。

content.js
content.js
// Slackのツールバーが読み込まれるまで待機
const observer = new MutationObserver(() => {
  injectTemplateButton();
});

observer.observe(document.body, {
  childList: true,
  subtree: true,
});

// 初回実行
setTimeout(() => {
  injectTemplateButton();
}, 1000);

function injectTemplateButton() {
  // Slackの書式設定ツールバーを全て探す(メインとスレッド返信)
  const formattingToolbars = document.querySelectorAll('[data-qa="wysiwyg-container_formatting-enabled"]');

  if (formattingToolbars.length === 0) {
    console.log('Formatting toolbar not found');
    return;
  }

  console.log(`Found ${formattingToolbars.length} formatting toolbar(s)`);

  // 各ツールバーに対してボタンを追加
  formattingToolbars.forEach((formattingToolbar, index) => {
    // このツールバーに既にボタンが追加されているか確認
    const existingButton = formattingToolbar.querySelector('.template-button-extension');
    if (existingButton) {
      console.log(`Button already exists in toolbar ${index}`);
      return;
    }

    console.log(`Injecting button into toolbar ${index}`);

    // 定型文ボタンを作成
    const templateButton = document.createElement('button');
    templateButton.className = 'template-button-extension';
    templateButton.type = 'button';
    templateButton.textContent = '定型文';
    templateButton.setAttribute('aria-label', '定型文');
    templateButton.setAttribute('data-qa', 'template-button');
    templateButton.title = '定型文';

    // クリックイベント
    templateButton.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();

      // このボタンに対応する入力欄を探す
      const targetEditor = findEditorForToolbar(formattingToolbar);
      showTemplateMenu(templateButton, targetEditor);
    });

    // ツールバーの先頭に追加(左寄せ)
    formattingToolbar.insertBefore(templateButton, formattingToolbar.firstChild);
  });
}

// ツールバーに対応する入力欄を探す
function findEditorForToolbar(toolbar) {
  // ツールバーの親要素を辿って入力エリア全体を探す
  let container = toolbar.parentElement;
  while (container && !container.querySelector('.ql-editor[contenteditable="true"]')) {
    container = container.parentElement;
    // 安全のため、10階層以上は探さない
    if (!container || container === document.body) {
      break;
    }
  }

  if (container) {
    const editor = container.querySelector('.ql-editor[contenteditable="true"]');
    if (editor) {
      return editor;
    }
  }

  // 見つからない場合は、ツールバーに最も近い入力欄を探す
  return null;
}

function showTemplateMenu(button, targetEditor) {
  // 既存のメニューを削除
  const existingMenu = document.querySelector('#template-menu-extension');
  if (existingMenu) {
    existingMenu.remove();
    return;
  }

  // テンプレートメニューを作成
  const menu = document.createElement('div');
  menu.id = 'template-menu-extension';
  menu.className = 'template-menu';

  // ストレージから定型文を取得
  chrome.storage.sync.get(['templates'], (result) => {
    const templates = result.templates || [];

    menu.innerHTML = `
      <div class="template-menu-header">定型文</div>
      <div class="template-menu-list"></div>
      <div class="template-menu-footer">
        <button class="template-menu-settings">定型文を管理</button>
      </div>
    `;

    const list = menu.querySelector('.template-menu-list');

    if (templates.length === 0) {
      list.innerHTML = `
        <div class="template-menu-empty">
          定型文が登録されていません
        </div>
      `;
    } else {
      templates.forEach((template) => {
        const item = document.createElement('div');
        item.className = 'template-menu-item';
        item.textContent = template.name;
        item.addEventListener('click', () => {
          insertTemplate(template.content, targetEditor);
          menu.remove();
        });
        list.appendChild(item);
      });
    }

    // 設定ボタンのイベントリスナー
    const settingsButton = menu.querySelector('.template-menu-settings');
    settingsButton.addEventListener('click', () => {
      menu.remove();
      showTemplateModal();
    });

    document.body.appendChild(menu);

    // ボタンの位置を取得してメニューを上に配置
    const rect = button.getBoundingClientRect();

    // メニューをボタンの上に表示
    menu.style.bottom = `${window.innerHeight - rect.top + 5}px`;
    menu.style.left = `${rect.left}px`;
    menu.style.top = 'auto';

    // メニュー外をクリックしたら閉じる
    setTimeout(() => {
      document.addEventListener('click', function closeMenu(e) {
        if (!menu.contains(e.target) && e.target !== button) {
          menu.remove();
          document.removeEventListener('click', closeMenu);
        }
      });
    }, 0);
  });
}

function insertTemplate(content, targetEditor) {
  // 対象の入力欄を使用(指定されていない場合はフォールバック)
  const textArea = targetEditor || document.querySelector('.ql-editor[contenteditable="true"]');

  if (!textArea) {
    console.error('Message input not found');
    alert('メッセージ入力欄が見つかりませんでした。入力欄をクリックしてからもう一度お試しください。');
    return;
  }

  // 入力欄にフォーカス
  textArea.focus();

  // 既存のテキストを取得
  const existingText = textArea.textContent || '';

  // テキストを設定
  textArea.textContent = existingText + content;

  // カーソルを末尾に移動
  const range = document.createRange();
  const selection = window.getSelection();

  // テキストノードが存在する場合
  if (textArea.childNodes.length > 0) {
    const lastNode = textArea.childNodes[textArea.childNodes.length - 1];
    range.setStartAfter(lastNode);
    range.setEndAfter(lastNode);
  } else {
    range.selectNodeContents(textArea);
    range.collapse(false);
  }

  selection.removeAllRanges();
  selection.addRange(range);

  // Slackに変更を通知するために複数のイベントを発火
  textArea.dispatchEvent(new Event('input', { bubbles: true }));
  textArea.dispatchEvent(new Event('change', { bubbles: true }));
  textArea.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
  textArea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
}

function showTemplateModal() {
  // 既存のモーダルを削除
  const existingModal = document.querySelector('#template-modal-extension');
  if (existingModal) {
    existingModal.remove();
    return;
  }

  // モーダルオーバーレイを作成
  const overlay = document.createElement('div');
  overlay.id = 'template-modal-extension';
  overlay.className = 'template-modal-overlay';

  // モーダルコンテンツを作成
  const modal = document.createElement('div');
  modal.className = 'template-modal';

  modal.innerHTML = `
    <div class="template-modal-header">
      <h2>定型文管理</h2>
      <button class="template-modal-close">×</button>
    </div>
    <div class="template-modal-body">
      <div class="template-modal-main-view">
        <button class="template-modal-new-button">+ 新しい定型文を追加</button>
        <div class="template-modal-list">
          <div class="template-modal-items"></div>
        </div>
      </div>
      <div class="template-modal-edit-view" style="display: none;">
        <div class="template-modal-edit-header">
          <button class="template-modal-back-button">← 一覧に戻る</button>
        </div>
        <div class="template-modal-form">
          <input type="text" class="template-modal-input-name" placeholder="定型文の名前">
          <textarea class="template-modal-input-content" placeholder="定型文の内容"></textarea>
          <div class="template-modal-form-buttons">
            <button class="template-modal-save-button">保存</button>
            <button class="template-modal-cancel-button">キャンセル</button>
          </div>
        </div>
      </div>
    </div>
  `;

  overlay.appendChild(modal);

  // モーダルを表示
  document.body.appendChild(overlay);

  // 定型文リストを読み込み
  loadTemplateList();

  // イベントリスナーを設定
  const closeButton = modal.querySelector('.template-modal-close');
  closeButton.addEventListener('click', () => {
    overlay.remove();
  });

  // オーバーレイクリックで閉じる
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) {
      overlay.remove();
    }
  });

  // 新規追加ボタン
  const newButton = modal.querySelector('.template-modal-new-button');
  newButton.addEventListener('click', () => {
    showEditView();
  });

  // 戻るボタン
  const backButton = modal.querySelector('.template-modal-back-button');
  backButton.addEventListener('click', () => {
    showMainView();
  });

  // 保存ボタン
  const saveButton = modal.querySelector('.template-modal-save-button');
  saveButton.addEventListener('click', saveTemplate);

  // キャンセルボタン
  const cancelButton = modal.querySelector('.template-modal-cancel-button');
  cancelButton.addEventListener('click', () => {
    showMainView();
  });
}

function loadTemplateList() {
  chrome.storage.sync.get(['templates'], (result) => {
    const templates = result.templates || [];
    const container = document.querySelector('.template-modal-items');

    if (!container) return;

    if (templates.length === 0) {
      container.innerHTML = '<p class="template-modal-empty">定型文が登録されていません</p>';
      return;
    }

    container.innerHTML = '';
    templates.forEach((template, index) => {
      const item = document.createElement('div');
      item.className = 'template-modal-item';

      item.innerHTML = `
        <div class="template-modal-item-name">${escapeHtml(template.name)}</div>
        <div class="template-modal-item-actions">
          <button class="template-modal-item-edit" data-index="${index}">編集</button>
          <button class="template-modal-item-delete" data-index="${index}">削除</button>
        </div>
      `;

      // 編集ボタンのイベントリスナー
      const editButton = item.querySelector('.template-modal-item-edit');
      editButton.addEventListener('click', () => {
        showEditView(index);
      });

      // 削除ボタンのイベントリスナー
      const deleteButton = item.querySelector('.template-modal-item-delete');
      deleteButton.addEventListener('click', () => {
        deleteTemplateFromModal(index);
      });

      container.appendChild(item);
    });
  });
}

function showMainView() {
  const mainView = document.querySelector('.template-modal-main-view');
  const editView = document.querySelector('.template-modal-edit-view');

  if (mainView && editView) {
    mainView.style.display = 'block';
    editView.style.display = 'none';
    loadTemplateList();
  }
}

function showEditView(editIndex = null) {
  const mainView = document.querySelector('.template-modal-main-view');
  const editView = document.querySelector('.template-modal-edit-view');
  const nameInput = document.querySelector('.template-modal-input-name');
  const contentInput = document.querySelector('.template-modal-input-content');
  const saveButton = document.querySelector('.template-modal-save-button');

  if (!mainView || !editView) return;

  mainView.style.display = 'none';
  editView.style.display = 'block';

  // 編集モードの場合
  if (editIndex !== null) {
    chrome.storage.sync.get(['templates'], (result) => {
      const templates = result.templates || [];
      const template = templates[editIndex];

      if (template) {
        nameInput.value = template.name;
        contentInput.value = template.content;
        saveButton.dataset.editIndex = editIndex;
      }
    });
  } else {
    // 新規追加モード
    nameInput.value = '';
    contentInput.value = '';
    delete saveButton.dataset.editIndex;
  }

  nameInput.focus();
}

function saveTemplate() {
  const nameInput = document.querySelector('.template-modal-input-name');
  const contentInput = document.querySelector('.template-modal-input-content');
  const saveButton = document.querySelector('.template-modal-save-button');

  const name = nameInput.value.trim();
  const content = contentInput.value.trim();

  if (!name || !content) {
    alert('名前と内容を入力してください');
    return;
  }

  chrome.storage.sync.get(['templates'], (result) => {
    const templates = result.templates || [];
    const editIndex = saveButton.dataset.editIndex;

    if (editIndex !== undefined) {
      // 編集モード
      templates[parseInt(editIndex)] = { name, content };
    } else {
      // 新規追加モード
      templates.push({ name, content });
    }

    chrome.storage.sync.set({ templates }, () => {
      showMainView();
    });
  });
}

function deleteTemplateFromModal(index) {
  if (!confirm('この定型文を削除しますか?')) {
    return;
  }

  chrome.storage.sync.get(['templates'], (result) => {
    const templates = result.templates || [];
    templates.splice(index, 1);

    chrome.storage.sync.set({ templates }, () => {
      loadTemplateList();
    });
  });
}

function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  return text.replace(/[&<>"']/g, (m) => map[m]);
}

content.cssファイルも作成していますが、本記事では機能部分にフォーカスして記述しますので割愛します。

実装の方針

実装の方針としては以下のようになります。

  • メッセージ入力欄の書式設定ツールバーに「定型文」ボタンを追加
    image.png

  • 「定型文」ボタンを押すと登録した定型文が表示され、選択すると入力欄に設定した文章が入力される
    image.png
    image.png

  • 定型文の設定はモーダル画面を別途表示して実施
    image.png
    image.png

実装内容の解説

定型文ボタンの追加

書式設定ツールバーのdivタグにはdata-qa属性に"wysiwyg-container_formatting-enabled"という値が設定されていたので、それを利用して書式設定ツールバーを探しています。
スレッドへの返信のパターンも考慮して、複数の場所に設定されるようにしました。

  // Slackの書式設定ツールバーを全て探す(メインとスレッド返信)
  const formattingToolbars = document.querySelectorAll('[data-qa="wysiwyg-container_formatting-enabled"]');

  if (formattingToolbars.length === 0) {
    console.log('Formatting toolbar not found');
    return;
  }

見つけた書式設定ツールバーに対して以下の処理でボタンを追加しています。ボタンにはクリックイベントとしてメニュー表示(設定した定型文を選択するメニュー画面)を設定しています。

  // 各ツールバーに対してボタンを追加
  formattingToolbars.forEach((formattingToolbar, index) => {
    // このツールバーに既にボタンが追加されているか確認
    const existingButton = formattingToolbar.querySelector('.template-button-extension');
    if (existingButton) {
      console.log(`Button already exists in toolbar ${index}`);
      return;
    }

    // 定型文ボタンを作成
    const templateButton = document.createElement('button');
    templateButton.className = 'template-button-extension';
    templateButton.type = 'button';
    templateButton.textContent = '定型文';
    templateButton.setAttribute('aria-label', '定型文');
    templateButton.setAttribute('data-qa', 'template-button');
    templateButton.title = '定型文';

    // クリックイベント
    templateButton.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();

      // このボタンに対応する入力欄を探す
      const targetEditor = findEditorForToolbar(formattingToolbar);
      showTemplateMenu(templateButton, targetEditor);
    });

    // ツールバーの先頭に追加(左寄せ)
    formattingToolbar.insertBefore(templateButton, formattingToolbar.firstChild);
  });
}

選択した定型文を入力欄に追加

まずは対応するメッセージ入力欄にフォーカスしています。
Slackではメインのメッセージ入力欄に加えてスレッドへの返信でもメッセージ入力欄がありますので、定型文ボタンの追加で対応する入力欄を特定して判別可能にしています。
以下ではtargetEditorという引数で取得・使用しています。

function insertTemplate(content, targetEditor) {
  const textArea = targetEditor || document.querySelector('.ql-editor[contenteditable="true"]');

  if (!textArea) {
    console.error('Message input not found');
    alert('メッセージ入力欄が見つかりませんでした。入力欄をクリックしてからもう一度お試しください。');
    return;
  }

  // 入力欄にフォーカス
  textArea.focus();

既に入力されているテキストに追加する形で定型文文字列を追加します。

  // 既存のテキストを取得
  const existingText = textArea.textContent || '';

  // テキストを設定
  textArea.textContent = existingText + content;

  // カーソルを末尾に移動
  const range = document.createRange();
  const selection = window.getSelection();

  // テキストノードが存在する場合
  if (textArea.childNodes.length > 0) {
    const lastNode = textArea.childNodes[textArea.childNodes.length - 1];
    range.setStartAfter(lastNode);
    range.setEndAfter(lastNode);
  } else {
    range.selectNodeContents(textArea);
    range.collapse(false);
  }

  selection.removeAllRanges();
  selection.addRange(range);

  // Slackに変更を通知するために複数のイベントを発火
  textArea.dispatchEvent(new Event('input', { bubbles: true }));
  textArea.dispatchEvent(new Event('change', { bubbles: true }));
  textArea.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
  textArea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
}

ツールバーに対応するメッセージ入力欄を探す処理は以下のように実装しています。

function findEditorForToolbar(toolbar) {
  // ツールバーの親要素を辿って入力エリア全体を探す
  let container = toolbar.parentElement;
  while (container && !container.querySelector('.ql-editor[contenteditable="true"]')) {
    container = container.parentElement;
    // 安全のため、10階層以上は探さない
    if (!container || container === document.body) {
      break;
    }
  }

  if (container) {
    const editor = container.querySelector('.ql-editor[contenteditable="true"]');
    if (editor) {
      return editor;
    }
  }

  // 見つからない場合は、ツールバーに最も近い入力欄を探す
  return null;
}

定型文の設定

定型文管理用のモーダルを表示させます。

function showTemplateModal() {
  // 既存のモーダルを削除
  const existingModal = document.querySelector('#template-modal-extension');
  if (existingModal) {
    existingModal.remove();
    return;
  }

  // モーダルオーバーレイを作成
  const overlay = document.createElement('div');
  overlay.id = 'template-modal-extension';
  overlay.className = 'template-modal-overlay';

  // モーダルコンテンツを作成
  const modal = document.createElement('div');
  modal.className = 'template-modal';

  modal.innerHTML = `
    <div class="template-modal-header">
      <h2>定型文管理</h2>
      <button class="template-modal-close">×</button>
    </div>
    <div class="template-modal-body">
      <div class="template-modal-main-view">
        <button class="template-modal-new-button">+ 新しい定型文を追加</button>
        <div class="template-modal-list">
          <div class="template-modal-items"></div>
        </div>
      </div>
      <div class="template-modal-edit-view" style="display: none;">
        <div class="template-modal-edit-header">
          <button class="template-modal-back-button">← 一覧に戻る</button>
        </div>
        <div class="template-modal-form">
          <input type="text" class="template-modal-input-name" placeholder="定型文の名前">
          <textarea class="template-modal-input-content" placeholder="定型文の内容"></textarea>
          <div class="template-modal-form-buttons">
            <button class="template-modal-save-button">保存</button>
            <button class="template-modal-cancel-button">キャンセル</button>
          </div>
        </div>
      </div>
    </div>
  `;

  overlay.appendChild(modal);

  // モーダルを表示
  document.body.appendChild(overlay);

  // 定型文リストを読み込み
  loadTemplateList();

  // イベントリスナーを設定
  const closeButton = modal.querySelector('.template-modal-close');
  closeButton.addEventListener('click', () => {
    overlay.remove();
  });

  // オーバーレイクリックで閉じる
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) {
      overlay.remove();
    }
  });

  // 新規追加ボタン
  const newButton = modal.querySelector('.template-modal-new-button');
  newButton.addEventListener('click', () => {
    showEditView();
  });

  // 戻るボタン
  const backButton = modal.querySelector('.template-modal-back-button');
  backButton.addEventListener('click', () => {
    showMainView();
  });

  // 保存ボタン
  const saveButton = modal.querySelector('.template-modal-save-button');
  saveButton.addEventListener('click', saveTemplate);

  // キャンセルボタン
  const cancelButton = modal.querySelector('.template-modal-cancel-button');
  cancelButton.addEventListener('click', () => {
    showMainView();
  });
}

上記のモーダル表示の中でloadTemplateListという関数を使用しています。以下がその内容です。

function loadTemplateList() {
  chrome.storage.sync.get(['templates'], (result) => {
    const templates = result.templates || [];
    const container = document.querySelector('.template-modal-items');

    if (!container) return;

    if (templates.length === 0) {
      container.innerHTML = '<p class="template-modal-empty">定型文が登録されていません</p>';
      return;
    }

    container.innerHTML = '';
    templates.forEach((template, index) => {
      const item = document.createElement('div');
      item.className = 'template-modal-item';

      item.innerHTML = `
        <div class="template-modal-item-name">${escapeHtml(template.name)}</div>
        <div class="template-modal-item-actions">
          <button class="template-modal-item-edit" data-index="${index}">編集</button>
          <button class="template-modal-item-delete" data-index="${index}">削除</button>
        </div>
      `;

      // 編集ボタンのイベントリスナー
      const editButton = item.querySelector('.template-modal-item-edit');
      editButton.addEventListener('click', () => {
        showEditView(index);
      });

      // 削除ボタンのイベントリスナー
      const deleteButton = item.querySelector('.template-modal-item-delete');
      deleteButton.addEventListener('click', () => {
        deleteTemplateFromModal(index);
      });

      container.appendChild(item);
    });
  });
}

Chrome拡張機能のストレージAPIを使用してテンプレートデータを保存しています。いくつかデータ保存用のストレージ機能が提供されているのですが、今回はstorage.syncを使用しました。使用できるデータ量は小さいですが、Chromeブラウザに同期させることができます。

同期が有効になっている場合、データはユーザーがログインしているすべての Chrome ブラウザに同期されます。無効になっている場合、storage.local と同様に動作します。ブラウザがオフラインのときは、Chrome はデータをローカルに保存し、オンラインに戻ると同期を再開します。割り当ての上限は、約 100 KB、アイテムあたり 8 KB です。

おわりに

今回はChrome拡張機能でブラウザ版Slackに便利機能を追加する開発を行ってみました。
ブラウザ拡張機能はWebアプリケーションのUIにも干渉できるので、ちょっとした便利機能を作る際には有効な手段になるのでまた機会があれば何か作ってみたいと思います。

※なお、えらそうに記事を書いてみましたが、実装はClaude Codeで行っていて自分自身ではコードは書いていません。サクッと作ってくれて助かりました。
※本記事は自力で書きました。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?