Reactで、長いファイル名の真ん中を省略して拡張子を残す方法

​​React​CSS,Intl.Segmenter,Playwright,TypeScript

ファイル名を前と後ろの2つの span に分け、前の span だけに text-overflow: ellipsis を付けると、拡張子を残したまま真ん中が「…」になります。後ろの span は縮めない設定にしました。枠の幅を 343px、240px、170px にして試したところ、どの幅でも .docx や v2.pdf のような末尾は枠の中に残りました。「…」の後ろに空く隙間は、後ろの span を少し左へずらして詰めました。それでも 2px ほどの隙間は残ります。

<select> の選択肢のように、2つの span に分けられない場所には、文字数で真ん中を切る関数を使います。この記事では、その2つを React のコンポーネントと TypeScript の関数として作り、Chromium で表示を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、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 で変換し、開発用ビルドで動かしました。日本語は Noto Sans CJK JP で表示しています。コピーは、スクリプトで範囲を選んでから Ctrl+C を押して確かめたもので、マウスでドラッグして選ぶ操作は試していません。Firefox と Safari、スマートフォンのブラウザ、本番用ビルド、右から左に書く言語のファイル名、スクリーンリーダーでの読み上げも試していません。

幅343px、240px、170pxの3つの枠に、同じ5つのファイル名が並んでいる。幅が狭いほど、長いファイル名は、幅240pxでは「2026年度_…版_v3.docx」、170pxでは「20…版_v3.docx」のように真ん中が「…」になり、「…」のすぐ後ろに拡張子の側が続いている。「backup.tar.gz」と「memo.txt」はどの幅でも省略されていない
幅を変えたときの表示。拡張子と、その前の4文字は消えない

表示の幅に合わせて省くコンポーネント

ファイル名は、最後の点から後ろを拡張子として分けます。拡張子と、その前の4文字を後ろの span に入れ、残りを前の span に入れます。前の span には、ファイル名の頭から途中までが入ります。

文字数を数えるときは、Intl.Segmenter で見た目の1文字(書記素)ごとに分けます。黒猫の絵文字(🐈‍⬛)は、length では4、Array.from で分けても3つになりました。'写真🐈‍⬛'.slice(0, 3) は、絵文字の途中の \ud83d で切れてしまいます。

ts
// truncateMiddle.ts(前半)
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });

// 見た目の1文字(書記素)ごとに分ける
export const graphemes = (s: string) => Array.from(segmenter.segment(s), (x) => x.segment);

// 最後の点から後ろを拡張子とみなす。.bashrc のように点で始まる名前と、点で終わる名前は拡張子なし
export function splitExtension(name: string): [base: string, ext: string] {
  const dot = name.lastIndexOf('.');
  if (dot <= 0 || dot === name.length - 1) return [name, ''];
  return [name.slice(0, dot), name.slice(dot)];
}

splitExtension は、a.tar.gz を a.tar と .gz に分け、.bashrc と README は拡張子なしとして扱いました。.tar.gz の .tar は拡張子の側に入りませんが、拡張子の前の4文字として後ろの span に入るので、画面からは消えません。

コンポーネントは次のとおりです。

tsx
// FileName.tsx
import { useLayoutEffect, useRef, type ClipboardEvent } from 'react';
import { graphemes, splitExtension } from './truncateMiddle';
import './FileName.css';

type Props = {
  name: string;
  tail?: number; // 拡張子の前に、省略せずに残す文字数
};

// 2つの span の間に入る改行を除いてコピーする
function copyWithoutBreak(e: ClipboardEvent<HTMLSpanElement>) {
  const sel = window.getSelection();
  if (!sel || !e.currentTarget.contains(sel.anchorNode) || !e.currentTarget.contains(sel.focusNode)) return;
  e.preventDefault();
  e.clipboardData.setData('text/plain', sel.toString().replace('\n', ''));
}

// 前の span の「…」の後ろに余った幅(px)を求める
function measureGap(head: HTMLElement): number {
  const text = head.firstChild;
  if (!(text instanceof Text) || head.scrollWidth <= head.clientWidth) return 0; // 省略されていない
  const ctx = document.createElement('canvas').getContext('2d');
  if (!ctx) return 0;
  ctx.font = getComputedStyle(head).font;
  const ellipsis = ctx.measureText('…').width;
  const box = head.getBoundingClientRect();
  const range = document.createRange();
  let end = box.left; // 表示されている最後の文字の右端
  let offset = 0;
  for (const g of graphemes(text.data)) {
    range.setStart(text, offset);
    range.setEnd(text, offset + g.length);
    const right = range.getBoundingClientRect().right;
    if (right + ellipsis > box.right) break; // この文字は「…」と一緒には入らないので隠れる
    end = right;
    offset += g.length;
  }
  return Math.max(box.right - (end + ellipsis), 0);
}

