X の公式「検索の保存」が新規追加・削除できなくなり、よく使う検索を自分で管理できなくなっていました。代替として Chrome 拡張を作ることにしました。あわせて、ツイートの画像をまとめて別タブで開きたい場面も多かったので、右クリックで「画像をすべて開く」「ツイートのリンクをコピー」ができる機能も足しました。

検索の保存

保存データは chrome.storage.local にこんな形で持っています。

savedSearches: [
  { id, query, createdAt },
  ...
]

ポップアップにテキスト入力と追加ボタン、保存済み一覧を実装しました。検索ページにいる最中にワンクリックで保存できるようにしたくて、UI の配置を検討しました。検索入力欄の近くにボタンを挿入する案は、X の DOM 構造に強く依存し、UI 変更で壊れやすいと判断して却下。採用したのは [data-testid="primaryColumn"] の右端に動的配置するフローティングボタンです。getBoundingClientRect で primaryColumn の位置を取得し、fixed でボタンを配置。X は SPA なので検索ページへの遷移で content script が再実行されないため、2 秒ごとに URL をチェックしてボタンの表示・位置を更新しています。

function updateSaveButtonPosition() {
    const wrapper = document.getElementById(SAVE_BUTTON_ID);
    if (!wrapper) return;
    const primaryColumn = document.querySelector('[data-testid="primaryColumn"]');
    if (!primaryColumn) return;
    const rect = primaryColumn.getBoundingClientRect();
    wrapper.style.left = (rect.right + 12) + 'px';
    wrapper.style.top = (rect.top + 16) + 'px';
}
setInterval(() => {
    if (document.visibilityState === 'visible') updateSearchSaveUI();
}, 2000);

削除ボタンクリック時に stopPropagation を入れ忘れたら、親要素のクリック(検索を開く)も発火してしまいます。ドラッグ並び替えはハンドル(⋮⋮)を分離することで、「クリックで検索を開く」と「ドラッグで並び替え」を明確に区別できるようにしています。

div.querySelector('.search-item-delete').addEventListener('click', (e) => {
    e.stopPropagation();  // 親のクリック(検索実行)を防ぐ
    deleteSearch(item.id);
});

ツイート右クリック

ツイート右クリックで画像をまとめて開く機能では、Chrome 標準の contextMenus API を検討しました。ただ、メニュー表示時点では「どのツイートか」の情報が background に渡せず、画像 0 枚のツイートでメニューを非表示にする動的制御が難しいと判断しました。採用したのはカスタムコンテキストメニューです。content script で contextmenupreventDefault し、右クリック時に同期的にツイートを特定して画像 URL を取得。画像が 1 枚以上あるときだけ自前の div メニューを表示する形にしました。

画像 URL は [data-testid="tweetPhoto"] img から取得し、pbs.twimg.com/media/ を含むものだけ対象にしています。/profile_images/ のプロフィールアイコンは除外。原寸表示のため URL に name=orig を付与しています。

function getImageUrlsFromTweet(article) {
    const imgs = article.querySelectorAll('[data-testid="tweetPhoto"] img');
    const seen = new Set();
    const urls = [];
    imgs.forEach((img) => {
        const src = img.src || img.getAttribute('data-src');
        if (src && src.includes('pbs.twimg.com/media/') && !seen.has(src)) {
            seen.add(src);
            urls.push(toOrigUrl(src));  // name=orig で原寸
        }
    });
    return urls;
}

右クリック時に e.target.closest('article[data-testid="tweet"]') でツイートを特定し、画像・リンクがあれば preventDefault してカスタムメニューを表示します。

document.addEventListener('contextmenu', (e) => {
    const article = e.target.closest('article[data-testid="tweet"]');
    if (!article) return;
    const imageUrls = getImageUrlsFromTweet(article);
    const tweetLink = getTweetLink(article);
    if (imageUrls.length > 0 || tweetLink) {
        e.preventDefault();
        e.stopPropagation();
        showTweetContextMenu(e.clientX, e.clientY, imageUrls, tweetLink);
    }
}, true);

実装した機能は、検索の保存(ポップアップ・検索ページのワンクリック)、保存検索の管理(一覧・ドラッグ並び替え・削除・クリックで検索を開く)、ツイート右クリック(画像をすべて開く・リンクをコピー)です。