From b6027aa2fb05aeb5c4076d3b63971460cf6ea7ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 20:36:49 +0000 Subject: [PATCH] refactor(editor): extract shared extensions and create React node views Phase 1 of RichEditor variant implementation: - Extract NostrPasteHandler extension for reuse across editors - Extract FilePasteHandler extension for clipboard file handling - Create NostrEventPreviewRich React node view using DetailKindRenderer - Create BlobAttachmentRich React node view with full-size media previews - Create rich TipTap node extensions using ReactNodeViewRenderer - Update MentionEditor to use shared extensions These shared components will be used by the upcoming RichEditor variant for long-form content composition with full previews. --- src/components/editor/MentionEditor.tsx | 207 +----------------- .../editor/extensions/blob-attachment-rich.ts | 44 ++++ .../editor/extensions/file-paste-handler.ts | 54 +++++ .../extensions/nostr-event-preview-rich.ts | 59 +++++ .../editor/extensions/nostr-paste-handler.ts | 170 ++++++++++++++ .../editor/node-views/BlobAttachmentRich.tsx | 123 +++++++++++ .../node-views/NostrEventPreviewRich.tsx | 56 +++++ 7 files changed, 508 insertions(+), 205 deletions(-) create mode 100644 src/components/editor/extensions/blob-attachment-rich.ts create mode 100644 src/components/editor/extensions/file-paste-handler.ts create mode 100644 src/components/editor/extensions/nostr-event-preview-rich.ts create mode 100644 src/components/editor/extensions/nostr-paste-handler.ts create mode 100644 src/components/editor/node-views/BlobAttachmentRich.tsx create mode 100644 src/components/editor/node-views/NostrEventPreviewRich.tsx diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index dbe0436..b271d12 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; import { Extension, Node, mergeAttributes } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; import Mention from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; @@ -32,9 +31,8 @@ import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; import type { ChatAction } from "@/types/chat-actions"; import { nip19 } from "nostr-tools"; -import eventStore from "@/services/event-store"; -import { getProfileContent } from "applesauce-core/helpers"; -import { getDisplayName } from "@/lib/nostr-utils"; +import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; +import { FilePasteHandler } from "./extensions/file-paste-handler"; /** * Represents an emoji tag for NIP-30 @@ -364,207 +362,6 @@ const NostrEventPreview = Node.create({ }, }); -// Helper to get display name for a pubkey (synchronous lookup from cache) -function getDisplayNameForPubkey(pubkey: string): string { - try { - // Try to get profile from event store (check if it's a BehaviorSubject with .value) - const profile$ = eventStore.replaceable(0, pubkey) as any; - if (profile$ && profile$.value) { - const profileEvent = profile$.value; - if (profileEvent) { - const content = getProfileContent(profileEvent); - if (content) { - // Use the Grimoire helper which handles fallbacks - return getDisplayName(pubkey, content); - } - } - } - } catch (err) { - // Ignore errors, fall through to default - console.debug( - "[NostrPasteHandler] Could not get profile for", - pubkey.slice(0, 8), - err, - ); - } - // Fallback to short pubkey - return pubkey.slice(0, 8); -} - -// Paste handler extension to transform bech32 strings into preview nodes -const NostrPasteHandler = Extension.create({ - name: "nostrPasteHandler", - - addProseMirrorPlugins() { - return [ - new Plugin({ - key: new PluginKey("nostrPasteHandler"), - - props: { - handlePaste: (view, event) => { - const text = event.clipboardData?.getData("text/plain"); - if (!text) return false; - - // Regex to detect nostr bech32 strings (with or without nostr: prefix) - const bech32Regex = - /(?:nostr:)?(npub1[\w]{58,}|note1[\w]{58,}|nevent1[\w]+|naddr1[\w]+|nprofile1[\w]+)/g; - const matches = Array.from(text.matchAll(bech32Regex)); - - if (matches.length === 0) return false; // No bech32 found, use default paste - - // Build content with text and preview nodes - const nodes: any[] = []; - let lastIndex = 0; - - for (const match of matches) { - const matchedText = match[0]; - const matchIndex = match.index!; - const bech32 = match[1]; // The bech32 without nostr: prefix - - // Add text before this match - if (lastIndex < matchIndex) { - const textBefore = text.slice(lastIndex, matchIndex); - if (textBefore) { - nodes.push(view.state.schema.text(textBefore)); - } - } - - // Try to decode bech32 and create preview node - try { - const decoded = nip19.decode(bech32); - - // For npub/nprofile, create regular mention nodes (reuse existing infrastructure) - if (decoded.type === "npub") { - const pubkey = decoded.data as string; - const displayName = getDisplayNameForPubkey(pubkey); - nodes.push( - view.state.schema.nodes.mention.create({ - id: pubkey, - label: displayName, - }), - ); - } else if (decoded.type === "nprofile") { - const pubkey = (decoded.data as any).pubkey; - const displayName = getDisplayNameForPubkey(pubkey); - nodes.push( - view.state.schema.nodes.mention.create({ - id: pubkey, - label: displayName, - }), - ); - } else if (decoded.type === "note") { - nodes.push( - view.state.schema.nodes.nostrEventPreview.create({ - type: "note", - data: decoded.data, - }), - ); - } else if (decoded.type === "nevent") { - nodes.push( - view.state.schema.nodes.nostrEventPreview.create({ - type: "nevent", - data: decoded.data, - }), - ); - } else if (decoded.type === "naddr") { - nodes.push( - view.state.schema.nodes.nostrEventPreview.create({ - type: "naddr", - data: decoded.data, - }), - ); - } - - // Add space after preview node - nodes.push(view.state.schema.text(" ")); - } catch (err) { - // Invalid bech32, insert as plain text - console.warn( - "[NostrPasteHandler] Failed to decode:", - bech32, - err, - ); - nodes.push(view.state.schema.text(matchedText)); - } - - lastIndex = matchIndex + matchedText.length; - } - - // Add remaining text after last match - if (lastIndex < text.length) { - const textAfter = text.slice(lastIndex); - if (textAfter) { - nodes.push(view.state.schema.text(textAfter)); - } - } - - // Insert all nodes at cursor position - if (nodes.length > 0) { - const { tr } = view.state; - const { from } = view.state.selection; - - // Insert content - nodes.forEach((node, index) => { - tr.insert(from + index, node); - }); - - view.dispatch(tr); - return true; // Prevent default paste - } - - return false; - }, - }, - }), - ]; - }, -}); - -// File paste handler extension to intercept file pastes and trigger upload -const FilePasteHandler = Extension.create({ - name: "filePasteHandler", - - addProseMirrorPlugins() { - const onFilePaste = this.options.onFilePaste; - - return [ - new Plugin({ - key: new PluginKey("filePasteHandler"), - - props: { - handlePaste: (_view, event) => { - // Handle paste events with files (e.g., pasting images from clipboard) - const files = event.clipboardData?.files; - if (!files || files.length === 0) return false; - - // Check if files are images, videos, or audio - const validFiles = Array.from(files).filter((file) => - file.type.match(/^(image|video|audio)\//), - ); - - if (validFiles.length === 0) return false; - - // Trigger the file paste callback - if (onFilePaste) { - onFilePaste(validFiles); - event.preventDefault(); - return true; // Prevent default paste behavior - } - - return false; - }, - }, - }), - ]; - }, - - addOptions() { - return { - onFilePaste: undefined, - }; - }, -}); - export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps diff --git a/src/components/editor/extensions/blob-attachment-rich.ts b/src/components/editor/extensions/blob-attachment-rich.ts new file mode 100644 index 0000000..a6f5a43 --- /dev/null +++ b/src/components/editor/extensions/blob-attachment-rich.ts @@ -0,0 +1,44 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { BlobAttachmentRich } from "../node-views/BlobAttachmentRich"; + +/** + * Rich blob attachment node for long-form editors + * + * Uses React components to render full-size image/video previews + */ +export const BlobAttachmentRichNode = Node.create({ + name: "blobAttachment", + group: "block", + inline: false, + atom: true, + + addAttributes() { + return { + url: { default: null }, + sha256: { default: null }, + mimeType: { default: null }, + size: { default: null }, + server: { default: null }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-blob-attachment="true"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(BlobAttachmentRich); + }, +}); diff --git a/src/components/editor/extensions/file-paste-handler.ts b/src/components/editor/extensions/file-paste-handler.ts new file mode 100644 index 0000000..2148f10 --- /dev/null +++ b/src/components/editor/extensions/file-paste-handler.ts @@ -0,0 +1,54 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +/** + * File paste handler extension to intercept file pastes and trigger upload + * + * Handles clipboard paste events with files (e.g., pasting images from clipboard) + * and triggers a callback to open the upload dialog. + */ +export const FilePasteHandler = Extension.create<{ + onFilePaste?: (files: File[]) => void; +}>({ + name: "filePasteHandler", + + addOptions() { + return { + onFilePaste: undefined, + }; + }, + + addProseMirrorPlugins() { + const onFilePaste = this.options.onFilePaste; + + return [ + new Plugin({ + key: new PluginKey("filePasteHandler"), + + props: { + handlePaste: (_view, event) => { + // Handle paste events with files (e.g., pasting images from clipboard) + const files = event.clipboardData?.files; + if (!files || files.length === 0) return false; + + // Check if files are images, videos, or audio + const validFiles = Array.from(files).filter((file) => + file.type.match(/^(image|video|audio)\//), + ); + + if (validFiles.length === 0) return false; + + // Trigger the file paste callback + if (onFilePaste) { + onFilePaste(validFiles); + event.preventDefault(); + return true; // Prevent default paste behavior + } + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/editor/extensions/nostr-event-preview-rich.ts b/src/components/editor/extensions/nostr-event-preview-rich.ts new file mode 100644 index 0000000..8d4c5f3 --- /dev/null +++ b/src/components/editor/extensions/nostr-event-preview-rich.ts @@ -0,0 +1,59 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { NostrEventPreviewRich } from "../node-views/NostrEventPreviewRich"; +import { nip19 } from "nostr-tools"; + +/** + * Rich Nostr event preview node for long-form editors + * + * Uses React components to render full event previews with KindRenderer + */ +export const NostrEventPreviewRichNode = Node.create({ + name: "nostrEventPreview", + group: "block", + inline: false, + atom: true, + + addAttributes() { + return { + type: { default: null }, // 'note' | 'nevent' | 'naddr' + data: { default: null }, // Decoded bech32 data (varies by type) + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-nostr-preview="true"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }), + ]; + }, + + renderText({ node }) { + // Serialize back to nostr: URI for plain text export + const { type, data } = node.attrs; + try { + if (type === "note") { + return `nostr:${nip19.noteEncode(data)}`; + } else if (type === "nevent") { + return `nostr:${nip19.neventEncode(data)}`; + } else if (type === "naddr") { + return `nostr:${nip19.naddrEncode(data)}`; + } + } catch (err) { + console.error("[NostrEventPreviewRich] Failed to encode:", err); + } + return ""; + }, + + addNodeView() { + return ReactNodeViewRenderer(NostrEventPreviewRich); + }, +}); diff --git a/src/components/editor/extensions/nostr-paste-handler.ts b/src/components/editor/extensions/nostr-paste-handler.ts new file mode 100644 index 0000000..6aeefd3 --- /dev/null +++ b/src/components/editor/extensions/nostr-paste-handler.ts @@ -0,0 +1,170 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { nip19 } from "nostr-tools"; +import eventStore from "@/services/event-store"; +import { getProfileContent } from "applesauce-core/helpers"; +import { getDisplayName } from "@/lib/nostr-utils"; + +/** + * Helper to get display name for a pubkey (synchronous lookup from cache) + */ +function getDisplayNameForPubkey(pubkey: string): string { + try { + // Try to get profile from event store (check if it's a BehaviorSubject with .value) + const profile$ = eventStore.replaceable(0, pubkey) as any; + if (profile$ && profile$.value) { + const profileEvent = profile$.value; + if (profileEvent) { + const content = getProfileContent(profileEvent); + if (content) { + // Use the Grimoire helper which handles fallbacks + return getDisplayName(pubkey, content); + } + } + } + } catch (err) { + // Ignore errors, fall through to default + console.debug( + "[NostrPasteHandler] Could not get profile for", + pubkey.slice(0, 8), + err, + ); + } + // Fallback to short pubkey + return pubkey.slice(0, 8); +} + +/** + * Paste handler extension to transform bech32 strings into preview nodes + * + * Detects and transforms: + * - npub/nprofile → @mention nodes + * - note/nevent/naddr → nostrEventPreview nodes + */ +export const NostrPasteHandler = Extension.create({ + name: "nostrPasteHandler", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("nostrPasteHandler"), + + props: { + handlePaste: (view, event) => { + const text = event.clipboardData?.getData("text/plain"); + if (!text) return false; + + // Regex to detect nostr bech32 strings (with or without nostr: prefix) + const bech32Regex = + /(?:nostr:)?(npub1[\w]{58,}|note1[\w]{58,}|nevent1[\w]+|naddr1[\w]+|nprofile1[\w]+)/g; + const matches = Array.from(text.matchAll(bech32Regex)); + + if (matches.length === 0) return false; // No bech32 found, use default paste + + // Build content with text and preview nodes + const nodes: any[] = []; + let lastIndex = 0; + + for (const match of matches) { + const matchedText = match[0]; + const matchIndex = match.index!; + const bech32 = match[1]; // The bech32 without nostr: prefix + + // Add text before this match + if (lastIndex < matchIndex) { + const textBefore = text.slice(lastIndex, matchIndex); + if (textBefore) { + nodes.push(view.state.schema.text(textBefore)); + } + } + + // Try to decode bech32 and create preview node + try { + const decoded = nip19.decode(bech32); + + // For npub/nprofile, create regular mention nodes (reuse existing infrastructure) + if (decoded.type === "npub") { + const pubkey = decoded.data as string; + const displayName = getDisplayNameForPubkey(pubkey); + nodes.push( + view.state.schema.nodes.mention.create({ + id: pubkey, + label: displayName, + }), + ); + } else if (decoded.type === "nprofile") { + const pubkey = (decoded.data as any).pubkey; + const displayName = getDisplayNameForPubkey(pubkey); + nodes.push( + view.state.schema.nodes.mention.create({ + id: pubkey, + label: displayName, + }), + ); + } else if (decoded.type === "note") { + nodes.push( + view.state.schema.nodes.nostrEventPreview.create({ + type: "note", + data: decoded.data, + }), + ); + } else if (decoded.type === "nevent") { + nodes.push( + view.state.schema.nodes.nostrEventPreview.create({ + type: "nevent", + data: decoded.data, + }), + ); + } else if (decoded.type === "naddr") { + nodes.push( + view.state.schema.nodes.nostrEventPreview.create({ + type: "naddr", + data: decoded.data, + }), + ); + } + + // Add space after preview node + nodes.push(view.state.schema.text(" ")); + } catch (err) { + // Invalid bech32, insert as plain text + console.warn( + "[NostrPasteHandler] Failed to decode:", + bech32, + err, + ); + nodes.push(view.state.schema.text(matchedText)); + } + + lastIndex = matchIndex + matchedText.length; + } + + // Add remaining text after last match + if (lastIndex < text.length) { + const textAfter = text.slice(lastIndex); + if (textAfter) { + nodes.push(view.state.schema.text(textAfter)); + } + } + + // Insert all nodes at cursor position + if (nodes.length > 0) { + const { tr } = view.state; + const { from } = view.state.selection; + + // Insert content + nodes.forEach((node, index) => { + tr.insert(from + index, node); + }); + + view.dispatch(tr); + return true; // Prevent default paste + } + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/editor/node-views/BlobAttachmentRich.tsx b/src/components/editor/node-views/BlobAttachmentRich.tsx new file mode 100644 index 0000000..64de953 --- /dev/null +++ b/src/components/editor/node-views/BlobAttachmentRich.tsx @@ -0,0 +1,123 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; +import { X, FileIcon, Music, Film } from "lucide-react"; + +function formatBlobSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** + * Rich preview component for blob attachments in the editor + * + * Shows full-size images and videos with remove button + */ +export function BlobAttachmentRich({ node, deleteNode }: ReactNodeViewProps) { + const { url, mimeType, size } = node.attrs as { + url: string; + sha256: string; + mimeType: string; + size: number; + server: string; + }; + + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + return ( + +
+ {isImage && url && ( +
+ attachment + {deleteNode && ( + + )} +
+ )} + + {isVideo && url && ( +
+
+ )} + + {isAudio && url && ( +
+
+ +
+
+
+ {deleteNode && ( + + )} +
+ )} + + {!isImage && !isVideo && !isAudio && ( +
+
+ {isVideo ? ( + + ) : ( + + )} +
+
+

{url}

+

+ {mimeType || "Unknown"} • {formatBlobSize(size || 0)} +

+
+ {deleteNode && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/editor/node-views/NostrEventPreviewRich.tsx b/src/components/editor/node-views/NostrEventPreviewRich.tsx new file mode 100644 index 0000000..2579c18 --- /dev/null +++ b/src/components/editor/node-views/NostrEventPreviewRich.tsx @@ -0,0 +1,56 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { DetailKindRenderer } from "@/components/nostr/kinds"; +import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; +import { Loader2 } from "lucide-react"; + +/** + * Rich preview component for Nostr events in the editor + * + * Uses the full DetailKindRenderer to show event content + */ +export function NostrEventPreviewRich({ node }: ReactNodeViewProps) { + const { type, data } = node.attrs as { + type: "note" | "nevent" | "naddr"; + data: any; + }; + + // Build pointer for useNostrEvent hook + let pointer: EventPointer | AddressPointer | string | null = null; + + if (type === "note") { + pointer = data; // Just the event ID + } else if (type === "nevent") { + pointer = { + id: data.id, + relays: data.relays || [], + author: data.author, + kind: data.kind, + } as EventPointer; + } else if (type === "naddr") { + pointer = { + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + relays: data.relays || [], + } as AddressPointer; + } + + // Fetch the event (only if we have a valid pointer) + const event = useNostrEvent(pointer || undefined); + + return ( + +
+ {!event ? ( +
+ + Loading event... +
+ ) : ( + + )} +
+
+ ); +}