テキスト入力をかっこよく自動検閲してみたい

## 成果物

![書を.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/d0d8fa3b-4ab2-a06f-5362-9ef47016a6b6.gif)


## 経緯
『駟も舌に及ばず』
>いったん口外した言葉は、4頭立ての馬車で追いかけても取り返すことはできないことをいう。言葉は慎むべきであることのたとえ。（引用：https://imidas.jp/proverb/detail/X-02-C-12-7-0005.html ）

近年、モーショングラフィックスを音楽ライブやMV, CMなど様々なところで目にする機会が増えました。

"遊び心の塊"みたいなイメージで大好きで、結婚式でもちょっとした作品をAfter Effectで作りましたが、今回はReactで作ってみました。

本開発では[Claude Haiku](https://claude.ai)に初期段階の開発は任せて、その後自分の手で調整する流れを取りましたのでその流れを記事にしました。

## 何を作るか
Webアプリで作る場合はリアルタイムのインプットに応じてエフェクトをかけられる点が違いを出せる点だと思いました。

そのため、テキストに入力される文字に対してエフェクトをかける一つのアイデアとして **「即時検閲アプリ」** を作ることにしました。

テキスト送信後に検閲対象文字が伏字になるものは見たことがありますが、それを入力時にかっこよく◼️◼️◼️◼️◼️...!!

## さっそく設計
◻︎理想の設計
![Group 4 (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/dabf5b75-7354-f557-ab8d-59b2e3595e94.png)

1. 枠のサイズが同じ①テキスト入力レイヤーと②マスクレイヤーの2つを用意する
2. 2つのレイヤーを重ねる
3. ①の入力内の検閲対象文字に応じて、②に対してマスクを表示する

ただ、理想とは異なりまして少し手を...抜きました。

◻︎実際の設計
![Group 4 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/a53237f3-f5e3-9dbb-57d6-f597876ca7bb.png)

1. 枠のサイズが同じ①テキスト入力レイヤーと②マスクレイヤーの2つを用意する
2. 2つのレイヤーを重ねる
3. ①の入力に応じて②にも同じテキスト内容を表示する
4. 検閲対象の文字が現れた場合に②側の対象の文字列をマスクに置き換えをする

◻︎なぜそうしたか
①で入力したテキストを②に反映させていることから同じ文字が重なっている状態になっています。そのおかげで検閲対象の文字の開始地点の取得が簡単になりました。

## 実装
まず、Claude 3.5 Haikuを用いてAIと対話をしながらコードを生成してもらいます。

◻︎「文字を検閲して、部分的に文字をマスクするアプリ」と依頼
![スクリーンショット 2024-11-28 18.53.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/9ed16965-d18c-1023-aeee-100236990f99.png)


◻︎やり取りを続けることでイメージに近づきました。
![スクリーンショット 2024-11-28 18.51.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/2038d00a-c1d3-d98e-20f1-c25fbc12efe6.png)


ここから少し手を加えて完成形に至ります。
まずは、検閲対象の文字をマスクに置き換える処理です。

``` typescript
// 検閲対象の文字列の配列
const wordsToCheck = [
  "いい天気"
];

const censorText = useCallback((text: string) => {
  if (!text) return "";

  // 入力テキストを行ごとに分割
  const lines = text.split("\n");
  
  // textareaのスタイルを取得
  const computedStyle = window.getComputedStyle(
    document.querySelector("textarea") || document.body
  );

  // フォント情報の構築
  const font = `${computedStyle.fontSize} ${computedStyle.fontFamily}`;

  // 検閲処理を実行
  const censoredText = wordsToCheck.reduce((censoredLine, word) => {
      const regex = new RegExp(`(${word})`, "g");
      return censoredLine.replace(regex, (match) => {
        const width = measureTextWidth(match, font);
        return `<span class="censored-block" style="width: ${width}px;"></span>`;
      });
    }, text);
  );

  // 結果の結合
  return censoredLines.join("\n");
}, []);
```
:::note info
ポイント
各文字の幅を正確に取るためフォントサイズを加味して幅を取得すること（半角、全角などあるため）
:::



次にマスクのアニメーションですが、行区切りで起動するようにします。
``` typescript
{inputText.split("\n").map((line, index) => (
    <div
      aria-hidden="true"
      key={index}
      className="text-container"
      dangerouslySetInnerHTML={{ __html: censorText(line) }}
    />
))}
```
CSSは以下の通り
``` css
.censored-block {
  display: inline-block;
  background-color: #000; /* 黒い背景 */
  margin-top: 3px;
  height: 1em;  /* 文字の高さと同じに設定 */
  padding-left: 1px;
  position: relative;
  opacity: 0; /* 初期状態は透明 */
  transform: scaleX(0); /* 初期状態は縮小 */
  transform-origin: left; /* 左から広がる */
  animation: censorAnimation 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
  animation-delay: 0.8s; /* 遅延を追加 */
}

.text-container {
  white-space: pre-wrap;  /* 改行を保持 */
  word-wrap: break-word;  /* 単語が長すぎる場合は改行 */
  font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif; /* フォント設定 */
}

@keyframes censorAnimation {
  0% {
    opacity: 0;
    transform: scaleX(0); /* 左から縮小状態 */
  }
  30% {
    opacity: 1;
    transform: scaleX(1); /* 徐々に広がる */
  }
  100% {
    opacity: 1;
    transform: scaleX(1); /* 最後に完全に広がる */
  }
}
```

## 振り返り
自由度は低いですが形にできました。
この道を極めていくのも面白そうと思ったのでまた気が向いたら...作ります！
いつかAIにより言葉が即時に検閲される時代が来るかも知れませんね、、お付き合い頂きありがとうございました！