Lens Text Editor

PreviousNext

A rich text editor component for composing Lens posts using TipTap, with support for mentions.

Docs
lens-blockscomponent

Preview

Loading preview…
registry/new-york/components/common/editor/lens-text-editor.tsx
"use client";

import { forwardRef, useEffect, useImperativeHandle } from "react";
import { BubbleMenu, EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeRemark from "rehype-remark";
import remarkStringify from "remark-stringify";
import remarkGfm from "remark-gfm";
import { RegEx } from "@/registry/new-york/lib/regex";
// @ts-ignore
import linkifyRegex from "remark-linkify-regex";
import { Mention } from "@/registry/new-york/lib/mention";
import { rehypeMentionToMarkdown } from "@/registry/new-york/lib/rehype-mention-to-markdown";
import { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon, TextQuoteIcon } from "lucide-react";
import { Button } from "@/registry/new-york/ui/button";
import remarkParse from "remark-parse";
import rehypeStringify from "rehype-stringify";
import { PublicClient, SessionClient } from "@lens-protocol/react";

export interface TextEditorRef {
  getContent: () => string;
  setContent: (content: string) => void;
  clearContent: () => void;
}

type Props = {
  lensClient?: PublicClient | SessionClient | null | undefined;
  editable?: boolean;
  className?: string;
  placeholder?: string;
};

export const LensTextEditor = forwardRef<TextEditorRef, Props>(
  ({ lensClient, editable = true, className, placeholder }, ref) => {
    const editor = useEditor({
      immediatelyRender: false,
      extensions: [
        StarterKit.configure({
          hardBreak: false,
          heading: false,
          horizontalRule: false,
        }),
        Placeholder.configure({
          placeholder: placeholder ?? "What's happening?",
          showOnlyWhenEditable: false,
        }),
        Mention.configure({ lensClient }),
        Link.configure({
          openOnClick: false,
          autolink: true,
        }),
      ],
    });

    useEffect(() => {
      editor?.setEditable(editable);
      if (!editable && editor?.getText().trim().length === 0) {
        editor?.view?.dom?.classList.add("opacity-20");
        editor?.view?.dom?.classList.add("cursor-not-allowed");
      } else {
        editor?.view?.dom?.classList.remove("opacity-20");
        editor?.view?.dom?.classList.remove("cursor-not-allowed");
      }
    }, [editor, editable]);

    // Allow underscores in username mentions
    const unescapeUnderscore = (str: string) => {
      return str.replace(/(^|[^\\])\\_/g, "$1_");
    };

    const getContent = () => {
      let html = editor?.getHTML();
      if (!html) return "";

      const markdown = unified()
        .use(rehypeParse)
        .use(rehypeMentionToMarkdown)
        .use(rehypeRemark)
        .use(remarkGfm)
        .use(linkifyRegex(RegEx.URL))
        .use(remarkStringify)
        .processSync(html)
        .toString();

      return unescapeUnderscore(markdown);
    };

    const setContent = (markdown: string) => {
      const html = unified()
        .use(remarkParse)
        .use(remarkGfm)
        .use(remarkStringify)
        .use(rehypeRemark)
        .use(rehypeStringify)
        .processSync(markdown)
        .toString();

      editor?.commands.setContent(html);
    };

    useImperativeHandle(ref, () => ({
      getContent,
      setContent,
      clearContent: () => editor?.commands.clearContent(),
    }));

    return (
      <>
        {editor && (
          <div>
            <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
              <div className="bubble-menu bg-background border rounded-lg shadow-lg flex py-1 px-2 gap-2 text-sm font-semibold">
                <Button
                  variant="ghost"
                  size="icon"
                  onClick={() => editor.chain().focus().toggleBold().run()}
                  className={editor.isActive("bold") ? "is-active" : ""}
                >
                  <BoldIcon />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  onClick={() => editor.chain().focus().toggleItalic().run()}
                  className={editor.isActive("italic") ? "is-active" : ""}
                >
                  <ItalicIcon />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  onClick={() => editor.chain().focus().toggleStrike().run()}
                  className={editor.isActive("strike") ? "is-active" : ""}
                >
                  <StrikethroughIcon />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  onClick={() => editor.chain().focus().toggleCode().run()}
                  className={editor.isActive("code") ? "is-active" : ""}
                >
                  <CodeIcon />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  onClick={() => editor.chain().focus().toggleBlockquote().run()}
                  className={editor.isActive("blockquote") ? "is-active" : ""}
                >
                  <TextQuoteIcon />
                </Button>
              </div>
            </BubbleMenu>
          </div>
        )}
        <EditorContent editor={editor} className={className} />
      </>
    );
  },
);

Installation

npx shadcn@latest add @lens-blocks/text-editor

Usage

import { TextEditor } from "@/components/text-editor"
<TextEditor />