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?

GitHub Pagesで実装するドラッグ式しおり機能(localStorage+Pointer Events)

0
Last updated at Posted at 2026-04-13

概要

静的ホスティング環境であるGitHub Pagesでは、ユーザーごとの状態をサーバー側で保持することはできない。そのため「しおり機能」を実現するには、ブラウザ側に状態を保存する設計が必要になる。本記事では、ドラッグ操作で見出しに紐づくしおりを配置し、その状態をlocalStorageに保存する方法を解説する。さらに、スマートフォン対応としてPointer Eventsを用いた入力統一、編集操作のUX改善として長押し操作を導入する。

もの

コード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>しおり(ダブルタップ編集)</title>

<style>
body {
  font-family: sans-serif;
  line-height: 1.6;
  padding: 40px;
}

h2 {
  margin-top: 100px;
  padding: 10px;
  background: #f0f0f0;
}

.h2-text {
  display: inline-block;
}

/* しおり */
#bookmark {
  position: absolute;
  padding: 4px 8px;
  background: #ffcc00;
  border-radius: 6px;
  cursor: grab;
  z-index: 1000;
  user-select: none;
  touch-action: none;
}
</style>
</head>

<body>

<div id="bookmark">しおり</div>

<h1>テスト記事</h1>

<h2 id="sec1"><span class="h2-text">セクション1</span></h2>
<p>内容...</p>

<h2 id="sec2"><span class="h2-text">セクション2</span></h2>
<p>内容...</p>

<h2 id="sec3"><span class="h2-text">セクション3</span></h2>
<p>内容...</p>

<h2 id="sec4"><span class="h2-text">セクション4</span></h2>
<p>内容...</p>

<script>
const bookmark = document.getElementById("bookmark");
const sections = document.querySelectorAll("h2");

let offsetX, offsetY;
let isDragging = false;
let dragged = false;

let pressTimer = null;

let current = {
  id: null,
  label: "しおり"
};

/* ======================
   ドラッグ
====================== */
bookmark.addEventListener("pointerdown", (e) => {
  isDragging = true;
  dragged = false;

  bookmark.setPointerCapture(e.pointerId);

  offsetX = e.clientX - bookmark.offsetLeft;
  offsetY = e.clientY - bookmark.offsetTop;

  // 長押し開始
  pressTimer = setTimeout(() => {
    if (!dragged) {
      editLabel();
      isDragging = false;
    }
  }, 500); // ←長押し判定(500ms)
});

bookmark.addEventListener("pointermove", (e) => {
  if (!isDragging) return;

  dragged = true;

  clearTimeout(pressTimer);

  bookmark.style.left = (e.clientX - offsetX) + "px";
  bookmark.style.top = (e.clientY - offsetY) + "px";
});

bookmark.addEventListener("pointerup", (e) => {
  isDragging = false;

  bookmark.releasePointerCapture(e.pointerId);

  clearTimeout(pressTimer);

  if (dragged) snapToSection();
});

/* ======================
   吸着
====================== */
function snapToSection() {
  let closest = null;
  let minDist = Infinity;

  const by = bookmark.getBoundingClientRect().top;

  sections.forEach(sec => {
    const rect = sec.getBoundingClientRect();
    const dist = Math.abs(rect.top - by);

    if (dist < minDist) {
      minDist = dist;
      closest = sec;
    }
  });

  if (closest) setBookmark(closest);
}

/* ======================
   配置
====================== */
function setBookmark(section) {
  const textEl = section.querySelector(".h2-text");
  const rect = textEl.getBoundingClientRect();

  current.id = section.id;

  bookmark.textContent = current.label;

  bookmark.style.left = (window.scrollX + rect.right + 6) + "px";
  bookmark.style.top = (window.scrollY + rect.top) + "px";

  save();
}

/* ======================
   編集
====================== */
function editLabel() {
  const input = prompt("しおりの名前を変更", current.label);

  if (input !== null && input.trim() !== "") {
    current.label = input.trim();
    bookmark.textContent = current.label;
    save();
  }
}

/* ======================
   保存
====================== */
function save() {
  localStorage.setItem("bookmark", JSON.stringify(current));
}

/* ======================
   復元
====================== */
function load() {
  const data = localStorage.getItem("bookmark");
  if (!data) return;

  current = JSON.parse(data);

  const section = document.getElementById(current.id);
  if (!section) return;

  const textEl = section.querySelector(".h2-text");
  const rect = textEl.getBoundingClientRect();

  bookmark.textContent = current.label;

  bookmark.style.left = (window.scrollX + rect.right + 6) + "px";
  bookmark.style.top = (window.scrollY + rect.top) + "px";
}

load();
</script>
  
</body>
</html>

設計方針

しおり機能の本質は「位置」ではなく「意味のある参照」である。単純に座標を保存するとレイアウト変更に弱くなるため、見出しIDと紐づけて管理する。UI上ではドラッグで移動させるが、最終的には最も近い見出しに吸着させることで意味を確定させる。このとき座標ではなく「どの見出しか」を保存することで、ページ構造が変わっても破綻しない設計になる。表示位置は見出し文字列の右端を基準とし、テキスト単位で整合性を取る。

実装の中核

ドラッグ操作はPointer Eventsを用いて実装する。これによりマウスとタッチの両方を単一のイベントモデルで扱える。pointerdownで開始し、pointermoveで座標更新、pointerupで吸着処理を行う。吸着は縦方向の距離のみで評価し、最も近い見出しを選択する。配置時には見出し内のテキスト要素を取得し、その右端座標を基準にしおりを配置する。状態は{ id, label }の形式でlocalStorageに保存し、ページロード時に復元する。

スマートフォン対応

モバイル環境ではスクロール操作とドラッグ操作が競合するため、適切な制御が必要になる。CSSのtouch-actionをnoneに設定することで、対象要素上でのブラウザ標準ジェスチャーを無効化し、ドラッグ操作を優先できる。これによりpointerイベントが安定して取得可能になる。またPointer Eventsを使うことで、タッチとマウスの分岐処理を排除し、コードの一貫性を保てる。

編集操作の設計

初期実装ではクリックで編集を行うと誤操作が多発する。これはドラッグ終了後にもclickイベントが発火するためである。この問題を回避するため、操作の意味を分離する必要がある。本実装では「長押し」で編集を行う。pointerdown時にタイマーを開始し、一定時間経過で編集処理を発火、pointermoveやpointerupでキャンセルする。これによりドラッグと編集が明確に分離され、意図しない編集が発生しなくなる。

まとめ

本実装は、ドラッグ操作による直感的なUIと、見出しIDによる意味的な状態管理を組み合わせた構造になっている。localStorageによる永続化により、静的サイトでもユーザーごとの状態を保持できる。さらにPointer Eventsとtouch-actionの組み合わせにより、デバイス差異を吸収した安定した操作が可能になる。結果として「操作」「意味」「保存」の三要素が整合したしおり機能が実現される。

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?