Speech to Text Plugin

PreviousNext

A plugin for the speech to text.

Docs
shadcn-editorui

Preview

Loading preview…
registry/new-york-v4/editor/plugins/actions/speech-to-text-plugin.tsx
"use client"

/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */
import { useEffect, useRef, useState } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import type { LexicalCommand, LexicalEditor, RangeSelection } from "lexical"
import {
  $getSelection,
  $isRangeSelection,
  COMMAND_PRIORITY_EDITOR,
  createCommand,
  REDO_COMMAND,
  UNDO_COMMAND,
} from "lexical"
import { MicIcon } from "lucide-react"

import { useReport } from "@/registry/new-york-v4/editor/editor-hooks/use-report"
import { CAN_USE_DOM } from "@/registry/new-york-v4/editor/shared/can-use-dom"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"

export const SPEECH_TO_TEXT_COMMAND: LexicalCommand<boolean> = createCommand(
  "SPEECH_TO_TEXT_COMMAND"
)

const VOICE_COMMANDS: Readonly<
  Record<
    string,
    (arg0: { editor: LexicalEditor; selection: RangeSelection }) => void
  >
> = {
  "\n": ({ selection }) => {
    selection.insertParagraph()
  },
  redo: ({ editor }) => {
    editor.dispatchCommand(REDO_COMMAND, undefined)
  },
  undo: ({ editor }) => {
    editor.dispatchCommand(UNDO_COMMAND, undefined)
  },
}

export const SUPPORT_SPEECH_RECOGNITION: boolean =
  CAN_USE_DOM &&
  ("SpeechRecognition" in window || "webkitSpeechRecognition" in window)

function SpeechToTextPluginImpl() {
  const [editor] = useLexicalComposerContext()
  const [isEnabled, setIsEnabled] = useState<boolean>(false)
  const [isSpeechToText, setIsSpeechToText] = useState<boolean>(false)
  const SpeechRecognition =
    // @ts-expect-error missing type
    CAN_USE_DOM && (window.SpeechRecognition || window.webkitSpeechRecognition)
  const recognition = useRef<typeof SpeechRecognition | null>(null)
  const report = useReport()

  useEffect(() => {
    if (isEnabled && recognition.current === null) {
      recognition.current = new SpeechRecognition()
      recognition.current.continuous = true
      recognition.current.interimResults = true
      recognition.current.addEventListener(
        "result",
        (event: typeof SpeechRecognition) => {
          const resultItem = event.results.item(event.resultIndex)
          const { transcript } = resultItem.item(0)
          report(transcript)

          if (!resultItem.isFinal) {
            return
          }

          editor.update(() => {
            const selection = $getSelection()

            if ($isRangeSelection(selection)) {
              const command = VOICE_COMMANDS[transcript.toLowerCase().trim()]

              if (command) {
                command({
                  editor,
                  selection,
                })
              } else if (transcript.match(/\s*\n\s*/)) {
                selection.insertParagraph()
              } else {
                selection.insertText(transcript)
              }
            }
          })
        }
      )
    }

    if (recognition.current) {
      if (isEnabled) {
        recognition.current.start()
      } else {
        recognition.current.stop()
      }
    }

    return () => {
      if (recognition.current !== null) {
        recognition.current.stop()
      }
    }
  }, [SpeechRecognition, editor, isEnabled, report])
  useEffect(() => {
    return editor.registerCommand(
      SPEECH_TO_TEXT_COMMAND,
      (_isEnabled: boolean) => {
        setIsEnabled(_isEnabled)
        return true
      },
      COMMAND_PRIORITY_EDITOR
    )
  }, [editor])

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          onClick={() => {
            editor.dispatchCommand(SPEECH_TO_TEXT_COMMAND, !isSpeechToText)
            setIsSpeechToText(!isSpeechToText)
          }}
          variant={isSpeechToText ? "secondary" : "ghost"}
          title="Speech To Text"
          aria-label={`${isSpeechToText ? "Enable" : "Disable"} speech to text`}
          className="p-2"
          size={"sm"}
        >
          <MicIcon className="size-4" />
        </Button>
      </TooltipTrigger>
      <TooltipContent>Speech To Text</TooltipContent>
    </Tooltip>
  )
}

export const SpeechToTextPlugin = SUPPORT_SPEECH_RECOGNITION
  ? SpeechToTextPluginImpl
  : () => null

Installation

npx shadcn@latest add @shadcn-editor/speech-to-text-plugin

Usage

import { SpeechToTextPlugin } from "@/components/ui/speech-to-text-plugin"
<SpeechToTextPlugin />