自動化厨のプログラミングメモブログ │ CODE:LIFE

Python/VBA/GAS/JavaScript/Raspberry Piなどで色んなことを自動化

スター・ブックマーク・共有ボタンを左側に固定表示する

アクションバーを本文左側に置く構成のイメージです。

記事末尾に集まっていた、はてなスター・はてなブックマーク・X共有・URLコピーを、PCでは本文カードの左側へ縦一列で置くようにした。読み進めている途中でも操作しやすくなり、記事の最後まで戻る手間を減らせる。

このページでは、見た目だけでなく、はてなブログの標準ボタンをどう扱うか、どの画面幅で表示を止めるかまで含めて整理する。

この記事でできるようになること

  • はてなスター・はてなブックマーク・X共有・URLコピーを本文左側へ配置する
  • はてなブログが後から生成する既存のスター追加ボタンを、独自ボタンから操作する
  • スター追加ボタンがまだ見つからない場合に、記事末尾へ自然にフォールバックする
  • 本文に近づきすぎる画面幅では、アクションバーを非表示にして読書領域を守る
  • PCとモバイルで、操作性と本文幅の優先順位を切り替える

変更前の課題

はてなブログ標準のスター・ブックマーク・共有ボタンは、記事末尾にまとまっている。読み終わる前に反応したくなっても、そのためにページ下部まで移動しなければならない。

記事が長くなるほど、この移動が小さなストレスになる。そこで、記事を読みながら操作できるよう、本文の左側にアクションバーを追加した。

ただし、画面幅が十分でない状態で左側へ押し出すと、本文やサイドバーと重なる。操作ボタンを常に表示するのではなく、本文を優先して表示範囲を決めることが重要だった。

仕上がり

PC表示:読書領域の外側に4つの操作を置く

実サイトでの表示例。本文カードの左側に、スター・ブックマーク・X共有・URLコピーのボタンが縦に並ぶ。

実装上のポイント

ここでいう「固定」は、position: fixedで画面全体に貼り付ける意味ではない。実装ではposition: stickyを使い、記事コンテナの範囲内でスクロールに追従させている。

記事の外までボタンが残り続けないため、本文との関係が崩れにくい。一方で、親要素の高さやoverflowの指定によっては、stickyが効かなくなるので注意が必要だ。

実装方針

  1. body.page-entryが付いた記事詳細ページだけで実行する
  2. .entryまたは.hentryと、#mainまたは#wrapperを取得する
  3. .floating-actionsというアクションバーをJavaScriptで生成し、mainの先頭へ挿入する
  4. 4つの操作を、独自ボタンまたはリンクとして配置する
  5. はてなスターだけは自前実装せず、標準の追加ボタンへクリックを委譲する
  6. 1320px以下では非表示にして、本文との重なりを避ける

完成版のJavaScript

先に完成版を載せる。はてなブログの「デザイン」→「カスタマイズ」→「フッタ」へ貼り付ければ動く構成にした。

