【JavaScript】文章内の文字を選択（ドラッグ）したらSNS（Xへ）シェアできる機能の作り方

## できたもの

<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="zxOmYdz" data-pen-title="view_Xlink_by_drag_sentence" data-user="benjuwan" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/benjuwan/pen/zxOmYdz">
  view_Xlink_by_drag_sentence</a> by benjuwan (<a href="https://codepen.io/benjuwan">@benjuwan</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

文章内にある任意の文字を選択（ドラッグ）すると、その周囲に`Xへのリンク`ボタン（ツールチップ）が表示されるはずです。

---

[『週刊文春 電子版』](https://bunshun.jp/denshiban)や[『yahoo』](https://www.yahoo.co.jp/)など各種メディアの**コンテンツ（記事）内にある単語をドラッグで選択すると検索機能やシェア機能が選択中の単語周りに表示**されます。

こんなのや

![週刊文春 電子版](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/e2ab205c-c9ca-b9db-468d-c77e12c83e88.png)

こんなんの

![yahoo](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/723c1519-8523-d9da-b7b4-edaeea2452c9.png)

いままでどうやって実現しているのか不思議に思っていたので、今回[『週刊文春 電子版』](https://bunshun.jp/denshiban)さんの記事ページを参考に作ってみました。

:::note info
- `codepen`の埋め込み最適化
余談ですが、`codepen`がエディタ上で表示できず少し困りました。
解決策としては埋め込みコードの最終行に生成される`script`タグを以下のように修正しただけです。

```diff
<p class="codepen"....
</p>
- <script async src="https://public.codepenassets.com/embed/index.js"></script>
+ <script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>
```

備忘録＆情報共有として残しておきます。
:::

## コードの中身
`html`と`css`に関してはあまり参考にせず自分で主に調整しました。
- `html`
```html
<div class="snsShareBtn"><a href="" target="_blank">[Xへのリンク]</a></div>
<main>
    <section class="targetArea">
        <h2>heading</h2>
        <p>The quick, brown fox jumps over a lazy dog...
        （ただのダミーテキスト列挙なので以下は略）...
```
- `css`
```swift:css
.snsShareBtn{
  display: none;
  position: fixed; /* レイアウトシフト対策 */
  transform: translate(0px, 0px);
  
  &.snsShareBtn_view {
    display: block;
  }
  
  & a {
    display: block;
    width: fit-content;
    color: #fff;
    background-color: #333;
    padding: .5em 1em;
  }
}
```
`position: fixed`でレイアウトシフト対策しているくらいで、特別変わった記述は無しです。

---

- `JavaScript`
```js
document.addEventListener("DOMContentLoaded", () => {
    // イベント発生対象の指定（.targetArea内の各文章）
    const selectableTextArea = document.querySelectorAll(".targetArea p");

    // シェア可能を示唆する要素（ボタン）
    const snsShareBtn = document.querySelector(".snsShareBtn");

    selectableTextArea.forEach((elem) => {
        // 各文章（イベント発生対象要素内のpタグ）のマウスアップ時に「座標取得」
        elem.addEventListener("mouseup", selectableTextAreaMouseUp);
        // タッチイベント
        elem.addEventListener("touchend", selectableTextAreaMouseUp);
    });

    // ドキュメント要素（DOM全体を対象に）のマウスダウン時に「シェアボタンを非表示化」
    document.addEventListener("mousedown", documentMouseDown);
    document.addEventListener("touchstart", documentMouseDown);

    // param の文字数をチェックして条件に応じた整形を行う
    function checkParamLenght(baseText) {
        // location.search： 現在のページのURLからクエリ文字列（? 以降の部分）を取得する
        // 例： https://example.com/page?foo=bar&baz=qux → "?foo=bar&baz=qux"
        const param = location.search;
        const separator = param.length > 0 ? "&" : "?";
        return `${baseText}${separator}utm_source=x.com&utm_medium=social&utm_campaign=quoteLink`;
    }

    // シェア文章生成及びシェア実行準備
    function createShareSentence_prepareShareAction() {
        // ドラッグ箇所（文章・単語）
        const selectedText = window.getSelection().toString().trim();
        if (selectedText.length > 0) {
            const xShareUrl = "https://x.com/intent/tweet";
            // ドラッグ箇所（文章・単語）のトリミング整形無しver
            let text = window.getSelection().toString();

            if (text.length > 126) {
                text = text.slice(0, 125);
                const baseText = `"${text}…"\n${location.href}`;
                text = checkParamLenght(baseText)
            } else {
                const baseText = `"${text}…"\n${location.href}`;
                text = checkParamLenght(baseText)
            }

            // 最終整形を href に渡す
            const snsShare = snsShareBtn.querySelector('a');
            snsShare.href = `${xShareUrl}?text=${text}`;
        }
    }

    // 座標取得
    function selectableTextAreaMouseUp(event) {
        // 擬似的な遅延処理で高速ダブルクリックに対応
        setTimeout(() => {
            // window.getSelection()： 各文章（イベント発生対象要素内のpタグ）内の選択（ドラッグした）部分（文章・単語）
            // ドラッグ箇所（文章・単語）を文字列化（※数字や特殊な記号などを考慮した型変換作業）して改行やスペースなどをトリミング（排除）した文字列に整形
            const selectedText = window.getSelection().toString().trim();

            if (selectedText.length > 0) {
                // スマホ・タブレットかどうかを判定するフラグ
                const isTouchDevice = event instanceof TouchEvent;
                
                // 各文章（イベント発生対象要素内のpタグ）内のドラッグ位置に対する x座標
                const xPos = !isTouchDevice ? event.clientX : event.changedTouches[0].clientX;
                // 各文章（イベント発生対象要素内のpタグ）内のドラッグ位置に対する y座標
                const yPos = !isTouchDevice ? event.clientY : event.changedTouches[0].clientY;

                // アクティブな要素（ドラッグ対象）がシェアボタンではない場合
                if (document.activeElement !== snsShareBtn) {
                    snsShareBtn.classList.add('snsShareBtn_view');
                    snsShareBtn.style.transform = `translate(${xPos}px, ${yPos}px)`;
                    // シェア文章生成及びシェア実行準備
                    createShareSentence_prepareShareAction();
                }
            }
        }, 200); // .2sが適当
    }

    // シェアボタンを非表示化
    function documentMouseDown() {
        //  シェアボタンが表示中の場合、表示用クラスの解除とドラッグ箇所（文章・単語）を空（初期化・リセット）に
        if (snsShareBtn.classList.contains('snsShareBtn_view')) {
            window.getSelection().empty();
            // シェア実行を実現するために非表示化を擬似的に遅延させている
            if (!isMobileDevice) {
                setTimeout(() => snsShareBtn.classList.remove('snsShareBtn_view'), 250);
            } else {
                // スマホ・タブレット操作時は更に遅延
                setTimeout(() => snsShareBtn.classList.remove('snsShareBtn_view'), 500);
            }
        }
    }
});

```

処理や挙動に関する詳細はコード内のコメントにある通りなのですが、参照元コードでは一部非推奨な記述があったり、整理できそうな部分があったりしたのでリファクタリングしています。

例えば、参照元ではツールチップ表示の座標取得において[`layerX`](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/layerX)と[`layerY`](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/layerY)を使っていました。

これらはリンク先ページにあるように現在は非推奨だそうです。
> 非標準: この機能は標準ではなく、標準化の予定もありません。公開されているウェブサイトには使用しないでください。ユーザーによっては使用できないことがあります。実装ごとに大きな差があることもあり、将来は振る舞いが変わるかもしれません。

```js
// 参照元コード
const x = event.layerX;
const y = event.layerY;

// リファクタリング
const xPos = !isTouchDevice ? event.clientX : event.changedTouches[0].clientX;
const yPos = !isTouchDevice ? event.clientY : event.changedTouches[0].clientY;
```

ページ流入チェックを意図したコードで、記述の一部が繰り返されていたのでシンプルにまとめました。
```js
// param の文字数をチェックして条件に応じた整形を行う
function checkParamLenght(baseText) {
    // location.search： 現在のページのURLからクエリ文字列（? 以降の部分）を取得する
    // 例： https://example.com/page?foo=bar&baz=qux → "?foo=bar&baz=qux"
    const param = location.search;
    const separator = param.length > 0 ? "&" : "?";
    return `${baseText}${separator}utm_source=x.com&utm_medium=social&utm_campaign=quoteLink`;
}
```

メディアサイトという性質もあるかもですが、こういったキャンペーン付与を行うプログラムが施されていたりして細部まで意識されているなぁと感心しました。

## さいごに
ここまで読んでいただきありがとうございます。

参照元コードから結構リファクタリングしていますし、もし個人または自社サイトなどのコンテンツに今回のコードを使用したい場合は自由に使ってください。

今回は「Xへのシェア機能」ですがコードの以下部分（`createShareSentence_prepareShareAction`メソッドの部分）を別の関数や処理に変更（してHTMLのボタン要素も調整）すると、柔軟にツールチップの中身を変更できるはずです。

```js
// 座標取得
    function selectableTextAreaMouseUp(event) {
      ...
      ..
      .
      // アクティブな要素（ドラッグ対象）がシェアボタンではない場合
        if (document.activeElement !== snsShareBtn) {
            snsShareBtn.classList.add('snsShareBtn_view');
            snsShareBtn.style.transform = `translate(${xPos}px, ${yPos}px)`;

            /* --- 以下の部分を別の関数や処理に変更（してHTMLのボタン要素も調整）--- */
            createShareSentence_prepareShareAction();
            }
        }
      }, 200); // .2sが適当
    }
```