export function FileName({ name, tail = 4 }: Props) {
  const [base, ext] = splitExtension(name);
  const chars = graphemes(base);
  const cut = Math.max(chars.length - tail, 0);
  const head = chars.slice(0, cut).join('');
  const end = chars.slice(cut).join('') + ext;

  const headRef = useRef<HTMLSpanElement>(null);
  const tailRef = useRef<HTMLSpanElement>(null);

  // 後ろの span を、余った幅だけ左へずらして「…」に寄せる
  useLayoutEffect(() => {
    const headEl = headRef.current;
    const tailEl = tailRef.current;
    if (!headEl || !tailEl) return;
    const update = () => {
      const gap = measureGap(headEl);
      tailEl.style.transform = gap > 0 ? `translateX(${-gap}px)` : '';
    };
    const observer = new ResizeObserver(update);
    observer.observe(headEl);
    document.fonts.ready.then(update);
    return () => observer.disconnect();
  }, [head, end]);

  return (
    <span className="filename" title={name} onCopy={copyWithoutBreak}>
      <span ref={headRef} className="filename-head">{head}</span>
      <span ref={tailRef} className="filename-tail">{end}</span>
    </span>
  );
}
css
/* FileName.css */
.filename {
  display: flex;
  min-width: 0; /* 親が flex のときに、縮められるようにする */
  white-space: pre; /* 折り返さず、続いた空白もそのまま出す */
}
.filename-head {
  overflow: hidden;
  text-overflow: ellipsis;
}
.filename-tail {
  flex-shrink: 0; /* 拡張子の側は縮めない */
}

onCopy の copyWithoutBreak と、useLayoutEffect の中の処理は、後の節で説明します。

text-overflow は、それだけではあふれを起こしません。overflow: hidden と、折り返さない white-space を合わせて指定します。ここでは、外側の .filename に white-space: pre を指定して、前の span にも効かせています。

MDN の min-width の説明によると、flex の中の要素は、初期値の min-width: auto のままだと、中身が収まる最小の幅(min-content)より狭くなりません。ただし、スクロールコンテナ(overflow: hidden を付けた要素もこれに当たります)は、最小の幅が 0 になります。そのため、前の span だけが縮み、縮んだ分が「…」になります。

Playwright で枠の幅を 343px、240px、170px にして調べました。後ろの span の幅はどの幅でも同じ(版_v3.docx は 82px)で、枠からはみ出しませんでした。前の span は 211px、108px、38px と縮み、どれも「…」で省略されていました。memo.txt のように、拡張子を除いた部分が4文字以下の名前は、前の span が空になり、全体が後ろの span に入ります。

title 属性には、省略しない名前を入れました。ただし、MDN の title 属性の説明には、タッチ操作だけの端末を使う人や、キーボードで操作する人にとって、title の使用には大きな問題があると書かれています。全体を必ず見せたい画面では、詳細の表示など、別の手段も用意してください。

親が flex のときは、包んだ要素にも min-width: 0 を付ける

アイコンとファイル名を display: flex で横に並べた行に置くと、.filename 自身が flex の中の要素になります。.filename の min-width: 0 は、このときのための指定です。

幅 240px の枠で、.filename の min-width を初期値の auto に戻すと、ファイル名は 379px に広がり、行の外にはみ出しました。「…」も出ませんでした。min-width: 0 があれば、ファイル名は 190px に収まりました。

ファイル名を <a> で包んでリンクにしたときも、同じことが起きます。flex の中の要素になるのは <a> のほうです。.filename に min-width: 0 があっても、<a> とファイル名がどちらも 379px に広がり、行からはみ出しました。<a style={{ minWidth: 0 }}> のように、包んだ要素にも min-width: 0 を付けると収まりました。

tsx
<div className="row">
  <span className="icon">📄</span>
  <a href="#" style={{ minWidth: 0 }}>
    <FileName name={name} />
  </a>
</div>

行の CSS は次のとおりです。

css
.row { display: flex; gap: 6px; align-items: center; }
.icon { flex-shrink: 0; }