<script>
(() => {
  'use strict';

  const ICONS = {
    star: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>`,
    hatebu: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"></path></svg>`,
    x: `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"></path></svg>`,
    copy: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
    copied: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`
  };

  const handleStarClick = () => {
    const originalStarButton = document.querySelector(
      '.hatena-star-add-button'
    );

    if (originalStarButton) {
      originalStarButton.click();
      return;
    }

    const entryFooter = document.querySelector('.entry-footer');
    if (entryFooter) {
      entryFooter.scrollIntoView({ behavior: 'smooth' });
    }
  };

  const handleCopyUrl = async (button) => {
    try {
      await navigator.clipboard.writeText(window.location.href);
      button.classList.add('copied');
      button.innerHTML = ICONS.copied;

      setTimeout(() => {
        button.classList.remove('copied');
        button.innerHTML = ICONS.copy;
      }, 1500);
    } catch (error) {
      console.error('URLのコピーに失敗しました:', error);
    }
  };

  const initFloatingActionBar = () => {
    if (!document.body.classList.contains('page-entry')) {
      return;
    }

    const entry = document.querySelector('.entry')
      || document.querySelector('.hentry');
    const main = document.querySelector('#main')
      || document.querySelector('#wrapper');

    if (!entry || !main) {
      return;
    }

    const currentUrl = window.location.href;
    const pageUrlEncoded = encodeURIComponent(currentUrl);
    const pageTitleEncoded = encodeURIComponent(document.title);
    const hatebuUrl = 'https://b.hatena.ne.jp/entry/' + currentUrl;
    const xShareUrl = 'https://twitter.com/intent/tweet?url='
      + pageUrlEncoded
      + '&text='
      + pageTitleEncoded;

    const actionsBar = document.createElement('div');
    actionsBar.className = 'floating-actions';
    actionsBar.setAttribute('aria-label', '記事アクション');

    actionsBar.innerHTML = `
      <button type="button" class="article-action-btn btn-star" title="はてなスターを付ける" aria-label="はてなスターを付ける">
        ${ICONS.star}
      </button>
      <a href="${hatebuUrl}" target="_blank" rel="noopener noreferrer" class="article-action-btn btn-hatebu" title="はてなブックマークに追加" aria-label="はてなブックマークに追加">
        ${ICONS.hatebu}
      </a>
      <a href="${xShareUrl}" target="_blank" rel="noopener noreferrer" class="article-action-btn btn-x" title="Xでポスト" aria-label="Xでポスト">
        ${ICONS.x}
      </a>
      <button type="button" class="article-action-btn btn-copy-url" title="記事URLをコピー" aria-label="記事URLをコピー">
        ${ICONS.copy}
      </button>
    `;

    main.insertBefore(actionsBar, main.firstChild);

    const starButton = actionsBar.querySelector('.btn-star');
    if (starButton) {
      starButton.addEventListener('click', handleStarClick);
    }

    const copyButton = actionsBar.querySelector('.btn-copy-url');
    if (copyButton) {
      copyButton.addEventListener('click', () => {
        handleCopyUrl(copyButton);
      });
    }
  };

  if (document.readyState === 'loading') {
    document.addEventListener(
      'DOMContentLoaded',
      initFloatingActionBar
    );
  } else {
    initFloatingActionBar();
  }
})();
</script>

元コードはGitHubのfooter.htmlで管理している。

操作の役割は次のとおり。

  • はてなスター:標準のスター追加ボタンを代理クリックする
  • はてなブックマーク:b.hatena.ne.jp/entry/へ記事URLを渡す
  • X共有:twitter.com/intent/tweetへ記事URLとタイトルを渡す
  • URLコピー:navigator.clipboard.writeText()で現在のURLをコピーする

動的に生成されるはてなスターを扱う

はてなスターの追加ボタンは、ページ読み込み直後から存在するとは限らない。読み込み時に一度だけ取得して保存すると、ボタンがまだ生成されていない状態を記憶してしまう。

そこで、スター用ボタンがクリックされた瞬間に.hatena-star-add-buttonを検索する。

const handleStarClick = () => {
  const originalStarButton =
    document.querySelector('.hatena-star-add-button');

  if (originalStarButton) {
    originalStarButton.click();
    return;
  }

  const entryFooter = document.querySelector('.entry-footer');
  if (entryFooter) {
    entryFooter.scrollIntoView({ behavior: 'smooth' });
  }
};

見つかった場合は、はてなブログ標準の処理へ任せる。見つからない場合はエラーを表示せず、記事末尾へスムーススクロールする。

この「クリック時に再検索する」作りが、動的生成される標準UIを独自UIから扱うときのポイントになる。

完成版のCSS

CSSは、はてなブログの「デザイン」→「カスタマイズ」→「デザインCSS」へ追加する。

アクションバーは本文カードの左側へ少しはみ出す。float: leftとmargin-left: -54pxを組み合わせ、記事本文のレイアウトを大きく変えずに操作列を外側へ出した。

/* 記事ページ以外では表示しない */
body:not(.page-entry) .floating-actions {
  display: none !important;
}

/* 記事本文の左側へ配置 */
.page-entry .floating-actions {
  position: sticky;
  top: 80px;
  float: left;
  margin-top: 0;
  margin-left: -54px;
  z-index: 90;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

/* 操作ボタン共通 */
.article-action-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border: 1px solid var(--ntn-border);
  border-radius: var(--ntn-radius);
  background: var(--ntn-surface);
  color: var(--ntn-muted);
  box-shadow: var(--ntn-shadow-sm);
  cursor: pointer;
  text-decoration: none;
  transition: all 0.15s ease;
}

.article-action-btn:hover {
  background: var(--ntn-bg-hover);
  color: var(--ntn-text);
  border-color: var(--ntn-border-strong);
}

/* URLコピー成功時 */
.article-action-btn.copied {
  background: var(--callout-green-bg);
  color: #2b7a3e;
  border-color: rgba(43, 122, 62, 0.25);
}

このCSSは、サイト全体で定義済みの--ntn-*変数を参照している。別テーマへ移植する場合は、背景色・文字色・境界線・影を固定値へ置き換えれば使える。

画面幅が狭いときは本文を優先する

左側へはみ出すレイアウトは、画面幅が足りないと本文やサイドバーへ重なりやすい。そこで、現在の実装では1320px以下でアクションバーを非表示にしている。

@media (max-width: 1320px) {
  .floating-actions {
    display: none !important;
  }
}

これは機能を無理に残すための調整ではなく、本文を読みやすく保つための判断である。狭い画面では、記事末尾にある標準の導線を使う方が、本文を圧迫しない。

モバイル表示:アクションバーを隠して本文幅を守る

600px表示の実サイト例。アクションバーは表示せず、タイトルや本文を画面幅に合わせて読めるようにしている。

レスポンシブ設計の整理

画面幅 アクションバー 判断
1321px以上 表示 本文カードの外側に操作列を置く
1320px以下 非表示 本文やサイドバーとの重なりを避ける
モバイル 非表示 操作列より本文幅とタップしやすさを優先する

ブレークポイントを細かく増やすより、「操作列を置くための余白があるか」を基準に表示・非表示を決めた。

検証項目

  • ☐ PC幅(1321px以上)で、本文カードの左側に4つのボタンが表示されるか
  • ☐ スター追加ボタンが後から生成された場合でも、スター操作が標準ボタンへ届くか
  • ☐ スター追加ボタンが見つからない場合、記事末尾へスムーススクロールするか
  • ☐ ブックマークリンクが現在の記事URLを開くか
  • ☐ X共有リンクに記事URLとタイトルが渡るか
  • ☐ URLコピー後にボタンの表示が一時的に完了状態へ変わるか
  • ☐ 1320px以下でアクションバーが非表示になるか
  • ☐ 一覧ページやアーカイブページにはアクションバーが表示されないか
  • ☐ サイドバーや本文と操作ボタンが重ならないか

まとめ

記事末尾にあったスター・ブックマーク・共有・URLコピーの導線を、PCでは本文左側へ縦一列で配置した。

ポイントは、はてなスターを自前で再実装せず、クリック時に標準の.hatena-star-add-buttonを検索して代理操作すること。そして、ボタンが見つからない場合のフォールバックを用意して、動的生成のタイミング差を読者へ見せないことである。

見た目の「固定」にはposition: stickyを使い、本文との距離が近づく1320px以下では非表示にする。操作性と読みやすさの境界を、CSSの表示ルールとして決めた。

関連記事