mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
44
src/components/editor/extensions/blob-attachment-rich.ts
Normal file
44
src/components/editor/extensions/blob-attachment-rich.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
54
src/components/editor/extensions/file-paste-handler.ts
Normal file
54
src/components/editor/extensions/file-paste-handler.ts
Normal file
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
59
src/components/editor/extensions/nostr-event-preview-rich.ts
Normal file
59
src/components/editor/extensions/nostr-event-preview-rich.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
170
src/components/editor/extensions/nostr-paste-handler.ts
Normal file
170
src/components/editor/extensions/nostr-paste-handler.ts
Normal file
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
123
src/components/editor/node-views/BlobAttachmentRich.tsx
Normal file
123
src/components/editor/node-views/BlobAttachmentRich.tsx
Normal file
@@ -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 (
|
||||
<NodeViewWrapper className="my-3 relative group">
|
||||
<div className="rounded-lg border border-border bg-background overflow-hidden">
|
||||
{isImage && url && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={url}
|
||||
alt="attachment"
|
||||
className="max-w-full h-auto"
|
||||
draggable={false}
|
||||
/>
|
||||
{deleteNode && (
|
||||
<button
|
||||
onClick={deleteNode}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-background/90 hover:bg-background border border-border opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
contentEditable={false}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideo && url && (
|
||||
<div className="relative">
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="max-w-full h-auto"
|
||||
preload="metadata"
|
||||
/>
|
||||
{deleteNode && (
|
||||
<button
|
||||
onClick={deleteNode}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-background/90 hover:bg-background border border-border opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
contentEditable={false}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAudio && url && (
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<Music className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<audio src={url} controls className="w-full" />
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Audio • {formatBlobSize(size || 0)}
|
||||
</p>
|
||||
</div>
|
||||
{deleteNode && (
|
||||
<button
|
||||
onClick={deleteNode}
|
||||
className="p-1.5 rounded-full hover:bg-muted transition-colors"
|
||||
contentEditable={false}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isImage && !isVideo && !isAudio && (
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
{isVideo ? (
|
||||
<Film className="size-6 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{url}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mimeType || "Unknown"} • {formatBlobSize(size || 0)}
|
||||
</p>
|
||||
</div>
|
||||
{deleteNode && (
|
||||
<button
|
||||
onClick={deleteNode}
|
||||
className="p-1.5 rounded-full hover:bg-muted transition-colors"
|
||||
contentEditable={false}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
56
src/components/editor/node-views/NostrEventPreviewRich.tsx
Normal file
56
src/components/editor/node-views/NostrEventPreviewRich.tsx
Normal file
@@ -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 (
|
||||
<NodeViewWrapper className="my-2">
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
{!event ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading event...</span>
|
||||
</div>
|
||||
) : (
|
||||
<DetailKindRenderer event={event} />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user