1
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?

拡張機能について自分でも作ってみた

1
Posted at

前書き

今回の記事の作成に関して主なきっかけとしては社内の発表で拡張機能の実装方法について学ぶ機会があったことです。
開発の中で開発を便利にするためのツールとしても扱うことができるのはもちろん、Salesforce上でボタンの実装などではない形で便利にする機能があれば現場で実際にシステムを使用してくださる方にも還元ができそうだなと思い、実際に作成を行ってみることにしました。
今回は拡張機能の導入に関してではなく主にコードの構成を記事にできればと思います。

全体の構成

とはいっても構成はかなり簡単です
拡張機能作成の際はまず拡張機能の概要をまとめたファイルを一つ用意します(manifest.json)

manifest.json
{
  "manifest_version": 3,
  "name": "Salesforce API Display",
  "version": "1.0.0",
  "description": "Salseforceで項目のAPI名を表示する機能",
  "content_scripts": [
    {
      "matches": ["(対象のURL)"],
      "js": ["content.js"],
      "css": ["content.css"],
      "run_at": "document_idle"
    }
  ]
}

内容としてはそこまで難しくなく、以下のようになっています

  • name:拡張機能の名前
  • version:拡張機能のバージョン
  • description:拡張機能の概要
  • content_scripts:読み込む(実行する)ファイルの設定
  • matches:実行するwebページを指定
  • js:実行するjsファイルを指定
  • css: 実行するcssファイルを指定
  • run_at:実行を行うタイミングを指定(document_idleの場合はDOMの読み込みや主要な処理が終わった後)

manifest.jsonで記載したjs、cssファイルを用意します