white-space を nowrap ではなく pre にしたのは、ファイル名の中の空白を残すためです。a b.txt(空白2つ)を nowrap で表示すると、空白が1つにまとまり、innerText も a b.txt になりました。pre では a b.txt のままでした。MDN の white-space の説明にも、pre では続いた空白が保たれるとあります。

コピーすると、2つの span の間に改行が入る

2つの span を flex で並べたこの形では、Chromium で、onCopy を付けずにファイル名の全体を選んで Ctrl+C でコピーすると、クリップボードには 2026年度_営業会議_議事録_第12回_最終 と 版_v3.docx の間に改行が入りました。innerText も同じでした。

copy イベントでは、clipboardData.setData() で中身を差し替え、preventDefault() で元のコピーを止められます。copyWithoutBreak は、選んだ範囲の始まりと終わりがどちらも同じファイル名の中にあるときだけ、その改行を取り除いてコピーします。

onCopy を付けると、全体を選んだときは改行のない 2026年度_営業会議_議事録_第12回_最終版_v3.docx になりました。前の span の6文字目から、後ろの span の3文字目まで選んだときは、度_営業会議_議事録_第12回_最終版_v になりました。

複数のファイル名にまたがって選ぶと、copyWithoutBreak は何もしません。backup.tar.gz と memo.txt の2行を選んでコピーすると、backup と .tar.gz の間にも改行が入り、アイコンの 📄 も一緒にコピーされました。一覧をまとめてコピーできる画面では、この改行が残ることに気をつけてください。

「…」の後ろの隙間は、後ろの span をずらして詰める

FileName の useLayoutEffect の中の処理は、「…」と後ろの span の間に空く隙間を詰めるためのものです。

隙間ができるのは、「…」を付けるときに、文字が1文字ずつ丸ごと隠れるからです。CSS の仕様(CSS Overflow Module Level 3)の text-overflow の定義では、ellipsis のときは、「…」が入るように行の終わりの文字を隠し、「…」は残った文字のすぐ後ろに置くとされています。仕様では、ここでいう文字は書記素(grapheme cluster)のことだと書かれています。一方、前の span の幅は flex で決まるので、文字が隠れても変わりません。そのため、隠れた文字の分の余りが、「…」と後ろの span の間に残ります。

検証環境で、詰める処理を入れる前の形を調べました。幅 343px の枠では、前の span に 2026年度_営業会議_議事 と「…」が表示され、次の「録」は「…」と一緒には入らないため隠れていました。「…」の後ろの余りは 13.3px でした。表示される文字は枠の幅によって変わるので、余りの大きさも枠の幅によって変わります。

そこで、余りの幅を JavaScript で求め、後ろの span をその分だけ transform: translateX() で左へずらしました。measureGap は、Range で1文字ずつ右端の位置を測り、「…」の幅(canvas の measureText で測ったもの)を足しても前の span の右端を越えない、最後の文字を探します。その文字の右端に「…」の幅を足した位置から、前の span の右端までが余りです。ずらす前とずらしたあとで、前の span の幅(211px、108px、38px)と、表示される文字は変わりませんでした。その代わり、空いた場所は行の右の端に移ります。

ResizeObserver で前の span の大きさの変化を見ているので、表示したあとに枠の幅が変わっても詰め直されます。343px の枠を表示後に 200px にすると、表示される文字は 2026年 になり、余りは 0.2px になりました。後ろの span もその分だけずらし直され、343px に戻すと、ずらす幅も 13.3px に戻りました。

2026年度_営業会議_議事録_第12回_最終版_v3.docx の行で、隙間を測った結果です。「見た目の隙間」は、スクリーンショットの画素を読み、「…」の点の右端から 版 の左端までを測ったものです。画素は 0.5px 単位で読んでいます。

枠の幅 前の span に表示された文字 「…」の後ろの余り(ずらした幅) 見た目の隙間(詰める前) 見た目の隙間(詰めた後)
343px 2026年度_営業会議_議事 13.3px 15.9px 2.5px
240px 2026年度_ 15.3px 17.4px 2.0px
170px 20 4.0px 6.4px 2.5px

詰めたあとも、2.0〜2.5px の隙間は残りました。これは「…」と 版 の字形に初めからある余白で、余りとは別のものです。16px の大きさで表示した Noto Sans CJK JP の「…」は幅が 16px あり、3つの点の右には 1.0〜1.6px の余白がありました。この余白は「…」の字の幅の中にあるので、後ろの span を「…」の幅の右端に寄せるこの方法では詰められません。

