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
This commit is contained in:
Claude
2026-01-28 20:18:35 +00:00
parent fdc7b1499f
commit 7dd89b7e17
13 changed files with 1936 additions and 745 deletions

View File

@@ -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<TextEditorHandle>(null);
const [showPreview, setShowPreview] = useState(initialPreview);
const [editor, setEditor] = useState<Editor | null>(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 (
<div className={`markdown-editor flex flex-col ${className}`}>
<MarkdownToolbar
editor={editor}
showPreview={showPreview}
onTogglePreview={enablePreview ? togglePreview : undefined}
disabled={showPreview}
/>
{showPreview ? (
<div
className="prose prose-sm max-w-none p-3 border border-border rounded bg-muted/30 overflow-y-auto"
style={{
minHeight: props.minHeight || 200,
maxHeight: props.maxHeight || 600,
}}
>
{previewContent ? (
<MarkdownContent content={previewContent} />
) : (
<p className="text-muted-foreground italic">Nothing to preview</p>
)}
</div>
) : (
<TextEditor
ref={(node) => {
textEditorRef.current = node;
// Get editor after mount
setTimeout(handleEditorReady, 0);
}}
className="flex-1"
{...props}
/>
)}
</div>
);
},
);
MarkdownEditor.displayName = "MarkdownEditor";

View File

@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={active ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8"
onClick={onClick}
disabled={disabled}
type="button"
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>
{label}
{shortcut && (
<span className="ml-2 text-muted-foreground">{shortcut}</span>
)}
</p>
</TooltipContent>
</Tooltip>
);
}
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 (
<div
className={`flex items-center gap-0.5 border-b border-border pb-2 mb-2 ${className}`}
>
{/* Text formatting */}
<ToolbarButton
icon={<Bold className="h-4 w-4" />}
label="Bold"
shortcut="Ctrl+B"
onClick={() => wrapSelection("**")}
disabled={isDisabled}
active={isActive("bold")}
/>
<ToolbarButton
icon={<Italic className="h-4 w-4" />}
label="Italic"
shortcut="Ctrl+I"
onClick={() => wrapSelection("*")}
disabled={isDisabled}
active={isActive("italic")}
/>
<ToolbarButton
icon={<Code className="h-4 w-4" />}
label="Inline code"
shortcut="Ctrl+`"
onClick={() => wrapSelection("`")}
disabled={isDisabled}
active={isActive("code")}
/>
<div className="w-px h-5 bg-border mx-1" />
{/* Block formatting */}
<ToolbarButton
icon={<Heading2 className="h-4 w-4" />}
label="Heading"
onClick={() => insertAtLineStart("## ")}
disabled={isDisabled}
/>
<ToolbarButton
icon={<Quote className="h-4 w-4" />}
label="Quote"
onClick={() => insertAtLineStart("> ")}
disabled={isDisabled}
/>
<ToolbarButton
icon={<List className="h-4 w-4" />}
label="Bullet list"
onClick={() => insertAtLineStart("- ")}
disabled={isDisabled}
/>
<ToolbarButton
icon={<ListOrdered className="h-4 w-4" />}
label="Numbered list"
onClick={() => insertAtLineStart("1. ")}
disabled={isDisabled}
/>
<div className="w-px h-5 bg-border mx-1" />
{/* Link */}
<ToolbarButton
icon={<Link className="h-4 w-4" />}
label="Insert link"
shortcut="Ctrl+K"
onClick={insertLink}
disabled={isDisabled}
/>
{/* Spacer */}
<div className="flex-1" />
{/* Preview toggle */}
{onTogglePreview && (
<Button
variant={showPreview ? "secondary" : "ghost"}
size="sm"
onClick={onTogglePreview}
disabled={disabled}
className="h-8 px-2 gap-1.5"
type="button"
>
{showPreview ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
<span className="text-xs">Preview</span>
</Button>
)}
</div>
);
}

View File

@@ -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({

View File

@@ -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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
// 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<RichEditorHandle, RichEditorProps>(
(
{
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<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 for : emojis
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 = 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 (
<div className={`rich-editor ${className}`}>
<EditorContent editor={editor} />
</div>
);
},
);
RichEditor.displayName = "RichEditor";
export {
TextEditor as RichEditor,
type TextEditorHandle as RichEditorHandle,
type TextEditorProps as RichEditorProps,
type EmojiTag,
type BlobAttachment,
type SerializedContent,
} from "./TextEditor";

View File

@@ -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<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
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<TextEditorHandle, TextEditorProps>(
(
{
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<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 for : emojis
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 = 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 (
<div className={`rich-editor ${className}`}>
<EditorContent editor={editor} />
</div>
);
},
);
TextEditor.displayName = "TextEditor";
// Backwards compatibility alias
export const RichEditor = TextEditor;
export type RichEditorHandle = TextEditorHandle;
export type RichEditorProps = TextEditorProps;

View File

@@ -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,
};
};
},
});

View File

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

View File

@@ -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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
// 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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
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: [],
};
}

View File

@@ -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;
}

View File

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

41
src/lib/composer/index.ts Normal file
View File

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

245
src/lib/composer/schema.ts Normal file
View File

@@ -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, "");
}

300
src/lib/composer/schemas.ts Normal file
View File

@@ -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<number, ComposerSchema> = {
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;
}