content.js
(() => {
  // 即時実行関数でスコープを分離
  //const DICT = window.SF_HOVER_DICT || {}; // 辞書データを取得
  const DICT = {};
  const TOOLTIP_ID = "sf-hover-translation-tooltip"; // ツールチップID

  let tooltip = null; // ツールチップ要素
  let currentTarget = null; // 現在対象の要素
  let hideTimer = null; // 非表示用タイマー

  function createTooltip() {
    // ツールチップを作成
    if (document.getElementById(TOOLTIP_ID)) {
      // 既存要素があれば
      tooltip = document.getElementById(TOOLTIP_ID); // 既存要素をセット
      return; // 処理終了
    }

    tooltip = document.createElement("div"); // div要素を作成
    tooltip.id = TOOLTIP_ID; // IDを設定
    document.body.appendChild(tooltip); // bodyに追加
  }

  function normalizeText(text) {
    // 文字列を整形
    if (!text) return ""; // 空なら空文字
    return text.replace(/\s+/g, " ").trim(); // 空白を整理
  }

  function shouldIgnoreElement(el) {
    // 対象外要素か判定
    if (!el) return true; // 要素なしなら無視

    const tagName = el.tagName ? el.tagName.toLowerCase() : ""; // タグ名を取得

    if (["input", "textarea", "select", "option"].includes(tagName)) {
      // 入力系要素なら
      return true; // 無視する
    }

    if (el.isContentEditable) {
      // 編集可能要素なら
      return true; // 無視する
    }

    return false; // それ以外は対象
  }

  function getCandidateTexts(el) {
    // 候補文字列を取得
    const results = []; // 候補配列

    if (!el || shouldIgnoreElement(el)) {
      // 要素なし/対象外なら
      return results; // 空配列を返す
    }

    const text = normalizeText(el.innerText || el.textContent || ""); // 表示テキストを取得
    const ariaLabel = normalizeText(el.getAttribute?.("aria-label") || ""); // aria-labelを取得
    const title = normalizeText(el.getAttribute?.("title") || ""); // titleを取得
    const value = normalizeText(el.getAttribute?.("value") || ""); // valueを取得

    if (text) results.push(text); // textがあれば追加
    if (ariaLabel) results.push(ariaLabel); // aria-labelがあれば追加
    if (title) results.push(title); // titleがあれば追加
    if (value) results.push(value); // valueがあれば追加

    return [...new Set(results)]; // 重複を除いて返す
  }

  function findMatchFromElement(startEl) {
    // 要素から辞書一致を探す
    let el = startEl; // 開始要素
    let depth = 0; // 親探索の深さ

    while (el && el !== document.body && depth < 5) {
      // 最大5階層まで親を探索
      const candidates = getCandidateTexts(el); // 候補文字列を取得

      for (const candidate of candidates) {
        if (DICT[candidate]) {
          // 完全一致があれば
          return {
            // 一致情報を返す
            key: candidate, // 一致したキー
            value: DICT[candidate], // 辞書の値
            element: el, // 対象要素
          };
        }

        const exactShortLine = candidate
          .split("\n")
          .map(normalizeText)
          .find((line) => DICT[line]); // 改行ごとの一致を探す
        if (exactShortLine) {
          // 改行単位で一致があれば
          return {
            // 一致情報を返す
            key: exactShortLine, // 一致したキー
            value: DICT[exactShortLine], // 辞書の値
            element: el, // 対象要素
          };
        }
      }

      el = el.parentElement; // 親要素へ移動
      depth++; // 深さを加算
    }

    return null; // 一致なし
  }

  function escapeHtml(str) {
    // HTML文字をエスケープ
    return String(str) // 文字列化
      .replaceAll("&", "&amp;") // & を変換
      .replaceAll("<", "&lt;") // < を変換
      .replaceAll(">", "&gt;") // > を変換
      .replaceAll('"', "&quot;") // " を変換
      .replaceAll("'", "&#39;"); // ' を変換
  }

  function showTooltip(x, y, jp, translation) {
    // ツールチップを表示
    if (!tooltip) createTooltip(); // 未作成なら生成

    const jpHtml = translation.reading // 読みがあるか判定
      ? `<ruby>${escapeHtml(jp)}<rt>${escapeHtml(translation.reading)}</rt></ruby>` // ふりがな付き表示
      : escapeHtml(jp); // 通常表示

    //表示内容を設定
    tooltip.innerHTML = `
      <div class="jp">${jpHtml}</div>
      <div class="line"><span class="label">API:</span>${escapeHtml(translation.apiName || "")}</div>
      <div class="line"><span class="label">TYPE:</span>${escapeHtml(
        translation.dataType || "",
      )}</div>
    `;

    tooltip.style.display = "block"; // 表示する
    positionTooltip(x, y); // 位置を調整
  }

  function positionTooltip(x, y) {
    // ツールチップ位置を設定
    if (!tooltip) return; // 要素がなければ終了

    const offset = 7; // カーソルからの距離
    const padding = 12; // 画面端余白

    tooltip.style.left = "0px"; // 一旦左位置を初期化
    tooltip.style.top = "0px"; // 一旦上位置を初期化

    const rect = tooltip.getBoundingClientRect(); // サイズを取得

    let left = x + offset; // 基本の左位置
    let top = y + offset; // 基本の上位置

    if (left + rect.width > window.innerWidth - padding) {
      // 右にはみ出すなら
      left = x - rect.width - offset; // 左側に出す
    }

    if (top + rect.height > window.innerHeight - padding) {
      // 下にはみ出すなら
      top = y - rect.height - offset; // 上側に出す
    }

    if (left < padding) left = padding; // 左端補正
    if (top < padding) top = padding; // 上端補正

    tooltip.style.left = `${left}px`; // 左位置を反映
    tooltip.style.top = `${top}px`; // 上位置を反映
  }

  function hideTooltip() {
    // ツールチップを非表示
    if (tooltip) {
      // 要素があれば
      tooltip.style.display = "none"; // 非表示にする
    }
    currentTarget = null; // 現在対象をリセット
  }

  function scheduleHide() {
    // 少し遅らせて非表示
    clearTimeout(hideTimer); // 既存タイマーをクリア
    hideTimer = setTimeout(() => {
      // 新しいタイマーを設定
      hideTooltip(); // 非表示実行
    }, 60); // 60ms待つ
  }

  function onMouseMove(event) {
    // マウス移動時の処理
    const target = event.target; // 現在の対象要素
    if (!target) {
      // 対象がなければ
      scheduleHide(); // 非表示予約
      return; // 終了
    }

    const match = findMatchFromElement(target); // 辞書一致を検索

    if (!match) {
      // 一致しなければ
      scheduleHide(); // 非表示予約
      return; // 終了
    }

    clearTimeout(hideTimer); // 非表示タイマーを解除

    if (
      // 新しい対象か表示されていない場合
      currentTarget !== match.element ||
      tooltip.style.display !== "block"
    ) {
      currentTarget = match.element; // 現在対象を更新
      showTooltip(event.clientX, event.clientY, match.key, match.value); // 新しく表示
    } else {
      positionTooltip(event.clientX, event.clientY); // 位置だけ更新
    }
  }

  async function init() {
    await getAPIName();
    // 初期化処理
    createTooltip(); // ツールチップを準備
    document.addEventListener("mousemove", onMouseMove, true); // マウス移動を監視
    window.addEventListener("scroll", hideTooltip, true); // スクロール時に非表示
    window.addEventListener("blur", hideTooltip); // フォーカス外れ時に非表示
  }

  async function getAPIName() {
    const objectName = getObjectApiName();
    if (objectName == null) return;

    const response = await fetch(
      `/services/data/v62.0/ui-api/object-info/${objectName}`,
    );

    const json = await response.json();

    //console.log(json.fields);

    Object.values(json.fields).forEach((field) => {
      DICT[field.label] = {
        apiName: field.apiName,
        dataType: field.dataType,
      };
    });
    console.log(DICT);
  }

  function getObjectApiName() {
    const match = location.pathname.match(/\/lightning\/r\/([^\/]+)\//);

    return match ? match[1] : null;
  }

  init(); // 初期化実行
})(); // 即時実行終了
content.css
#sf-hover-translation-tooltip {
  position: fixed;
  z-index: 2147483647;
  max-width: 320px;
  background: rgba(34, 34, 34, 0.96);
  color: #ffffff;
  padding: 10px 12px;
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.5;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
  pointer-events: auto;
  display: none;
  white-space: normal;
}