文字数で切る関数は、select の選択肢に使う

MDN の option 要素の説明によると、従来の <select> では、<option> の中に書けるのは文字列だけです(カスタマイズ可能な select では、ほかの要素も置けると書かれています)。そのため、2つの span に分ける方法は使えません。そこで、文字数を決めて真ん中を切る関数も作りました。

ts
// truncateMiddle.ts(後半)
// max 文字(省略記号を含む)に収まるように、拡張子と、その前の tail 文字を残して真ん中を省く
export function truncateMiddle(name: string, max: number, tail = 4, ellipsis = '…'): string {
  const all = graphemes(name);
  if (all.length <= max) return name;

  const [base, ext] = splitExtension(name);
  const baseChars = graphemes(base);
  const room = max - graphemes(ext).length - 1; // 拡張子と省略記号を除いて使える文字数

  if (room < 2) {
    // 拡張子が長すぎるときは、拡張子にこだわらず全体の真ん中を省く
    const head = Math.ceil((max - 1) / 2);
    return all.slice(0, head).join('') + ellipsis + all.slice(all.length - (max - 1 - head)).join('');
  }

  const tailCount = Math.min(tail, Math.floor(room / 2));
  const headCount = room - tailCount;
  return (
    baseChars.slice(0, headCount).join('') +
    ellipsis +
    baseChars.slice(baseChars.length - tailCount).join('') +
    ext
  );
}

Node.js で試した結果です。

元の名前 最大の文字数 結果
2026年度_営業会議_議事録_第12回_最終版_v3.docx 20 2026年度_営業会…版_v3.docx
IMG_20261108_093015_panorama_edited.jpeg 20 IMG_202611…ited.jpeg
quarterly-report-2026-q3-final-v2.pdf 20 quarterly-r…l-v2.pdf
backup-2026-11-08-full.tar.gz 16 backup-2….tar.gz
memo.txt 20 memo.txt
.bashrc_backup_2026_11_08_old 16 .bashrc_bac…_old
とても長いファイル名ですが拡張子はありません 12 とても長いファ…りません
写真 の後に黒猫の絵文字を8つ並べた .png 10 写真🐈‍⬛…🐈‍⬛🐈‍⬛.png
data.verylongextensionname 12 data.v…nname

絵文字も途中で切れず、結果はすべて指定した文字数以内でした。拡張子が長すぎて本体の文字が残らないときは、拡張子を含めた全体の真ん中を省きます。

拡張子の前に残す文字数は、tail で変えられます。末尾の日付を全部見せたいときは、tail を多めにします。30文字に切ると、次のようになりました。

元の名前 tail が 4 tail が 12
quarterly-report_final_2026-11-08.pdf quarterly-report_fina…1-08.pdf quarterly-rep…l_2026-11-08.pdf
quarterly-report_draft_2026-11-08.pdf quarterly-report_draf…1-08.pdf quarterly-rep…t_2026-11-08.pdf

tail が 4 では、日付は 1-08 しか残りません。12 にすると 2026-11-08 が全部残りました。そのぶん前が短くなり、final と draft の違いは、l と t の1文字だけになりました。

残す文字数は、使える文字数の半分までに抑えています(Math.min(tail, Math.floor(room / 2)) の部分)。20文字に切って .pdf を残すときは、使える文字数が 20 − 4 − 1(「…」)= 15 なので、7文字までです。20文字に切る quarterly-report-2026-q3-final-v2.pdf では、tail を 8 にしても 20 にしても、残ったのは inal-v2 の7文字で、結果は quarterl…inal-v2.pdf でした。

選択肢には切った名前を、value には元の名前を入れます。

tsx
<select value={value} onChange={(e) => setValue(e.target.value)}>
  {files.map((f) => (
    <option key={f} value={f}>
      {truncateMiddle(f, 20)}
    </option>
  ))}
</select>

Chromium で quarterly-report-2026-q3-final-v2.pdf を選ぶと、value は省略していない元の名前になりました。

文字数は、表示の幅とは一致しません。同じ20文字に切った名前でも、16px で表示した幅は、日本語の多い あいうえおかきくけこ…_最終版.docx が 273.3px、英字の abcdefghij…inal.docx が 161.8px でした。幅に収めたい一覧には、最初の節の、CSS で省く方法が向いています。

​Posted by 86