From c685144d2e102edc7e3ba490268a14dd1e103aff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 20:41:17 +0000 Subject: [PATCH] feat(editor): add RichEditor component for long-form content Phase 2 of RichEditor variant implementation: - Create RichEditor component based on MentionEditor - No slash commands (removed SlashCommand extension) - Multi-line support with Enter for newlines - Ctrl/Cmd+Enter to submit - Full-size image/video previews using BlobAttachmentRich - Full event rendering using NostrEventPreviewRich - Configurable min/max height (defaults: 200px-600px) - Retains @mentions and :emoji: autocomplete - Reuses shared extensions (NostrPasteHandler, FilePasteHandler) - Targets kind 1 notes composition Key differences from MentionEditor: - Block-level rich previews instead of inline badges - Multi-line editing without Enter-to-submit - Resizable with overflow-y: auto - No command palette functionality --- src/components/editor/RichEditor.tsx | 536 +++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 src/components/editor/RichEditor.tsx diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx new file mode 100644 index 0000000..03ef009 --- /dev/null +++ b/src/components/editor/RichEditor.tsx @@ -0,0 +1,536 @@ +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 { 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[], + ) => void; + searchProfiles: (query: string) => Promise; + searchEmojis?: (query: string) => Promise; + onFilePaste?: (files: File[]) => void; + autoFocus?: boolean; + className?: string; + /** Minimum height in pixels */ + minHeight?: number; + /** Maximum height in pixels */ + maxHeight?: number; +} + +export interface RichEditorHandle { + focus: () => void; + clear: () => void; + getContent: () => string; + getSerializedContent: () => SerializedContent; + isEmpty: () => boolean; + submit: () => void; + /** Insert text at the current cursor position */ + insertText: (text: string) => void; + /** Insert a blob attachment with rich preview */ + insertBlob: (blob: BlobAttachment) => void; +} + +// 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 + */ +function serializeContent(editor: any): SerializedContent { + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + + // Get plain text representation + const text = editor.getText(); + + // Walk the document to collect emoji and blob 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 }); + } + } + }); + + return { text, emojiTags, blobAttachments }; +} + +export const RichEditor = forwardRef( + ( + { + placeholder = "Write your note...", + onSubmit, + searchProfiles, + searchEmojis, + onFilePaste, + autoFocus = false, + className = "", + minHeight = 200, + maxHeight = 600, + }, + ref, + ) => { + // Ref to access handleSubmit from keyboard shortcuts + const handleSubmitRef = useRef<(editor: any) => void>(() => {}); + + // Create mention suggestion configuration for @ mentions + const mentionSuggestion: Omit = useMemo( + () => ({ + char: "@", + allowSpaces: false, + items: async ({ query }) => { + return await searchProfiles(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(ProfileSuggestionList, { + props: { items: [], command: props.command }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "mention", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props.event) || false; + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }), + [searchProfiles], + ); + + // Create emoji suggestion configuration for : emojis + const emojiSuggestion: Omit | undefined = + useMemo(() => { + if (!searchEmojis) return undefined; + + return { + char: ":", + allowSpaces: false, + items: async ({ query }) => { + return await searchEmojis(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(EmojiSuggestionList, { + props: { items: [], command: props.command }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "mention", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props.event) || false; + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }; + }, [searchEmojis]); + + // Handle submit + const handleSubmit = useCallback( + (editorInstance: any) => { + if (editorInstance.isEmpty) { + return; + } + + const serialized = serializeContent(editorInstance); + + if (onSubmit) { + onSubmit( + serialized.text, + serialized.emojiTags, + serialized.blobAttachments, + ); + editorInstance.commands.clearContent(); + } + }, + [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.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, + }); + + // Expose editor methods + useImperativeHandle( + ref, + () => ({ + focus: () => editor?.commands.focus(), + clear: () => editor?.commands.clearContent(), + getContent: () => editor?.getText() || "", + getSerializedContent: () => { + if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; + return serializeContent(editor); + }, + isEmpty: () => editor?.isEmpty ?? true, + submit: () => { + if (editor) { + handleSubmit(editor); + } + }, + insertText: (text: string) => { + editor?.commands.insertContent(text); + }, + insertBlob: (blob: BlobAttachment) => { + editor?.commands.insertContent({ + type: "blobAttachment", + attrs: blob, + }); + }, + }), + [editor, handleSubmit], + ); + + // Handle submit on Ctrl/Cmd+Enter + useEffect(() => { + if (!editor) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + handleSubmit(editor); + } + }; + + editor.view.dom.addEventListener("keydown", handleKeyDown); + return () => { + editor.view.dom.removeEventListener("keydown", handleKeyDown); + }; + }, [editor, handleSubmit]); + + if (!editor) { + return null; + } + + return ( +
+ +
+ ); + }, +); + +RichEditor.displayName = "RichEditor";