Reactで、入力した文章を原稿用紙のマス目に並べる方法

​​React​CSS,Intl,Playwright,TypeScript

入力した文章を原稿用紙のマス目に並べるには、文章を1字ずつに分けて、20字ごとに行、20行ごとに1枚へまとめ、それをマス目の要素として描きます。縦書きにするときは、マス目を囲む要素に writing-mode: vertical-rl を指定するだけで、マスは右上から下へ、行は右から左へ並びます。

ここでは、入力欄の文章をそのまま400字詰めの原稿用紙に並べる GenkoEditor というコンポーネントを作りました。縦書きと横書きを切り替えられ、400字を超えると2枚目、3枚目と増えていきます。

補足

検証環境は 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 で、フォントは IPAゴシック です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作し、マスの位置と大きさを調べました。Firefox と Safari、スマートフォンの実機、ほかのフォント、印刷したときの見え方、本番用のビルドでの処理時間は試していません。

縦書きの原稿用紙。右の行から順に「原稿用紙のマス目に、文章を一字ずつ入れ」「ていきます。一行は二十字です。」「「ここで行が終わります。」」「ABCやcm、ラーメン、ちょっと待って」と1マスに1字ずつ入り、4行目の最後のマスの下、枠の外に「。」が出ている
見本の文章を縦書きで並べたところ。4行目の最後の「。」は、マスの外に出している

文章を1字ずつに分けて、行と枚にまとめる

マス目に並べる前の処理は、genko.ts にまとめました。文章を1字ずつに分け、20字ごとに行を作り、20行ごとに1枚にします。

ts
// 文章を、原稿用紙のマス目に入れる形に分ける関数

export type Cell = { ch: string; hang?: string };
export type Line = Cell[];
export type Sheet = Line[];

// 行の頭に置かず、前の行の最後のマスに入れる文字
const HANG = new Set(['、', '。', ',', '.', '」', '』', ')']);

const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });

// 見た目の1文字(書記素)ごとに分ける
export function splitGraphemes(text: string): string[] {
  return Array.from(segmenter.segment(text), (s) => s.segment);
}

// 1行 cols マスで、行に分ける。改行のあとは次の行から書く
export function toLines(text: string, cols: number): Line[] {
  const lines: Line[] = [];
  for (const paragraph of text.replace(/\r\n?/g, '\n').split('\n')) {
    let line: Line = [];
    for (const ch of splitGraphemes(paragraph)) {
      if (line.length === cols) {
        const last = line[cols - 1];
        // 行の頭に来る句読点と閉じかっこは、前の行の最後のマスに入れる(「。」」のように2文字まで)
        const hang = last.hang ?? '';
        if (HANG.has(ch) && hang.length < 2) {
          last.hang = hang + ch;
          continue;
        }
        lines.push(line);
        line = [];
      }
      line.push({ ch });
    }
    lines.push(line);
  }
  return lines;
}

// rows 行ごとに、1枚の原稿用紙にまとめる
export function toSheets(text: string, cols: number, rows: number): Sheet[] {
  const lines = toLines(text, cols);
  const sheets: Sheet[] = [];
  for (let i = 0; i < lines.length; i += rows) {
    sheets.push(lines.slice(i, i + rows));
  }
  return sheets;
}

1マスに入れる「1字」は Intl.Segmenter で決める

文字列を length で数えたり Array.from() で分けたりすると、見た目は1字なのに2マス以上を使う文字が出ます。そこで Intl.Segmenter を granularity: 'grapheme' で使い、見た目の1文字(書記素)ごとに分けました。Node.js で数を比べた結果は次のとおりです。

文字 length Array.from() splitGraphemes()
猫 1 1 1
か゚(か + 半濁点の結合文字) 2 2 1
👍🏽(肌の色を指定した絵文字) 4 2 1
👨‍👩‍👧(家族の絵文字) 8 5 1
🇯🇵(国旗) 4 2 1
葛󠄀(異体字セレクタ付きの漢字) 3 2 1

表のとおり、Array.from() では、肌の色を指定した絵文字や国旗のように、組み合わせでできた文字が2つ以上に分かれました。家族の絵文字なら5マスに分かれてしまいます。splitGraphemes() では、表の文字はどれも1マスに収まりました。

行の頭に来た句読点は、前の行の最後のマスに入れる

20マスが埋まった直後の文字が「、」「。」や閉じかっこのときは、次の行の頭に置かず、前の行の最後のマスに hang として持たせることにしました。1行5マスにして試すと、次のように分かれます。

文章 1行目 2行目
あいうえおかき あいうえお かき
あいうえお。かき あいうえお+「。」 かき
あいうえお。」かき あいうえお+「。」」 かき
あいうえ。おか あいうえ。 おか

「。」」のように句点と閉じかっこが続いたときも、2文字までは前の行に入れます。3文字目からは、次の行の頭に置きました。「あいうえ。おか」のように、句点が行の途中の5マス目に収まるときは、そのまま1マスを使います。

