From 7dd89b7e177afab01279c55f17f8cd375e5eee6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 20:18:35 +0000 Subject: [PATCH] feat: add schema-driven composer infrastructure for multi-kind event creation - Extract shared editor code into core/ module (types, EmojiMention, serialization) - Rename RichEditor to TextEditor (keep RichEditor as backwards-compatible alias) - Create MarkdownEditor variant with formatting toolbar - Create MarkdownToolbar component with bold, italic, code, heading, list buttons - Add ComposerSchema type system for describing event kind schemas - Define schemas for kinds 1, 9, 1111, 1621, 30023, 30818 The schema system captures: - Content type and editor variant - Metadata fields (title, summary, labels) - Context binding (standalone, address, group, comment) - Threading style (nip10, nip22, q-tag) - Relay selection strategy - Draft persistence configuration This lays the groundwork for a generic Composer component that can handle any event kind based on its schema definition. https://claude.ai/code/session_01WpZc66saVdASHKrrnz3Tme --- src/components/editor/MarkdownEditor.tsx | 134 ++++ src/components/editor/MarkdownToolbar.tsx | 275 +++++++++ src/components/editor/MentionEditor.tsx | 126 +--- src/components/editor/RichEditor.tsx | 638 +------------------- src/components/editor/TextEditor.tsx | 479 +++++++++++++++ src/components/editor/core/emoji-mention.ts | 90 +++ src/components/editor/core/index.ts | 23 + src/components/editor/core/serialization.ts | 203 +++++++ src/components/editor/core/types.ts | 80 +++ src/components/editor/index.ts | 47 ++ src/lib/composer/index.ts | 41 ++ src/lib/composer/schema.ts | 245 ++++++++ src/lib/composer/schemas.ts | 300 +++++++++ 13 files changed, 1936 insertions(+), 745 deletions(-) create mode 100644 src/components/editor/MarkdownEditor.tsx create mode 100644 src/components/editor/MarkdownToolbar.tsx create mode 100644 src/components/editor/TextEditor.tsx create mode 100644 src/components/editor/core/emoji-mention.ts create mode 100644 src/components/editor/core/index.ts create mode 100644 src/components/editor/core/serialization.ts create mode 100644 src/components/editor/core/types.ts create mode 100644 src/components/editor/index.ts create mode 100644 src/lib/composer/index.ts create mode 100644 src/lib/composer/schema.ts create mode 100644 src/lib/composer/schemas.ts diff --git a/src/components/editor/MarkdownEditor.tsx b/src/components/editor/MarkdownEditor.tsx new file mode 100644 index 0000000..e713128 --- /dev/null +++ b/src/components/editor/MarkdownEditor.tsx @@ -0,0 +1,134 @@ +/** + * MarkdownEditor - TextEditor with markdown formatting toolbar + * + * Combines the TextEditor with a MarkdownToolbar for easy formatting. + * Ideal for long-form content like articles, issues, wiki pages. + */ + +import { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { + TextEditor, + type TextEditorProps, + type TextEditorHandle, +} from "./TextEditor"; +import { MarkdownToolbar } from "./MarkdownToolbar"; +import { MarkdownContent } from "@/components/nostr/MarkdownContent"; +import type { Editor } from "@tiptap/react"; + +export interface MarkdownEditorProps extends TextEditorProps { + /** Show preview toggle button in toolbar */ + enablePreview?: boolean; + /** Initial preview state */ + initialPreview?: boolean; +} + +export interface MarkdownEditorHandle extends TextEditorHandle { + /** Toggle preview mode */ + togglePreview: () => void; + /** Check if preview is active */ + isPreviewActive: () => boolean; +} + +export const MarkdownEditor = forwardRef< + MarkdownEditorHandle, + MarkdownEditorProps +>( + ( + { enablePreview = true, initialPreview = false, className = "", ...props }, + ref, + ) => { + const textEditorRef = useRef(null); + const [showPreview, setShowPreview] = useState(initialPreview); + const [editor, setEditor] = useState(null); + + // Track when editor is available + const handleEditorReady = useCallback(() => { + const ed = textEditorRef.current?.getEditor(); + if (ed) { + setEditor(ed); + } + }, []); + + // Toggle preview mode + const togglePreview = useCallback(() => { + setShowPreview((prev) => !prev); + }, []); + + // Expose handle methods + useImperativeHandle( + ref, + () => ({ + focus: () => textEditorRef.current?.focus(), + clear: () => textEditorRef.current?.clear(), + getContent: () => textEditorRef.current?.getContent() || "", + getSerializedContent: () => + textEditorRef.current?.getSerializedContent() || { + text: "", + emojiTags: [], + blobAttachments: [], + addressRefs: [], + }, + isEmpty: () => textEditorRef.current?.isEmpty() ?? true, + submit: () => textEditorRef.current?.submit(), + insertText: (text: string) => textEditorRef.current?.insertText(text), + insertBlob: (blob) => textEditorRef.current?.insertBlob(blob), + getJSON: () => textEditorRef.current?.getJSON(), + setContent: (json) => textEditorRef.current?.setContent(json), + getEditor: () => textEditorRef.current?.getEditor(), + togglePreview, + isPreviewActive: () => showPreview, + }), + [showPreview, togglePreview], + ); + + // Get content for preview + const previewContent = showPreview + ? textEditorRef.current?.getContent() || "" + : ""; + + return ( +
+ + + {showPreview ? ( +
+ {previewContent ? ( + + ) : ( +

Nothing to preview

+ )} +
+ ) : ( + { + textEditorRef.current = node; + // Get editor after mount + setTimeout(handleEditorReady, 0); + }} + className="flex-1" + {...props} + /> + )} +
+ ); + }, +); + +MarkdownEditor.displayName = "MarkdownEditor"; diff --git a/src/components/editor/MarkdownToolbar.tsx b/src/components/editor/MarkdownToolbar.tsx new file mode 100644 index 0000000..b75660d --- /dev/null +++ b/src/components/editor/MarkdownToolbar.tsx @@ -0,0 +1,275 @@ +/** + * MarkdownToolbar - Formatting toolbar for markdown editors + * + * Provides buttons for common markdown formatting operations: + * - Bold, Italic, Code (inline) + * - Heading, Link, Quote, List (block) + * + * Works with TipTap editor by inserting markdown syntax at cursor. + */ + +import { useCallback } from "react"; +import { + Bold, + Italic, + Code, + Link, + List, + ListOrdered, + Quote, + Heading2, + Eye, + EyeOff, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { Editor } from "@tiptap/react"; + +export interface MarkdownToolbarProps { + editor: Editor | null; + /** Whether preview mode is active */ + showPreview?: boolean; + /** Toggle preview mode */ + onTogglePreview?: () => void; + /** Additional class name */ + className?: string; + /** Disable all buttons */ + disabled?: boolean; +} + +interface ToolbarButtonProps { + icon: React.ReactNode; + label: string; + shortcut?: string; + onClick: () => void; + disabled?: boolean; + active?: boolean; +} + +function ToolbarButton({ + icon, + label, + shortcut, + onClick, + disabled, + active, +}: ToolbarButtonProps) { + return ( + + + + + +

+ {label} + {shortcut && ( + {shortcut} + )} +

+
+
+ ); +} + +export function MarkdownToolbar({ + editor, + showPreview, + onTogglePreview, + className = "", + disabled = false, +}: MarkdownToolbarProps) { + // Wrap selection with markdown syntax + const wrapSelection = useCallback( + (before: string, after: string = before) => { + if (!editor) return; + + const { from, to, empty } = editor.state.selection; + + if (empty) { + // No selection - insert placeholder + editor + .chain() + .focus() + .insertContent(`${before}text${after}`) + .setTextSelection({ + from: from + before.length, + to: from + before.length + 4, + }) + .run(); + } else { + // Wrap selection + const selectedText = editor.state.doc.textBetween(from, to); + editor + .chain() + .focus() + .deleteSelection() + .insertContent(`${before}${selectedText}${after}`) + .run(); + } + }, + [editor], + ); + + // Insert at start of line(s) + const insertAtLineStart = useCallback( + (prefix: string) => { + if (!editor) return; + + const { from } = editor.state.selection; + + // Find the start of the current line + const $from = editor.state.doc.resolve(from); + const lineStart = $from.start(); + + editor.chain().focus().insertContentAt(lineStart, prefix).run(); + }, + [editor], + ); + + // Insert link with placeholder + const insertLink = useCallback(() => { + if (!editor) return; + + const { from, to, empty } = editor.state.selection; + + if (empty) { + // No selection - insert placeholder link + editor + .chain() + .focus() + .insertContent("[link text](url)") + .setTextSelection({ from: from + 1, to: from + 10 }) + .run(); + } else { + // Use selection as link text + const selectedText = editor.state.doc.textBetween(from, to); + editor + .chain() + .focus() + .deleteSelection() + .insertContent(`[${selectedText}](url)`) + .setTextSelection({ + from: from + selectedText.length + 3, + to: from + selectedText.length + 6, + }) + .run(); + } + }, [editor]); + + // Check if editor has a specific mark active + const isActive = useCallback( + (mark: string) => { + if (!editor) return false; + return editor.isActive(mark); + }, + [editor], + ); + + const isDisabled = disabled || !editor; + + return ( +
+ {/* Text formatting */} + } + label="Bold" + shortcut="Ctrl+B" + onClick={() => wrapSelection("**")} + disabled={isDisabled} + active={isActive("bold")} + /> + } + label="Italic" + shortcut="Ctrl+I" + onClick={() => wrapSelection("*")} + disabled={isDisabled} + active={isActive("italic")} + /> + } + label="Inline code" + shortcut="Ctrl+`" + onClick={() => wrapSelection("`")} + disabled={isDisabled} + active={isActive("code")} + /> + +
+ + {/* Block formatting */} + } + label="Heading" + onClick={() => insertAtLineStart("## ")} + disabled={isDisabled} + /> + } + label="Quote" + onClick={() => insertAtLineStart("> ")} + disabled={isDisabled} + /> + } + label="Bullet list" + onClick={() => insertAtLineStart("- ")} + disabled={isDisabled} + /> + } + label="Numbered list" + onClick={() => insertAtLineStart("1. ")} + disabled={isDisabled} + /> + +
+ + {/* Link */} + } + label="Insert link" + shortcut="Ctrl+K" + onClick={insertLink} + disabled={isDisabled} + /> + + {/* Spacer */} +
+ + {/* Preview toggle */} + {onTogglePreview && ( + + )} +
+ ); +} diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 976f5f7..14b669d 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -34,45 +34,15 @@ import { nip19 } from "nostr-tools"; import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; import { FilePasteHandler } from "./extensions/file-paste-handler"; -/** - * Represents an emoji tag for NIP-30 - */ -export interface EmojiTag { - shortcode: string; - url: string; -} +// Re-export types from core for backwards compatibility +export type { + EmojiTag, + BlobAttachment, + SerializedContent, + AddressRef, +} from "./core"; -/** - * Represents a blob attachment for imeta tags (NIP-92) - */ -export interface BlobAttachment { - /** The URL of the blob */ - url: string; - /** SHA256 hash of the blob content */ - sha256: string; - /** MIME type of the blob */ - mimeType?: string; - /** Size in bytes */ - size?: number; - /** Blossom server URL */ - server?: string; -} - -/** - * Result of serializing editor content - * Note: mentions, event quotes, and hashtags are extracted automatically by applesauce - * from the text content (nostr: URIs and #hashtags), so we don't need to extract them here. - */ -export interface SerializedContent { - /** The text content with mentions as nostr: URIs and emoji as :shortcode: */ - text: string; - /** Emoji tags to include in the event (NIP-30) */ - emojiTags: EmojiTag[]; - /** Blob attachments for imeta tags (NIP-92) */ - blobAttachments: BlobAttachment[]; - /** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */ - addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>; -} +import type { EmojiTag, BlobAttachment, SerializedContent } from "./core"; export interface MentionEditorProps { placeholder?: string; @@ -103,84 +73,8 @@ export interface MentionEditorHandle { insertBlob: (blob: BlobAttachment) => void; } -// Create emoji extension by extending Mention with a different name and custom node view -const EmojiMention = Mention.extend({ - name: "emoji", - - // Add custom attributes for emoji (url and source) - addAttributes() { - return { - ...this.parent?.(), - url: { - default: null, - parseHTML: (element) => element.getAttribute("data-url"), - renderHTML: (attributes) => { - if (!attributes.url) return {}; - return { "data-url": attributes.url }; - }, - }, - source: { - default: null, - parseHTML: (element) => element.getAttribute("data-source"), - renderHTML: (attributes) => { - if (!attributes.source) return {}; - return { "data-source": attributes.source }; - }, - }, - }; - }, - - // Override renderText to return empty string (nodeView handles display) - renderText({ node }) { - // Return the emoji character for unicode, or empty for custom - // This is what gets copied to clipboard - if (node.attrs.source === "unicode") { - return node.attrs.url || ""; - } - return `:${node.attrs.id}:`; - }, - - addNodeView() { - return ({ node }) => { - const { url, source, id } = node.attrs; - const isUnicode = source === "unicode"; - - // Create wrapper span - const dom = document.createElement("span"); - dom.className = "emoji-node"; - dom.setAttribute("data-emoji", id || ""); - - if (isUnicode && url) { - // Unicode emoji - render as text span - const span = document.createElement("span"); - span.className = "emoji-unicode"; - span.textContent = url; - span.title = `:${id}:`; - dom.appendChild(span); - } else if (url) { - // Custom emoji - render as image - const img = document.createElement("img"); - img.src = url; - img.alt = `:${id}:`; - img.title = `:${id}:`; - img.className = "emoji-image"; - img.draggable = false; - img.onerror = () => { - // Fallback to shortcode if image fails to load - dom.textContent = `:${id}:`; - }; - dom.appendChild(img); - } else { - // Fallback if no url - show shortcode - dom.textContent = `:${id}:`; - } - - return { - dom, - }; - }; - }, -}); +// Import shared EmojiMention extension from core +import { EmojiMention } from "./core"; // Create blob attachment extension for media previews const BlobAttachmentNode = Node.create({ diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx index ff808bc..a732d03 100644 --- a/src/components/editor/RichEditor.tsx +++ b/src/components/editor/RichEditor.tsx @@ -1,631 +1,11 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useCallback, - useRef, -} from "react"; -import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; -import { Extension } from "@tiptap/core"; -import StarterKit from "@tiptap/starter-kit"; -import Mention from "@tiptap/extension-mention"; -import Placeholder from "@tiptap/extension-placeholder"; -import type { SuggestionOptions } from "@tiptap/suggestion"; -import tippy from "tippy.js"; -import type { Instance as TippyInstance } from "tippy.js"; -import "tippy.js/dist/tippy.css"; -import { - ProfileSuggestionList, - type ProfileSuggestionListHandle, -} from "./ProfileSuggestionList"; -import { - EmojiSuggestionList, - type EmojiSuggestionListHandle, -} from "./EmojiSuggestionList"; -import type { ProfileSearchResult } from "@/services/profile-search"; -import type { EmojiSearchResult } from "@/services/emoji-search"; -import { nip19 } from "nostr-tools"; -import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; -import { FilePasteHandler } from "./extensions/file-paste-handler"; -import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich"; -import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich"; -import type { - EmojiTag, - BlobAttachment, - SerializedContent, -} from "./MentionEditor"; - -export interface RichEditorProps { - placeholder?: string; - onSubmit?: ( - content: string, - emojiTags: EmojiTag[], - blobAttachments: BlobAttachment[], - addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, - ) => void; - onChange?: () => void; - searchProfiles: (query: string) => Promise; - searchEmojis?: (query: string) => Promise; - onFilePaste?: (files: File[]) => void; - autoFocus?: boolean; - className?: string; - /** Minimum height in pixels */ - minHeight?: number; - /** Maximum height in pixels */ - maxHeight?: number; -} - -export interface RichEditorHandle { - focus: () => void; - clear: () => void; - getContent: () => string; - getSerializedContent: () => SerializedContent; - isEmpty: () => boolean; - submit: () => void; - /** Insert text at the current cursor position */ - insertText: (text: string) => void; - /** Insert a blob attachment with rich preview */ - insertBlob: (blob: BlobAttachment) => void; - /** Get editor state as JSON (for persistence) */ - getJSON: () => any; - /** Set editor content from JSON (for restoration) */ - setContent: (json: any) => void; -} - -// Create emoji extension by extending Mention with a different name and custom node view -const EmojiMention = Mention.extend({ - name: "emoji", - - // Add custom attributes for emoji (url and source) - addAttributes() { - return { - ...this.parent?.(), - url: { - default: null, - parseHTML: (element) => element.getAttribute("data-url"), - renderHTML: (attributes) => { - if (!attributes.url) return {}; - return { "data-url": attributes.url }; - }, - }, - source: { - default: null, - parseHTML: (element) => element.getAttribute("data-source"), - renderHTML: (attributes) => { - if (!attributes.source) return {}; - return { "data-source": attributes.source }; - }, - }, - }; - }, - - // Override renderText to return empty string (nodeView handles display) - renderText({ node }) { - // Return the emoji character for unicode, or empty for custom - // This is what gets copied to clipboard - if (node.attrs.source === "unicode") { - return node.attrs.url || ""; - } - return `:${node.attrs.id}:`; - }, - - addNodeView() { - return ({ node }) => { - const { url, source, id } = node.attrs; - const isUnicode = source === "unicode"; - - // Create wrapper span - const dom = document.createElement("span"); - dom.className = "emoji-node"; - dom.setAttribute("data-emoji", id || ""); - - if (isUnicode && url) { - // Unicode emoji - render as text span - const span = document.createElement("span"); - span.className = "emoji-unicode"; - span.textContent = url; - span.title = `:${id}:`; - dom.appendChild(span); - } else if (url) { - // Custom emoji - render as image - const img = document.createElement("img"); - img.src = url; - img.alt = `:${id}:`; - img.title = `:${id}:`; - img.className = "emoji-image"; - img.draggable = false; - img.onerror = () => { - // Fallback to shortcode if image fails to load - dom.textContent = `:${id}:`; - }; - dom.appendChild(img); - } else { - // Fallback if no url - show shortcode - dom.textContent = `:${id}:`; - } - - return { - dom, - }; - }; - }, -}); - /** - * Serialize editor content to plain text with nostr: URIs - * Note: hashtags, mentions, and event quotes are extracted automatically by applesauce's - * NoteBlueprint from the text content, so we only need to extract what it doesn't handle: - * - Custom emojis (for emoji tags) - * - Blob attachments (for imeta tags) - * - Address references (naddr - not yet supported by applesauce) + * @deprecated Use TextEditor instead. RichEditor is kept for backwards compatibility. */ -function serializeContent(editor: any): SerializedContent { - const emojiTags: EmojiTag[] = []; - const blobAttachments: BlobAttachment[] = []; - const addressRefs: Array<{ - kind: number; - pubkey: string; - identifier: string; - }> = []; - const seenEmojis = new Set(); - const seenBlobs = new Set(); - const seenAddrs = new Set(); - - // Get plain text representation with single newline between blocks - // (TipTap's default is double newline which adds extra blank lines) - const text = editor.getText({ blockSeparator: "\n" }); - - // Walk the document to collect emoji, blob, and address reference data - editor.state.doc.descendants((node: any) => { - if (node.type.name === "emoji") { - const { id, url, source } = node.attrs; - // Only add custom emojis (not unicode) and avoid duplicates - if (source !== "unicode" && !seenEmojis.has(id)) { - seenEmojis.add(id); - emojiTags.push({ shortcode: id, url }); - } - } else if (node.type.name === "blobAttachment") { - const { url, sha256, mimeType, size, server } = node.attrs; - // Avoid duplicates - if (!seenBlobs.has(sha256)) { - seenBlobs.add(sha256); - blobAttachments.push({ url, sha256, mimeType, size, server }); - } - } else if (node.type.name === "nostrEventPreview") { - // Extract address references (naddr) for manual a tags - // Note: applesauce handles note/nevent automatically from nostr: URIs - const { type, data } = node.attrs; - if (type === "naddr" && data) { - const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; - if (!seenAddrs.has(addrKey)) { - seenAddrs.add(addrKey); - addressRefs.push({ - kind: data.kind, - pubkey: data.pubkey, - identifier: data.identifier || "", - }); - } - } - } - }); - - return { - text, - emojiTags, - blobAttachments, - addressRefs, - }; -} - -export const RichEditor = forwardRef( - ( - { - placeholder = "Write your note...", - onSubmit, - onChange, - searchProfiles, - searchEmojis, - onFilePaste, - autoFocus = false, - className = "", - minHeight = 200, - maxHeight = 600, - }, - ref, - ) => { - // Ref to access handleSubmit from keyboard shortcuts - const handleSubmitRef = useRef<(editor: any) => void>(() => {}); - - // Create mention suggestion configuration for @ mentions - const mentionSuggestion: Omit = useMemo( - () => ({ - char: "@", - allowSpaces: false, - items: async ({ query }) => { - return await searchProfiles(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - - return { - onStart: (props) => { - component = new ReactRenderer(ProfileSuggestionList, { - props: { items: [], command: props.command }, - editor: props.editor, - }); - - if (!props.clientRect) { - return; - } - - popup = tippy("body", { - getReferenceClientRect: props.clientRect as () => DOMRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - theme: "mention", - }); - }, - - onUpdate(props) { - component.updateProps({ - items: props.items, - command: props.command, - }); - - if (!props.clientRect) { - return; - } - - popup[0].setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); - }, - - onKeyDown(props) { - if (props.event.key === "Escape") { - popup[0].hide(); - return true; - } - return component.ref?.onKeyDown(props.event) || false; - }, - - onExit() { - popup[0].destroy(); - component.destroy(); - }, - }; - }, - }), - [searchProfiles], - ); - - // Create emoji suggestion configuration for : emojis - const emojiSuggestion: Omit | undefined = - useMemo(() => { - if (!searchEmojis) return undefined; - - return { - char: ":", - allowSpaces: false, - items: async ({ query }) => { - return await searchEmojis(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - - return { - onStart: (props) => { - component = new ReactRenderer(EmojiSuggestionList, { - props: { items: [], command: props.command }, - editor: props.editor, - }); - - if (!props.clientRect) { - return; - } - - popup = tippy("body", { - getReferenceClientRect: props.clientRect as () => DOMRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - theme: "mention", - }); - }, - - onUpdate(props) { - component.updateProps({ - items: props.items, - command: props.command, - }); - - if (!props.clientRect) { - return; - } - - popup[0].setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); - }, - - onKeyDown(props) { - if (props.event.key === "Escape") { - popup[0].hide(); - return true; - } - return component.ref?.onKeyDown(props.event) || false; - }, - - onExit() { - popup[0].destroy(); - component.destroy(); - }, - }; - }, - }; - }, [searchEmojis]); - - // Handle submit - const handleSubmit = useCallback( - (editorInstance: any) => { - if (editorInstance.isEmpty) { - return; - } - - const serialized = serializeContent(editorInstance); - - if (onSubmit) { - onSubmit( - serialized.text, - serialized.emojiTags, - serialized.blobAttachments, - serialized.addressRefs, - ); - // Don't clear content here - let the parent component decide when to clear - } - }, - [onSubmit], - ); - - // Keep ref updated with latest handleSubmit - handleSubmitRef.current = handleSubmit; - - // Build extensions array - const extensions = useMemo(() => { - // Custom extension for keyboard shortcuts - const SubmitShortcut = Extension.create({ - name: "submitShortcut", - addKeyboardShortcuts() { - return { - // Ctrl/Cmd+Enter submits - "Mod-Enter": ({ editor }) => { - handleSubmitRef.current(editor); - return true; - }, - // Plain Enter creates a new line (default behavior) - }; - }, - }); - - const exts = [ - SubmitShortcut, - StarterKit.configure({ - // Enable paragraph, hardBreak, etc. for multi-line - hardBreak: { - keepMarks: false, - }, - }), - Mention.extend({ - renderText({ node }) { - // Serialize to nostr: URI for plain text export - try { - return `nostr:${nip19.npubEncode(node.attrs.id)}`; - } catch (err) { - console.error("[Mention] Failed to encode pubkey:", err); - return `@${node.attrs.label}`; - } - }, - }).configure({ - HTMLAttributes: { - class: "mention", - }, - suggestion: { - ...mentionSuggestion, - command: ({ editor, range, props }: any) => { - // props is the ProfileSearchResult - editor - .chain() - .focus() - .insertContentAt(range, [ - { - type: "mention", - attrs: { - id: props.pubkey, - label: props.displayName, - }, - }, - { type: "text", text: " " }, - ]) - .run(); - }, - }, - renderLabel({ node }) { - return `@${node.attrs.label}`; - }, - }), - Placeholder.configure({ - placeholder, - }), - // Add blob attachment extension for full-size media previews - BlobAttachmentRichNode, - // Add nostr event preview extension for full event rendering - NostrEventPreviewRichNode, - // Add paste handler to transform bech32 strings into previews - NostrPasteHandler, - // Add file paste handler for clipboard file uploads - FilePasteHandler.configure({ - onFilePaste, - }), - ]; - - // Add emoji extension if search is provided - if (emojiSuggestion) { - exts.push( - EmojiMention.configure({ - HTMLAttributes: { - class: "emoji", - }, - suggestion: { - ...emojiSuggestion, - command: ({ editor, range, props }: any) => { - // props is the EmojiSearchResult - editor - .chain() - .focus() - .insertContentAt(range, [ - { - type: "emoji", - attrs: { - id: props.shortcode, - label: props.shortcode, - url: props.url, - source: props.source, - }, - }, - { type: "text", text: " " }, - ]) - .run(); - }, - }, - }), - ); - } - - return exts; - }, [mentionSuggestion, emojiSuggestion, onFilePaste, placeholder]); - - const editor = useEditor({ - extensions, - editorProps: { - attributes: { - class: "prose prose-sm max-w-none focus:outline-none", - style: `min-height: ${minHeight}px; max-height: ${maxHeight}px; overflow-y: auto;`, - }, - }, - autofocus: autoFocus, - onUpdate: () => { - onChange?.(); - }, - }); - - // Helper to check if editor view is ready (prevents "view not available" errors) - const isEditorReady = useCallback(() => { - return editor && editor.view && editor.view.dom; - }, [editor]); - - // Expose editor methods - useImperativeHandle( - ref, - () => ({ - focus: () => { - if (isEditorReady()) { - editor?.commands.focus(); - } - }, - clear: () => { - if (isEditorReady()) { - editor?.commands.clearContent(); - } - }, - getContent: () => { - if (!isEditorReady()) return ""; - return editor?.getText({ blockSeparator: "\n" }) || ""; - }, - getSerializedContent: () => { - if (!isEditorReady() || !editor) - return { - text: "", - emojiTags: [], - blobAttachments: [], - addressRefs: [], - }; - return serializeContent(editor); - }, - isEmpty: () => { - if (!isEditorReady()) return true; - return editor?.isEmpty ?? true; - }, - submit: () => { - if (isEditorReady() && editor) { - handleSubmit(editor); - } - }, - insertText: (text: string) => { - if (isEditorReady()) { - editor?.commands.insertContent(text); - } - }, - insertBlob: (blob: BlobAttachment) => { - if (isEditorReady()) { - editor?.commands.insertContent({ - type: "blobAttachment", - attrs: blob, - }); - } - }, - getJSON: () => { - if (!isEditorReady()) return null; - return editor?.getJSON() || null; - }, - setContent: (json: any) => { - // Check editor and view are ready before setting content - if (isEditorReady() && json) { - editor?.commands.setContent(json); - } - }, - }), - [editor, handleSubmit, isEditorReady], - ); - - // Handle submit on Ctrl/Cmd+Enter - useEffect(() => { - // Check both editor and editor.view exist (view may not be ready immediately) - if (!editor?.view?.dom) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { - event.preventDefault(); - handleSubmit(editor); - } - }; - - editor.view.dom.addEventListener("keydown", handleKeyDown); - return () => { - // Also check view.dom exists in cleanup (editor might be destroyed) - editor.view?.dom?.removeEventListener("keydown", handleKeyDown); - }; - }, [editor, handleSubmit]); - - if (!editor) { - return null; - } - - return ( -
- -
- ); - }, -); - -RichEditor.displayName = "RichEditor"; +export { + TextEditor as RichEditor, + type TextEditorHandle as RichEditorHandle, + type TextEditorProps as RichEditorProps, + type EmojiTag, + type BlobAttachment, + type SerializedContent, +} from "./TextEditor"; diff --git a/src/components/editor/TextEditor.tsx b/src/components/editor/TextEditor.tsx new file mode 100644 index 0000000..b60c3c5 --- /dev/null +++ b/src/components/editor/TextEditor.tsx @@ -0,0 +1,479 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useCallback, + useRef, +} from "react"; +import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; +import { Extension } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Mention from "@tiptap/extension-mention"; +import Placeholder from "@tiptap/extension-placeholder"; +import type { SuggestionOptions } from "@tiptap/suggestion"; +import tippy from "tippy.js"; +import type { Instance as TippyInstance } from "tippy.js"; +import "tippy.js/dist/tippy.css"; +import { + ProfileSuggestionList, + type ProfileSuggestionListHandle, +} from "./ProfileSuggestionList"; +import { + EmojiSuggestionList, + type EmojiSuggestionListHandle, +} from "./EmojiSuggestionList"; +import type { ProfileSearchResult } from "@/services/profile-search"; +import type { EmojiSearchResult } from "@/services/emoji-search"; +import { nip19 } from "nostr-tools"; +import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; +import { FilePasteHandler } from "./extensions/file-paste-handler"; +import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich"; +import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich"; +import { + EmojiMention, + serializeEditorContent, + emptySerializedContent, + type TextEditorHandle, + type EmojiTag, + type BlobAttachment, + type SerializedContent, +} from "./core"; + +export interface TextEditorProps { + placeholder?: string; + onSubmit?: ( + content: string, + emojiTags: EmojiTag[], + blobAttachments: BlobAttachment[], + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, + ) => void; + onChange?: () => void; + searchProfiles: (query: string) => Promise; + searchEmojis?: (query: string) => Promise; + onFilePaste?: (files: File[]) => void; + autoFocus?: boolean; + className?: string; + /** Minimum height in pixels */ + minHeight?: number; + /** Maximum height in pixels */ + maxHeight?: number; +} + +// Re-export types and handle for consumers +export type { TextEditorHandle, EmojiTag, BlobAttachment, SerializedContent }; + +export const TextEditor = forwardRef( + ( + { + placeholder = "Write your note...", + onSubmit, + onChange, + searchProfiles, + searchEmojis, + onFilePaste, + autoFocus = false, + className = "", + minHeight = 200, + maxHeight = 600, + }, + ref, + ) => { + // Ref to access handleSubmit from keyboard shortcuts + const handleSubmitRef = useRef<(editor: any) => void>(() => {}); + + // Create mention suggestion configuration for @ mentions + const mentionSuggestion: Omit = useMemo( + () => ({ + char: "@", + allowSpaces: false, + items: async ({ query }) => { + return await searchProfiles(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(ProfileSuggestionList, { + props: { items: [], command: props.command }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "mention", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props.event) || false; + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }), + [searchProfiles], + ); + + // Create emoji suggestion configuration for : emojis + const emojiSuggestion: Omit | undefined = + useMemo(() => { + if (!searchEmojis) return undefined; + + return { + char: ":", + allowSpaces: false, + items: async ({ query }) => { + return await searchEmojis(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(EmojiSuggestionList, { + props: { items: [], command: props.command }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "mention", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props.event) || false; + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }; + }, [searchEmojis]); + + // Handle submit + const handleSubmit = useCallback( + (editorInstance: any) => { + if (editorInstance.isEmpty) { + return; + } + + const serialized = serializeEditorContent(editorInstance); + + if (onSubmit) { + onSubmit( + serialized.text, + serialized.emojiTags, + serialized.blobAttachments, + serialized.addressRefs, + ); + // Don't clear content here - let the parent component decide when to clear + } + }, + [onSubmit], + ); + + // Keep ref updated with latest handleSubmit + handleSubmitRef.current = handleSubmit; + + // Build extensions array + const extensions = useMemo(() => { + // Custom extension for keyboard shortcuts + const SubmitShortcut = Extension.create({ + name: "submitShortcut", + addKeyboardShortcuts() { + return { + // Ctrl/Cmd+Enter submits + "Mod-Enter": ({ editor }) => { + handleSubmitRef.current(editor); + return true; + }, + // Plain Enter creates a new line (default behavior) + }; + }, + }); + + const exts = [ + SubmitShortcut, + StarterKit.configure({ + // Enable paragraph, hardBreak, etc. for multi-line + hardBreak: { + keepMarks: false, + }, + }), + Mention.extend({ + renderText({ node }) { + // Serialize to nostr: URI for plain text export + try { + return `nostr:${nip19.npubEncode(node.attrs.id)}`; + } catch (err) { + console.error("[Mention] Failed to encode pubkey:", err); + return `@${node.attrs.label}`; + } + }, + }).configure({ + HTMLAttributes: { + class: "mention", + }, + suggestion: { + ...mentionSuggestion, + command: ({ editor, range, props }: any) => { + // props is the ProfileSearchResult + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "mention", + attrs: { + id: props.pubkey, + label: props.displayName, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + renderLabel({ node }) { + return `@${node.attrs.label}`; + }, + }), + Placeholder.configure({ + placeholder, + }), + // Add blob attachment extension for full-size media previews + BlobAttachmentRichNode, + // Add nostr event preview extension for full event rendering + NostrEventPreviewRichNode, + // Add paste handler to transform bech32 strings into previews + NostrPasteHandler, + // Add file paste handler for clipboard file uploads + FilePasteHandler.configure({ + onFilePaste, + }), + ]; + + // Add emoji extension if search is provided + if (emojiSuggestion) { + exts.push( + EmojiMention.configure({ + HTMLAttributes: { + class: "emoji", + }, + suggestion: { + ...emojiSuggestion, + command: ({ editor, range, props }: any) => { + // props is the EmojiSearchResult + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "emoji", + attrs: { + id: props.shortcode, + label: props.shortcode, + url: props.url, + source: props.source, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + }), + ); + } + + return exts; + }, [mentionSuggestion, emojiSuggestion, onFilePaste, placeholder]); + + const editor = useEditor({ + extensions, + editorProps: { + attributes: { + class: "prose prose-sm max-w-none focus:outline-none", + style: `min-height: ${minHeight}px; max-height: ${maxHeight}px; overflow-y: auto;`, + }, + }, + autofocus: autoFocus, + onUpdate: () => { + onChange?.(); + }, + }); + + // Helper to check if editor view is ready (prevents "view not available" errors) + const isEditorReady = useCallback(() => { + return editor && editor.view && editor.view.dom; + }, [editor]); + + // Expose editor methods + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (isEditorReady()) { + editor?.commands.focus(); + } + }, + clear: () => { + if (isEditorReady()) { + editor?.commands.clearContent(); + } + }, + getContent: () => { + if (!isEditorReady()) return ""; + return editor?.getText({ blockSeparator: "\n" }) || ""; + }, + getSerializedContent: () => { + if (!isEditorReady() || !editor) return emptySerializedContent(); + return serializeEditorContent(editor); + }, + isEmpty: () => { + if (!isEditorReady()) return true; + return editor?.isEmpty ?? true; + }, + submit: () => { + if (isEditorReady() && editor) { + handleSubmit(editor); + } + }, + insertText: (text: string) => { + if (isEditorReady()) { + editor?.commands.insertContent(text); + } + }, + insertBlob: (blob: BlobAttachment) => { + if (isEditorReady()) { + editor?.commands.insertContent({ + type: "blobAttachment", + attrs: blob, + }); + } + }, + getJSON: () => { + if (!isEditorReady()) return null; + return editor?.getJSON() || null; + }, + setContent: (json: any) => { + // Check editor and view are ready before setting content + if (isEditorReady() && json) { + editor?.commands.setContent(json); + } + }, + getEditor: () => { + if (!isEditorReady()) return null; + return editor; + }, + }), + [editor, handleSubmit, isEditorReady], + ); + + // Handle submit on Ctrl/Cmd+Enter + useEffect(() => { + // Check both editor and editor.view exist (view may not be ready immediately) + if (!editor?.view?.dom) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + handleSubmit(editor); + } + }; + + editor.view.dom.addEventListener("keydown", handleKeyDown); + return () => { + // Also check view.dom exists in cleanup (editor might be destroyed) + editor.view?.dom?.removeEventListener("keydown", handleKeyDown); + }; + }, [editor, handleSubmit]); + + if (!editor) { + return null; + } + + return ( +
+ +
+ ); + }, +); + +TextEditor.displayName = "TextEditor"; + +// Backwards compatibility alias +export const RichEditor = TextEditor; +export type RichEditorHandle = TextEditorHandle; +export type RichEditorProps = TextEditorProps; diff --git a/src/components/editor/core/emoji-mention.ts b/src/components/editor/core/emoji-mention.ts new file mode 100644 index 0000000..d6b3a71 --- /dev/null +++ b/src/components/editor/core/emoji-mention.ts @@ -0,0 +1,90 @@ +/** + * EmojiMention - TipTap extension for emoji autocomplete + * + * Supports both Unicode emojis and custom Nostr emojis (NIP-30). + * Triggered by typing ":" followed by a shortcode. + */ + +import Mention from "@tiptap/extension-mention"; + +/** + * Extended Mention node for emoji support + * - Unicode emojis render as text + * - Custom emojis render as images with fallback to shortcode + */ +export const EmojiMention = Mention.extend({ + name: "emoji", + + // Add custom attributes for emoji (url and source) + addAttributes() { + return { + ...this.parent?.(), + url: { + default: null, + parseHTML: (element) => element.getAttribute("data-url"), + renderHTML: (attributes) => { + if (!attributes.url) return {}; + return { "data-url": attributes.url }; + }, + }, + source: { + default: null, + parseHTML: (element) => element.getAttribute("data-source"), + renderHTML: (attributes) => { + if (!attributes.source) return {}; + return { "data-source": attributes.source }; + }, + }, + }; + }, + + // Override renderText to return appropriate text for clipboard + renderText({ node }) { + // Return the emoji character for unicode, or :shortcode: for custom + if (node.attrs.source === "unicode") { + return node.attrs.url || ""; + } + return `:${node.attrs.id}:`; + }, + + addNodeView() { + return ({ node }) => { + const { url, source, id } = node.attrs; + const isUnicode = source === "unicode"; + + // Create wrapper span + const dom = document.createElement("span"); + dom.className = "emoji-node"; + dom.setAttribute("data-emoji", id || ""); + + if (isUnicode && url) { + // Unicode emoji - render as text span + const span = document.createElement("span"); + span.className = "emoji-unicode"; + span.textContent = url; + span.title = `:${id}:`; + dom.appendChild(span); + } else if (url) { + // Custom emoji - render as image + const img = document.createElement("img"); + img.src = url; + img.alt = `:${id}:`; + img.title = `:${id}:`; + img.className = "emoji-image"; + img.draggable = false; + img.onerror = () => { + // Fallback to shortcode if image fails to load + dom.textContent = `:${id}:`; + }; + dom.appendChild(img); + } else { + // Fallback if no url - show shortcode + dom.textContent = `:${id}:`; + } + + return { + dom, + }; + }; + }, +}); diff --git a/src/components/editor/core/index.ts b/src/components/editor/core/index.ts new file mode 100644 index 0000000..c4950b8 --- /dev/null +++ b/src/components/editor/core/index.ts @@ -0,0 +1,23 @@ +/** + * Core editor utilities and shared code + */ + +// Types +export type { + EmojiTag, + BlobAttachment, + AddressRef, + SerializedContent, + BaseEditorHandle, + TextEditorHandle, +} from "./types"; + +// Extensions +export { EmojiMention } from "./emoji-mention"; + +// Serialization +export { + serializeEditorContent, + serializeEditorContentFromJSON, + emptySerializedContent, +} from "./serialization"; diff --git a/src/components/editor/core/serialization.ts b/src/components/editor/core/serialization.ts new file mode 100644 index 0000000..26b6b17 --- /dev/null +++ b/src/components/editor/core/serialization.ts @@ -0,0 +1,203 @@ +/** + * Editor content serialization utilities + * + * Converts TipTap editor content to plain text with Nostr-specific handling: + * - Mentions → nostr:npub... + * - Emojis → :shortcode: (custom) or unicode character + * - Event references → nostr:note.../nevent.../naddr... + * - Blob attachments → URL + * + * Also extracts metadata for NIP tags: + * - Custom emoji tags (NIP-30) + * - Blob attachments for imeta tags (NIP-92) + * - Address references for a tags (naddr) + */ + +import { nip19 } from "nostr-tools"; +import type { Editor } from "@tiptap/react"; +import type { + SerializedContent, + EmojiTag, + BlobAttachment, + AddressRef, +} from "./types"; + +/** + * Serialize editor content using getText + descendants walk + * Used by TextEditor (block-level nodes) + */ +export function serializeEditorContent(editor: Editor): SerializedContent { + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const addressRefs: AddressRef[] = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + const seenAddrs = new Set(); + + // Get plain text representation with single newline between blocks + // (TipTap's default is double newline which adds extra blank lines) + const text = editor.getText({ blockSeparator: "\n" }); + + // Walk the document to collect emoji, blob, and address reference data + editor.state.doc.descendants((node) => { + if (node.type.name === "emoji") { + const { id, url, source } = node.attrs; + // Only add custom emojis (not unicode) and avoid duplicates + if (source !== "unicode" && !seenEmojis.has(id)) { + seenEmojis.add(id); + emojiTags.push({ shortcode: id, url }); + } + } else if (node.type.name === "blobAttachment") { + const { url, sha256, mimeType, size, server } = node.attrs; + // Avoid duplicates + if (sha256 && !seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ url, sha256, mimeType, size, server }); + } + } else if (node.type.name === "nostrEventPreview") { + // Extract address references (naddr) for manual a tags + // Note: applesauce handles note/nevent automatically from nostr: URIs + const { type, data } = node.attrs; + if (type === "naddr" && data) { + const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!seenAddrs.has(addrKey)) { + seenAddrs.add(addrKey); + addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } + } + }); + + return { + text, + emojiTags, + blobAttachments, + addressRefs, + }; +} + +/** + * Serialize editor content by walking JSON structure + * Used by ChatEditor (inline nodes) + */ +export function serializeEditorContentFromJSON( + editor: Editor, +): SerializedContent { + let text = ""; + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const addressRefs: AddressRef[] = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + const seenAddrs = new Set(); + const json = editor.getJSON(); + + json.content?.forEach((node: any) => { + if (node.type === "paragraph") { + node.content?.forEach((child: any) => { + if (child.type === "text") { + text += child.text; + } else if (child.type === "hardBreak") { + // Preserve newlines from Shift+Enter + text += "\n"; + } else if (child.type === "mention") { + const pubkey = child.attrs?.id; + if (pubkey) { + try { + const npub = nip19.npubEncode(pubkey); + text += `nostr:${npub}`; + } catch { + // Fallback to display name if encoding fails + text += `@${child.attrs?.label || "unknown"}`; + } + } + } else if (child.type === "emoji") { + const shortcode = child.attrs?.id; + const url = child.attrs?.url; + const source = child.attrs?.source; + + if (source === "unicode" && url) { + // Unicode emoji - output the actual character + text += url; + } else if (shortcode) { + // Custom emoji - output :shortcode: and add tag + text += `:${shortcode}:`; + + if (url && !seenEmojis.has(shortcode)) { + seenEmojis.add(shortcode); + emojiTags.push({ shortcode, url }); + } + } + } else if (child.type === "blobAttachment") { + // Blob attachment - output URL and track for imeta tag + const { url, sha256, mimeType, size, server } = child.attrs; + if (url) { + text += url; + // Add to blob attachments for imeta tags (dedupe by sha256) + if (sha256 && !seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ + url, + sha256, + mimeType: mimeType || undefined, + size: size || undefined, + server: server || undefined, + }); + } + } + } else if (child.type === "nostrEventPreview") { + // Nostr event preview - serialize back to nostr: URI + const { type, data } = child.attrs; + try { + if (type === "note") { + text += `nostr:${nip19.noteEncode(data)}`; + } else if (type === "nevent") { + text += `nostr:${nip19.neventEncode(data)}`; + } else if (type === "naddr") { + text += `nostr:${nip19.naddrEncode(data)}`; + // Extract addressRefs for manual a tags (applesauce doesn't handle naddr yet) + const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!seenAddrs.has(addrKey)) { + seenAddrs.add(addrKey); + addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } + } catch (err) { + console.error( + "[serializeEditorContent] Failed to serialize nostr preview:", + err, + ); + } + } + }); + text += "\n"; + } + }); + + return { + text: text.trim(), + emojiTags, + blobAttachments, + addressRefs, + }; +} + +/** + * Create empty serialized content + */ +export function emptySerializedContent(): SerializedContent { + return { + text: "", + emojiTags: [], + blobAttachments: [], + addressRefs: [], + }; +} diff --git a/src/components/editor/core/types.ts b/src/components/editor/core/types.ts new file mode 100644 index 0000000..1d335f5 --- /dev/null +++ b/src/components/editor/core/types.ts @@ -0,0 +1,80 @@ +/** + * Shared types for editor components + */ + +/** + * Represents an emoji tag for NIP-30 + */ +export interface EmojiTag { + shortcode: string; + url: string; +} + +/** + * Represents a blob attachment for imeta tags (NIP-92) + */ +export interface BlobAttachment { + /** The URL of the blob */ + url: string; + /** SHA256 hash of the blob content */ + sha256: string; + /** MIME type of the blob */ + mimeType?: string; + /** Size in bytes */ + size?: number; + /** Blossom server URL */ + server?: string; +} + +/** + * Address reference for a-tags (from naddr) + */ +export interface AddressRef { + kind: number; + pubkey: string; + identifier: string; +} + +/** + * Result of serializing editor content + * Note: mentions, event quotes, and hashtags are extracted automatically by applesauce + * from the text content (nostr: URIs and #hashtags), so we don't need to extract them here. + */ +export interface SerializedContent { + /** The text content with mentions as nostr: URIs and emoji as :shortcode: */ + text: string; + /** Emoji tags to include in the event (NIP-30) */ + emojiTags: EmojiTag[]; + /** Blob attachments for imeta tags (NIP-92) */ + blobAttachments: BlobAttachment[]; + /** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */ + addressRefs: AddressRef[]; +} + +/** + * Common editor handle interface + */ +export interface BaseEditorHandle { + focus: () => void; + clear: () => void; + getContent: () => string; + getSerializedContent: () => SerializedContent; + isEmpty: () => boolean; + submit: () => void; + /** Insert text at the current cursor position */ + insertText: (text: string) => void; + /** Insert a blob attachment */ + insertBlob: (blob: BlobAttachment) => void; +} + +/** + * Extended editor handle with JSON state support (for drafts) + */ +export interface TextEditorHandle extends BaseEditorHandle { + /** Get editor state as JSON (for persistence) */ + getJSON: () => any; + /** Set editor content from JSON (for restoration) */ + setContent: (json: any) => void; + /** Get the underlying TipTap editor instance (for toolbar integration) */ + getEditor: () => any | null; +} diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts new file mode 100644 index 0000000..0c87338 --- /dev/null +++ b/src/components/editor/index.ts @@ -0,0 +1,47 @@ +/** + * Editor components for Nostr content creation + * + * Available editors: + * - TextEditor: Base rich text editor with mentions, emoji, media (formerly RichEditor) + * - MarkdownEditor: TextEditor with markdown formatting toolbar + * - MentionEditor: Lightweight chat/message editor + * + * For backwards compatibility, RichEditor is exported as an alias for TextEditor. + */ + +// Core types +export type { + EmojiTag, + BlobAttachment, + AddressRef, + SerializedContent, + BaseEditorHandle, + TextEditorHandle, +} from "./core"; + +// Text editor (main editor for posts, articles, etc.) +export { TextEditor, type TextEditorProps } from "./TextEditor"; + +// Markdown editor (TextEditor + formatting toolbar) +export { + MarkdownEditor, + type MarkdownEditorProps, + type MarkdownEditorHandle, +} from "./MarkdownEditor"; + +// Markdown toolbar (can be used standalone) +export { MarkdownToolbar, type MarkdownToolbarProps } from "./MarkdownToolbar"; + +// Mention editor (lightweight for chat) +export { + MentionEditor, + type MentionEditorProps, + type MentionEditorHandle, +} from "./MentionEditor"; + +// Backwards compatibility - RichEditor is now TextEditor +export { + RichEditor, + type RichEditorHandle, + type RichEditorProps, +} from "./RichEditor"; diff --git a/src/lib/composer/index.ts b/src/lib/composer/index.ts new file mode 100644 index 0000000..f0dd66f --- /dev/null +++ b/src/lib/composer/index.ts @@ -0,0 +1,41 @@ +/** + * Composer module - Schema-driven event composition + * + * This module provides types and schemas for composing different Nostr event kinds. + */ + +// Schema types +export type { + ComposerSchema, + ComposerContext, + ComposerInput, + ContentType, + EditorVariant, + TitleFieldConfig, + LabelsConfig, + MetadataConfig, + CustomFieldConfig, + ContextBinding, + ThreadingStyle, + RelayStrategy, + MediaConfig, + EmojiConfig, + IdentifierConfig, + DraftConfig, +} from "./schema"; + +// Utilities +export { slugify } from "./schema"; + +// Predefined schemas +export { + NOTE_SCHEMA, + COMMENT_SCHEMA, + ISSUE_SCHEMA, + GROUP_MESSAGE_SCHEMA, + ARTICLE_SCHEMA, + WIKI_ARTICLE_SCHEMA, + SCHEMAS, + getSchema, + hasSchema, +} from "./schemas"; diff --git a/src/lib/composer/schema.ts b/src/lib/composer/schema.ts new file mode 100644 index 0000000..82ab035 --- /dev/null +++ b/src/lib/composer/schema.ts @@ -0,0 +1,245 @@ +/** + * ComposerSchema - Schema definitions for event kind composers + * + * This module defines the structure for describing how to compose different + * Nostr event kinds. Each schema specifies: + * - Content type and editor to use + * - Metadata fields (title, summary, etc.) + * - Context binding (what the event relates to) + * - Threading style (for replies) + * - Relay selection strategy + * - Whether the event is replaceable + */ + +import type { AddressPointer, EventPointer } from "nostr-tools/nip19"; + +/** + * Content type determines which editor to use + */ +export type ContentType = "text" | "markdown"; + +/** + * Editor variant to render + */ +export type EditorVariant = "text" | "markdown" | "chat"; + +/** + * Title/subject field configuration + */ +export interface TitleFieldConfig { + /** Which tag to use for the title */ + tag: "title" | "subject" | "name"; + /** Whether the title is required */ + required: boolean; + /** Label shown in the UI */ + label: string; + /** Placeholder text */ + placeholder?: string; +} + +/** + * Label/tag field configuration (for t-tags) + */ +export interface LabelsConfig { + /** Tag name (usually "t") */ + tag: string; + /** How to handle labels */ + style: "auto-extract" | "explicit" | "both"; + /** Label shown in the UI */ + label?: string; + /** Placeholder text */ + placeholder?: string; +} + +/** + * Metadata fields configuration + */ +export interface MetadataConfig { + title?: TitleFieldConfig; + summary?: { tag: "summary" | "description"; label?: string }; + image?: { tag: "image"; label?: string }; + publishedAt?: { tag: "published_at"; auto?: boolean }; + labels?: LabelsConfig; + /** Custom domain-specific fields */ + custom?: CustomFieldConfig[]; +} + +/** + * Custom field configuration for domain-specific metadata + */ +export interface CustomFieldConfig { + tag: string; + type: "text" | "number" | "date" | "timestamp" | "select" | "location"; + label: string; + placeholder?: string; + required?: boolean; + options?: string[]; // For select type +} + +/** + * Context binding - what this event relates to + */ +export type ContextBinding = + | { type: "standalone" } + | { type: "address"; tag: "a"; required: boolean } + | { type: "event"; tag: "e"; marker?: "root" } + | { type: "group"; tag: "h" } + | { type: "comment"; style: "nip22" } + | { type: "multi"; tags: ("a" | "e" | "p" | "r")[] }; + +/** + * Threading style for replies + */ +export type ThreadingStyle = + | { style: "none" } + | { style: "nip10"; markers: ("root" | "reply")[] } + | { style: "nip22" } + | { style: "q-tag" } + | { style: "custom"; tag: string; values: string[] }; + +/** + * Relay selection strategy + */ +export type RelayStrategy = + | { type: "user-outbox" } + | { type: "user-outbox"; additional: "context-hints" } + | { type: "context-only"; fromContext: true } + | { type: "address-hints"; fallback: "user-outbox" }; + +/** + * Media configuration + */ +export interface MediaConfig { + allowed: boolean; + tag: "imeta"; + types?: string[]; // MIME type filter +} + +/** + * Emoji configuration + */ +export interface EmojiConfig { + allowed: boolean; + tag: "emoji"; +} + +/** + * Identifier configuration (for replaceable events) + */ +export interface IdentifierConfig { + tag: "d"; + source: "auto" | "from-title" | "user-input" | "prop"; + /** Function to generate identifier from input */ + generator?: (input: ComposerInput) => string; +} + +/** + * Draft configuration + */ +export interface DraftConfig { + supported: boolean; + /** Kind to use for drafts (e.g., 30024 for 30023 articles) */ + draftKind?: number; + /** Function to generate storage key */ + storageKey?: (context: ComposerContext) => string; +} + +/** + * Input provided to identifier generators + */ +export interface ComposerInput { + title?: string; + content: string; + labels?: string[]; +} + +/** + * Context provided to the composer + */ +export interface ComposerContext { + /** Repository, article, etc. this event relates to */ + address?: AddressPointer; + /** Event this is replying to */ + replyTo?: EventPointer; + /** Group identifier (for NIP-29) */ + groupId?: string; + /** Single relay (for groups) */ + groupRelay?: string; + /** Window/instance ID (for draft storage) */ + windowId?: string; +} + +/** + * Complete schema for a composable event kind + */ +export interface ComposerSchema { + /** Event kind number */ + kind: number; + + /** Human-readable name */ + name: string; + + /** Description for UI */ + description?: string; + + // ═══════════════════════════════════════════════════════ + // CONTENT + // ═══════════════════════════════════════════════════════ + content: { + type: ContentType; + editor: EditorVariant; + placeholder?: string; + }; + + // ═══════════════════════════════════════════════════════ + // METADATA + // ═══════════════════════════════════════════════════════ + metadata: MetadataConfig; + + // ═══════════════════════════════════════════════════════ + // CONTEXT BINDING + // ═══════════════════════════════════════════════════════ + context: ContextBinding; + + // ═══════════════════════════════════════════════════════ + // THREADING + // ═══════════════════════════════════════════════════════ + threading: ThreadingStyle; + + // ═══════════════════════════════════════════════════════ + // MEDIA & EMOJI + // ═══════════════════════════════════════════════════════ + media: MediaConfig; + emoji: EmojiConfig; + + // ═══════════════════════════════════════════════════════ + // IDENTIFIER (for replaceable events) + // ═══════════════════════════════════════════════════════ + identifier?: IdentifierConfig; + + // ═══════════════════════════════════════════════════════ + // RELAY STRATEGY + // ═══════════════════════════════════════════════════════ + relays: RelayStrategy; + + // ═══════════════════════════════════════════════════════ + // BEHAVIOR + // ═══════════════════════════════════════════════════════ + /** Whether this is a replaceable event (kind 10000-19999 or 30000-39999) */ + replaceable: boolean; + + /** Draft configuration */ + drafts: DraftConfig; +} + +/** + * Helper to create a slug from title + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/src/lib/composer/schemas.ts b/src/lib/composer/schemas.ts new file mode 100644 index 0000000..abafad7 --- /dev/null +++ b/src/lib/composer/schemas.ts @@ -0,0 +1,300 @@ +/** + * Predefined schemas for common event kinds + * + * These schemas define how to compose different Nostr event kinds. + * Add new schemas here as more kinds are supported. + */ + +import type { ComposerSchema } from "./schema"; +import { slugify } from "./schema"; + +/** + * Kind 1: Short text note + */ +export const NOTE_SCHEMA: ComposerSchema = { + kind: 1, + name: "Note", + description: "Short text note", + + content: { + type: "text", + editor: "text", + placeholder: "What's on your mind?", + }, + + metadata: { + title: { + tag: "subject", + required: false, + label: "Subject (optional)", + placeholder: "Add a subject line", + }, + labels: { + tag: "t", + style: "auto-extract", + }, + }, + + context: { type: "standalone" }, + threading: { style: "nip10", markers: ["root", "reply"] }, + + media: { allowed: true, tag: "imeta" }, + emoji: { allowed: true, tag: "emoji" }, + + relays: { type: "user-outbox" }, + + replaceable: false, + drafts: { + supported: true, + storageKey: (ctx) => `note-draft-${ctx.windowId || "default"}`, + }, +}; + +/** + * Kind 1111: Comment (NIP-22) + */ +export const COMMENT_SCHEMA: ComposerSchema = { + kind: 1111, + name: "Comment", + description: "Comment on any Nostr event or external content", + + content: { + type: "markdown", + editor: "text", // Comments use plain text editor, not markdown toolbar + placeholder: "Write a comment...", + }, + + metadata: { + labels: { + tag: "t", + style: "auto-extract", + }, + }, + + context: { type: "comment", style: "nip22" }, + threading: { style: "nip22" }, + + media: { allowed: true, tag: "imeta" }, + emoji: { allowed: true, tag: "emoji" }, + + relays: { type: "user-outbox", additional: "context-hints" }, + + replaceable: false, + drafts: { supported: false }, +}; + +/** + * Kind 1621: Issue (NIP-34) + */ +export const ISSUE_SCHEMA: ComposerSchema = { + kind: 1621, + name: "Issue", + description: "Bug report or feature request for a repository", + + content: { + type: "markdown", + editor: "markdown", + placeholder: "Describe the issue...", + }, + + metadata: { + title: { + tag: "subject", + required: false, + label: "Title (optional)", + placeholder: "Brief description of the issue", + }, + labels: { + tag: "t", + style: "explicit", + label: "Labels", + placeholder: "bug, enhancement, help-wanted", + }, + }, + + context: { type: "address", tag: "a", required: true }, + threading: { style: "nip10", markers: ["root", "reply"] }, + + media: { allowed: true, tag: "imeta" }, + emoji: { allowed: true, tag: "emoji" }, + + relays: { type: "address-hints", fallback: "user-outbox" }, + + replaceable: false, + drafts: { + supported: true, + storageKey: (ctx) => + ctx.address + ? `issue-draft-${ctx.address.kind}:${ctx.address.pubkey}:${ctx.address.identifier}` + : "issue-draft-new", + }, +}; + +/** + * Kind 9: Group chat message (NIP-29) + */ +export const GROUP_MESSAGE_SCHEMA: ComposerSchema = { + kind: 9, + name: "Group Message", + description: "Message in a relay-based group", + + content: { + type: "text", + editor: "chat", + placeholder: "Type a message...", + }, + + metadata: { + labels: { + tag: "t", + style: "auto-extract", + }, + }, + + context: { type: "group", tag: "h" }, + threading: { style: "q-tag" }, + + media: { allowed: true, tag: "imeta" }, + emoji: { allowed: true, tag: "emoji" }, + + relays: { type: "context-only", fromContext: true }, + + replaceable: false, + drafts: { supported: false }, +}; + +/** + * Kind 30023: Long-form article (NIP-23) + */ +export const ARTICLE_SCHEMA: ComposerSchema = { + kind: 30023, + name: "Article", + description: "Long-form content with rich formatting", + + content: { + type: "markdown", + editor: "markdown", + placeholder: "Write your article...", + }, + + metadata: { + title: { + tag: "title", + required: true, + label: "Title", + placeholder: "Article title", + }, + summary: { + tag: "summary", + label: "Summary (optional)", + }, + image: { + tag: "image", + label: "Cover image URL", + }, + publishedAt: { + tag: "published_at", + auto: true, + }, + labels: { + tag: "t", + style: "both", + label: "Topics", + placeholder: "nostr, development, tutorial", + }, + }, + + context: { type: "standalone" }, + threading: { style: "none" }, + + media: { allowed: true, tag: "imeta" }, + emoji: { allowed: true, tag: "emoji" }, + + identifier: { + tag: "d", + source: "from-title", + generator: (input) => slugify(input.title || "untitled"), + }, + + relays: { type: "user-outbox" }, + + replaceable: true, + drafts: { + supported: true, + draftKind: 30024, + storageKey: (ctx) => `article-draft-${ctx.windowId || "new"}`, + }, +}; + +/** + * Kind 30818: Wiki article (NIP-54) + */ +export const WIKI_ARTICLE_SCHEMA: ComposerSchema = { + kind: 30818, + name: "Wiki Article", + description: "Collaborative wiki entry", + + content: { + type: "markdown", // Actually AsciiDoc per spec, but we can treat as markdown + editor: "markdown", + placeholder: "Write the article content...", + }, + + metadata: { + title: { + tag: "title", + required: false, + label: "Display Title", + placeholder: "Optional display title (d-tag used if empty)", + }, + summary: { + tag: "summary", + label: "Summary", + }, + }, + + context: { type: "standalone" }, + threading: { style: "none" }, + + media: { allowed: true, tag: "imeta" }, + emoji: { allowed: true, tag: "emoji" }, + + identifier: { + tag: "d", + source: "user-input", // Wiki articles need explicit topic identifiers + }, + + relays: { type: "user-outbox" }, + + replaceable: true, + drafts: { + supported: true, + storageKey: (ctx) => `wiki-draft-${ctx.windowId || "new"}`, + }, +}; + +/** + * Registry of all schemas by kind + */ +export const SCHEMAS: Record = { + 1: NOTE_SCHEMA, + 9: GROUP_MESSAGE_SCHEMA, + 1111: COMMENT_SCHEMA, + 1621: ISSUE_SCHEMA, + 30023: ARTICLE_SCHEMA, + 30818: WIKI_ARTICLE_SCHEMA, +}; + +/** + * Get schema for a kind, or undefined if not supported + */ +export function getSchema(kind: number): ComposerSchema | undefined { + return SCHEMAS[kind]; +} + +/** + * Check if a kind has a composer schema + */ +export function hasSchema(kind: number): boolean { + return kind in SCHEMAS; +}