Reactで、選んだ文に直接飛べるリンクを作る方法

​​React​Intl.Segmenter,Playwright,Text Fragments,TypeScript

URLの最後に #:~:text= を付けると、ページの中の文に直接飛び、その文をハイライトするリンクになります。リンク先のページに id がなくても使えます。この形を Text Fragments と呼びます(MDN の Text fragments の説明)。

読者がページの文を選ぶと、その文へのリンクをコピーできる React のコンポーネントを作りました。選んだ文字列を encodeURIComponent で URL に入れるだけでは、- を含む文や、日本語の単語の途中から選んだ文で、飛ばないリンクになりました。ブラウザで開いて、どの URL ならハイライトされるかを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4(ICU 78.3)、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。ブラウザは Playwright で、言語を日本語、画面の幅を 375px にして開いています。Firefox と Safari、本番用ビルド、別のオリジンのページへのリンクは試していません。

#:~:text= の書き方

MDN の説明では、書き方は次のとおりです。

text
https://example.com/#:~:text=[prefix-,]textStart[,textEnd][,-suffix]

textStart だけを書くと、その文字列が最初に出てくる箇所に飛びます。textEnd を書くと、textStart から textEnd までの範囲になります。prefix- と -suffix は、すぐ前とすぐ後ろにある文字列の指定で、同じ文字列がページの中に何か所もあるときに、どれを指すかを決めるのに使います。英語の大文字と小文字は区別しません。

ブラウザは、ページを読み込むときに :~: から後ろを URL から取り除きます(MDN の Text fragments の説明)。検証環境でも、#:~:text= 付きで開いたページの location.hash は空の文字列で、location.href にも #:~:text= は残っていませんでした。ページのスクリプトから location.hash を読んでも、この部分は見えません。

ブラウザが対応しているかは、document.fragmentDirective があるかで調べられます(MDN の FragmentDirective の説明)。MDN の互換性の表では、リンクで飛ぶ機能は Chrome 80、Firefox 131、Safari 16.1 から使えます。ただし、document.fragmentDirective は Chrome 86、Safari 18.4 からです。表のとおりなら、Chrome 80〜85 と Safari 16.1〜18.3 では、リンクで飛べても、この判定では対応していないことになります。

「-」と「,」は % の形にしないと飛ばない

textStart などの文字列は、パーセントエンコードして書きます。さらに仕様では、-、&、, の3つは書き方の区切りに使うので、文字として含むときは % の形にすると決められています(Text Fragments の仕様)。

encodeURIComponent は & と , を % の形にしますが、- はそのまま残します(MDN の encodeURIComponent() の説明)。Node.js で試した結果です。

text
encodeURIComponent('well-known, A&B') => "well-known%2C%20A%26B"
encodeTextPart('well-known, A&B') => "well%2Dknown%2C%20A%26B"

well-known を指す #:~:text=well-known のURLを開くと、どこにも飛ばず、ハイライトもされませんでした。well%2Dknown にすると、その語がハイライトされました。1,000円 も、カンマをそのまま書いた #:~:text=1,000%E5%86%86 ではハイライトされず、1%2C000%E5%86%86 ならハイライトされました。

文字列が見つからないときは、エラーにはならず、ページの先頭が表示されるだけです(MDN の Text fragments の説明)。書き方を間違えても、開いた人の画面ではページの先頭が出るだけなので、間違いに気づきにくくなります。そこで、encodeURIComponent のあとで - も置き換える関数を用意しました。

日本語は単語の途中から選ぶと飛ばない

「猫の手も借りたいほど忙しい日に、ブラウザの機能を試しました。」という段落で、ラウザ だけを指定したリンクを開くと、ハイライトされませんでした。ブラウザ にするとハイライトされます。英語でも、well-known の途中の ell ではハイライトされませんでした。

仕様では、指定した文字列の始まりと終わりが、単語の切れ目に来ている必要があります(仕様の Word Boundaries の節)。単語の切れ目は言語に合わせて決めるとされ、日本語のように単語の間に空白がない言語で辞書を使うときの注意も書かれています。

マウスで選ぶと、単語の途中から選ぶことはよくあります。そこで、選んだ範囲を Intl.Segmenter で単語に分け、途中で切れている単語は、その単語の端まで広げることにしました(MDN の Intl.Segmenter.prototype.segment() の説明)。Node.js で分けた結果は次のとおりです。

