feat(editor): add WYSIWYG MarkdownEditor with formatting toolbar

Generic reusable editor component for creating markdown-based Nostr events
(issues, articles, etc.). Features:

- MarkdownToolbar with controls for headings, bold, italic, strikethrough,
  code, code blocks, blockquotes, lists, links, and horizontal rules
- Custom ProseMirror-to-markdown serializer handling standard markdown plus
  Nostr-specific nodes (mentions, custom emojis, blob attachments, event previews)
- Preview toggle showing rendered markdown via MarkdownContent
- Reuses existing Tiptap extensions: mentions, emojis, blob attachments,
  nostr event previews, paste handlers
- Link support via @tiptap/extension-link
- Exposes MarkdownEditorHandle with getMarkdown(), insertBlob(), etc.

https://claude.ai/code/session_01TUzfLDbarxHDYQRA2fyYTr
This commit is contained in:
Claude
2026-02-15 12:47:35 +00:00
parent 2a662a9101
commit de59d61cbb
5 changed files with 1169 additions and 12 deletions

25
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
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<SuggestionOptions, "editor"> = useMemo(
() => ({
char: "@",
allowSpaces: false,
items: async ({ query }) => {
return await searchProfiles(query);
},
render: () => {
let component: ReactRenderer<ProfileSuggestionListHandle>;
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<SuggestionOptions, "editor"> | undefined =
useMemo(() => {
if (!searchEmojis) return undefined;
return {
char: ":",
allowSpaces: false,
items: async ({ query }) => {
return await searchEmojis(query);
},
render: () => {
let component: ReactRenderer<EmojiSuggestionListHandle>;
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 (
<div
className={`markdown-editor flex flex-col border border-border rounded overflow-hidden ${className}`}
>
<MarkdownToolbar
editor={editor}
preview={preview}
onTogglePreview={togglePreview}
/>
{preview ? (
<div className="p-4 overflow-y-auto" style={{ minHeight, maxHeight }}>
{previewContent ? (
<MarkdownContent content={previewContent} />
) : (
<p className="text-sm text-muted-foreground italic">
Nothing to preview
</p>
)}
</div>
) : (
<EditorContent editor={editor} />
)}
</div>
);
},
);
MarkdownEditor.displayName = "MarkdownEditor";

View File

@@ -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 (
<button
type="button"
onMouseDown={(e) => {
// Prevent stealing focus from editor
e.preventDefault();
onClick();
}}
disabled={disabled}
title={title}
className={`p-1.5 rounded transition-colors ${
active
? "bg-primary/20 text-primary"
: disabled
? "text-muted-foreground/40 cursor-not-allowed"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
{children}
</button>
);
}
function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5" />;
}
/**
* 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<string, any>) => {
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 (
<div className="flex flex-col border-b border-border">
<div className="flex items-center gap-0.5 px-2 py-1 flex-wrap">
{/* Inline marks */}
<ToolbarButton
onClick={() => run((c) => c.toggleBold().run())}
active={isActive("bold")}
disabled={disabled}
title="Bold (Ctrl+B)"
>
<Bold className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleItalic().run())}
active={isActive("italic")}
disabled={disabled}
title="Italic (Ctrl+I)"
>
<Italic className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleStrike().run())}
active={isActive("strike")}
disabled={disabled}
title="Strikethrough"
>
<Strikethrough className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleCode().run())}
active={isActive("code")}
disabled={disabled}
title="Inline code"
>
<Code className={iconSize} />
</ToolbarButton>
<ToolbarSeparator />
{/* Headings */}
<ToolbarButton
onClick={() => run((c) => c.toggleHeading({ level: 1 }).run())}
active={isActive("heading", { level: 1 })}
disabled={disabled}
title="Heading 1"
>
<Heading1 className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleHeading({ level: 2 }).run())}
active={isActive("heading", { level: 2 })}
disabled={disabled}
title="Heading 2"
>
<Heading2 className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleHeading({ level: 3 }).run())}
active={isActive("heading", { level: 3 })}
disabled={disabled}
title="Heading 3"
>
<Heading3 className={iconSize} />
</ToolbarButton>
<ToolbarSeparator />
{/* Block elements */}
<ToolbarButton
onClick={() => run((c) => c.toggleBulletList().run())}
active={isActive("bulletList")}
disabled={disabled}
title="Bullet list"
>
<List className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleOrderedList().run())}
active={isActive("orderedList")}
disabled={disabled}
title="Ordered list"
>
<ListOrdered className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleBlockquote().run())}
active={isActive("blockquote")}
disabled={disabled}
title="Blockquote"
>
<Quote className={iconSize} />
</ToolbarButton>
<ToolbarButton
onClick={() => run((c) => c.toggleCodeBlock().run())}
active={isActive("codeBlock")}
disabled={disabled}
title="Code block"
>
<CodeSquare className={iconSize} />
</ToolbarButton>
<ToolbarSeparator />
{/* Link */}
{isActive("link") ? (
<ToolbarButton
onClick={handleUnlink}
active
disabled={disabled}
title="Remove link"
>
<Unlink className={iconSize} />
</ToolbarButton>
) : (
<ToolbarButton
onClick={() => {
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"
>
<Link className={iconSize} />
</ToolbarButton>
)}
{/* Horizontal rule */}
<ToolbarButton
onClick={() => run((c) => c.setHorizontalRule().run())}
disabled={disabled}
title="Horizontal rule"
>
<Minus className={iconSize} />
</ToolbarButton>
{/* Spacer */}
<div className="flex-1" />
{/* Preview toggle */}
<ToolbarButton
onClick={onTogglePreview}
active={preview}
title={preview ? "Edit" : "Preview"}
>
{preview ? (
<Pencil className={iconSize} />
) : (
<Eye className={iconSize} />
)}
</ToolbarButton>
</div>
{/* Link input row */}
{linkInput.open && (
<div className="flex items-center gap-2 px-2 py-1.5 border-t border-border bg-muted/30">
<Link className="size-3.5 text-muted-foreground flex-shrink-0" />
<input
type="url"
value={linkInput.url}
onChange={(e) =>
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
/>
<button
type="button"
onClick={handleLinkSubmit}
className="text-xs px-2 py-0.5 bg-primary/20 text-primary rounded hover:bg-primary/30"
>
Apply
</button>
<button
type="button"
onClick={() => setLinkInput({ open: false, url: "" })}
className="text-xs px-2 py-0.5 text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</div>
)}
</div>
);
}

View File

@@ -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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
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<string>;
seenBlobs: Set<string>;
seenAddrs: Set<string>;
}
/**
* 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<string, number> = {
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 "";
}
}