#sf-hover-translation-tooltip ruby {
  ruby-align: center;
  ruby-position: over;
}

#sf-hover-translation-tooltip rt {
  font-size: 10px;
  color: #d8d8d8;
}

#sf-hover-translation-tooltip .jp {
  font-weight: 700;
  margin-bottom: 8px;
}

#sf-hover-translation-tooltip .line {
  margin-top: 2px;
}

#sf-hover-translation-tooltip .label {
  font-weight: 700;
  margin-right: 4px;
}

全体の構造としては以上となります

処理の大まかな流れ

細かいところは個人で追ってもらうとして、動作の大まかな流れのみメソッドに分けてまとめておこうと思います

1.まず以下のメソッドで初期設定を行います

content.js:init
async function init() {
    // 初期化処理
    await getAPIName();//項目名とAPI名の対応を取得
    createTooltip(); // ツールチップを準備
    document.addEventListener("mousemove", onMouseMove, true); // マウス移動を監視
    window.addEventListener("scroll", hideTooltip, true); // スクロール時に非表示
    window.addEventListener("blur", hideTooltip); // フォーカス外れ時に非表示
}

2.次に以下のメソッドでマウスカーソルが動いたときの処理を定義しています

content:onMouseMove
function onMouseMove(event) {
    // マウス移動時の処理
    const target = event.target; // 現在の対象要素
    if (!target) {
      // 対象がなければ
      scheduleHide(); // 非表示予約
      return; // 終了
    }

    const match = findMatchFromElement(target); // 辞書一致を検索

    if (!match) {
      // 一致しなければ
      scheduleHide(); // 非表示予約
      return; // 終了
    }

    clearTimeout(hideTimer); // 非表示タイマーを解除

    if (
      // 新しい対象か表示されていない場合
      currentTarget !== match.element ||
      tooltip.style.display !== "block"
    ) {
      currentTarget = match.element; // 現在対象を更新
      showTooltip(event.clientX, event.clientY, match.key, match.value); // 新しく表示
    } else {
      positionTooltip(event.clientX, event.clientY); // 位置だけ更新
    }
}

3.以下のメソッドで表示する内容を設定しています

content:showTooltip
function showTooltip(x, y, jp, translation) {
    // ツールチップを表示
    if (!tooltip) createTooltip(); // 未作成なら生成
    
    const jpHtml = translation.reading // 読みがあるか判定
      ? `<ruby>${escapeHtml(jp)}<rt>${escapeHtml(translation.reading)}</rt></ruby>` // ふりがな付き表示
      : escapeHtml(jp); // 通常表示
    
    //表示内容を設定
    tooltip.innerHTML = `
      <div class="jp">${jpHtml}</div>
      <div class="line"><span class="label">API:</span>${escapeHtml(translation.apiName || "")}</div>
      <div class="line"><span class="label">TYPE:</span>${escapeHtml(
        translation.dataType || "",
      )}</div>
    `;
    
    tooltip.style.display = "block"; // 表示する
    positionTooltip(x, y); // 位置を調整
}

動作の主要な部分はこのくらいです。あとのコードは細かい調整をしている部分だと思ってください。

動作

項目名にカーソルを合わせると以下のようにAPI名とデータ型が表示されるようになります。
image.png

まとめ

実際にコーディングを行ってみた感想としては思ったより簡単にできてびっくりといった感じでした(もちろんAIには頼りました)。
また今回はマウスの動作に合わせた拡張機能でしたが、例えばクリックや、キーボード入力などを起点としたものも作成できたりすると思うのでSalesforceのものに限らず自分の仕事を効率化したいときはなかなか便利な内容かなと思います。
ぜひ活用してみてください!
ちなみに現状ではかなり致命的な弱点があったりします

追記:全然公式の拡張機能でもある機能なので実用性はそこまでないかもです(ただし作成したものはEdgeでも動きます)

1
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
1
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?