Files
grimoire/src/components/editor/RichEditor.tsx
Alejandro f329e9c766 fix: guard all RichEditor imperative methods against unmounted view (#196)
Add isEditorReady() helper to check if editor.view.dom is mounted
before accessing editor commands. This prevents the TipTap error
"The editor view is not available" that occurred when PostViewer's
draft loading called editor methods before the view was fully mounted.

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-22 12:30:16 +01:00

632 lines
19 KiB
TypeScript

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)
*/
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";