{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-editor",
  "type": "registry:component",
  "title": "Lens Text Editor",
  "description": "A rich text editor component for composing Lens posts using TipTap, with support for mentions.",
  "dependencies": [
    "@lens-protocol/react@0.0.0-canary-20250820102520",
    "@lens-protocol/client@0.0.0-canary-20250820102520",
    "@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",
    "@types/hast@3.0.4",
    "unified@11.0.5",
    "rehype-parse@9.0.1",
    "rehype-remark@10.0.0",
    "rehype-stringify@10.0.1",
    "remark-gfm@4.0.1",
    "remark-linkify-regex@1.2.1",
    "tippy.js@6.3.7",
    "unist-util-visit@5.0.0"
  ],
  "registryDependencies": ["avatar", "button", "skeleton", "https://lensblocks.com/r/utils.json"],
  "files": [
    {
      "path": "registry/new-york/components/common/editor/lens-text-editor.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useImperativeHandle } from \"react\";\nimport { BubbleMenu, EditorContent, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport Placeholder from \"@tiptap/extension-placeholder\";\nimport Link from \"@tiptap/extension-link\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeRemark from \"rehype-remark\";\nimport remarkStringify from \"remark-stringify\";\nimport remarkGfm from \"remark-gfm\";\nimport { RegEx } from \"@/registry/new-york/lib/regex\";\n// @ts-ignore\nimport linkifyRegex from \"remark-linkify-regex\";\nimport { Mention } from \"@/registry/new-york/lib/mention\";\nimport { rehypeMentionToMarkdown } from \"@/registry/new-york/lib/rehype-mention-to-markdown\";\nimport { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon, TextQuoteIcon } from \"lucide-react\";\nimport { Button } from \"@/registry/new-york/ui/button\";\nimport remarkParse from \"remark-parse\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { PublicClient, SessionClient } from \"@lens-protocol/react\";\n\nexport interface TextEditorRef {\n  getContent: () => string;\n  setContent: (content: string) => void;\n  clearContent: () => void;\n}\n\ntype Props = {\n  lensClient?: PublicClient | SessionClient | null | undefined;\n  editable?: boolean;\n  className?: string;\n  placeholder?: string;\n};\n\nexport const LensTextEditor = forwardRef<TextEditorRef, Props>(\n  ({ lensClient, editable = true, className, placeholder }, ref) => {\n    const editor = useEditor({\n      immediatelyRender: false,\n      extensions: [\n        StarterKit.configure({\n          hardBreak: false,\n          heading: false,\n          horizontalRule: false,\n        }),\n        Placeholder.configure({\n          placeholder: placeholder ?? \"What's happening?\",\n          showOnlyWhenEditable: false,\n        }),\n        Mention.configure({ lensClient }),\n        Link.configure({\n          openOnClick: false,\n          autolink: true,\n        }),\n      ],\n    });\n\n    useEffect(() => {\n      editor?.setEditable(editable);\n      if (!editable && editor?.getText().trim().length === 0) {\n        editor?.view?.dom?.classList.add(\"opacity-20\");\n        editor?.view?.dom?.classList.add(\"cursor-not-allowed\");\n      } else {\n        editor?.view?.dom?.classList.remove(\"opacity-20\");\n        editor?.view?.dom?.classList.remove(\"cursor-not-allowed\");\n      }\n    }, [editor, editable]);\n\n    // Allow underscores in username mentions\n    const unescapeUnderscore = (str: string) => {\n      return str.replace(/(^|[^\\\\])\\\\_/g, \"$1_\");\n    };\n\n    const getContent = () => {\n      let html = editor?.getHTML();\n      if (!html) return \"\";\n\n      const markdown = unified()\n        .use(rehypeParse)\n        .use(rehypeMentionToMarkdown)\n        .use(rehypeRemark)\n        .use(remarkGfm)\n        .use(linkifyRegex(RegEx.URL))\n        .use(remarkStringify)\n        .processSync(html)\n        .toString();\n\n      return unescapeUnderscore(markdown);\n    };\n\n    const setContent = (markdown: string) => {\n      const html = unified()\n        .use(remarkParse)\n        .use(remarkGfm)\n        .use(remarkStringify)\n        .use(rehypeRemark)\n        .use(rehypeStringify)\n        .processSync(markdown)\n        .toString();\n\n      editor?.commands.setContent(html);\n    };\n\n    useImperativeHandle(ref, () => ({\n      getContent,\n      setContent,\n      clearContent: () => editor?.commands.clearContent(),\n    }));\n\n    return (\n      <>\n        {editor && (\n          <div>\n            <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>\n              <div className=\"bubble-menu bg-background border rounded-lg shadow-lg flex py-1 px-2 gap-2 text-sm font-semibold\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => editor.chain().focus().toggleBold().run()}\n                  className={editor.isActive(\"bold\") ? \"is-active\" : \"\"}\n                >\n                  <BoldIcon />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => editor.chain().focus().toggleItalic().run()}\n                  className={editor.isActive(\"italic\") ? \"is-active\" : \"\"}\n                >\n                  <ItalicIcon />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => editor.chain().focus().toggleStrike().run()}\n                  className={editor.isActive(\"strike\") ? \"is-active\" : \"\"}\n                >\n                  <StrikethroughIcon />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => editor.chain().focus().toggleCode().run()}\n                  className={editor.isActive(\"code\") ? \"is-active\" : \"\"}\n                >\n                  <CodeIcon />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={() => editor.chain().focus().toggleBlockquote().run()}\n                  className={editor.isActive(\"blockquote\") ? \"is-active\" : \"\"}\n                >\n                  <TextQuoteIcon />\n                </Button>\n              </div>\n            </BubbleMenu>\n          </div>\n        )}\n        <EditorContent editor={editor} className={className} />\n      </>\n    );\n  },\n);\n",
      "type": "registry:component"
    },
    {
      "path": "registry/new-york/components/common/editor/lens-mentions-list.tsx",
      "content": "import { FC, forwardRef, useEffect, useImperativeHandle, useRef, useState } from \"react\";\nimport { Editor } from \"@tiptap/core\";\nimport { Account } from \"@lens-protocol/react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/registry/new-york/ui/avatar\";\nimport { parseUri, truncateAddress } from \"@/registry/new-york/lib/lens-utils\";\nimport { UserCircle2 } from \"lucide-react\";\n\ninterface IProps {\n  editor: Editor;\n  items: Array<Account>;\n  command: any;\n}\n\nexport const LensMentionsList: FC<IProps> = forwardRef((props, ref) => {\n  const [selectedIndex, setSelectedIndex] = useState(0);\n\n  const scrollContainer = useRef<HTMLDivElement>(null);\n\n  const containerPaddingTop = scrollContainer.current\n    ? parseInt(window.getComputedStyle(scrollContainer.current).paddingTop, 10)\n    : 0;\n  const containerPaddingBottom = scrollContainer.current\n    ? parseInt(window.getComputedStyle(scrollContainer.current).paddingBottom, 10)\n    : 0;\n\n  const selectItem = (index: number) => {\n    const item = props.items[index];\n\n    if (item) {\n      props.command({ id: item.username?.value });\n    }\n  };\n\n  const upHandler = () => {\n    let index = (selectedIndex + props.items.length - 1) % props.items.length;\n    setSelectedIndex(index);\n    scrollToItem(index);\n  };\n\n  const downHandler = () => {\n    let index = (selectedIndex + 1) % props.items.length;\n    setSelectedIndex(index);\n    scrollToItem(index);\n  };\n\n  const enterHandler = () => {\n    selectItem(selectedIndex);\n  };\n\n  const scrollToItem = (index: number) => {\n    const container = scrollContainer.current;\n    const item = container?.children[index] as HTMLElement;\n    if (item && container) {\n      const itemOffsetTop = item.offsetTop;\n      const itemOffsetHeight = item.offsetHeight;\n      const containerScrollTop = container.scrollTop;\n      const containerOffsetHeight = container.clientHeight;\n\n      if (itemOffsetTop < containerScrollTop + containerPaddingTop) {\n        container.scrollTop = itemOffsetTop - containerPaddingTop;\n      } else if (\n        itemOffsetTop + itemOffsetHeight >\n        containerScrollTop + containerOffsetHeight - containerPaddingBottom\n      ) {\n        container.scrollTop = itemOffsetTop + itemOffsetHeight - containerOffsetHeight + containerPaddingBottom;\n      }\n    }\n  };\n\n  useEffect(() => setSelectedIndex(0), [props.items]);\n\n  useImperativeHandle(ref, () => ({\n    onKeyDown: ({ event }: { event: KeyboardEvent }) => {\n      if (event.key === \"ArrowUp\") {\n        upHandler();\n        return true;\n      }\n\n      if (event.key === \"ArrowDown\") {\n        downHandler();\n        return true;\n      }\n\n      if (event.key === \"Enter\") {\n        enterHandler();\n        return true;\n      }\n\n      return false;\n    },\n  }));\n\n  if (!props.items.length) {\n    return null;\n  }\n\n  return (\n    <div\n      ref={scrollContainer}\n      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\"\n    >\n      {props.items.map((item, index) => (\n        <div\n          className={`flex items-center gap-x-2 pl-2 py-2 min-w-48 cursor-pointer rounded-lg ${index === selectedIndex ? \"bg-accent\" : \"\"}`}\n          key={index}\n          onClick={() => selectItem(index)}\n        >\n          <Avatar className=\"w-10 h-10\">\n            <AvatarImage src={parseUri(item.metadata?.picture)} alt={\"@\" + item.username?.localName} />\n            <AvatarFallback className=\"bg-black\">\n              <UserCircle2 className=\"opacity-45\" />\n            </AvatarFallback>\n          </Avatar>\n          <div className=\"flex flex-col w-full min-w-0\">\n            <div className=\"text-base font-bold -mb-0.5 truncate\">\n              {item.metadata?.name\n                ? item.metadata?.name\n                : item.username\n                  ? \"@\" + item.username.localName\n                  : truncateAddress(item.address, 12)}\n            </div>\n            {item.metadata?.name && item.username ? (\n              <div className=\"text-sm text-gray-600 truncate\">{\"@\" + item.username.value.replace(\"lens/\", \"\")}</div>\n            ) : (\n              <div className=\"text-sm text-gray-600\">{truncateAddress(item.address, 12)}</div>\n            )}\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n});\n",
      "type": "registry:component"
    },
    {
      "path": "registry/new-york/lib/mention.ts",
      "content": "import BuiltInMention, { MentionOptions } from \"@tiptap/extension-mention\";\nimport { ReactRenderer } from \"@tiptap/react\";\nimport { LensMentionsList } from \"../components/common/editor/lens-mentions-list\";\nimport tippy, { Instance } from \"tippy.js\";\nimport { mainnet, PublicClient, SessionClient } from \"@lens-protocol/react\";\nimport { fetchAccounts } from \"@lens-protocol/client/actions\";\n\nconst suggestion = (client: PublicClient | SessionClient) => ({\n  items: async ({ query }: { query: string }) => {\n    if (query.length === 0) return [];\n\n    const accounts = await fetchAccounts(client, {\n      filter: {\n        searchBy: {\n          localNameQuery: query,\n        },\n      },\n    });\n    if (accounts.isOk()) {\n      return [...accounts.value.items];\n    }\n\n    return [];\n  },\n\n  render: () => {\n    let component: ReactRenderer<any>;\n    let popup: Instance[];\n\n    return {\n      onStart: (props: any) => {\n        component = new ReactRenderer(LensMentionsList, {\n          props,\n          editor: props.editor,\n        });\n\n        if (!props.clientRect) {\n          return;\n        }\n\n        popup = tippy(\"body\", {\n          getReferenceClientRect: props.clientRect,\n          appendTo: () => document.body,\n          content: component.element,\n          showOnCreate: true,\n          interactive: true,\n          trigger: \"manual\",\n          placement: \"bottom-start\",\n        });\n      },\n\n      onUpdate(props: any) {\n        component.updateProps(props);\n\n        if (!props.clientRect) {\n          return;\n        }\n\n        popup[0].setProps({\n          getReferenceClientRect: props.clientRect,\n        });\n      },\n\n      onKeyDown(props: any) {\n        if (props.event.key === \"Escape\") {\n          popup[0].hide();\n\n          return true;\n        }\n\n        return component.ref?.onKeyDown(props);\n      },\n\n      onExit() {\n        popup[0].destroy();\n        component.destroy();\n      },\n    };\n  },\n});\n\ntype Options = MentionOptions & {\n  lensClient: PublicClient | (SessionClient | null | undefined);\n};\n\nconst defaultClient = PublicClient.create({\n  environment: mainnet,\n});\n\nconst mentionNode = BuiltInMention.extend<Options>({\n  addOptions() {\n    return {\n      ...this.parent?.(),\n      lensClient: defaultClient,\n    };\n  },\n});\n\nexport const Mention = mentionNode.configure({\n  HTMLAttributes: {\n    class: \"font-bold\",\n  },\n  renderHTML({ options, node }) {\n    return [\"span\", options.HTMLAttributes, `@${node.attrs.id.replace(\"lens/\", \"\")}`];\n  },\n  renderText({ node }) {\n    return `@${node.attrs.id.replace(\"lens/\", \"\")}`;\n  },\n  deleteTriggerWithBackspace: true,\n  suggestion: suggestion(mentionNode.options.lensClient ?? defaultClient),\n});\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/new-york/lib/rehype-mention-to-markdown.ts",
      "content": "import { visit } from \"unist-util-visit\";\nimport { Root } from \"hast\";\n\n/**\n * Rehype plugin to convert local name mention elements to full mentions with namespace.\n *\n * eg: @paul -> @lens/paul\n */\nexport const rehypeMentionToMarkdown = () => (tree: Root) => {\n  visit(tree, \"element\", node => {\n    if (\n      node.tagName === \"span\" &&\n      node.properties &&\n      node.properties.dataType === \"mention\" &&\n      node.properties.dataId\n    ) {\n      const dataId = String(node.properties.dataId);\n      node.children = [{ type: \"text\", value: `@${dataId}` }];\n    }\n  });\n};\n",
      "type": "registry:lib"
    }
  ]
}