text
Intl.Segmenter ja => "猫|の|手|も|借り|たい|ほど|忙しい|日|に|、|ブラウザ|の|機能|を|試し|ま|した|。"
snapToWords('ラウザ' を選んだ場合) => "ブラウザ"
snapToWords('しい日' を選んだ場合) => "忙しい日"

仕様は、単語の切れ目を Intl.Segmenter と同じにするとは決めていません。検証環境では、この記事で単語の端まで広げて作ったリンクは、どれもハイライトされました。

同じ文が2回あるときは、前後の言葉を付ける

「猫は窓辺で眠ります。」が2つの段落にあるページで、2つ目の段落の文を選んだとします。textStart だけのリンクでは、1つ目の段落がハイライトされました。

前後の言葉を prefix- と -suffix に入れると、2つ目の段落がハイライトされました。作ったリンクの # から後ろをデコードすると、次のようになっています。

text
:~:text=になると、-,猫は窓辺で眠ります。,-夜には

今回の関数では、選んだ文字列がページの中に2回以上あるときだけ、前と後ろからそれぞれ単語を3つまで取って付けます。ラウザ を選んだ場合も、広げた ブラウザ が別の段落にもあったので、前後の言葉が付きました。

選んだ範囲からURLを作る関数

ここまでの扱いを、1つのファイルにまとめました。directiveFor は、段落の文字列と、その中で選んだ範囲(何文字目から何文字目まで)を受け取り、#:~:text= に入れる内容を返します。40文字を超える長い文は、先頭と末尾の言葉だけを textStart と textEnd にして、URLを短くします。

ts
// textFragment.ts
// 「-」「&」「,」は text= の区切りに使われるので、文字として含むときも % の形にする
export function encodeTextPart(text: string): string {
  return encodeURIComponent(text).replace(/-/g, '%2D');
}

export type TextDirective = {
  start: string;
  end?: string;
  prefix?: string;
  suffix?: string;
};

// #:~:text=[prefix-,]start[,end][,-suffix] の形にする
export function buildTextFragmentUrl(baseUrl: string, d: TextDirective): string {
  const url = new URL(baseUrl);
  url.hash = '';
  const parts = [
    d.prefix ? `${encodeTextPart(d.prefix)}-` : '',
    encodeTextPart(d.start),
    d.end ? encodeTextPart(d.end) : '',
    d.suffix ? `-${encodeTextPart(d.suffix)}` : '',
  ].filter(Boolean);
  return `${url.href}#:~:text=${parts.join(',')}`;
}

// 選んだ範囲 [from, to) を、単語の切れ目まで広げる
export function snapToWords(text: string, from: number, to: number, locale = 'ja'): [number, number] {
  const seg = new Intl.Segmenter(locale, { granularity: 'word' });
  let start = from;
  let end = to;
  for (const s of seg.segment(text)) {
    const sEnd = s.index + s.segment.length;
    if (s.index < from && from < sEnd) start = s.index;
    if (s.index < to && to < sEnd) end = sEnd;
  }
  return [start, end];
}

const countOf = (haystack: string, needle: string) => {
  const h = haystack.toLowerCase();
  const n = needle.toLowerCase();
  let count = 0;
  for (let i = h.indexOf(n); i !== -1; i = h.indexOf(n, i + 1)) count++;
  return count;
};

// 選んだ文の前後から、単語を max 個まで取る(空白と記号だけの区切りは数えない)
function wordsAround(text: string, from: number, to: number, locale: string, max = 3) {
  const segs = [...new Intl.Segmenter(locale, { granularity: 'word' }).segment(text)];
  const before = segs.filter((s) => s.index + s.segment.length <= from);
  const after = segs.filter((s) => s.index >= to);
  const take = (list: Intl.SegmentData[]) => {
    const out: Intl.SegmentData[] = [];
    for (const s of list) {
      out.push(s);
      if (out.filter((x) => x.isWordLike).length >= max) break;
    }
    return out;
  };
  const pre = take(before.reverse()).reverse();
  const suf = take(after);
  return {
    prefix: pre.map((s) => s.segment).join('').trim(),
    suffix: suf.map((s) => s.segment).join('').trim(),
  };
}

const LONG = 40; // これより長い文は、先頭と末尾だけを start と end にする

