diff --git a/package-lock.json b/package-lock.json index b5615be..a3352f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tiptap/core": "^3.15.3", + "@tiptap/extension-link": "^3.19.0", "@tiptap/extension-mention": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", "@tiptap/pm": "^3.15.3", @@ -5283,16 +5284,16 @@ } }, "node_modules/@tiptap/core": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz", - "integrity": "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", + "integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.18.0" + "@tiptap/pm": "^3.19.0" } }, "node_modules/@tiptap/extension-blockquote": { @@ -5488,9 +5489,9 @@ } }, "node_modules/@tiptap/extension-link": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.18.0.tgz", - "integrity": "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.19.0.tgz", + "integrity": "sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==", "license": "MIT", "dependencies": { "linkifyjs": "^4.3.2" @@ -5500,8 +5501,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.18.0", - "@tiptap/pm": "^3.18.0" + "@tiptap/core": "^3.19.0", + "@tiptap/pm": "^3.19.0" } }, "node_modules/@tiptap/extension-list": { @@ -5652,9 +5653,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.18.0.tgz", - "integrity": "sha512-8RoI5gW0xBVCsuxahpK8vx7onAw6k2/uR3hbGBBnH+HocDMaAZKot3nTyY546ij8ospIC1mnQ7k4BhVUZesZDQ==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz", + "integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", diff --git a/package.json b/package.json index 4cf5d98..78cc0d0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tiptap/core": "^3.15.3", + "@tiptap/extension-link": "^3.19.0", "@tiptap/extension-mention": "^3.15.3", "@tiptap/extension-placeholder": "^3.15.3", "@tiptap/pm": "^3.15.3", diff --git a/src/components/editor/MarkdownEditor.tsx b/src/components/editor/MarkdownEditor.tsx new file mode 100644 index 0000000..cedf741 --- /dev/null +++ b/src/components/editor/MarkdownEditor.tsx @@ -0,0 +1,528 @@ +import { + forwardRef, + useImperativeHandle, + useMemo, + useCallback, + useRef, + useState, +} 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 Link from "@tiptap/extension-link"; +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 { BlobAttachment, SerializedContent } from "./MentionEditor"; +import { MarkdownToolbar } from "./MarkdownToolbar"; +import { MarkdownContent } from "@/components/nostr/MarkdownContent"; +import { serializeEditorToMarkdown } from "@/lib/markdown-serializer"; + +export interface MarkdownEditorProps { + placeholder?: string; + onSubmit?: (markdown: string, serialized: SerializedContent) => void; + onChange?: () => void; + searchProfiles: (query: string) => Promise; + searchEmojis?: (query: string) => Promise; + onFilePaste?: (files: File[]) => void; + autoFocus?: boolean; + className?: string; + /** Minimum editor height in pixels */ + minHeight?: number; + /** Maximum editor height in pixels */ + maxHeight?: number; +} + +export interface MarkdownEditorHandle { + focus: () => void; + clear: () => void; + /** Get the content serialized as a markdown string */ + getMarkdown: () => string; + /** Get full serialized content with emoji tags, blob attachments, etc. */ + 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/drafts) */ + getJSON: () => any; + /** Restore editor content from JSON */ + setContent: (json: any) => void; +} + +// Create emoji extension by extending Mention with a different name and custom node view +const EmojiMention = Mention.extend({ + name: "emoji", + + 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 }; + }, + }, + }; + }, + + renderText({ node }) { + 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"; + + const dom = document.createElement("span"); + dom.className = "emoji-node"; + dom.setAttribute("data-emoji", id || ""); + + if (isUnicode && url) { + const span = document.createElement("span"); + span.className = "emoji-unicode"; + span.textContent = url; + span.title = `:${id}:`; + dom.appendChild(span); + } else if (url) { + const img = document.createElement("img"); + img.src = url; + img.alt = `:${id}:`; + img.title = `:${id}:`; + img.className = "emoji-image"; + img.draggable = false; + img.onerror = () => { + dom.textContent = `:${id}:`; + }; + dom.appendChild(img); + } else { + dom.textContent = `:${id}:`; + } + + return { dom }; + }; + }, +}); + +export const MarkdownEditor = forwardRef< + MarkdownEditorHandle, + MarkdownEditorProps +>( + ( + { + placeholder = "Write markdown...", + onSubmit, + onChange, + searchProfiles, + searchEmojis, + onFilePaste, + autoFocus = false, + className = "", + minHeight = 200, + maxHeight = 600, + }, + ref, + ) => { + const [preview, setPreview] = useState(false); + const [previewContent, setPreviewContent] = useState(""); + const handleSubmitRef = useRef<(editor: any) => void>(() => {}); + + // Create mention suggestion configuration + 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 + 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 = serializeEditorToMarkdown(editorInstance); + + if (onSubmit) { + onSubmit(serialized.text, serialized); + } + }, + [onSubmit], + ); + + handleSubmitRef.current = handleSubmit; + + // Build extensions + const extensions = useMemo(() => { + const SubmitShortcut = Extension.create({ + name: "submitShortcut", + addKeyboardShortcuts() { + return { + "Mod-Enter": ({ editor }) => { + handleSubmitRef.current(editor); + return true; + }, + }; + }, + }); + + const exts = [ + SubmitShortcut, + StarterKit.configure({ + hardBreak: { keepMarks: false }, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: "text-accent underline decoration-dotted cursor-pointer", + }, + }), + Mention.extend({ + renderText({ node }) { + try { + return `nostr:${nip19.npubEncode(node.attrs.id)}`; + } catch { + return `@${node.attrs.label}`; + } + }, + }).configure({ + HTMLAttributes: { class: "mention" }, + suggestion: { + ...mentionSuggestion, + command: ({ editor, range, props }: any) => { + 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 }), + BlobAttachmentRichNode, + NostrEventPreviewRichNode, + NostrPasteHandler, + FilePasteHandler.configure({ onFilePaste }), + ]; + + if (emojiSuggestion) { + exts.push( + EmojiMention.configure({ + HTMLAttributes: { class: "emoji" }, + suggestion: { + ...emojiSuggestion, + command: ({ editor, range, props }: any) => { + 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; padding: 1rem;`, + }, + }, + autofocus: autoFocus, + onUpdate: () => { + onChange?.(); + }, + }); + + const isEditorReady = useCallback(() => { + return editor && editor.view && editor.view.dom; + }, [editor]); + + // Toggle preview mode + const togglePreview = useCallback(() => { + setPreview((prev) => { + if (!prev && isEditorReady() && editor) { + // Entering preview: capture current markdown + const serialized = serializeEditorToMarkdown(editor); + setPreviewContent(serialized.text); + } + return !prev; + }); + }, [editor, isEditorReady]); + + // Expose editor methods + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (isEditorReady()) { + editor?.commands.focus(); + } + }, + clear: () => { + if (isEditorReady()) { + editor?.commands.clearContent(); + } + }, + getMarkdown: () => { + if (!isEditorReady() || !editor) return ""; + return serializeEditorToMarkdown(editor).text; + }, + getSerializedContent: () => { + if (!isEditorReady() || !editor) + return { + text: "", + emojiTags: [], + blobAttachments: [], + addressRefs: [], + }; + return serializeEditorToMarkdown(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) => { + if (isEditorReady() && json) { + editor?.commands.setContent(json); + } + }, + }), + [editor, handleSubmit, isEditorReady], + ); + + if (!editor) { + return null; + } + + return ( +
+ + + {preview ? ( +
+ {previewContent ? ( + + ) : ( +

+ Nothing to preview +

+ )} +
+ ) : ( + + )} +
+ ); + }, +); + +MarkdownEditor.displayName = "MarkdownEditor"; diff --git a/src/components/editor/MarkdownToolbar.tsx b/src/components/editor/MarkdownToolbar.tsx new file mode 100644 index 0000000..4c1c212 --- /dev/null +++ b/src/components/editor/MarkdownToolbar.tsx @@ -0,0 +1,323 @@ +import { useCallback, useState } from "react"; +import type { Editor } from "@tiptap/core"; +import { + Bold, + Italic, + Strikethrough, + Code, + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Quote, + CodeSquare, + Link, + Unlink, + Minus, + Eye, + Pencil, +} from "lucide-react"; + +interface MarkdownToolbarProps { + editor: Editor | null; + preview: boolean; + onTogglePreview: () => void; +} + +interface ToolbarButtonProps { + onClick: () => void; + active?: boolean; + disabled?: boolean; + title: string; + children: React.ReactNode; +} + +function ToolbarButton({ + onClick, + active = false, + disabled = false, + title, + children, +}: ToolbarButtonProps) { + return ( + + ); +} + +function ToolbarSeparator() { + return
; +} + +/** + * Formatting toolbar for the MarkdownEditor. + * Provides buttons for common markdown formatting, link insertion, + * and a preview toggle. + */ +export function MarkdownToolbar({ + editor, + preview, + onTogglePreview, +}: MarkdownToolbarProps) { + const [linkInput, setLinkInput] = useState<{ + open: boolean; + url: string; + }>({ open: false, url: "" }); + + const isActive = useCallback( + (name: string, attrs?: Record) => { + if (!editor) return false; + return editor.isActive(name, attrs); + }, + [editor], + ); + + const run = useCallback( + (command: (chain: any) => any) => { + if (!editor) return; + command(editor.chain().focus()); + }, + [editor], + ); + + const handleLinkSubmit = useCallback(() => { + if (!editor || !linkInput.url) { + setLinkInput({ open: false, url: "" }); + return; + } + + // Ensure URL has a protocol + let url = linkInput.url.trim(); + if (url && !/^https?:\/\//.test(url)) { + url = `https://${url}`; + } + + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + setLinkInput({ open: false, url: "" }); + }, [editor, linkInput.url]); + + const handleUnlink = useCallback(() => { + if (!editor) return; + editor.chain().focus().unsetLink().run(); + setLinkInput({ open: false, url: "" }); + }, [editor]); + + const disabled = !editor || preview; + const iconSize = "size-4"; + + return ( +
+
+ {/* Inline marks */} + run((c) => c.toggleBold().run())} + active={isActive("bold")} + disabled={disabled} + title="Bold (Ctrl+B)" + > + + + run((c) => c.toggleItalic().run())} + active={isActive("italic")} + disabled={disabled} + title="Italic (Ctrl+I)" + > + + + run((c) => c.toggleStrike().run())} + active={isActive("strike")} + disabled={disabled} + title="Strikethrough" + > + + + run((c) => c.toggleCode().run())} + active={isActive("code")} + disabled={disabled} + title="Inline code" + > + + + + + + {/* Headings */} + run((c) => c.toggleHeading({ level: 1 }).run())} + active={isActive("heading", { level: 1 })} + disabled={disabled} + title="Heading 1" + > + + + run((c) => c.toggleHeading({ level: 2 }).run())} + active={isActive("heading", { level: 2 })} + disabled={disabled} + title="Heading 2" + > + + + run((c) => c.toggleHeading({ level: 3 }).run())} + active={isActive("heading", { level: 3 })} + disabled={disabled} + title="Heading 3" + > + + + + + + {/* Block elements */} + run((c) => c.toggleBulletList().run())} + active={isActive("bulletList")} + disabled={disabled} + title="Bullet list" + > + + + run((c) => c.toggleOrderedList().run())} + active={isActive("orderedList")} + disabled={disabled} + title="Ordered list" + > + + + run((c) => c.toggleBlockquote().run())} + active={isActive("blockquote")} + disabled={disabled} + title="Blockquote" + > + + + run((c) => c.toggleCodeBlock().run())} + active={isActive("codeBlock")} + disabled={disabled} + title="Code block" + > + + + + + + {/* Link */} + {isActive("link") ? ( + + + + ) : ( + { + if (disabled) return; + // Get existing link href if cursor is on a link + const attrs = editor?.getAttributes("link"); + setLinkInput({ + open: true, + url: attrs?.href || "", + }); + }} + disabled={disabled} + title="Insert link" + > + + + )} + + {/* Horizontal rule */} + run((c) => c.setHorizontalRule().run())} + disabled={disabled} + title="Horizontal rule" + > + + + + {/* Spacer */} +
+ + {/* Preview toggle */} + + {preview ? ( + + ) : ( + + )} + +
+ + {/* Link input row */} + {linkInput.open && ( +
+ + + setLinkInput((s) => ({ ...s, url: e.target.value })) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleLinkSubmit(); + } else if (e.key === "Escape") { + setLinkInput({ open: false, url: "" }); + } + }} + placeholder="https://example.com" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50" + autoFocus + /> + + +
+ )} +
+ ); +} diff --git a/src/lib/markdown-serializer.ts b/src/lib/markdown-serializer.ts new file mode 100644 index 0000000..034efc5 --- /dev/null +++ b/src/lib/markdown-serializer.ts @@ -0,0 +1,304 @@ +import { nip19 } from "nostr-tools"; +import type { + EmojiTag, + BlobAttachment, + SerializedContent, +} from "@/components/editor/MentionEditor"; + +/** + * Serialize a Tiptap/ProseMirror document to markdown. + * + * Handles standard markdown formatting (headings, bold, italic, code, lists, + * blockquotes, links, horizontal rules) plus Nostr-specific nodes (mentions, + * custom emojis, blob attachments, event previews). + * + * Returns both the markdown string and extracted metadata (emoji tags, blob + * attachments, address refs) needed for building Nostr events. + */ +export function serializeEditorToMarkdown(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(); + + const ctx = { + emojiTags, + blobAttachments, + addressRefs, + seenEmojis, + seenBlobs, + seenAddrs, + }; + + const doc = editor.state.doc; + const text = serializeBlocks(doc, ctx, ""); + + return { text, emojiTags, blobAttachments, addressRefs }; +} + +interface SerializerContext { + emojiTags: EmojiTag[]; + blobAttachments: BlobAttachment[]; + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>; + seenEmojis: Set; + seenBlobs: Set; + seenAddrs: Set; +} + +/** + * Serialize all block-level children of a node, joined by double newlines. + */ +function serializeBlocks( + node: any, + ctx: SerializerContext, + indent: string, +): string { + const blocks: string[] = []; + + node.forEach((child: any) => { + const result = serializeBlock(child, ctx, indent); + if (result !== null) { + blocks.push(result); + } + }); + + return blocks.join("\n\n"); +} + +/** + * Serialize a single block-level node to markdown. + */ +function serializeBlock( + node: any, + ctx: SerializerContext, + indent: string, +): string | null { + switch (node.type.name) { + case "paragraph": + return indent + serializeInline(node, ctx); + + case "heading": { + const level = node.attrs.level || 1; + const prefix = "#".repeat(Math.min(level, 6)); + return `${indent}${prefix} ${serializeInline(node, ctx)}`; + } + + case "codeBlock": { + const lang = node.attrs.language || ""; + const code = node.textContent; + return `${indent}\`\`\`${lang}\n${code}\n${indent}\`\`\``; + } + + case "blockquote": { + const inner = serializeBlocks(node, ctx, ""); + return inner + .split("\n") + .map((line) => `${indent}> ${line}`) + .join("\n"); + } + + case "bulletList": { + const items: string[] = []; + node.forEach((item: any) => { + const content = serializeListItemContent(item, ctx, indent + " "); + items.push(`${indent}- ${content}`); + }); + return items.join("\n"); + } + + case "orderedList": { + const items: string[] = []; + const start = node.attrs.start || 1; + node.forEach((item: any, _offset: number, idx: number) => { + const num = start + idx; + const content = serializeListItemContent(item, ctx, indent + " "); + items.push(`${indent}${num}. ${content}`); + }); + return items.join("\n"); + } + + case "horizontalRule": + return `${indent}---`; + + case "blobAttachment": { + const { url, sha256, mimeType, size, server } = node.attrs; + if (!ctx.seenBlobs.has(sha256)) { + ctx.seenBlobs.add(sha256); + ctx.blobAttachments.push({ url, sha256, mimeType, size, server }); + } + // Images become markdown images, others just the URL + if (mimeType?.startsWith("image/")) { + return `${indent}![](${url})`; + } + return `${indent}${url}`; + } + + case "nostrEventPreview": { + const { type, data } = node.attrs; + // Collect address refs for manual a-tags + if (type === "naddr" && data) { + const key = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!ctx.seenAddrs.has(key)) { + ctx.seenAddrs.add(key); + ctx.addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } + return `${indent}${renderNostrEventPreviewText(type, data)}`; + } + + default: + // For unknown block nodes, try to get text content + if (node.textContent) { + return indent + node.textContent; + } + return null; + } +} + +/** + * Serialize a list item's children. The first paragraph is inlined, + * subsequent blocks get their own lines with indentation. + */ +function serializeListItemContent( + item: any, + ctx: SerializerContext, + continuationIndent: string, +): string { + const parts: string[] = []; + let first = true; + + item.forEach((child: any) => { + if (first) { + // First child is inlined (no leading indent) + parts.push(serializeBlock(child, ctx, "") || ""); + first = false; + } else { + // Subsequent children get continuation indent + parts.push(serializeBlock(child, ctx, continuationIndent) || ""); + } + }); + + return parts.join("\n"); +} + +/** + * Serialize inline content of a block node (text with marks + inline nodes). + */ +function serializeInline(node: any, ctx: SerializerContext): string { + let result = ""; + + node.forEach((child: any) => { + if (child.isText) { + let text = child.text || ""; + // Apply marks — order matters: link wraps bold wraps italic etc. + const marks = [...child.marks].sort(markPriority); + for (const mark of marks) { + text = applyMark(mark, text); + } + result += text; + } else { + result += serializeInlineNode(child, ctx); + } + }); + + return result; +} + +/** + * Sort marks so nesting is correct: innermost marks first. + * code < strike < italic < bold < link + */ +function markPriority(a: any, b: any): number { + const order: Record = { + code: 0, + strike: 1, + italic: 2, + bold: 3, + link: 4, + }; + return (order[a.type.name] ?? 5) - (order[b.type.name] ?? 5); +} + +/** + * Wrap text with the markdown syntax for a mark. + */ +function applyMark(mark: any, text: string): string { + switch (mark.type.name) { + case "bold": + return `**${text}**`; + case "italic": + return `*${text}*`; + case "code": + return `\`${text}\``; + case "strike": + return `~~${text}~~`; + case "link": + return `[${text}](${mark.attrs.href || ""})`; + default: + return text; + } +} + +/** + * Serialize a non-text inline node (mention, emoji, hardBreak). + */ +function serializeInlineNode(node: any, ctx: SerializerContext): string { + switch (node.type.name) { + case "mention": { + try { + return `nostr:${nip19.npubEncode(node.attrs.id)}`; + } catch { + return `@${node.attrs.label || "unknown"}`; + } + } + + case "emoji": { + const { id, url, source } = node.attrs; + if (source === "unicode") { + return url || ""; + } + // Custom emoji — collect tag + if (!ctx.seenEmojis.has(id)) { + ctx.seenEmojis.add(id); + ctx.emojiTags.push({ shortcode: id, url }); + } + return `:${id}:`; + } + + case "hardBreak": + return "\n"; + + default: + return node.textContent || ""; + } +} + +/** + * Render a nostr event preview node back to its bech32 URI. + */ +function renderNostrEventPreviewText(type: string, data: any): string { + try { + switch (type) { + case "note": + return `nostr:${nip19.noteEncode(data)}`; + case "nevent": + return `nostr:${nip19.neventEncode(data)}`; + case "naddr": + return `nostr:${nip19.naddrEncode(data)}`; + default: + return ""; + } + } catch { + return ""; + } +}