はじめに
ReactでSVGを描いてます。今回は、SVGの要素である<text>
(文字を描く要素)と、<rect>
(矩形を描く要素)を使って、文字列がぴったり収まるハコを描くコンポーネントを作ったお話です。
黄色のハコと文字の部分がそのコンポーネント。それ以外の灰色の部分や下の入力欄は、コンポーネントに与えるプロパティなどのコントローラーです。
実際に動かしてみる場合はこちらで。
ソースはこちら。
技術説明
本筋のコンポーネントの話しだけにします。コントローラーの部分は、そう難しくないので割愛。
このコンポーネントは、<svg><RectContainText /></svg>
のように、<svg>
要素の中で使う想定です。
入力props
interface RectContainTextProps {
text: string;
fontSize: number;
x: number;
y: number;
minWidth: number;
maxWidth: number;
// サイズの変更後、フィードバックする
onSetSize: (width: number, height: number) => void;
}
text
、fontSize
はいいとして。
SVGの中に置く要素ということで、x
、y
をストレートに渡しています。これは、<rect>
でも<text>
でも必須のプロパティですが、特に<text>
のy
(縦方向)については、baselineという位置を示すもので、結構厄介です。(次に説明する「出力するJSX」で対応してます。)ここでのプロパティの意味は、<rect>
の左上の座標という意味です。
minWidth
は、文字列の長さが短い場合でも、矩形は最低限この幅にして、という指示です。
maxWidth
は、文字列の長さが長い場合は文字列を折り返して、矩形は最大この幅にして、という指示です。折り返す箇所は、今は"スペース"としているので、英語っぽい感じです。日本語の場合は無条件に文字数で判断した方がよいかも。この処理は後の「文字列の解析」で説明します。
onSetSize
は、私が、作成したサイズを呼び出し元で利用したいので、追加してあります。描画には直接関係ないけど、呼び出すタイミングが難しいかも。
出力するJSX
return (
<g>
<rect
ref={rectRef}
x={x}
y={y}
width={10}
height={10}
fill="#f1dfa2"
stroke="#a79a70"
strokeWidth={1}
/>
<text
ref={textRef}
x={x}
y={y}
dominantBaseline={"text-after-edge"}
fontSize={fontSize}
></text>
</g>
);
useRef
を使い、<rect>
と<text>
それぞれに、ref
を設定。これは次の「大きな流れ」で説明します。
ここでのちょっとしたポイントは、<text>
のdominantBaseline={"text-after-edge"}
です。これを指定することで、y
はバウンダリボックスの上端の座標値を示すように変更されます。
デフォルトの位置は、文字の下端。L
でいう横線の位置あたりです。g
は、その位置から下にはみ出ることになります。英語っぽい仕様ですね。この仕様によって、一連の文章の中(インライン)で、強調したい部分だけフォントサイズを大きくしても違和感なく表示することを目的としているんだと思います。
大きな流れ(ここがポイント)
ReactのuseLayoutEffect
の処理だけで実現しています。
-
useLayoutEffect
の中で、文字列を解析 -
minWidth
~maxWidth
に収まる文字列へ分解 - 分解した各々を、
document.createElementNS()
で<tspan>
として新たに要素を作成 - 各々の
<tspan>
を、<text>
へappendChild()
- 合わせて、
<rect>
のwidth
、height
属性を書き換える
ポイントは、useLayoutEffect
は描画の直前に呼び出されるのでそこで、useRef
を使って、<text>
のinnerHTML
と、<rect>
の属性を書き換えていることです。
試行錯誤している中で、どこかでuseState
のstateをセッターで変更し、そのstateを使ってJSXを描くということをいろいろ試したのですが、描画のタイミングと文字列の解析のタイミングが難しく、成立させられませんでした。描画しないと、ref.currentを得られないというところがキモで、描画が少なくとも2度走ることになりそうでした。
今回の場合だとその点がシンプルで、JSXを作って、描画する直前にuseLayoutEffect
が呼び出され、JSXを書き換え、描画という、1回の流れだと理解しています。
文字列を解析するところ
- 文字列をスペースで区切って、
string[]
にする -
string
に1つ加えて、<text>
に描画させてみて、getBBox()
でサイズを得る - サイズによって、可否判定を行う
2つポイントがあって、1つはgetBBox()
の話し、もう1つは文字列の区切り。
getBBox()
の使い方
<text>
のサイズはgetBBox()
で得ており、これは実際にブラウザに描かせてみることでしか得られないという縛りがあるので、こんな処理です。refが生きていないとこの方法ができないです。
document
にappendChild()
していないと、ダメです。例えば、createElementNS("http://www.w3.org/2000/svg", "tspan")
で作った状態のままの<tspan>
で、textContent
に文字列を突っ込んでも、getBBox()
は得られない。
styleでdisplay:none
の状態でも得られないという、もう本当に描画されているところから取り出すという感じです。
これはReactに限らず、素のJavaScriptでも同じなので、気を付けないといけない。
今回のコンポーネントでは、<text>
のtextContent
へ試しに入れてサイズを確認し、最終的にはtextContent
は消し、<text>
の子要素として<tspan>
を作成していく方法にしてあります。
ちなみに<tspan>
は、文字に修飾する文字、ふりがなとかべき乗とかシグマとか、そういうところで使うためのタグだと思いますが、今回は、<text>
によるメインの文字列はなしで、<tspan>
によって改行された文字列群として使っています。
文字列の解析
改行する位置を、スペースの位置としています。これは下記のようにスペースで区切って、words
を1つずつ追加して、「maxWidth
を超えている?入ってる?」と調べています。
// textをスペース区切りで配列にする
const words: string[] = text.split(' ');
日本語の自然言語でやるとしたら、1文字ずつに分解する必要がありそうです。私は"."(ピリオド)で区切りたいという要件があるので、そういうことも、ここで調整します。
1文字ずつに分解するとしたら、追加してみて「超えてる?」と確認するループ回数が多くなるので、重くなることには注意しないといけないです。
まとめ
解として大きな流れを得られれば、実装はそんなに難しい話ではないです。コロンブスの卵的な🥚
Reactは、いろんな道具があって(useState
を使うことが多いけど、他にもたくさん)それらを組み合わせて、今回やりたいこと実現する方法を考えることがパズルのようで面白いです。フレームワークを使うというのはそういうことかもしれません。ただ、私の技術力不足で解を得られないことも多く、キーッ🤬となることもあるんですけど、だんだん仲良くなってきました😊
SVGに関しては、どうやらReactでSVGを描く人はあまり多くないようで、ネットで調べても多く出てこないし、ChatGPT先生に聞いても微妙な回答が多いです。今回はuseState
を使う先生の案に振り回され半日つぶしましたが、最終的には使ってません。
そんなこんなで今回の方法は、本職の"リアクター"でない私が、試行錯誤の末たどり着いた結果です。本来の使い方はこうすべき、とか、もっといい方法がある、とかあったら教えてください!
ちなみに"React"で"rect"を扱う処理は、タイプミスしやすいので注意です!
ではよきReact-rectライフを!