// ブロックの文字列 blockText のうち [from, to) を指す text= を作る
// pageText は、同じ文字列がほかにもあるかを数えるためのページ全体の文字列
export function directiveFor(
  blockText: string,
  from: number,
  to: number,
  pageText: string,
  locale = 'ja',
): TextDirective | null {
  const [s, e] = snapToWords(blockText, from, to, locale);
  const raw = blockText.slice(s, e);
  const lead = raw.length - raw.trimStart().length;
  const trail = raw.length - raw.trimEnd().length;
  const start = s + lead;
  const end = e - trail;
  const selected = blockText.slice(start, end);
  if (!selected) return null;

  let d: TextDirective = { start: selected };
  if (selected.length > LONG) {
    const [, headEnd] = snapToWords(blockText, start, start + 15, locale);
    const [tailStart] = snapToWords(blockText, end - 15, end, locale);
    const head = blockText.slice(start, headEnd).trim();
    const tail = blockText.slice(tailStart, end).trim();
    const rest = blockText.slice(headEnd, end);
    // end の文字列が途中にも出てくると、そこで終わってしまうので使わない
    if (head && tail && tailStart > headEnd && rest.toLowerCase().indexOf(tail.toLowerCase()) === rest.length - tail.length) {
      d = { start: head, end: tail };
    }
  }
  if (countOf(pageText, d.start) > 1) {
    d = { ...d, ...wordsAround(blockText, start, end, locale) };
    if (!d.prefix) delete d.prefix;
    if (!d.suffix) delete d.suffix;
  }
  return d;
}

buildTextFragmentUrl は、元のURLに #intro のようなハッシュがあれば取り除いてから #:~:text= を付けます。https://example.com/post?id=1#intro から作ると https://example.com/post?id=1#:~:text=%E7%8C%AB%E3%81%AE%E6%89%8B になりました。

段落全体(65文字)を選んだときは、次のように先頭と末尾だけが入り、段落全体がハイライトされました。

text
:~:text=長い文を選んだときは、文の始まり,探すので、URLが短くなります。

選んだ文を読み取るフック

selectionchange イベントで選んだ範囲を読み、URLを作るフックです。選んだ範囲が段落(p や li など)の中に収まっているときだけ、URLを作ります。MDN の Text fragments の説明によると、textStart などの文字列は、それぞれ1つのブロック要素の中に収まっている必要があります(textStart と textEnd が別の段落にあるのはかまいません)。今回は簡単にするため、段落をまたいで選んだときは、作らずに案内を出すことにしました。

ts
// useSelectionLink.ts
import { useEffect, useState, type RefObject } from 'react';
import { buildTextFragmentUrl, directiveFor } from './textFragment';

const BLOCK = 'p, li, h1, h2, h3, h4, blockquote, figcaption, td, th';

export type SelectionLink = { text: string; url: string } | { error: string } | null;

export function useSelectionLink(rootRef: RefObject<HTMLElement | null>, locale = 'ja'): SelectionLink {
  const [link, setLink] = useState<SelectionLink>(null);

  useEffect(() => {
    const update = () => {
      const root = rootRef.current;
      const sel = document.getSelection();
      if (!root || !sel || sel.rangeCount === 0 || sel.isCollapsed) return setLink(null);
      const range = sel.getRangeAt(0);
      if (!root.contains(range.commonAncestorContainer)) return setLink(null);

      const block = (n: Node) => (n instanceof Element ? n : n.parentElement)?.closest(BLOCK);
      const startBlock = block(range.startContainer);
      if (!startBlock || startBlock !== block(range.endContainer)) {
        return setLink({ error: '1つの段落の中で選んでください' });
      }
      // 段落の先頭から、選んだ範囲の始まりと終わりまでの文字数
      const toOffset = (node: Node, offset: number) => {
        const r = document.createRange();
        r.setStart(startBlock, 0);
        r.setEnd(node, offset);
        return r.toString().length;
      };
      const from = toOffset(range.startContainer, range.startOffset);
      const to = toOffset(range.endContainer, range.endOffset);
      const d = directiveFor(startBlock.textContent ?? '', from, to, root.textContent ?? '', locale);
      if (!d) return setLink(null);
      setLink({ text: sel.toString(), url: buildTextFragmentUrl(location.href, d) });
    };
    document.addEventListener('selectionchange', update);
    return () => document.removeEventListener('selectionchange', update);
  }, [rootRef, locale]);

  return link;
}

