shadimage
lens-blocks
componenttext-editorsource: ok · hit

Lens Text Editor

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

npx shadcn add "https://lensblocks.com/r/text-editor.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
rich-text@tiptap/core@2.26.1@tiptap/pm@2.26.1@tiptap/react@2.26.1@tiptap/starter-kit@2.26.1@tiptap/extension-placeholder@2.26.1@tiptap/extension-mention@2.26.1

Registry dependencies

avatarbuttonskeletonhttps://lensblocks.com/r/utils.json

Files4 · source cached 10/14/2026, 3:38:14 PM

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} />
      </>
    );
  },
);
registry/new-york/components/common/editor/lens-mentions-list.tsx
import { FC, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
import { Account } from "@lens-protocol/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/registry/new-york/ui/avatar";
import { parseUri, truncateAddress } from "@/registry/new-york/lib/lens-utils";
import { UserCircle2 } from "lucide-react";

interface IProps {
  editor: Editor;
  items: Array<Account>;
  command: any;
}

export const LensMentionsList: FC<IProps> = forwardRef((props, ref) => {
  const [selectedIndex, setSelectedIndex] = useState(0);

  const scrollContainer = useRef<HTMLDivElement>(null);

  const containerPaddingTop = scrollContainer.current
    ? parseInt(window.getComputedStyle(scrollContainer.current).paddingTop, 10)
    : 0;
  const containerPaddingBottom = scrollContainer.current
    ? parseInt(window.getComputedStyle(scrollContainer.current).paddingBottom, 10)
    : 0;

  const selectItem = (index: number) => {
    const item = props.items[index];

    if (item) {
      props.command({ id: item.username?.value });
    }
  };

  const upHandler = () => {
    let index = (selectedIndex + props.items.length - 1) % props.items.length;
    setSelectedIndex(index);
    scrollToItem(index);
  };

  const downHandler = () => {
    let index = (selectedIndex + 1) % props.items.length;
    setSelectedIndex(index);
    scrollToItem(index);
  };

  const enterHandler = () => {
    selectItem(selectedIndex);
  };

  const scrollToItem = (index: number) => {
    const container = scrollContainer.current;
    const item = container?.children[index] as HTMLElement;
    if (item && container) {
      const itemOffsetTop = item.offsetTop;
      const itemOffsetHeight = item.offsetHeight;
      const containerScrollTop = container.scrollTop;
      const containerOffsetHeight = container.clientHeight;

      if (itemOffsetTop < containerScrollTop + containerPaddingTop) {
        container.scrollTop = itemOffsetTop - containerPaddingTop;
      } else if (
        itemOffsetTop + itemOffsetHeight >
        containerScrollTop + containerOffsetHeight - containerPaddingBottom
      ) {
        container.scrollTop = itemOffsetTop + itemOffsetHeight - containerOffsetHeight + containerPaddingBottom;
      }
    }
  };

  useEffect(() => setSelectedIndex(0), [props.items]);

  useImperativeHandle(ref, () => ({
    onKeyDown: ({ event }: { event: KeyboardEvent }) => {
      if (event.key === "ArrowUp") {
        upHandler();
        return true;
      }

      if (event.key === "ArrowDown") {
        downHandler();
        return true;
      }

      if (event.key === "Enter") {
        enterHandler();
        return true;
      }

      return false;
    },
  }));

  if (!props.items.length) {
    return null;
  }

  return (
    <div
      ref={scrollContainer}
      className="w-60 flex flex-col gap-2 overflow-auto p-1 relative border rounded-xl bg-background shadow-lg max-h-64 overscroll-y-auto"
    >
      {props.items.map((item, index) => (
        <div
          className={`flex items-center gap-x-2 pl-2 py-2 min-w-48 cursor-pointer rounded-lg ${index === selectedIndex ? "bg-accent" : ""}`}
          key={index}
          onClick={() => selectItem(index)}
        >
          <Avatar className="w-10 h-10">
            <AvatarImage src={parseUri(item.metadata?.picture)} alt={"@" + item.username?.localName} />
            <AvatarFallback className="bg-black">
              <UserCircle2 className="opacity-45" />
            </AvatarFallback>
          </Avatar>
          <div className="flex flex-col w-full min-w-0">
            <div className="text-base font-bold -mb-0.5 truncate">
              {item.metadata?.name
                ? item.metadata?.name
                : item.username
                  ? "@" + item.username.localName
                  : truncateAddress(item.address, 12)}
            </div>
            {item.metadata?.name && item.username ? (
              <div className="text-sm text-gray-600 truncate">{"@" + item.username.value.replace("lens/", "")}</div>
            ) : (
              <div className="text-sm text-gray-600">{truncateAddress(item.address, 12)}</div>
            )}
          </div>
        </div>
      ))}
    </div>
  );
});
registry/new-york/lib/mention.ts
import BuiltInMention, { MentionOptions } from "@tiptap/extension-mention";
import { ReactRenderer } from "@tiptap/react";
import { LensMentionsList } from "../components/common/editor/lens-mentions-list";
import tippy, { Instance } from "tippy.js";
import { mainnet, PublicClient, SessionClient } from "@lens-protocol/react";
import { fetchAccounts } from "@lens-protocol/client/actions";

const suggestion = (client: PublicClient | SessionClient) => ({
  items: async ({ query }: { query: string }) => {
    if (query.length === 0) return [];

    const accounts = await fetchAccounts(client, {
      filter: {
        searchBy: {
          localNameQuery: query,
        },
      },
    });
    if (accounts.isOk()) {
      return [...accounts.value.items];
    }

    return [];
  },

  render: () => {
    let component: ReactRenderer<any>;
    let popup: Instance[];

    return {
      onStart: (props: any) => {
        component = new ReactRenderer(LensMentionsList, {
          props,
          editor: props.editor,
        });

        if (!props.clientRect) {
          return;
        }

        popup = tippy("body", {
          getReferenceClientRect: props.clientRect,
          appendTo: () => document.body,
          content: component.element,
          showOnCreate: true,
          interactive: true,
          trigger: "manual",
          placement: "bottom-start",
        });
      },

      onUpdate(props: any) {
        component.updateProps(props);

        if (!props.clientRect) {
          return;
        }

        popup[0].setProps({
          getReferenceClientRect: props.clientRect,
        });
      },

      onKeyDown(props: any) {
        if (props.event.key === "Escape") {
          popup[0].hide();

          return true;
        }

        return component.ref?.onKeyDown(props);
      },

      onExit() {
        popup[0].destroy();
        component.destroy();
      },
    };
  },
});

type Options = MentionOptions & {
  lensClient: PublicClient | (SessionClient | null | undefined);
};

const defaultClient = PublicClient.create({
  environment: mainnet,
});

const mentionNode = BuiltInMention.extend<Options>({
  addOptions() {
    return {
      ...this.parent?.(),
      lensClient: defaultClient,
    };
  },
});

export const Mention = mentionNode.configure({
  HTMLAttributes: {
    class: "font-bold",
  },
  renderHTML({ options, node }) {
    return ["span", options.HTMLAttributes, `@${node.attrs.id.replace("lens/", "")}`];
  },
  renderText({ node }) {
    return `@${node.attrs.id.replace("lens/", "")}`;
  },
  deleteTriggerWithBackspace: true,
  suggestion: suggestion(mentionNode.options.lensClient ?? defaultClient),
});
registry/new-york/lib/rehype-mention-to-markdown.ts
import { visit } from "unist-util-visit";
import { Root } from "hast";

/**
 * Rehype plugin to convert local name mention elements to full mentions with namespace.
 *
 * eg: @paul -> @lens/paul
 */
export const rehypeMentionToMarkdown = () => (tree: Root) => {
  visit(tree, "element", node => {
    if (
      node.tagName === "span" &&
      node.properties &&
      node.properties.dataType === "mention" &&
      node.properties.dataId
    ) {
      const dataId = String(node.properties.dataId);
      node.children = [{ type: "text", value: `@${dataId}` }];
    }
  });
};

More from lens-blocks

Browse all components in this library →