17 KiB
name, description
| name | description |
|---|---|
| welshman-editor | Use this skill when working with @welshman/editor: the batteries-included Tiptap-based rich-text editor for composing nostr notes with mention autocomplete, media upload, and inline nostr objects. |
welshman/editor — Nostr Editor Component
@welshman/editor provides a batteries-included text editor for composing nostr notes, built on top of Tiptap and nostr-editor. It bundles a curated set of extensions that handle nostr-specific concerns (nprofile mentions, nevent/naddr embeds, file upload, Lightning invoices) as well as general composition features (history, placeholder, inline code, word count). It is framework-agnostic — the core is plain TypeScript/DOM, though it powers the Svelte-based editors of Coracle and Flotilla.
Installation
npm install @welshman/editor
# or
pnpm add @welshman/editor
# or
yarn add @welshman/editor
Peer dependencies that must be installed separately:
npm install @welshman/lib @welshman/util nostr-tools nostr-editor
Import the bundled CSS to get default object/suggestion styles (optional but recommended):
import "@welshman/editor/index.css"
Key Exports
Extensions
| Export | Description |
|---|---|
WelshmanExtension |
The main all-in-one Tiptap extension. Configure once; it registers every sub-extension below. submit is required. |
BreakOrSubmit |
Keyboard handler: Mod-Enter always submits; Enter submits only when aggressive: true (chat-style); Shift-Enter inserts a hard break. |
CodeInline |
Inline code node with backtick input/paste rules. |
WordCount |
Extension that tracks editor.storage.wordCount.words and editor.storage.wordCount.chars on every document update. |
Node Views
These are drop-in Tiptap node-view factory functions that render inline pill elements with .tiptap-object CSS class. Override them via WelshmanExtensionOptions to render richer UI.
| Export | Renders |
|---|---|
MentionNodeView |
nprofile nodes — shows @<bech32 prefix>… |
MediaNodeView |
Image and video nodes — shows filename or URL; adds .tiptap-uploading animation while uploading |
EventNodeView |
nevent and naddr nodes — shows bech32 prefix |
Bolt11NodeView |
bolt11 Lightning invoice nodes — shows the first 16 characters of the Lightning invoice string (the lnbc attribute) followed by ... |
Plugins
| Export | Description |
|---|---|
TippySuggestion |
Generic Tippy.js-powered @tiptap/suggestion wrapper. Requires char, name, editor, search, and select. Optional: updateSignal, createSuggestion. |
MentionSuggestion |
Pre-configured TippySuggestion for @-triggered nprofile autocomplete. Requires editor, search, and getRelays. Optional: updateSignal, createSuggestion. |
DefaultSuggestionsWrapper |
Default dropdown renderer used by TippySuggestion. Implements ISuggestionsWrapper; replace to use a framework component. |
TippySuggestion options:
| Option | Required | Description |
|---|---|---|
char |
yes | Trigger character (e.g. "@", "~") |
name |
yes | ProseMirror node type name to insert on selection |
editor |
yes | The Tiptap Editor instance |
search |
yes | (term: string) => string[] — returns item values matching the query |
select |
yes | (value: string, props) => void — called when the user picks an item; call props.command({...attrs}) to insert the node |
updateSignal |
no | A Svelte Readable store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
createSuggestion |
no | (value: string) => Element — renders a custom DOM element for each dropdown item |
MentionSuggestion is a pre-wired TippySuggestion for nprofile nodes. It handles select internally (encodes the pubkey as an nprofile with relay hints from getRelays) so you only need to supply editor, search, and getRelays.
Re-exports from upstream
| Export | Source |
|---|---|
Editor |
@tiptap/core — the editor instance class |
NodeViewProps |
@tiptap/core — prop type for node view factories (Tiptap's type) |
NodeViewRendererProps |
@tiptap/core — alternate props type used in Node.create({ addNodeView }) |
UploadTask |
nostr-editor — shape of an in-progress or completed file upload |
FileAttributes |
nostr-editor — { file: File, … } passed to the upload callback |
editorProps |
nostr-editor — base ProseMirror editorProps used by nostr-editor; pass directly to new Editor({ editorProps }) |
WelshmanExtensionOptions Reference
All keys are optional. Pass false to disable a built-in extension entirely. Pass { extend?, config? } to override defaults.
type WelshmanExtensionOptions = {
bolt11?: false
breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions }
codeInline?: false | { extend?, config? }
codeBlock?: false | { extend?, config?: CodeBlockOptions }
document?: false
dropcursor?: false | { extend?, config?: DropcursorOptions }
fileUpload?: { extend?: Partial<any>, config?: Partial<FileUploadOptions> & Pick<FileUploadOptions, "upload"> }
gapcursor?: false
history?: false | { extend?, config?: HistoryOptions }
image?: false | { extend?, config?: ImageOptions }
link?: false | { extend?, config?: LinkOptions }
naddr?: false | { extend?, config? }
nevent?: false | { extend?, config? }
nprofile?: false | { extend?, config? }
nsecReject?: false | { extend?, config?: NSecRejectOptions }
paragraph?: false | { extend?, config?: ParagraphOptions }
placeholder?: false | { extend?, config?: PlaceholderOptions }
tag?: false
text?: false
video?: false
wordCount?: false
}
fileUpload.config requires at minimum an upload function. The upload callback must return Promise<UploadTask>:
interface UploadResult { url: string; sha256: string; tags: string[][] }
interface UploadTask { result?: UploadResult; error?: string }
Default allowed MIME types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/mpeg, video/webm. immediateUpload defaults to true.
Example
A full-featured editor factory covering file upload, @-mention and custom-trigger autocomplete, reactive node views, word count, and draft persistence.
import {get, writable} from "svelte/store"
import {Node, Extension, mergeAttributes} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
import type {NodeViewRendererProps} from "@tiptap/core"
import {Router} from "@welshman/router"
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
import {
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
} from "@welshman/editor"
import type {FileAttributes, UploadTask} from "@welshman/editor"
// ── Custom inline node: room reference (~) ───────────────────────────────────
// Defines a new ProseMirror node type for inline room references.
// Register it alongside WelshmanExtension so Tiptap knows about the "roomref" name.
const RoomReferenceExtension = Node.create({
name: "roomref",
atom: true, inline: true, group: "inline", selectable: true, priority: 1000,
addAttributes: () => ({url: {default: undefined}, h: {default: undefined}}),
parseHTML: () => [{tag: 'span[data-type="roomref"]'}],
renderHTML: ({HTMLAttributes}) =>
["span", mergeAttributes(HTMLAttributes, {"data-type": "roomref"}), "~"],
renderText: ({node}) => `~${node.attrs.url ?? ""}:${node.attrs.h ?? ""}`,
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("room-ref")
const unsub = deriveRoomDisplay(node.attrs.url, node.attrs.h)
.subscribe(d => { dom.textContent = "~" + d })
return {
dom, destroy: unsub,
selectNode: () => dom.classList.add("room-ref--active"),
deselectNode: () => dom.classList.remove("room-ref--active"),
}
},
})
// ── Editor factory ────────────────────────────────────────────────────────────
export const makeEditor = ({
content = "" as string | object,
placeholder = "",
uploading, // optional Writable<boolean>
wordCount, // optional Writable<number>
charCount, // optional Writable<number>
submit,
}: {
content?: string | object
placeholder?: string
uploading?: ReturnType<typeof writable<boolean>>
wordCount?: ReturnType<typeof writable<number>>
charCount?: ReturnType<typeof writable<number>>
submit: () => void
}) => {
const profileSearch = createSearch(get(profiles), {
onSearch: searchProfiles,
getValue: (p: any) => p.event.pubkey,
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
})
const editor = new Editor({
content,
editorProps,
element: document.createElement("div"),
extensions: [
RoomReferenceExtension,
WelshmanExtension.configure({
submit,
extensions: {
// Chat-style: Enter submits, Shift-Enter inserts line break
breakOrSubmit: {config: {aggressive: true}},
placeholder: {config: {placeholder}},
// File upload — upload() must return Promise<UploadTask>
fileUpload: {
config: {
upload: async (attrs: FileAttributes): Promise<UploadTask> => {
try {
const {url, sha256, tags} = await myUploadServer(attrs.file)
return {result: {url, sha256, tags}}
} catch (e) {
return {error: String(e)}
}
},
onDrop: () => uploading?.set(true),
onComplete: () => uploading?.set(false),
onUploadError: (ed, _task) => {
ed.commands.removeFailedUploads()
uploading?.set(false)
},
},
},
// Custom reactive nprofile node view + "@" and "~" autocomplete
nprofile: {
extend: {
addNodeView: () => ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
dom.classList.add("mention")
const unsub = deriveProfileDisplay(node.attrs.pubkey)
.subscribe($d => { dom.textContent = "@" + $d })
return {
dom, destroy: unsub,
selectNode: () => dom.classList.add("mention--active"),
deselectNode: () => dom.classList.remove("mention--active"),
}
},
addProseMirrorPlugins() {
return [
// "@" — nprofile mention; updateSignal re-renders when search index changes
MentionSuggestion({
editor: (this as any).editor,
search: term => profileSearch.searchValues(term),
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(),
createSuggestion: pubkey => {
const el = document.createElement("span")
el.textContent = pubkey.slice(0, 12) + "…"
return el
},
}),
// "~" — custom roomref node; select must call props.command({...attrs})
TippySuggestion({
char: "~", name: "roomref",
editor: (this as any).editor,
search: term => roomSearch.searchValues(term),
select: (id, props) => {
const [url, h] = splitRoomId(id)
if (url && h) props.command({url, h})
},
createSuggestion: id => {
const el = document.createElement("span")
el.textContent = id.slice(0, 16) + "…"
return el
},
}),
]
},
},
},
},
}),
],
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
},
})
return editor
}
// ── Reading content on submit ─────────────────────────────────────────────────
const onSubmit = (editor: Editor) => {
const content = editor.getText({blockSeparator: "\n"}).trim()
const tags = editor.storage.nostr.getEditorTags() // NIP-10 / NIP-27 tags
console.log({content, tags})
editor.chain().clearContent().run()
}
// ── Mounting in Svelte ────────────────────────────────────────────────────────
<script lang="ts">
import type {Editor} from "@welshman/editor"
import {onMount, onDestroy} from "svelte"
const {editor, autofocus = false}: {editor: Editor; autofocus?: boolean} = $props()
let element: HTMLElement
onMount(() => {
element.append(editor.options.element)
if (autofocus) {
const atEnd = editor.getText().trim().length > 0
requestAnimationFrame(() => editor.commands.focus(atEnd ? "end" : "start"))
}
})
onDestroy(() => editor.destroy())
</script>
<div bind:this={element}></div>
Integration Notes
@welshman/app—profileSearchandderiveProfileDisplayare the typical sources for mention autocomplete data and display names.@welshman/router—Router.get().FromPubkeys([pubkey]).getUrls()provides the relay hints encoded into nprofile bech32 strings.@welshman/util—fromNostrURIis used internally byEventNodeViewto strip thenostr:scheme before displaying.nostr-editor—WelshmanExtensionextendsNostrExtensionfrom this package. Storage ateditor.storage.nostr(includinggetEditorTags()) is provided bynostr-editor, not welshman itself.@tiptap/core—Editor,NodeViewProps, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from@tiptap/coreas needed.
Gotchas & Tips
submitis required.WelshmanExtension.configure({submit})will throw during editor initialization (when extensions are registered) ifsubmitis omitted, not at theconfigure()call site.- Extension options are deep-merged, not replaced. User-supplied
extensionsoptions are merged with welshman defaults viadeepMergeLeft, so you only need to specify the keys you want to change. Supplyingfalsefor a key fully disables that extension. - Default node views are plain DOM. The built-in
MentionNodeView,MediaNodeView, etc. render minimal pill text. Override them via theextend.addNodeViewpattern (see pattern 4) to render framework components, avatars, or rich previews. selectFiles()command. To open a file picker without a UI button inside the editor, calleditor.chain().selectFiles().run()from any external button click handler.- CSS variables. The bundled
index.cssexposes--tiptap-object-bg,--tiptap-object-fg,--tiptap-active-bg,--tiptap-active-fgfor theming pills and the suggestion dropdown without overriding classes. tiptap-uploadinganimation. While a file is being uploaded,MediaNodeViewadds the.tiptap-uploadingclass which triggers a pulsing opacity animation defined inindex.css. No manual wiring is needed.- Tippy appends to dialog when open.
TippySuggestionchecks for an open<dialog>element and appends the suggestion popover inside it to avoid z-index stacking issues in modal composers. getEditorTags()returns NIP-10/27 tags. This method oneditor.storage.nostrcollects all inline nostr objects (mentions, links, embeds) and returns the appropriatep,e,a,t,rtags for the published event. Always call this when building the event to publish.- Initial content. Pass either a plain string or a ProseMirror JSON object to
new Editor({content}). To restore a draft, saveeditor.getJSON()and pass it back ascontent. removeFailedUploads()command. Calleditor.commands.removeFailedUploads()inonUploadErrorto clean up any partially-inserted upload nodes so the composer stays in a clean state.addFile(file, pos)command. Programmatically inserts a file upload node at a given ProseMirror position — useful for native clipboard paste (e.g. mobile) where the browser paste event carries no file data.editor.commands.focus('start' | 'end' | number). Pass"end"to place the cursor after existing content (restoring a draft),"start"for a fresh empty editor. Call insiderequestAnimationFramewhen the editor element was just mounted.