mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
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
This commit is contained in:
536
src/components/editor/RichEditor.tsx
Normal file
536
src/components/editor/RichEditor.tsx
Normal file
@@ -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<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;
|
||||
}
|
||||
|
||||
// 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<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
|
||||
// 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<RichEditorHandle, RichEditorProps>(
|
||||
(
|
||||
{
|
||||
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<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,
|
||||
);
|
||||
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 (
|
||||
<div className={`rich-editor ${className}`}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RichEditor.displayName = "RichEditor";
|
||||
Reference in New Issue
Block a user