Files
welshman/skills/welshman-editor/SKILL.md
T
Jon Staab 48bf9d6ebe
tests / tests (push) Failing after 5m7s
Quote skill descriptions
2026-06-10 14:52:43 -07:00

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/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.
  • 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 inside requestAnimationFrame when the editor element was just mounted.