SVGでtextがぴったり収まるrectを描く

# はじめに
ReactでSVGを描いてます。今回は、SVGの要素である`<text>`（文字を描く要素）と、`<rect>`（矩形を描く要素）を使って、文字列がぴったり収まるハコを描くコンポーネントを作ったお話です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/a98c9e6a-4100-afd6-9f89-7a195262ddcb.png)

黄色のハコと文字の部分がそのコンポーネント。それ以外の灰色の部分や下の入力欄は、コンポーネントに与えるプロパティなどのコントローラーです。

実際に動かしてみる場合はこちらで。

https://small-pieces-sandbox.onrender.com/contents/svg_text_resize_box

ソースはこちら。

https://github.com/yo16/svg_text_resize_box




# 技術説明
本筋のコンポーネントの話しだけにします。コントローラーの部分は、そう難しくないので割愛。

このコンポーネントは、`<svg><RectContainText /></svg>`のように、`<svg>`要素の中で使う想定です。

## 入力props
```typescript: RectContainText.tsx(抜粋)
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
```typescript: RectContainText.tsx(抜粋)
    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`の処理だけで実現しています。
1. `useLayoutEffect`の中で、文字列を解析
1. `minWidth`～`maxWidth`に収まる文字列へ分解
1. 分解した各々を、`document.createElementNS()`で`<tspan>`として新たに要素を作成
1. 各々の`<tspan>`を、`<text>`へ`appendChild()`
1. 合わせて、`<rect>`の`width`、`height`属性を書き換える

ポイントは、**`useLayoutEffect`は描画の直前に呼び出されるのでそこで、`useRef`を使って、`<text>`の`innerHTML`と、`<rect>`の属性を書き換えていること**です。

試行錯誤している中で、どこかで`useState`のstateをセッターで変更し、そのstateを使ってJSXを描くということをいろいろ試したのですが、描画のタイミングと文字列の解析のタイミングが難しく、成立させられませんでした。描画しないと、ref.currentを得られないというところがキモで、描画が少なくとも２度走ることになりそうでした。

今回の場合だとその点がシンプルで、JSXを作って、描画する直前に`useLayoutEffect`が呼び出され、JSXを書き換え、描画という、１回の流れだと理解しています。

## 文字列を解析するところ
1. 文字列をスペースで区切って、`string[]`にする
1. `string`に１つ加えて、`<text>`に描画させてみて、`getBBox()`でサイズを得る
1. サイズによって、可否判定を行う

２つポイントがあって、１つは`getBBox()`の話し、もう１つは文字列の区切り。

### `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>`によって改行された文字列群として使っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/41f26864-eb50-c4a8-437a-409788541bbc.png)
（上は生成されたHTMLをchromeで見たもの）

### 文字列の解析
改行する位置を、スペースの位置としています。これは下記のようにスペースで区切って、`words`を１つずつ追加して、「`maxWidth`を超えている？入ってる？」と調べています。
```typescript
// textをスペース区切りで配列にする
const words: string[] = text.split(' ');
```
日本語の自然言語でやるとしたら、１文字ずつに分解する必要がありそうです。私は"."（ピリオド）で区切りたいという要件があるので、そういうことも、ここで調整します。

１文字ずつに分解するとしたら、追加してみて「超えてる？」と確認するループ回数が多くなるので、重くなることには注意しないといけないです。


# まとめ
解として大きな流れを得られれば、実装はそんなに難しい話ではないです。コロンブスの卵的な🥚

Reactは、いろんな道具があって（`useState`を使うことが多いけど、他にもたくさん）それらを組み合わせて、今回やりたいこと実現する方法を考えることがパズルのようで面白いです。フレームワークを使うというのはそういうことかもしれません。ただ、私の技術力不足で解を得られないことも多く、キーッ🤬となることもあるんですけど、だんだん仲良くなってきました😊

SVGに関しては、どうやらReactでSVGを描く人はあまり多くないようで、ネットで調べても多く出てこないし、ChatGPT先生に聞いても微妙な回答が多いです。今回は`useState`を使う先生の案に振り回され半日つぶしましたが、最終的には使ってません。
そんなこんなで今回の方法は、本職の"リアクター"でない私が、試行錯誤の末たどり着いた結果です。本来の使い方はこうすべき、とか、もっといい方法がある、とかあったら教えてください！:metal:

ちなみに"React"で"rect"を扱う処理は、タイプミスしやすいので注意です！

ではよきReact-rectライフを！:black_large_square: