「セクションをまたぐコピー」のブロックをSelectionとRangeでやってみた
はじめに
先日、「セクション内のテキストコピーは許可したいが、複数セクションにまたがるコピーはブロックしたい」という要件に遭遇しました。
コピーを全面禁止にするだけなら user-select: none や copy イベントの preventDefault() で一発です。
ただ今回は 「同一セクション内ならOK、またいだらNG」 という条件付きのため少々複雑です。
色々調べてみても、要件にピッタリ当てはまる内容で解説している記事が見当たらなかったので、自分で実装してみました。
外部ライブラリ等も使わずに実装可能でしたので共有します。
完成イメージ
- 各セクション内のテキスト → 普通にコピーできる
- 複数セクションにまたがる選択 → コピー時にブロック。トースト通知が出て、該当セクションが赤枠でハイライトされる
やることの整理
やりたいことを分解すると、以下の3ステップです。
- コピー操作を検知する →
copyイベント - 選択範囲がどのセクションにかかっているか調べる → Selection + Range
- 2つ以上のセクションにまたがっていたらブロックする →
preventDefault()
それぞれ見ていきます。
copy イベント
ユーザーが Cmd+C(Ctrl+C)を押したり右クリックで「コピー」を選んだりすると、ブラウザは copy イベントを発火します。このイベントは preventDefault() でデフォルトのコピー動作を止められます。
document.addEventListener("copy", (e) => {
e.preventDefault();
});
SelectionとRange
window.getSelection() で現在の選択状態を取得し、そこからRangeオブジェクトを引っ張り出して、各セクションとの位置関係を調べます。
判定に使うのは2つのメソッドです。
Range.intersectsNode(node) :Range がそのノードと交差しているか。ただし、これだけだと隣接するセクションも拾ってしまうケースがあります。例えば、セクションAの末尾ぎりぎりまで選択すると、隣のセクションBにもtrueが返ることがあります。
そこで Selection.containsNode(node, true) を組み合わせます。第2引数のtrueが重要で、「ノードの一部でも選択に含まれていればtrue」という部分一致モードになります(デフォルトのfalseだと全体一致)。
この2つを AND で判定すれば、実際にユーザーの選択範囲になっているセクションだけを正確に拾えます。
実装
HTML
<div class="sections-wrapper" id="sectionsWrapper">
<section class="content-section" data-section="A">
<h2>セクション A</h2>
<p>テキスト……</p>
</section>
<section class="content-section" data-section="B">
<h2>セクション B</h2>
<p>テキスト……</p>
</section>
<section class="content-section" data-section="C">
<h2>セクション C</h2>
<p>テキスト……</p>
</section>
</div>
<div class="toast" id="toast">セクションをまたぐコピーはできません</div>
各セクションに .content-section を付けておき、親の #sectionsWrapper の中だけを対象にします。
CSS
.content-section {
outline: 2px solid transparent;
outline-offset: -2px;
}
.toast {
position: fixed;
top: 32px;
left: 50%;
transform: translateX(-50%);
background: #ff3b30;
color: #fff;
padding: 14px 32px;
border-radius: 12px;
font-weight: 600;
opacity: 0;
pointer-events: none;
z-index: 9999;
}
CSSは初期状態だけ定義しておけばOKです。制御はJSで全部やります。理由は後述。
JavaScript
const wrapper = document.getElementById("sectionsWrapper");
const toast = document.getElementById("toast");
const getSpannedSections = (selection) => {
if (!selection.rangeCount) return [];
const range = selection.getRangeAt(0);
const sections = [...wrapper.querySelectorAll(".content-section")];
const hit = sections.filter(
(sec) => range.intersectsNode(sec) && selection.containsNode(sec, true),
);
if (hit.length) return hit;
// フォールバック
const ancestor = range.commonAncestorContainer;
const node =
ancestor.nodeType === Node.ELEMENT_NODE ? ancestor : ancestor.parentElement;
const section = node?.closest(".content-section");
return section ? [section] : [];
};
document.addEventListener("copy", (e) => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const spanned = getSpannedSections(sel);
if (spanned.length <= 1) return;
e.preventDefault();
toast.getAnimations().forEach((a) => a.cancel());
toast.animate(
[
{ opacity: 0, transform: "translateX(-50%) translateY(-20px)" },
{ opacity: 1, transform: "translateX(-50%)", offset: 0.1 },
{ opacity: 1, transform: "translateX(-50%)", offset: 0.85 },
{ opacity: 0, transform: "translateX(-50%) translateY(-20px)" },
],
{ duration: 2400, fill: "forwards" },
);
for (const sec of spanned) {
sec.getAnimations().forEach((a) => a.cancel());
sec.animate(
[{ outlineColor: "#ff3b30" }, { outlineColor: "transparent" }],
{ duration: 2400 },
);
}
});
やっていることを順に説明します。
getSpannedSections — どのセクションにかかっているか
const hit = sections.filter(
(sec) => range.intersectsNode(sec) && selection.containsNode(sec, true),
);
全セクションを .filter() で回して、Range と交差していて、かつ Selection に含まれているセクションだけ残します。
ここで hit が空になるケースがあります。テキストノードの一部だけを選択している場合、intersectsNode と containsNode の組み合わせではヒットしないことがあ。その場合は Range.commonAncestorContainer(選択範囲の開始点と終了点の共通祖先)から closest() で .content-section を探すフォールバックを入れています。
注意
当然ですが、これは完全なコピーガードではありません。DevTools からリスナーを外されたらおしまいだし、スクリーンショットも防げません。あくまで「うっかりまたいで選択しちゃった」ときの UX ガードです。今回の私の目的としてはそれで十分なものだったので、このような対応になっています。








