Reactで、入力した文章を原稿用紙のマス目に並べる方法
入力した文章を原稿用紙のマス目に並べるには、文章を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、スマートフォンの実機、ほかのフォント、印刷したときの見え方、本番用のビルドでの処理時間は試していません。

文章を1字ずつに分けて、行と枚にまとめる
マス目に並べる前の処理は、genko.ts にまとめました。文章を1字ずつに分け、20字ごとに行を作り、20行ごとに1枚にします。
// 文章を、原稿用紙のマス目に入れる形に分ける関数
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 として描きます。
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>
);
});
.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行目はその左に並びました。横書きに切り替えても、入力欄の文章はそのまま残り、マスの並びだけが変わります。

行の外に出す句読点は、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 のときと同じ向きのままでした。

画面より広いときは、マス目の要素の中でスクロールする
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枚目が見えています。
入力欄と組み合わせる
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>
);
}
.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 が違うので、マス目を半透明にしています。





