見出し画像

ほぼChatGPTだけでTwitterのエディタ的なものを作れてしまった件

7月6日にThreadsがリリースされ、世界中がその話題で持ちきりの中、Twitterのエディタですか?というのはちょっと置いといて。

実はTwitterのエディタってすごく複雑で、真似して作るのはすごく難しいのです。例えば、ツイート編集エリアは<textarea>要素ではなく、<div>要素に contenteditable 属性を使用して編集可能にしています。これでハッシュタグなどの特定の部分をハイライトしたりできるようになります。しかしこの contenteditable は便利な反面、バグが多く、泥沼にハマる可能性があります。

ということで、シンプルに<textarea>に入力した内容を、隣の<div>エリアに表示する方法を採用しました。今回はやっていませんが、エリアを分割したくない場合は、<textarea>の上に<div>を重ねるだけです。実際にLINEはこの方法で実装しているようです。

機能:

  • 文字数カウント

    • 半角文字は1文字、全角文字は2文字でカウント

    • URLは一律22文字でカウント

    • 280文字を超えると赤色で表示

  • ハッシュタグ、ハンドル名、URLは青色で表示

  • テキストコピーボタン

はい、たったこれだけです、すいません。でもこれだけでも自力で実装すると結構大変なのです。これをChatGPTのみで作れるか、という実験です。GPT-4を使いたいところですが、まだGPT-3.5を使っている人の方が多いと思うので、GPT-3.5でレッツトライ!

プロンプト:

I want to build an editor with TypeScript, React, and Tailwind CSS. Please help me write the code by following the instructions below:

