mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
feat: emoji autocompletion (#54)
* feat: add NIP-30 emoji autocompletion to editor Implement emoji autocomplete triggered by `:` in the MentionEditor: - EmojiSearchService: flexsearch-based indexing for emoji shortcodes - useEmojiSearch hook: loads Unicode emojis + user's custom emoji (kind 10030/30030) - EmojiSuggestionList: grid-based suggestion UI with keyboard nav - Update MentionEditor with second Mention extension for emoji - Serialize emoji as `:shortcode:` format with NIP-30 emoji tags - Update chat adapters to include emoji tags in messages Sources: - Unicode: ~300 common emojis with shortcodes - Custom: user's emoji list (kind 10030) and referenced sets (kind 30030) - Context: emoji tags from events being replied to * feat: add rich emoji preview in editor Emoji inserted via the autocomplete now display as actual images/characters instead of :shortcode: text: - Custom emoji: renders as inline <img> with proper sizing - Unicode emoji: renders as text with emoji font sizing - Both show :shortcode: on hover via title attribute CSS styles ensure proper vertical alignment with surrounding text. * fix: store emoji url and source attributes in node schema The TipTap Mention extension only defines `id` and `label` by default. Added `addAttributes()` to EmojiMention extension to also store `url` and `source` attributes, fixing emoji tags not being included in sent messages. * fix: improve emoji node rendering in editor - Remove redundant renderLabel (nodeView handles display) - Add renderText for proper clipboard behavior - Make nodeView more robust with null checks - Add fallback to shortcode if image fails to load - Unicode emoji shows character, custom shows image * fix: serialize unicode emoji as actual characters, not shortcodes When sending messages: - Unicode emoji (😄, 🔥) → outputs 😄, 🔥 (the actual character) - Custom emoji (:pepe:) → outputs :pepe: with emoji tag for rendering --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,8 +25,10 @@ import { Button } from "./ui/button";
|
||||
import {
|
||||
MentionEditor,
|
||||
type MentionEditorHandle,
|
||||
type EmojiTag,
|
||||
} from "./editor/MentionEditor";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
interface ChatViewerProps {
|
||||
@@ -228,6 +230,9 @@ export function ChatViewer({
|
||||
// Profile search for mentions
|
||||
const { searchProfiles } = useProfileSearch();
|
||||
|
||||
// Emoji search for custom emoji autocomplete
|
||||
const { searchEmojis } = useEmojiSearch();
|
||||
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
@@ -288,9 +293,16 @@ export function ChatViewer({
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// Handle sending messages
|
||||
const handleSend = async (content: string, replyToId?: string) => {
|
||||
const handleSend = async (
|
||||
content: string,
|
||||
replyToId?: string,
|
||||
emojiTags?: EmojiTag[],
|
||||
) => {
|
||||
if (!conversation || !hasActiveAccount) return;
|
||||
await adapter.sendMessage(conversation, content, replyToId);
|
||||
await adapter.sendMessage(conversation, content, {
|
||||
replyTo: replyToId,
|
||||
emojiTags,
|
||||
});
|
||||
setReplyTo(undefined); // Clear reply context after sending
|
||||
};
|
||||
|
||||
@@ -418,9 +430,10 @@ export function ChatViewer({
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
searchProfiles={searchProfiles}
|
||||
onSubmit={(content) => {
|
||||
searchEmojis={searchEmojis}
|
||||
onSubmit={(content, emojiTags) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo);
|
||||
handleSend(content, replyTo, emojiTags);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0"
|
||||
|
||||
152
src/components/editor/EmojiSuggestionList.tsx
Normal file
152
src/components/editor/EmojiSuggestionList.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EmojiSuggestionListProps {
|
||||
items: EmojiSearchResult[];
|
||||
command: (item: EmojiSearchResult) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface EmojiSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
const GRID_COLS = 8;
|
||||
|
||||
export const EmojiSuggestionList = forwardRef<
|
||||
EmojiSuggestionListHandle,
|
||||
EmojiSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyboard navigation with grid support
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev - GRID_COLS;
|
||||
return newIndex < 0 ? Math.max(0, items.length + newIndex) : newIndex;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev + GRID_COLS;
|
||||
return newIndex >= items.length
|
||||
? Math.min(items.length - 1, newIndex % GRID_COLS)
|
||||
: newIndex;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.querySelector(
|
||||
`[data-index="${selectedIndex}"]`,
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
|
||||
No emoji found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[240px] w-[296px] overflow-y-auto border border-border/50 bg-popover p-2 shadow-md"
|
||||
>
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={`${item.shortcode}-${item.source}`}
|
||||
data-index={index}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded transition-colors",
|
||||
index === selectedIndex ? "bg-muted" : "hover:bg-muted/60",
|
||||
)}
|
||||
title={`:${item.shortcode}:`}
|
||||
>
|
||||
{item.source === "unicode" ? (
|
||||
// Unicode emoji - render as text
|
||||
<span className="text-lg leading-none">{item.url}</span>
|
||||
) : (
|
||||
// Custom emoji - render as image
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`:${item.shortcode}:`}
|
||||
className="size-6 object-contain"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// Replace with fallback on error
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Show selected emoji shortcode */}
|
||||
{items[selectedIndex] && (
|
||||
<div className="mt-2 border-t border-border/50 pt-2 text-center text-xs text-muted-foreground">
|
||||
:{items[selectedIndex].shortcode}:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmojiSuggestionList.displayName = "EmojiSuggestionList";
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
@@ -18,13 +17,37 @@ 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";
|
||||
|
||||
/**
|
||||
* Represents an emoji tag for NIP-30
|
||||
*/
|
||||
export interface EmojiTag {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
*/
|
||||
export interface SerializedContent {
|
||||
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
|
||||
text: string;
|
||||
/** Emoji tags to include in the event (NIP-30) */
|
||||
emojiTags: EmojiTag[];
|
||||
}
|
||||
|
||||
export interface MentionEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (content: string) => void;
|
||||
onSubmit?: (content: string, emojiTags: EmojiTag[]) => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -33,11 +56,90 @@ export interface MentionEditorHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getContent: () => string;
|
||||
getContentWithMentions: () => string;
|
||||
getSerializedContent: () => SerializedContent;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => 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,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const MentionEditor = forwardRef<
|
||||
MentionEditorHandle,
|
||||
MentionEditorProps
|
||||
@@ -47,13 +149,14 @@ export const MentionEditor = forwardRef<
|
||||
placeholder = "Type a message...",
|
||||
onSubmit,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Create mention suggestion configuration
|
||||
const suggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
// Create mention suggestion configuration for @ mentions
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
@@ -126,52 +229,155 @@ export const MentionEditor = forwardRef<
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions
|
||||
const serializeContent = useCallback((editorInstance: any) => {
|
||||
let text = "";
|
||||
const json = editorInstance.getJSON();
|
||||
// Create emoji suggestion configuration for : emoji
|
||||
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | null = useMemo(
|
||||
() =>
|
||||
searchEmojis
|
||||
? {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchEmojis(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<EmojiSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
if (node.type === "paragraph") {
|
||||
node.content?.forEach((child: any) => {
|
||||
if (child.type === "text") {
|
||||
text += child.text;
|
||||
} else if (child.type === "mention") {
|
||||
const pubkey = child.attrs?.id;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
// Fallback to display name if encoding fails
|
||||
text += `@${child.attrs?.label || "unknown"}`;
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(EmojiSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
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",
|
||||
});
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[searchEmojis],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions and emojis
|
||||
const serializeContent = useCallback(
|
||||
(editorInstance: any): SerializedContent => {
|
||||
let text = "";
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const json = editorInstance.getJSON();
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
if (node.type === "paragraph") {
|
||||
node.content?.forEach((child: any) => {
|
||||
if (child.type === "text") {
|
||||
text += child.text;
|
||||
} else if (child.type === "mention") {
|
||||
const pubkey = child.attrs?.id;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
// Fallback to display name if encoding fails
|
||||
text += `@${child.attrs?.label || "unknown"}`;
|
||||
}
|
||||
}
|
||||
} else if (child.type === "emoji") {
|
||||
const shortcode = child.attrs?.id;
|
||||
const url = child.attrs?.url;
|
||||
const source = child.attrs?.source;
|
||||
|
||||
if (source === "unicode" && url) {
|
||||
// Unicode emoji - output the actual character
|
||||
text += url;
|
||||
} else if (shortcode) {
|
||||
// Custom emoji - output :shortcode: and add tag
|
||||
text += `:${shortcode}:`;
|
||||
|
||||
if (url && !seenEmojis.has(shortcode)) {
|
||||
seenEmojis.add(shortcode);
|
||||
emojiTags.push({ shortcode, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
}, []);
|
||||
return {
|
||||
text: text.trim(),
|
||||
emojiTags,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper function to handle submission
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (!editorInstance || !onSubmit) return;
|
||||
|
||||
const content = serializeContent(editorInstance);
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
const { text, emojiTags } = serializeContent(editorInstance);
|
||||
if (text) {
|
||||
onSubmit(text, emojiTags);
|
||||
editorInstance.commands.clearContent();
|
||||
}
|
||||
},
|
||||
[onSubmit, serializeContent],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
// Build extensions array
|
||||
const extensions = useMemo(() => {
|
||||
const exts = [
|
||||
StarterKit.configure({
|
||||
// Disable Enter to submit via Mod-Enter instead
|
||||
hardBreak: {
|
||||
@@ -183,7 +389,7 @@ export const MentionEditor = forwardRef<
|
||||
class: "mention",
|
||||
},
|
||||
suggestion: {
|
||||
...suggestion,
|
||||
...mentionSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
// props is the ProfileSearchResult
|
||||
editor
|
||||
@@ -209,7 +415,47 @@ export const MentionEditor = forwardRef<
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
// 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();
|
||||
},
|
||||
},
|
||||
// Note: renderLabel is not used when nodeView is defined
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [mentionSuggestion, emojiSuggestion, placeholder]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
@@ -240,8 +486,8 @@ export const MentionEditor = forwardRef<
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getContentWithMentions: () => {
|
||||
if (!editor) return "";
|
||||
getSerializedContent: () => {
|
||||
if (!editor) return { text: "", emojiTags: [] };
|
||||
return serializeContent(editor);
|
||||
},
|
||||
isEmpty: () => editor?.isEmpty ?? true,
|
||||
|
||||
Reference in New Issue
Block a user