nostr-editor
v0.0.3
Published
[![CI Checks](https://github.com/cesardeazevedo/nostr-editor/actions/workflows/ci-checks.yml/badge.svg)](https://github.com/cesardeazevedo/nostr-editor/actions/workflows/ci-checks.yml)
Downloads
305
Readme
nostr-editor
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.
What is tiptap?
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.
What is prosemirror?
ProseMirror is the underlying core framework that powers Tiptap and other WYSIWYG (what-you-see-is-what-you-get) editors.
Features
- 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.
Demo
https://cesardeazevedo.github.io/nostr-editor/
- React: source-code
- Svelte (WIP): source-code
Installing
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
Usage
React
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} />
)
}
svelte
<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>
Rendering node views
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>
)
}
Image Upload
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
Parsing existing notes
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"]
}
}
]
}
]
}
Parsing existing long-form content notes
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
}),
],
})
Commands
nostr-editor provides several commands to insert various types of content and manage media uploads.
insertNevent
editor.commands.insertNEvent({ nevent: 'nostr:nevent1...' })
insertNprofile
editor.commands.insertNProfile({ nprofile: 'nostr:nprofile1...' })
insertNAddr
editor.commands.insertNAddr({ naddr: 'nostr:naddr1...' })
insertBolt11
editor.commands.insertBolt11({ lnbc: 'lnbc...' })
selectFiles
Triggers a input type='file' click
editor.commands.selectFiles()
uploadFiles
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
Roadmap
References
- tiptap docs
- prosemirror docs
- tiptap-markdown
- svelte-tiptap
- nip19 - bech32-encoded entities
- nip94 - File Integration
- nip96 - HTTP File Storage Integration
- blossom