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 · mediumrich-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}` }];
}
});
};