diff --git a/docs/editor/index.md b/docs/editor/index.md index c22a2ef..99afa8b 100644 --- a/docs/editor/index.md +++ b/docs/editor/index.md @@ -12,145 +12,155 @@ This package powers the editors of [Coracle](https://coracle.social) and [Flotil npm install @welshman/editor ``` -## WelshmanExtension - -The `WelshmanExtension` is the main entry point of the package, providing a pre-configured collection of extensions optimized for Nostr content creation. - -### Configuration +## Example ```typescript -interface WelshmanOptions { - // Required: Function to sign events - sign: (event: StampedEvent) => Promise +import {get} from "svelte/store" +import type {Writable} from "svelte/store" +import type {NodeViewProps} from "@tiptap/core" +import {Router} from "@welshman/router" +import {removeNil} from "@welshman/lib" +import type {FileAttributes} from "@welshman/editor" +import {Editor, MentionSuggestion, WelshmanExtension} from "@welshman/editor" +import {profileSearch, deriveProfileDisplay} from "@welshman/app" - // Required: Handler for submit action - submit: () => void +export const MentionNodeView = ({node}: NodeViewProps) => { + const dom = document.createElement("span") + const display = deriveProfileDisplay(node.attrs.pubkey, removeNil([url])) - // File upload configuration - defaultUploadUrl?: string // Default: "https://nostr.build" - defaultUploadType?: "nip96" | "blossom" // Default: "nip96" + dom.classList.add("tiptap-object") - // Extension configuration - extensions?: WelshmanExtensionOptions -} -``` + const unsubDisplay = display.subscribe($display => { + dom.textContent = "@" + $display + }) -### Included Extensions - -The extension bundles and configures multiple TipTap and nostr-editor extensions: - -#### Core TipTap Extensions -- Document -- Text -- Paragraph -- History -- CodeBlock -- CodeInline -- Dropcursor -- Gapcursor -- Placeholder - -#### Nostr-specific Extensions -- NostrExtension (base) -- Bolt11Extension (Lightning invoices) -- FileUploadExtension -- ImageExtension -- LinkExtension -- NAddrExtension (Nostr addresses) -- NEventExtension (Nostr events) -- NProfileExtension (Nostr profiles) -- TagExtension -- VideoExtension -- NSecRejectExtension - -#### Custom Extensions -- BreakOrSubmit (Enter key handling) -- WordCount - -### Usage - -```typescript -import { Editor } from '@tiptap/core' -import { WelshmanExtension } from '@welshman/editor' - -const editor = new Editor({ - extensions: [ - WelshmanExtension.configure({ - // Required: Event signing function - sign: async (event) => { - return signEvent(event) - }, - - // Required: Submit handler - submit: () => { - handleSubmit(editor.getText()) - }, - - // Optional: Custom upload configuration - defaultUploadUrl: "https://nostr.build", - defaultUploadType: "nip96", - - // Optional: Extension configuration - extensions: { - // Disable specific extensions - wordCount: false, - tag: false, - - // Configure extensions - placeholder: { - config: { - placeholder: 'What\'s on your mind?' - } - }, - - // Extend existing extensions - codeBlock: { - extend: { - renderText: (props) => '```' + props.node.textContent + '```' - } - }, - fileUpload: { - config: { - immediateUpload: true, - allowedMimeTypes: [ - "image/jpeg", - "image/png", - "video/mp4" - ] - } - } - } - }) - ] -}) -``` - -### Extension Configuration - -Each extension can be configured using the `WelshmanExtensionOptions`: - -```typescript -type WelshmanExtensionOptions = { - [ExtensionName: string]: { - // Disable the extension - false | - - // Configure the extension - { - // Extension-specific configuration - config?: Partial - - // Extend the extension's functionality - extend?: Partial - } + return { + dom, + destroy: () => { + unsubDisplay() + }, + selectNode() { + dom.classList.add("tiptap-active") + }, + deselectNode() { + dom.classList.remove("tiptap-active") + }, } } + +export const makeEditor = async ({ + content = "", + submit, + uploading, + charCount, + wordCount, +}: { + content?: string + submit: () => void + uploading?: Writable + charCount?: Writable + wordCount?: Writable +}) => { + return new Editor({ + content, // Initial content, either a string or editor JSON + autofocus: true, + element: document.createElement("div"), + extensions: [ + WelshmanExtension.configure({ + submit, + extensions: { + placeholder: { + config: { + placeholder: "What's up?", + }, + }, + breakOrSubmit: { + config: { + aggressive: true, // If this is a chat-type interface + }, + }, + fileUpload: { + config: { + upload: async (attrs: FileAttributes) => { + const server = "https://cdn.satellite.earth" + + try { + let {uploaded, url, ...task} = await uploadFile(server, attrs.file) + + if (!uploaded) { + return {error: "Server refused to process the file"} + } + + // Always append file extension if missing + if (new URL(url).pathname.split(".").length === 1) { + url += "." + attrs.file.type.split("/")[1] + } + + const result = {...task, url, tags: []} + + return {result} + } catch (e) { + return {error: e.toString()} + } + }, + onDrop() { + uploading?.set(true) + }, + onComplete() { + uploading?.set(false) + }, + onUploadError(currentEditor, task) { + currentEditor.commands.removeFailedUploads() + alert("Failed to upload file") + uploading?.set(false) + }, + }, + }, + nprofile: { + extend: { + addNodeView: () => MentionNodeView, + addProseMirrorPlugins() { + return [ + MentionSuggestion({ + editor: (this as any).editor, + search: (term: string) => get(profileSearch).searchValues(term), + getRelays: (pubkey: string) => Router.get().FromPubkeys([pubkey]).getUrls(), + createSuggestion: (value: string) => { + const target = document.createElement("div") + + target.textContent = value + + return target + }, + }), + ] + }, + }, + }, + }, + }), + ], + onUpdate({editor}) { + wordCount?.set(editor.storage.wordCount.words) + charCount?.set(editor.storage.wordCount.chars) + }, + }) +} + +// Create an editor +const editor = makeEditor({ + submit: async () => { + const ed = await editor + const content = ed.getText({blockSeparator: "\n"}).trim() + const tags = ed.storage.nostr.getEditorTags() + const event = makeEvent(NOTE, {content, tags}) + + await publish({event, relays: [/* ... */]}) + + ed.chain().clearContent().run() + }, +}) + +// This is how you trigger file uploading +const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run()) ``` - -### Custom Components - -The extension includes Svelte components for rendering various Nostr entities in the editor: -- EditBolt11: Lightning invoice -- EditMedia: Image and video -- EditEvent: Nostr event -- EditMention: Nostr profile mention