### Instructions ###
1. Use the <textarea> element for an input area
2. Count the number of characters entered in <textarea>
3. Half-width characters are counted as 1 character, and full-width characters are counted as 2 characters.
4. Strings representing URLs are counted uniformly as 22 characters.
5. Display in red if the number of characters in <textarea> exceeds 280 characters
6. Create another area with <div> to display the content in <textarea>
7. Create regular expressions for hashtags (#example), handles (@example), and URLs
8. In the <div> area, highlight in blue any part that matches any of the regular expressions
9. Create a button that copies the content in <textarea>

TypeScriptとReactとTailwind CSSでエディタを構築したいです。以下の指示に従いコードの作成を手伝ってください:

### 指示 ###
1. 入力エリアには<textarea>要素を使用する
2. <textarea>に入力された文字数をカウントする
3. 半角文字は1文字、全角文字は2文字としてカウントする
4. URLを表す文字列は一律22文字としてカウントする
5. <textarea>内の文字数が280文字を超えたら赤色で表示する
6. <textarea>内の内容を表示するための別エリアを<div>で作成する
7. ハッシュタグ(#example)、ハンドル(@example)、URLを表す正規表現を作成する
8. <div>エリアでは、正規表現のいずれかと一致する部分は青色で強調表示する
9. <textarea>内の内容をコピーするボタンを作成する

まずはここから始めます。プロンプトは日本語でもいいですが、少しでも精度を上げるため私は基本的に英語を使用しています。指示は一度に全部与えずに分割して与えて、1つずつ機能を追加していくやり方でも構いません。

もちろんこれで一発完成!というほど甘くはありません。バグがある場合は、「〇〇が機能していません。これを解決する方法はありますか?」のようにやり取りを重ねて解決していきます。他にも、例えば、dangerouslySetInnerHTML プロパティを使用したくない、という場合は代替案を出してもらうといいでしょう。

あとは、正規表現をTwitterのルール通り作り込んだり、レイアウトを調整したりと。

こんな感じで2時間ほどで作れてしまいました。

ほぼChatGPTだけで作ったTwitterのエディタ的なやつ

自分の今までのプログラマー人生は一体何だったのかと思いつつも、素晴らしいパートナーができたことに喜びを感じています。2-3箇所ほどChatGPTがどうしても修正できないバグを手動で直したので、「ほぼChatGPTのみで作った」ということで良いでしょう。

ソースコードは以下に置いておきます。GitHubでも確認できます。

import { useRef, useState, Fragment } from 'react'

const hashtagRegex =
  /(^|\s)#(?=.*[a-zA-Z\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF])[a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF]+(?=\s|$)/g
const handleRegex = /(^|\s)@[a-zA-Z0-9]+(?=\s|$)/g
const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g

export default function Home() {
  const [text, setText] = useState('')
  const [count, setCount] = useState(0)

  const contentRef = useRef<HTMLDivElement>(null)
  const textAreaRef = useRef<HTMLTextAreaElement>(null)

  const handleCopy = () => {
    if (contentRef.current) {
      const content = contentRef.current.innerText
      navigator.clipboard.writeText(content)
    }
  }

  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    const inputValue = event.target.value
    const urlMatches: string[] = inputValue.match(urlRegex) || []
    const urlLength = urlMatches.reduce((acc, match) => acc + 22, 0)
    const nonUrlText = inputValue.replace(urlRegex, '')
    const nonUrlLength = Array.from(nonUrlText).reduce(
      (acc, char) => acc + (char.charCodeAt(0) > 255 ? 2 : 1),
      0
    )
    setText(inputValue)
    setCount(urlLength + nonUrlLength)
    adjustTextAreaHeight()
  }

  const MAX_HEIGHT = 755 // Maximum height in pixels

  const adjustTextAreaHeight = () => {
    if (textAreaRef.current) {
      textAreaRef.current.style.height = 'auto'
      textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`

      if (textAreaRef.current.scrollHeight > MAX_HEIGHT) {
        textAreaRef.current.style.height = `${MAX_HEIGHT}px`
        textAreaRef.current.style.overflowY = 'auto'
      }
    }
  }

  const renderText = (text: string) => {
    const matches: { startIndex: number; endIndex: number; regex: RegExp }[] =
      []

    let match

    while ((match = hashtagRegex.exec(text)) !== null) {
      matches.push({
        startIndex: match.index,
        endIndex: hashtagRegex.lastIndex,
        regex: hashtagRegex,
      })
    }
    while ((match = handleRegex.exec(text)) !== null) {
      matches.push({
        startIndex: match.index,
        endIndex: handleRegex.lastIndex,
        regex: handleRegex,
      })
    }
    while ((match = urlRegex.exec(text)) !== null) {
      matches.push({
        startIndex: match.index,
        endIndex: urlRegex.lastIndex,
        regex: urlRegex,
      })
    }

    matches.sort((a, b) => a.startIndex - b.startIndex)

    const result: JSX.Element[] = []

    let lastIndex = 0

    for (const match of matches) {
      const { startIndex, endIndex, regex } = match

      const beforeSegment = text.slice(lastIndex, startIndex)
      const matchSegment = text.slice(startIndex, endIndex)

      result.push(<span key={`before-${startIndex}`}>{beforeSegment}</span>)
      result.push(
        <span key={`match-${startIndex}`} className="text-blue-500">
          {matchSegment}
        </span>
      )
      lastIndex = endIndex
    }

    if (lastIndex < text.length) {
      const remainingSegment = text.slice(lastIndex)
      result.push(
        <span key={`remaining-${lastIndex}`}>{remainingSegment}</span>
      )
    }

    return (
      <div className="whitespace-pre-wrap pb-2">
        {result.map((element, index) => (
          <Fragment key={index}>
            {element}
            {index < result.length - 1 && ''}
          </Fragment>
        ))}
      </div>
    )
  }

  return (
    <main className="mx-auto max-w-6xl px-4">
      <div className="py-8">
        <h1 className="mb-4 text-center text-3xl font-bold">Tweet Editor</h1>
        <div className="mb-1 flex items-center justify-between space-x-2">
          <div className="px-2">
            <span className={count > 280 ? 'text-red-500' : ''}>{count}</span>
          </div>
          <button
            onClick={handleCopy}
            className="rounded border border-gray-800 px-2 text-sm hover:border-gray-700"
          >
            Copy
          </button>
        </div>
        <div className="space-y-2 sm:flex sm:space-x-2 sm:space-y-0">
          <div className="h-full w-full rounded bg-gray-800 px-4 pt-2">
            <textarea
              rows={5}
              ref={textAreaRef}
              value={text}
              onChange={handleChange}
              className="w-full resize-none bg-gray-800 focus:outline-none"
            />
          </div>
          <div
            ref={contentRef}
            className="max-h-192 w-full overflow-y-auto rounded bg-gray-900 px-4 pt-2"
          >
            {renderText(text)}
          </div>
        </div>
      </div>
    </main>
  )
}

この記事が気に入ったらサポートをしてみませんか?