nostr-editor
is a collection of Tiptap extensions designed to enhance the user experience when creating and editing nostr notes. It also provides tools for parsing existing notes into a structured content schema.
Tiptap is a headless wrapper around ProseMirror, offering a more developer-friendly API for building rich text editors. nostr-editor uses Tiptap to simplify integration with frameworks like React and Svelte, making it easy to create customized nostr-compatible editors.
ProseMirror is the underlying core framework that powers Tiptap and other WYSIWYG (what-you-see-is-what-you-get) editors.
- Fully customizable extensions
- Parse existing nostr events, including
imeta
tags (NIP-94) - Automatically convert nostr links to their appropriate nodes during paste operations (
nostr:nevent1
,nostr:nprofile1
,nostr:naddr
,nostr:npub
,nostr:note1
) - Handle file uploads to a NIP-96 or blossom compatible server
- Supports markdown long-form content
- Supports bolt11 invoices
- Supports youtube and tweet links
- Automatically rejects and alerts if the user mistakenly pastes an
nsec1
key.
https://cesardeazevedo.github.io/nostr-editor/
- React: source-code
- Svelte (WIP): source-code
To use nostr-editor, you'll need to install a few dependencies:
pnpm add nostr-editor @tiptap/starter-kit @tiptap/core tiptap-markdown
react dependencies
pnpm add @tiptap/react
svelte dependencies
pnpm add svelte-tiptap
Here's a basic setup example using React:
import { Editor } from '@tiptap/core'
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
function MyEditor() {
const editor = useEditor({
autofocus: true,
extensions: [
StarterKit,
NostrExtension.configure({
extend: {
nprofile: { addNodeView: () => ReactNodeViewRenderer(MyReactMentionComponent) },
nevent: { addNodeView: () => ReactNodeViewRenderer(MyReactNEventComponent) },
naddr: { addNodeView: () => ReactNodeViewRenderer(MyReactNaddrComponent) },
image: { addNodeView: () => ReactNodeViewRenderer(MyReactImageComponent) },
video: { addNodeView: () => ReactNodeViewRenderer(MyReactVideoComponent) },
tweet: { addNodeView: () => ReactNodeViewRenderer(MyReactTweetComponent) },
},
link: { autolink: true }
}),
],
onUpdate: () => {
const contentSchema = editor.getJSON()
const contentText = editor.getText()
},
})
return (
<EditorContent editor={editor} />
)
}
<script lang="ts">
import { onMount } from 'svelte'
import type { Readable } from 'svelte/store'
import { createEditor, type Editor, EditorContent, SvelteNodeViewRenderer } from 'svelte-tiptap'
import StarterKit from '@tiptap/starter-kit'
import { NostrExtension } from 'nostr-editor'
let editor: Readable<Editor>
onMount(() => {
editor = new Editor({
extensions: [
StarterKit,
NostrExtension.configure({
extend: {
nprofile: { addNodeView: () => SvelteNodeViewRenderer(MySvelteMentionComponent) },
nevent: { addNodeView: () => SvelteNodeViewRenderer(MySvelteNEventComponent) },
naddr: { addNodeView: () => SvelteNodeViewRenderer(MySvelteNaddrComponent) },
image: { addNodeView: () => SvelteNodeViewRenderer(MySvelteImageComponent) },
video: { addNodeView: () => SvelteNodeViewRenderer(MySvelteVideoComponent) },
tweet: { addNodeView: () => SvelteNodeViewRenderer(MySvelteTweetComponent) },
},
}),
],
content: '',
onUpdate: () => {
contentSchema = $editor.getJSON()
contentText = $editor.getText()
},
})
})
</script>
<main>
<EditorContent editor={$editor} />
</main>
nostr-editor is framework-agnostic and does not ship with pre-built components (yet). You should provide your own React or Svelte components for each extension.
NostrExtension.configure({
extend: {
nprofile: { addNodeView: () => ReactNodeViewRenderer(MyReactMentionComponent) },
...
},
}),
import type { NodeViewProps } from '@tiptap/core'
import { NodeViewWrapper } from '@tiptap/react'
export function MyReactMentionComponent(props: NodeViewProps) {
const { pubkey, relays } = props.node.attrs
const { getProfile } = useNDK() // nostr-tools or other nostr client library
return (
<NodeViewWrapper as='span'>
@{getProfile(pubkey).display_name}
</NodeViewWrapper>
)
}
To handle image uploads with nostr-editor, you can configure the extension as follows:
NostrExtension.configure({
image: {
defaultUploadUrl: 'https://nostr.build',
defaultUploadType: 'nip96', // or blossom
},
video: {
defaultUploadUrl: 'https://nostr.build',
defaultUploadType: 'nip96', // or blossom
},
fileUpload: {
immediateUpload: true, // It will automatically upload when a file is added to the editor, if false, call `editor.commands.uploadFiles()` manually
sign: async (event) => {
if ('nostr' in window) {
const nostr = window.nostr as NostrExtension
return await nostr.signEvent(event)
}
},
onDrop() {
// File added to the editor
},
onComplete() {
// All files were successfully uploaded
},
},
}),
Trigger a input type='file' popup
You can set the editor an existing nostr event in order to parse it's contents
const event = {
kind: 1,
content: 'Hello nostr:nprofile1qy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcprfmhxue69uhhyetvv9ujuem9w3skccne9e3k7mf0wccsqgxxvqas78x0a339m8qgkaf7fam5atmarne8dy3rzfd4l4x6w2qpncmfs8zh'
...
}
editor.commands.setEventContent(event)
editor.getJSON()
Response
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello " },
{
"type": "nprofile",
"attrs": {
"nprofile": "nostr:nprofile1qy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcprfmhxue69uhhyetvv9ujuem9w3skccne9e3k7mf0wccsqgxxvqas78x0a339m8qgkaf7fam5atmarne8dy3rzfd4l4x6w2qpncmfs8zh",
"pubkey": "c6603b0f1ccfec625d9c08b753e4f774eaf7d1cf2769223125b5fd4da728019e",
"relays": ["wss://nos.lol/", "wss://relay.damus.io/", "wss://relay.getalby.com/v1"]
}
}
]
}
]
}
The same thing as a normal note, just make sure your added the Markdown extension from tiptap-markdown
import { Markdown } from 'tiptap-markdown'
const editor = useEditor({
autofocus: true,
extensions: [
StarterKit,
Markdown.configure({
transformCopiedText: true,
transformPastedText: true,
}),
NostrExtension.configure({
link: { autolink: true }, // needed for markdown links
}),
],
})
nostr-editor provides several commands to insert various types of content and manage media uploads.
editor.commands.insertNEvent({ nevent: 'nostr:nevent1...' })
editor.commands.insertNProfile({ nprofile: 'nostr:nprofile1...' })
editor.commands.insertNAddr({ naddr: 'nostr:naddr1...' })
editor.commands.insertBolt11({ lnbc: 'lnbc...' })
Triggers a input type='file' click
editor.commands.selectFiles()
Upload all pending images and videos,
editor.commands.uploadFiles()
This command returns true
when the upload starts, not when the upload is completed. You can use onComplete()
callback in the fileUpload extension options.
const editor = useEditor({
extensions: [
NostrExtension.configure({
fileUpload: {
onComplete: () => console.log('All files uploaded'),
},
}),
],
})
Note: all nostr:
prefixes are optional