改行は、そこで行を終わらせます。改行が2つ続くと、何も書かない行が1行できます。Windows の改行(\r\n)も、最初に \n にそろえているので1つの改行として数えました。枚数は、改行のない文章なら400字で1枚、401字で2枚になりました。何も入っていないときも、白紙の1枚を出します。

マス目を描くコンポーネント

GenkoPaper は、toSheets() の結果を、1枚ごとに20行×20マスの要素として描きます。文字のないマスも、空の span として描きます。

tsx
import { memo } from 'react';
import { toSheets } from './genko';

type Props = {
  text: string;
  cols?: number;
  rows?: number;
  vertical?: boolean;
};

// text などが変わらないときは、描き直さない
export const GenkoPaper = memo(function GenkoPaper({ text, cols = 20, rows = 20, vertical = true }: Props) {
  const sheets = toSheets(text, cols, rows);

  return (
    <div className={vertical ? 'genko vertical' : 'genko'}>
      {sheets.map((sheet, i) => (
        <div className="sheet" key={i}>
          {Array.from({ length: rows }, (_, r) => (
            <div className="line" key={r}>
              {Array.from({ length: cols }, (_, c) => {
                const cell = sheet[r]?.[c];
                return (
                  <span className="cell" key={c}>
                    {cell?.ch}
                    {cell?.hang && <span className="hang">{cell.hang}</span>}
                  </span>
                );
              })}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
});
css
.genko {
  --cell: 26px;
  --rule: #00c8e8;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 24px;
  /* 画面より広いときは、この要素の中でスクロールする */
  max-width: 100%;
  overflow-x: auto;
}
.genko.vertical {
  writing-mode: vertical-rl;
}
.sheet {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px;
  /* 行の外にはみ出す句読点のぶん、行の終わりの側をあけておく */
  padding-inline-end: calc(var(--cell) * 2);
  border: 1px solid var(--rule);
  background: #fff;
}
.line {
  display: flex;
  border: 1px solid var(--rule);
}
.cell {
  position: relative;
  display: grid;
  place-items: center;
  inline-size: var(--cell);
  block-size: var(--cell);
  font-size: calc(var(--cell) * 0.72);
  line-height: 1;
  /* 縦書きで、半角の英数字を横に倒さず立てる */
  text-orientation: upright;
}
.cell + .cell {
  border-inline-start: 1px solid var(--rule);
}
.hang {
  position: absolute;
  inset-inline-start: 100%;
  inset-block-start: 0;
  block-size: var(--cell);
  line-height: var(--cell);
  white-space: nowrap;
}

縦書きと横書きを、同じ CSS で切り替える

マスや行の大きさと位置には、width や left のような上下左右の向きを決めたプロパティを使わず、inline-size、border-inline-start、inset-inline-start のように、文字の進む向きを基準にした書き方をしています。flex-direction の column も、行が積み重なる向きに並べる指定です。そのため、writing-mode を変えるだけで、マスの並ぶ向きがそろって変わります。

幅 800px の画面で、マスの位置を調べた結果です(座標は要素の左上)。

調べたもの 縦書き(vertical-rl) 横書き(horizontal-tb)
1行目の1マス目 (638, 223) (30, 223)
1行目の2マス目 (638, 249):1マス目の下 (56, 223):1マス目の右
2行目の1マス目 (606, 223):1行目の左 (30, 255):1行目の下
1枚の大きさ 幅 662px、高さ 607px 幅 607px、高さ 662px

縦書きでは1行目が右端に来て、2行目はその左に並びました。横書きに切り替えても、入力欄の文章はそのまま残り、マスの並びだけが変わります。

横書きの原稿用紙。上の行から「原稿用紙のマス目に、文章を一字ずつ入れ」「ていきます。一行は二十字です。」「「ここで行が終わります。」」「ABCやcm、ラーメン、ちょっと待って」と並び、4行目の右端の枠の外に「。」が出ている
同じ文章を横書きに切り替えたところ

行の外に出す句読点は、inset-inline-start: 100% で置く

hang の句読点は、最後のマスの中に position: absolute の span として入れました。inset-inline-start を 100% にすると、マスの終わりのすぐ外に置かれます。縦書きでは、4行目の最後の「て」のマス(y=735、高さ 27px)のすぐ下の y=762 に「。」が出ました。横書きでは、同じ「て」のマス(x=542、幅 27px)のすぐ右の x=569 です。どちらも、行の枠線のすぐ外です。

はみ出した句読点が1枚の枠に重ならないように、.sheet の padding-inline-end を2マスぶん取っています。縦書きでは下の余白、横書きでは右の余白が広くなります。

縦書きの半角英字は、text-orientation: upright で立てる

検証環境(Chromium、IPAゴシック)では、句読点や長音のための指定を書かなくても、縦書きの「、」「。」はマスの右上に、小さい「ょ」は右上寄りに置かれ、長音の「ー」は縦棒の形になりました。

半角の英字は、text-orientation が既定の mixed のままだと、右に90°回して置かれます。マスの中で文字が横に寝てしまうので、.cell に text-orientation: upright を付けて立てました。かぎかっこと長音は、upright にしても mixed のときと同じ向きのままでした。

マスを大きくした縦書きの6マスを、左右に並べた画像。左は upright で、A・c・m が立っている。右は mixed で、かぎかっこと長音は左と同じだが、「A」「c」「m」は右に90°回って横向きになっている
始めかぎかっこ・A・長音・終わりかぎかっこ・c・m の6マスを、upright(左)と mixed(右)で並べたところ

画面より広いときは、マス目の要素の中でスクロールする

20行の原稿用紙は、幅が 662px あります。スマートフォンの幅では収まらないので、.genko に max-width: 100% と overflow-x: auto を付け、この要素の中で横にスクロールさせています。

幅 360px の画面で試したところ、スクロールの枠は幅 328px、中身は 662px になりました。最初の表示(scrollLeft が 0)で、1行目の1マス目は x=304〜330 にあり、文章の書き出しが見えています。縦書きの要素そのものをスクロールの枠にしたところ、Chromium ではスクロールが右端から始まりました。

比べるため、.genko ははみ出させたまま、その外側の横書きの div でスクロールさせると、1行目の1マス目は x=638 になりました。枠の幅は 328px なので、書き出しの行は枠の外にあり、最初の表示では見えません。

もう1つ、.genko を CSS Grid の中に置くときは、列の幅を minmax(0, 1fr) にしています。比べるため .genko-editor の grid-template-columns を外すと、幅 360px の画面でも、外側の列が中のマス目に合わせて 662px に広がり、max-width: 100% が効きませんでした。

400字を超えて2枚になると、縦書きでは2枚目が1枚目の左に並びました。幅 800px の画面で、1枚目は x=122 から、2枚目は x=−564 からで、最初の表示では1枚目が見えています。

入力欄と組み合わせる

tsx
import { useDeferredValue, useId, useState } from 'react';
import { GenkoPaper } from './GenkoPaper';
import { toSheets } from './genko';

export function GenkoEditor({ initial = '' }: { initial?: string }) {
  const id = useId();
  const [text, setText] = useState(initial);
  const [vertical, setVertical] = useState(true);
  // マス目は、入力欄より後回しにして描き直す
  const deferredText = useDeferredValue(text);
  const count = toSheets(deferredText, 20, 20).length;

  return (
    <div className="genko-editor">
      <label htmlFor={`${id}-text`}>文章</label>
      <textarea id={`${id}-text`} rows={5} value={text} onChange={(e) => setText(e.target.value)} />
      <label>
        <input type="checkbox" checked={vertical} onChange={(e) => setVertical(e.target.checked)} />
        縦書き
      </label>
      <p aria-live="polite">400字詰めで{count}枚</p>
      <div className={text !== deferredText ? 'stale' : undefined}>
        <GenkoPaper text={deferredText} vertical={vertical} />
      </div>
    </div>
  );
}
css
.genko-editor {
  display: grid;
  /* 列の幅を、中のマス目の幅に合わせて広げない */
  grid-template-columns: minmax(0, 1fr);
  gap: 6px;
}
.genko-editor .stale {
  opacity: 0.5;
}
.genko-editor textarea {
  font: inherit;
}

state に持つのは、入力欄の文章と、縦書きかどうかの2つです。行や枚への分け方は state に持たず、描くたびに文章から作ります。

長い文章でも入力が重くならないように、useDeferredValue を使う

マス目は1枚で400個の要素になり、1万字なら25枚、1万マスです。比べるため、text をそのまま GenkoPaper に渡す版も作りました。この版で、1万字を入れた状態で末尾に1字ずつ5回打ち、Event Timing API で keydown の duration を測ると、304ms、272ms、264ms、312ms、288ms でした。duration はイベントから次に画面が描かれるまでの時間なので、打った字が入力欄に出るまでにも、これだけかかっています。数値は、開発用のビルドを StrictMode で動かしたときのもので、8ms 単位に丸められています。

そこで、マス目には useDeferredValue で後回しにした deferredText を渡し、GenkoPaper を memo で包みました。公式リファレンスの説明では、useDeferredValue は、まず古い値のまま描き直し、そのあと新しい値での描き直しをバックグラウンドで行います。このバックグラウンドの描き直しは、次の入力が来ると中断されます。この効果を得るには、マス目のコンポーネントを memo で包み、props が変わらないときは描き直さないようにしておく必要があります。

この形にして同じように測ると、56ms、40ms、40ms、40ms、32ms になりました。打ち終わったあと、マス目の最後の行には打った5字が入っていました。マス目が入力に追いつくまでのあいだは、text と deferredText が違うので、マス目を半透明にしています。

​Posted by 86