Files
welshman/skills/welshman-editor/SKILL.md
T
2026-06-10 14:01:08 -07:00

12 KiB


name: welshman-editor description: 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 (trigger character), name (ProseMirror node type name), editor, search (returns string items), and select (handles item selection).
MentionSuggestion Pre-configured TippySuggestion for @-triggered nprofile autocomplete. Requires editor, search, and getRelays; accepts optional UI customization.
DefaultSuggestionsWrapper Default dropdown renderer used by TippySuggestion. Implements ISuggestionsWrapper; replace to use a framework component.

Re-exports from upstream

Export Source
Editor @tiptap/core — the editor instance class
NodeViewProps @tiptap/core — prop type for node view factories
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

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. Default allowed MIME types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/mpeg, video/webm. immediateUpload defaults to true.


Common Patterns

1. Minimal editor with submit on Mod-Enter

import {Editor} from "@welshman/editor"
import {WelshmanExtension} from "@welshman/editor"

const editor = new Editor({
  element: document.getElementById("editor")!,
  extensions: [
    WelshmanExtension.configure({
      submit: () => {
        const content = editor.getText({blockSeparator: "\n"}).trim()
        const tags = editor.storage.nostr.getEditorTags()
        console.log({content, tags})
        editor.chain().clearContent().run()
      },
    }),
  ],
})

2. Chat-style editor (Enter submits)

WelshmanExtension.configure({
  submit,
  extensions: {
    breakOrSubmit: {
      config: {aggressive: true}, // Enter submits; Shift-Enter inserts line break
    },
    placeholder: {
      config: {placeholder: "Send a message…"},
    },
  },
})

3. File upload with Blossom/NIP-96 server

import type {FileAttributes} from "@welshman/editor"

WelshmanExtension.configure({
  submit,
  extensions: {
    fileUpload: {
      config: {
        upload: async (attrs: FileAttributes) => {
          try {
            const {uploaded, url} = await uploadFile("https://cdn.satellite.earth", attrs.file)
            if (!uploaded) return {error: "Server refused the file"}
            return {result: {url, tags: []}}
          } catch (e) {
            return {error: String(e)}
          }
        },
        onDrop() { uploading.set(true) },
        onComplete() { uploading.set(false) },
        onUploadError(ed, task) {
          ed.commands.removeFailedUploads()
          uploading.set(false)
        },
      },
    },
  },
})

// Trigger the file picker programmatically:
editor.chain().selectFiles().run()

4. @-mention autocomplete with custom node view

import {get} from "svelte/store"
import type {NodeViewProps} from "@welshman/editor"
import {WelshmanExtension, MentionSuggestion} from "@welshman/editor"
import {profileSearch, deriveProfileDisplay} from "@welshman/app"
import {Router} from "@welshman/router"

const CustomMentionNodeView = ({node}: NodeViewProps) => {
  const dom = document.createElement("span")
  dom.classList.add("tiptap-object")

  const display = deriveProfileDisplay(node.attrs.pubkey)
  const unsub = display.subscribe($d => { dom.textContent = "@" + $d })

  return {
    dom,
    destroy: unsub,
    selectNode()   { dom.classList.add("tiptap-active") },
    deselectNode() { dom.classList.remove("tiptap-active") },
  }
}

WelshmanExtension.configure({
  submit,
  extensions: {
    nprofile: {
      extend: {
        addNodeView: () => CustomMentionNodeView,
        addProseMirrorPlugins() {
          return [
            MentionSuggestion({
              editor: (this as any).editor,
              search: (term: string) => get(profileSearch).searchValues(term),
              getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(),
              // Optional: render a richer item in the dropdown
              createSuggestion: (pubkey: string) => {
                const el = document.createElement("span")
                el.textContent = pubkey.slice(0, 12) + "…"
                return el
              },
            }),
          ]
        },
      },
    },
  },
})

5. Reading content and tags after submit

const submit = () => {
  // Plain text with newlines between paragraphs
  const content = editor.getText({blockSeparator: "\n"}).trim()

  // NIP-10 / NIP-27 tags built from inline nostr objects
  const tags = editor.storage.nostr.getEditorTags()

  // Full ProseMirror JSON (for saving draft state)
  const json = editor.getJSON()

  // Restore from saved JSON
  editor.commands.setContent(json)
}

6. Tracking word / character count

import {writable} from "svelte/store"
import {Editor, WelshmanExtension} from "@welshman/editor"

const wordCount = writable(0)
const charCount = writable(0)

const editor = new Editor({
  element,
  extensions: [WelshmanExtension.configure({submit})],
  onUpdate({editor}) {
    wordCount.set(editor.storage.wordCount.words)
    charCount.set(editor.storage.wordCount.chars)
  },
})

Integration Notes

  • @welshman/appprofileSearch and deriveProfileDisplay are the typical sources for mention autocomplete data and display names.
  • @welshman/routerRouter.get().FromPubkeys([pubkey]).getUrls() provides the relay hints encoded into nprofile bech32 strings.
  • @welshman/utilfromNostrURI is used internally by EventNodeView to strip the nostr: scheme before displaying.
  • nostr-editorWelshmanExtension extends NostrExtension from this package. Storage at editor.storage.nostr (including getEditorTags()) is provided by nostr-editor, not welshman itself.
  • @tiptap/coreEditor, NodeViewProps, and all extension primitives come from Tiptap. Welshman does not re-export every Tiptap helper; import additional ones directly from @tiptap/core as needed.

Gotchas & Tips

  • submit is required. WelshmanExtension.configure({submit}) will throw during editor initialization (when extensions are registered) if submit is omitted, not at the configure() call site.
  • Extension options are deep-merged, not replaced. User-supplied extensions options are merged with welshman defaults via deepMergeLeft, so you only need to specify the keys you want to change. Supplying false for 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 the extend.addNodeView pattern (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, call editor.chain().selectFiles().run() from any external button click handler.
  • CSS variables. The bundled index.css exposes --tiptap-object-bg, --tiptap-object-fg, --tiptap-active-bg, --tiptap-active-fg for theming pills and the suggestion dropdown without overriding classes.
  • tiptap-uploading animation. While a file is being uploaded, MediaNodeView adds the .tiptap-uploading class which triggers a pulsing opacity animation defined in index.css. No manual wiring is needed.
  • Tippy appends to dialog when open. TippySuggestion checks 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 on editor.storage.nostr collects all inline nostr objects (mentions, links, embeds) and returns the appropriate p, e, a, t, r tags 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, save editor.getJSON() and pass it back as content.
  • removeFailedUploads() command. Call editor.commands.removeFailedUploads() in onUploadError to clean up any partially-inserted upload nodes so the composer stays in a clean state.