文字数を数えるときは、段落の先頭から選んだ位置までの Range を作り、toString() の長さを使っています。こうすると、<em> などで文字列が分かれていても数えられます。「to specific text on」のように、<em> で囲んだ言葉をまたいで選んだ場合も、作ったリンクでハイライトされました。

コピーするボタンを出すコンポーネント

本文を ShareSelection で囲むと、文を選んだときにボタンとURLが出ます。検証ページでは、CSS で .share-bar を画面の下に固定しています。

tsx
// ShareSelection.tsx
import { useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import { useSelectionLink } from './useSelectionLink';

// 対応しているかは途中で変わらないので、購読する処理は何もしない
const subscribe = () => () => {};

// サーバーでは「対応している」として描き、hydrate で表示が食い違わないようにする
const useSupported = () =>
  useSyncExternalStore(
    subscribe,
    () => 'fragmentDirective' in document,
    () => true,
  );

export function ShareSelection({ children }: { children: ReactNode }) {
  const rootRef = useRef<HTMLDivElement>(null);
  const link = useSelectionLink(rootRef);
  const [copied, setCopied] = useState(false);
  const supported = useSupported();

  const copy = async (url: string) => {
    await navigator.clipboard.writeText(url);
    setCopied(true);
  };

  return (
    <>
      <div ref={rootRef} onPointerDown={() => setCopied(false)}>
        {children}
      </div>
      <div className="share-bar" role="status">
        {!supported && <p>このブラウザは、文へのリンクに対応していません。</p>}
        {link && 'error' in link && <p>{link.error}</p>}
        {link && 'url' in link && (
          <>
            <button
              type="button"
              // ボタンを押したあとも、選んだ範囲を残したままにする
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => copy(link.url)}
            >
              {copied ? 'コピーしました' : '選んだ文へのリンクをコピー'}
            </button>
            <output className="share-url">{link.url}</output>
          </>
        )}
      </div>
    </>
  );
}

対応しているかの判定には useSyncExternalStore の3つ目の引数を使い、サーバーで描くときは「対応している」として扱います。React の公式リファレンスにある、サーバーとクライアントで同じ値を返す getServerSnapshot の説明に合わせた書き方です。サーバーで描いたHTMLを hydrate したページでも、コンソールにエラーや警告は出ませんでした。

ボタンの onMouseDown では preventDefault() を呼び、選んだ範囲を残したままにしています。検証環境では、押したあとも選んだ文字列が残っていました。subscribe はコンポーネントの外に置いています。描画のたびに違う関数を渡すと購読し直すことが、公式リファレンスの subscribe が毎回呼ばれる場合の説明に書かれているためです。

マウスで「ラウザの機」の上をドラッグして選んだ画面です。ボタンを押すと表示が「コピーしました」に変わり、クリップボードには ブラウザの機能 を指すURLが入りました。

「ラウザの機」が選ばれ、画面の下に「コピーしました」のボタンと、作ったURLが表示されている
単語の途中から選んでも、URLは単語の端まで広げて作る

コピーしたURLを新しいタブで開いた画面です。::target-text でハイライトの色をピンクにしています(MDN の ::target-text の説明)。選んだ「ラウザの機」ではなく、「ブラウザの機能」の全体がハイライトされています。

段落の中の「ブラウザの機能」の部分だけが、ピンクの背景と白い文字になっている
コピーしたURLを開くと、その文までスクロールしてハイライトされる
css
::target-text {
  background: #d85878;
  color: #fff;
}

本文を出すのが遅いページでは、ハイライトされなかった

データを取得してから本文を描くページを想定して、本文を setTimeout で遅らせて描くページを作り、#:~:text=ブラウザ(% の形にしたもの)で開いてみました。

本文を描くまでの時間 結果
0ミリ秒 ハイライトされた
300ミリ秒 ハイライトされた
1000ミリ秒 ハイライトされず、先頭のまま
3000ミリ秒 ハイライトされず、先頭のまま

本文が出たあとで待っても、あとからスクロールすることはありませんでした。表の時間は、この検証環境で試した値です。

一方、本文を renderToString でHTMLに書き出しておき、hydrate だけを1秒遅らせたページでは、ハイライトされました。JavaScript が動く前から、HTMLに本文が入っているためです。hydrate が終わったあとは、文を選ぶとボタンとURLが出ました。

ポイント

文へのリンクで開かれるページは、本文を最初のHTMLに入れておきます。検証環境では、本文を描くのが1秒以上遅れたページで、ハイライトされませんでした。

​Posted by 86