mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
refactor(editor): replace DOM manipulation with React node views and floating-ui (#253)
* refactor(editor): replace DOM manipulation with React node views and floating-ui - Convert all inline node views (emoji, blob attachment, event preview) from imperative document.createElement() to React components via ReactNodeViewRenderer - Replace tippy.js with @floating-ui/react-dom for suggestion popup positioning - Create useSuggestionRenderer hook bridging Tiptap suggestion callbacks to React state - Extract shared EmojiMention, SubmitShortcut, and inline node extensions to separate files - Extract types (EmojiTag, BlobAttachment, SerializedContent) to editor/types.ts - Extract serialization logic to editor/utils/serialize.ts - Remove redundant DOM keydown listener from RichEditor (handled by SubmitShortcut extension) - Remove tippy.js dependency (-1045 lines net, RichEditor 632→297, MentionEditor 1038→354) https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * fix(editor): fix suggestion popover positioning, scrolling, and profile click behavior - Replace UserName component in ProfileSuggestionList with plain text display so clicking a suggestion autocompletes instead of opening their profile (UserName has an onClick that calls addWindow and stopPropagation) - Add react-virtuoso to ProfileSuggestionList for efficient lazy rendering of up to 20 search results with fixed item height scrolling - Add profile avatars with lazy loading and initial-letter fallback - Fix SuggestionPopover positioning with autoUpdate for scroll/resize tracking - Add size middleware to constrain popover max-height to available viewport space https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * refactor(editor): convert emoji suggestion from grid to scrollable list with Virtuoso Replace the 8-column grid layout with a vertical list matching the profile suggestion style — each row shows the emoji preview alongside its :shortcode: name. Uses react-virtuoso with fixedItemHeight for lazy rendering and smooth keyboard-driven scrolling through large emoji sets. https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * fix(editor): set mentionSuggestionChar to ':' for emoji nodes When backspacing over a mention-based node, Tiptap inserts the node's mentionSuggestionChar attribute as undo text. The EmojiMention extension inherits Mention's default of '@', so deleting an emoji left '@' instead of ':'. Fix by explicitly setting mentionSuggestionChar: ':' in the emoji command's attrs for both RichEditor and MentionEditor. https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * test(editor): add comprehensive test suite for custom TipTap extensions Tests all 8 custom extensions using headless TipTap Editor instances in jsdom environment (TipTap has no official testing package): - EmojiMention: schema, renderText (unicode vs custom), mentionSuggestionChar attribute handling, backspace behavior regression test - BlobAttachmentRichNode/InlineNode: schema (block vs inline), attributes, renderText URL serialization, parseHTML selectors - NostrEventPreviewRichNode/InlineNode: schema, renderText encoding for note/nevent/naddr back to nostr: URIs - SubmitShortcut: Mod-Enter always submits, Enter behavior with enterSubmits flag - FilePasteHandler: media type filtering (image/video/audio), non-media rejection, mixed paste filtering, edge cases (no files, no callback) - NostrPasteHandler: bech32 regex matching (npub/note/nevent/naddr/nprofile), nostr: prefix handling, URL exclusion, node creation (mention vs preview), surrounding text preservation, multiple entities - Serialization: formatBlobSize, serializeRichContent (emoji tag extraction, blob dedup, address refs), serializeInlineContent (mention→nostr: URI, emoji→shortcode, blob→URL, event preview encoding) 90 new tests total. https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * fix(editor): paste handler and serialization bugs found via adversarial testing NostrPasteHandler fixes: - Punctuation after bech32 now matches (npub1..., npub1...! etc.) Changed trailing lookahead from (?=$|\s) to (?=$|\s|[.,!?;:)\]}>]) - Fixed double-space between entities — unconditional " " after every entity caused doubled spaces. Now only adds trailing space when entity is at the very end of pasted text (for cursor positioning). - Tightened regex character class from [\w] to [a-z0-9] to match actual bech32 charset (rejects uppercase, underscore) - Wrapped dispatch in try/catch to handle block-node-at-inline-position errors gracefully (falls back to default paste) Serialization fix: - serializeRichContent now guards blob collection with `url && sha256` matching the defensive checks already in serializeInlineContent. Previously null sha256 would corrupt the dedup Set and null url would produce invalid BlobAttachment entries. Added 22 new edge case tests: - Paste handler: punctuation boundaries, double-space regression, malformed bech32 fallback, uppercase rejection, error resilience - Serialization: empty editor, null sha256/url blobs, invalid pubkey fallback, missing mention attrs, inline dedup, multi-paragraph https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * fix(editor): raise suggestion search limits for profiles and emojis Both suggestion dropdowns use Virtuoso for virtualized rendering, so they can handle large result sets without performance issues. The previous limits (20 profiles, 24 emojis) were too restrictive — users with many custom emojis sharing a substring or large contact lists couldn't scroll to find the right match. Raised both limits to 200 to allow thorough browsing while still bounding the result set. https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * refactor(chat): rework emoji picker to scrollable list with search Replace the fixed 1-row grid (8 emojis) with a scrollable virtualized list matching the editor's EmojiSuggestionList look & feel: - Search box at top with magnifying glass icon - Virtuoso-backed scrollable list (8 visible items, unlimited results) - Each row shows emoji icon + :shortcode: label - Keyboard navigation: arrow keys to select, Enter to confirm - Mouse hover highlights, click selects - Frequently used emojis still shown first when no search query - Narrower dialog (max-w-xs) for a compact picker feel https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u * fix: add address field to EmojiTag in editor types, fix GroupMessageOptions - Add optional `address` field to EmojiTag in editor/types.ts to match NIP-30 changes from main (30030 emoji set address) - Extend GroupMessageOptions with MetaTagOptions to fix type error in GroupMessageBlueprint's setMetaTags call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(editor): restore address attr, fix serialization, UserName, no-scroll - Restore `address` attribute in shared EmojiMention extension (emoji.ts) that was dropped during refactor — required for NIP-30 emoji set tracking - Extract `address` from emoji nodes in both serializeRichContent and serializeInlineContent so it makes it into published events - Fix MentionEditorProps.onSubmit signature: use EmojiTag[] (not the narrower inline type) so address field flows through to callers - Restore UserName component in ProfileSuggestionList for proper display with Grimoire member badges and supporter flame - Remove scrollbar when all items fit: set overflow:hidden on Virtuoso when items.length <= MAX_VISIBLE (profile list, emoji list, emoji picker dialog) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -28,8 +28,12 @@ import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { RichEditor, type RichEditorHandle } from "./editor/RichEditor";
|
||||
import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor";
|
||||
import {
|
||||
RichEditor,
|
||||
type RichEditorHandle,
|
||||
type BlobAttachment,
|
||||
type EmojiTag,
|
||||
} from "./editor/RichEditor";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import { Kind1Renderer } from "./nostr/kinds";
|
||||
import pool from "@/services/relay-pool";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { EmojiTag } from "@/lib/emoji-helpers";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { CustomEmoji } from "../nostr/CustomEmoji";
|
||||
|
||||
interface EmojiPickerDialogProps {
|
||||
open: boolean;
|
||||
@@ -18,6 +18,9 @@ interface EmojiPickerDialogProps {
|
||||
// Frequently used emojis stored in localStorage
|
||||
const STORAGE_KEY = "grimoire:reaction-history";
|
||||
|
||||
const ITEM_HEIGHT = 40;
|
||||
const MAX_VISIBLE = 8;
|
||||
|
||||
function getReactionHistory(): Record<string, number> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -44,10 +47,10 @@ function updateReactionHistory(emoji: string): void {
|
||||
* EmojiPickerDialog - Searchable emoji picker for reactions
|
||||
*
|
||||
* Features:
|
||||
* - Real-time search using FlexSearch
|
||||
* - Real-time search using FlexSearch with scrollable virtualized results
|
||||
* - Frequently used emoji at top when no search query
|
||||
* - Quick reaction bar for common emojis
|
||||
* - Supports both unicode and NIP-30 custom emoji
|
||||
* - Keyboard navigation (arrow keys, enter, escape)
|
||||
* - Tracks usage in localStorage
|
||||
*/
|
||||
export function EmojiPickerDialog({
|
||||
@@ -58,6 +61,8 @@ export function EmojiPickerDialog({
|
||||
}: EmojiPickerDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<EmojiSearchResult[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Use the same emoji search hook as chat autocomplete
|
||||
const { service } = useEmojiSearch();
|
||||
@@ -74,126 +79,192 @@ export function EmojiPickerDialog({
|
||||
// Perform search when query changes
|
||||
useEffect(() => {
|
||||
const performSearch = async () => {
|
||||
// Always fetch 8 emoji (1 row of 8) for consistent height
|
||||
const results = await service.search(searchQuery, { limit: 8 });
|
||||
const results = await service.search(searchQuery, { limit: 200 });
|
||||
setSearchResults(results);
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
performSearch();
|
||||
}, [searchQuery, service]);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Get frequently used emojis from history
|
||||
const frequentlyUsed = useMemo(() => {
|
||||
const history = getReactionHistory();
|
||||
return Object.entries(history)
|
||||
.sort((a, b) => b[1] - a[1]) // Sort by count descending
|
||||
.slice(0, 8) // Max 1 row
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([emoji]) => emoji);
|
||||
}, []);
|
||||
|
||||
// Combine recently used with search results for display
|
||||
// When no search query: show recently used first, then fill with other emoji
|
||||
// When searching: show search results
|
||||
// When no search query: show recently used first, then fill with search results
|
||||
// When searching: show search results only
|
||||
const displayEmojis = useMemo(() => {
|
||||
if (searchQuery.trim()) {
|
||||
// Show search results
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
// No search query: prioritize recently used, then fill with other emoji
|
||||
if (frequentlyUsed.length > 0) {
|
||||
const recentSet = new Set(frequentlyUsed);
|
||||
// Get additional emoji to fill to 8, excluding recently used
|
||||
const additional = searchResults
|
||||
.filter((r) => {
|
||||
const key = r.source === "unicode" ? r.url : `:${r.shortcode}:`;
|
||||
return !recentSet.has(key);
|
||||
})
|
||||
.slice(0, 8 - frequentlyUsed.length);
|
||||
const additional = searchResults.filter((r) => {
|
||||
const key = r.source === "unicode" ? r.url : `:${r.shortcode}:`;
|
||||
return !recentSet.has(key);
|
||||
});
|
||||
|
||||
// Combine: recently used get priority, but displayed as regular emoji
|
||||
const recentResults: EmojiSearchResult[] = [];
|
||||
for (const emojiStr of frequentlyUsed) {
|
||||
if (emojiStr.startsWith(":") && emojiStr.endsWith(":")) {
|
||||
const shortcode = emojiStr.slice(1, -1);
|
||||
const customEmoji = service.getByShortcode(shortcode);
|
||||
if (customEmoji) {
|
||||
recentResults.push(customEmoji);
|
||||
}
|
||||
if (customEmoji) recentResults.push(customEmoji);
|
||||
} else {
|
||||
// Unicode emoji - find it in search results
|
||||
const found = searchResults.find((r) => r.url === emojiStr);
|
||||
if (found) recentResults.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
return [...recentResults, ...additional].slice(0, 8);
|
||||
return [...recentResults, ...additional];
|
||||
}
|
||||
|
||||
// No history: just show top 8 emoji
|
||||
return searchResults;
|
||||
}, [searchQuery, searchResults, frequentlyUsed, service]);
|
||||
|
||||
const handleEmojiClick = (result: EmojiSearchResult) => {
|
||||
if (result.source === "unicode") {
|
||||
// For unicode emoji, the "url" field contains the emoji character
|
||||
onEmojiSelect(result.url);
|
||||
updateReactionHistory(result.url);
|
||||
} else {
|
||||
// For custom emoji, pass the shortcode as content and emoji tag info
|
||||
onEmojiSelect(`:${result.shortcode}:`, {
|
||||
shortcode: result.shortcode,
|
||||
url: result.url,
|
||||
address: result.address,
|
||||
});
|
||||
updateReactionHistory(`:${result.shortcode}:`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
setSearchQuery(""); // Reset search on close
|
||||
};
|
||||
const handleEmojiClick = useCallback(
|
||||
(result: EmojiSearchResult) => {
|
||||
if (result.source === "unicode") {
|
||||
onEmojiSelect(result.url);
|
||||
updateReactionHistory(result.url);
|
||||
} else {
|
||||
onEmojiSelect(`:${result.shortcode}:`, {
|
||||
shortcode: result.shortcode,
|
||||
url: result.url,
|
||||
address: result.address,
|
||||
});
|
||||
updateReactionHistory(`:${result.shortcode}:`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onEmojiSelect, onOpenChange],
|
||||
);
|
||||
|
||||
// Keyboard navigation in the search input
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < displayEmojis.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : displayEmojis.length - 1,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (displayEmojis[selectedIndex]) {
|
||||
handleEmojiClick(displayEmojis[selectedIndex]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[displayEmojis, selectedIndex, handleEmojiClick],
|
||||
);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
virtuosoRef.current?.scrollIntoView({
|
||||
index: selectedIndex,
|
||||
behavior: "auto",
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
const listHeight = Math.min(displayEmojis.length, MAX_VISIBLE) * ITEM_HEIGHT;
|
||||
|
||||
const renderItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = displayEmojis[index];
|
||||
return (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleEmojiClick(item);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center flex-shrink-0">
|
||||
{item.source === "unicode" ? (
|
||||
<span className="text-lg leading-none">{item.url}</span>
|
||||
) : (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`:${item.shortcode}:`}
|
||||
className="size-6 object-contain"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-popover-foreground/80">
|
||||
:{item.shortcode}:
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
[displayEmojis, selectedIndex, handleEmojiClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-xs p-4 gap-2">
|
||||
{/* Search input */}
|
||||
<div className="relative mt-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search emojis..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fixed 1-row emoji grid (8 emoji) with consistent height */}
|
||||
<div className="grid grid-cols-8 items-center gap-3 h-[1.5rem]">
|
||||
{displayEmojis.length > 0 ? (
|
||||
displayEmojis.map((result) => (
|
||||
<button
|
||||
key={`${result.source}:${result.shortcode}`}
|
||||
onClick={() => handleEmojiClick(result)}
|
||||
className="hover:bg-muted rounded p-2 transition-colors flex items-center justify-center aspect-square"
|
||||
title={`:${result.shortcode}:`}
|
||||
>
|
||||
{result.source === "unicode" ? (
|
||||
<span className="text-xl leading-none">{result.url}</span>
|
||||
) : (
|
||||
<CustomEmoji
|
||||
size="md"
|
||||
shortcode={result.shortcode}
|
||||
url={result.url}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-8 flex items-center justify-center text-sm text-muted-foreground">
|
||||
No emojis found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Scrollable emoji list */}
|
||||
{displayEmojis.length > 0 ? (
|
||||
<div
|
||||
role="listbox"
|
||||
className="rounded-md border border-border/50 bg-popover text-popover-foreground overflow-hidden"
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
totalCount={displayEmojis.length}
|
||||
fixedItemHeight={ITEM_HEIGHT}
|
||||
style={{
|
||||
height: listHeight,
|
||||
overflow:
|
||||
displayEmojis.length <= MAX_VISIBLE ? "hidden" : "auto",
|
||||
}}
|
||||
itemContent={renderItem}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||
No emojis found
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EmojiSuggestionListProps {
|
||||
items: EmojiSearchResult[];
|
||||
@@ -18,43 +19,26 @@ export interface EmojiSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
const GRID_COLS = 8;
|
||||
const ITEM_HEIGHT = 40;
|
||||
const MAX_VISIBLE = 8;
|
||||
|
||||
export const EmojiSuggestionList = forwardRef<
|
||||
EmojiSuggestionListHandle,
|
||||
EmojiSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Keyboard navigation with grid support
|
||||
// Keyboard navigation (linear list)
|
||||
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;
|
||||
});
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
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));
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -74,16 +58,12 @@ export const EmojiSuggestionList = forwardRef<
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
// Scroll selected item into view via Virtuoso
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.querySelector(
|
||||
`[data-index="${selectedIndex}"]`,
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
virtuosoRef.current?.scrollIntoView({
|
||||
index: selectedIndex,
|
||||
behavior: "auto",
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
@@ -91,62 +71,72 @@ export const EmojiSuggestionList = forwardRef<
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
return (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
command(item);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center flex-shrink-0">
|
||||
{item.source === "unicode" ? (
|
||||
<span className="text-lg leading-none">{item.url}</span>
|
||||
) : (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`:${item.shortcode}:`}
|
||||
className="size-6 object-contain"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-popover-foreground/80">
|
||||
:{item.shortcode}:
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
[items, selectedIndex, command],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
|
||||
<div className="rounded-md border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
|
||||
No emoji found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[280px] w-full max-w-[296px] overflow-y-auto border border-border/50 bg-popover p-2 text-popover-foreground shadow-md"
|
||||
className="w-[260px] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-6 md:grid-cols-8 gap-1 md: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-10 md: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-xl md:text-lg leading-none">
|
||||
{item.url}
|
||||
</span>
|
||||
) : (
|
||||
// Custom emoji - render as image
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`:${item.shortcode}:`}
|
||||
className="size-7 md: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-popover-foreground/60">
|
||||
:{items[selectedIndex].shortcode}:
|
||||
</div>
|
||||
)}
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
totalCount={items.length}
|
||||
fixedItemHeight={ITEM_HEIGHT}
|
||||
style={{
|
||||
height: listHeight,
|
||||
overflow: items.length <= MAX_VISIBLE ? "hidden" : "auto",
|
||||
}}
|
||||
itemContent={renderItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ import {
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
|
||||
@@ -18,12 +20,15 @@ export interface ProfileSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const MAX_VISIBLE = 6;
|
||||
|
||||
export const ProfileSuggestionList = forwardRef<
|
||||
ProfileSuggestionListHandle,
|
||||
ProfileSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Keyboard navigation
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -54,14 +59,12 @@ export const ProfileSuggestionList = forwardRef<
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
// Scroll selected item into view via Virtuoso
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[selectedIndex];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
virtuosoRef.current?.scrollIntoView({
|
||||
index: selectedIndex,
|
||||
behavior: "auto",
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
@@ -69,31 +72,35 @@ export const ProfileSuggestionList = forwardRef<
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
|
||||
No profiles found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[300px] w-full max-w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
const renderItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
return (
|
||||
<button
|
||||
key={item.pubkey}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
command(item);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-3 md:py-2 min-h-[44px] text-left transition-colors ${
|
||||
className={`flex w-full items-center gap-3 px-3 py-2.5 min-h-[44px] text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{item.picture ? (
|
||||
<img
|
||||
src={item.picture}
|
||||
alt=""
|
||||
className="size-8 rounded-full object-cover flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex-shrink-0 flex items-center justify-center text-xs text-muted-foreground">
|
||||
{item.displayName?.charAt(0)?.toUpperCase() || "?"}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<UserName pubkey={item.pubkey} />
|
||||
@@ -105,7 +112,36 @@ export const ProfileSuggestionList = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
},
|
||||
[items, selectedIndex, command],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
|
||||
No profiles found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
className="w-[320px] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
totalCount={items.length}
|
||||
fixedItemHeight={ITEM_HEIGHT}
|
||||
style={{
|
||||
height: listHeight,
|
||||
overflow: items.length <= MAX_VISIBLE ? "hidden" : "auto",
|
||||
}}
|
||||
itemContent={renderItem}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
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 { ProfileSuggestionList } from "./ProfileSuggestionList";
|
||||
import { EmojiSuggestionList } from "./EmojiSuggestionList";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import { nip19 } from "nostr-tools";
|
||||
@@ -30,17 +19,19 @@ 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";
|
||||
import { EmojiMention } from "./extensions/emoji";
|
||||
import { SubmitShortcut } from "./extensions/submit-shortcut";
|
||||
import { serializeRichContent } from "./utils/serialize";
|
||||
import { useSuggestionRenderer } from "./hooks/useSuggestionRenderer";
|
||||
import type { BlobAttachment, SerializedContent } from "./types";
|
||||
|
||||
export type { EmojiTag, BlobAttachment, SerializedContent } from "./types";
|
||||
|
||||
export interface RichEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
emojiTags: Array<{ shortcode: string; url: string }>,
|
||||
blobAttachments: BlobAttachment[],
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
) => void;
|
||||
@@ -73,159 +64,6 @@ export interface RichEditorHandle {
|
||||
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 };
|
||||
},
|
||||
},
|
||||
address: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-address"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.address) return {};
|
||||
return { "data-address": attributes.address };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// 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, address } = 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, address: address ?? undefined });
|
||||
}
|
||||
} 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>(
|
||||
(
|
||||
{
|
||||
@@ -242,200 +80,67 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
},
|
||||
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;
|
||||
}
|
||||
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
|
||||
}
|
||||
const serialized = serializeRichContent(editorInstance);
|
||||
onSubmit?.(
|
||||
serialized.text,
|
||||
serialized.emojiTags,
|
||||
serialized.blobAttachments,
|
||||
serialized.addressRefs,
|
||||
);
|
||||
},
|
||||
[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)
|
||||
};
|
||||
},
|
||||
});
|
||||
// React-based suggestion renderers (replace tippy.js)
|
||||
const { render: renderMentionSuggestion, portal: mentionPortal } =
|
||||
useSuggestionRenderer<ProfileSearchResult>(ProfileSuggestionList as any);
|
||||
|
||||
const { render: renderEmojiSuggestion, portal: emojiPortal } =
|
||||
useSuggestionRenderer<EmojiSearchResult>(EmojiSuggestionList as any);
|
||||
|
||||
// Mention suggestion config
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => searchProfiles(query),
|
||||
render: renderMentionSuggestion,
|
||||
}),
|
||||
[searchProfiles, renderMentionSuggestion],
|
||||
);
|
||||
|
||||
// Emoji suggestion config
|
||||
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | undefined =
|
||||
useMemo(() => {
|
||||
if (!searchEmojis) return undefined;
|
||||
return {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => searchEmojis(query),
|
||||
render: renderEmojiSuggestion,
|
||||
};
|
||||
}, [searchEmojis, renderEmojiSuggestion]);
|
||||
|
||||
// Build extensions
|
||||
const extensions = useMemo(() => {
|
||||
const exts = [
|
||||
SubmitShortcut,
|
||||
SubmitShortcut.configure({
|
||||
submitRef: handleSubmitRef,
|
||||
enterSubmits: false,
|
||||
}),
|
||||
StarterKit.configure({
|
||||
// Enable paragraph, hardBreak, etc. for multi-line
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
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) {
|
||||
@@ -444,23 +149,17 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
}
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
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,
|
||||
},
|
||||
attrs: { id: props.pubkey, label: props.displayName },
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
@@ -471,32 +170,20 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
// Add blob attachment extension for full-size media previews
|
||||
Placeholder.configure({ placeholder }),
|
||||
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,
|
||||
}),
|
||||
FilePasteHandler.configure({ onFilePaste }),
|
||||
];
|
||||
|
||||
// Add emoji extension if search is provided
|
||||
if (emojiSuggestion) {
|
||||
exts.push(
|
||||
EmojiMention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "emoji",
|
||||
},
|
||||
HTMLAttributes: { class: "emoji" },
|
||||
suggestion: {
|
||||
...emojiSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
// props is the EmojiSearchResult
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
@@ -508,7 +195,7 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
label: props.shortcode,
|
||||
url: props.url,
|
||||
source: props.source,
|
||||
address: props.address ?? null,
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
@@ -532,29 +219,21 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
onUpdate: () => {
|
||||
onChange?.();
|
||||
},
|
||||
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();
|
||||
}
|
||||
if (isEditorReady()) editor?.commands.focus();
|
||||
},
|
||||
clear: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.clearContent();
|
||||
}
|
||||
if (isEditorReady()) editor?.commands.clearContent();
|
||||
},
|
||||
getContent: () => {
|
||||
if (!isEditorReady()) return "";
|
||||
@@ -568,21 +247,17 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
blobAttachments: [],
|
||||
addressRefs: [],
|
||||
};
|
||||
return serializeContent(editor);
|
||||
return serializeRichContent(editor);
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (!isEditorReady()) return true;
|
||||
return editor?.isEmpty ?? true;
|
||||
},
|
||||
submit: () => {
|
||||
if (isEditorReady() && editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
if (isEditorReady() && editor) handleSubmit(editor);
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent(text);
|
||||
}
|
||||
if (isEditorReady()) editor?.commands.insertContent(text);
|
||||
},
|
||||
insertBlob: (blob: BlobAttachment) => {
|
||||
if (isEditorReady()) {
|
||||
@@ -597,41 +272,19 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
|
||||
return editor?.getJSON() || null;
|
||||
},
|
||||
setContent: (json: any) => {
|
||||
// Check editor and view are ready before setting content
|
||||
if (isEditorReady() && json) {
|
||||
editor?.commands.setContent(json);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className={`rich-editor ${className}`}>
|
||||
<EditorContent editor={editor} />
|
||||
{mentionPortal}
|
||||
{emojiPortal}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
89
src/components/editor/SuggestionPopover.tsx
Normal file
89
src/components/editor/SuggestionPopover.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
useFloating,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
autoUpdate,
|
||||
size,
|
||||
} from "@floating-ui/react-dom";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface SuggestionPopoverProps {
|
||||
/** Function that returns the cursor bounding rect (from Tiptap suggestion) */
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
/** Popover content (suggestion list component) */
|
||||
children: ReactNode;
|
||||
/** Floating-ui placement */
|
||||
placement?: "bottom-start" | "top-start";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic floating popover for suggestion dropdowns
|
||||
*
|
||||
* Uses @floating-ui/react-dom with a virtual reference element (cursor position)
|
||||
* to position suggestion lists. Rendered via React portal.
|
||||
*
|
||||
* Uses autoUpdate to keep position correct during scroll/resize, and a
|
||||
* size middleware to constrain max-height to available viewport space.
|
||||
*/
|
||||
export function SuggestionPopover({
|
||||
clientRect,
|
||||
children,
|
||||
placement = "bottom-start",
|
||||
}: SuggestionPopoverProps) {
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement,
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ padding: 8 }),
|
||||
shift({ padding: 8 }),
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
elements.floating.style.maxHeight = `${Math.max(100, availableHeight)}px`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Set up virtual reference and auto-update when clientRect changes
|
||||
useEffect(() => {
|
||||
// Clean up previous auto-update
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
|
||||
if (!clientRect) return;
|
||||
|
||||
const virtualEl = {
|
||||
getBoundingClientRect: () => clientRect() || new DOMRect(),
|
||||
};
|
||||
|
||||
refs.setReference(virtualEl);
|
||||
|
||||
// Start auto-update for scroll/resize tracking
|
||||
const floating = refs.floating.current;
|
||||
if (floating) {
|
||||
cleanupRef.current = autoUpdate(virtualEl, floating, update, {
|
||||
ancestorScroll: true,
|
||||
ancestorResize: true,
|
||||
elementResize: true,
|
||||
animationFrame: false,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
}, [clientRect, refs, update]);
|
||||
|
||||
return createPortal(
|
||||
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 50 }}>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
48
src/components/editor/extensions/blob-attachment-inline.ts
Normal file
48
src/components/editor/extensions/blob-attachment-inline.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { BlobAttachmentInline } from "../node-views/BlobAttachmentInline";
|
||||
|
||||
/**
|
||||
* Inline blob attachment node for MentionEditor (chat-style)
|
||||
*
|
||||
* Shows a compact badge with media type and size.
|
||||
* Uses ReactNodeViewRenderer for React-based rendering.
|
||||
*/
|
||||
export const BlobAttachmentInlineNode = Node.create({
|
||||
name: "blobAttachment",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
url: { default: null },
|
||||
sha256: { default: null },
|
||||
mimeType: { default: null },
|
||||
size: { default: null },
|
||||
server: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-blob-attachment="true"]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return node.attrs.url || "";
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(BlobAttachmentInline, {
|
||||
as: "span",
|
||||
className: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
190
src/components/editor/extensions/blob-attachment.test.ts
Normal file
190
src/components/editor/extensions/blob-attachment.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
|
||||
// Mock React node views (not needed for headless tests)
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
ReactNodeViewRenderer: () => () => null,
|
||||
}));
|
||||
vi.mock("../node-views/BlobAttachmentRich", () => ({
|
||||
BlobAttachmentRich: {},
|
||||
}));
|
||||
vi.mock("../node-views/BlobAttachmentInline", () => ({
|
||||
BlobAttachmentInline: {},
|
||||
}));
|
||||
|
||||
import { BlobAttachmentRichNode } from "./blob-attachment-rich";
|
||||
import { BlobAttachmentInlineNode } from "./blob-attachment-inline";
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
const SAMPLE_BLOB = {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: "abc123def456",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
server: "https://blossom.example.com",
|
||||
};
|
||||
|
||||
describe("BlobAttachmentRichNode (block-level)", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
function createRichEditor(content?: string) {
|
||||
return new Editor({
|
||||
extensions: [StarterKit, BlobAttachmentRichNode],
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
describe("schema", () => {
|
||||
it("should register blobAttachment node type", () => {
|
||||
editor = createRichEditor();
|
||||
expect(editor.schema.nodes.blobAttachment).toBeDefined();
|
||||
});
|
||||
|
||||
it("should be a block-level node", () => {
|
||||
editor = createRichEditor();
|
||||
const spec = editor.schema.nodes.blobAttachment.spec;
|
||||
expect(spec.group).toBe("block");
|
||||
expect(spec.inline).toBe(false);
|
||||
});
|
||||
|
||||
it("should be an atom node", () => {
|
||||
editor = createRichEditor();
|
||||
expect(editor.schema.nodes.blobAttachment.spec.atom).toBe(true);
|
||||
});
|
||||
|
||||
it("should have correct attributes", () => {
|
||||
editor = createRichEditor();
|
||||
const attrs = editor.schema.nodes.blobAttachment.spec.attrs!;
|
||||
expect(attrs).toHaveProperty("url");
|
||||
expect(attrs).toHaveProperty("sha256");
|
||||
expect(attrs).toHaveProperty("mimeType");
|
||||
expect(attrs).toHaveProperty("size");
|
||||
expect(attrs).toHaveProperty("server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderText", () => {
|
||||
it("should return URL as text", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: SAMPLE_BLOB,
|
||||
});
|
||||
expect(editor.getText()).toContain(SAMPLE_BLOB.url);
|
||||
});
|
||||
|
||||
it("should return empty string when url is null", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: { ...SAMPLE_BLOB, url: null },
|
||||
});
|
||||
expect(editor.getText().trim()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("node attributes", () => {
|
||||
it("should store all attributes on inserted node", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: SAMPLE_BLOB,
|
||||
});
|
||||
|
||||
let blobNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "blobAttachment") {
|
||||
blobNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(blobNode).not.toBeNull();
|
||||
expect(blobNode.attrs.url).toBe(SAMPLE_BLOB.url);
|
||||
expect(blobNode.attrs.sha256).toBe(SAMPLE_BLOB.sha256);
|
||||
expect(blobNode.attrs.mimeType).toBe(SAMPLE_BLOB.mimeType);
|
||||
expect(blobNode.attrs.size).toBe(SAMPLE_BLOB.size);
|
||||
expect(blobNode.attrs.server).toBe(SAMPLE_BLOB.server);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHTML", () => {
|
||||
it("should parse div with data-blob-attachment attribute", () => {
|
||||
editor = createRichEditor();
|
||||
const parseRules = editor.schema.nodes.blobAttachment.spec.parseDOM;
|
||||
expect(parseRules).toBeDefined();
|
||||
expect(parseRules![0].tag).toBe('div[data-blob-attachment="true"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlobAttachmentInlineNode", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
function createInlineEditor(content?: string) {
|
||||
return new Editor({
|
||||
extensions: [StarterKit, BlobAttachmentInlineNode],
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
describe("schema", () => {
|
||||
it("should be an inline node", () => {
|
||||
editor = createInlineEditor();
|
||||
const spec = editor.schema.nodes.blobAttachment.spec;
|
||||
expect(spec.group).toBe("inline");
|
||||
expect(spec.inline).toBe(true);
|
||||
});
|
||||
|
||||
it("should be an atom node", () => {
|
||||
editor = createInlineEditor();
|
||||
expect(editor.schema.nodes.blobAttachment.spec.atom).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderText", () => {
|
||||
it("should return URL as text", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: SAMPLE_BLOB,
|
||||
});
|
||||
expect(editor.getText()).toContain(SAMPLE_BLOB.url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHTML", () => {
|
||||
it("should parse span with data-blob-attachment attribute", () => {
|
||||
editor = createInlineEditor();
|
||||
const parseRules = editor.schema.nodes.blobAttachment.spec.parseDOM;
|
||||
expect(parseRules![0].tag).toBe('span[data-blob-attachment="true"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
260
src/components/editor/extensions/emoji.test.ts
Normal file
260
src/components/editor/extensions/emoji.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
|
||||
// Mock React node views (not needed for headless tests)
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
ReactNodeViewRenderer: () => () => null,
|
||||
}));
|
||||
vi.mock("../node-views/EmojiNodeView", () => ({ EmojiNodeView: {} }));
|
||||
|
||||
import { EmojiMention } from "./emoji";
|
||||
|
||||
// ProseMirror requires layout APIs that jsdom lacks
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
function createEditor(content?: string) {
|
||||
return new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
EmojiMention.configure({
|
||||
suggestion: { char: ":" },
|
||||
}),
|
||||
],
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
describe("EmojiMention", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe("schema", () => {
|
||||
it("should register emoji node type", () => {
|
||||
editor = createEditor();
|
||||
expect(editor.schema.nodes.emoji).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have url attribute", () => {
|
||||
editor = createEditor();
|
||||
expect(editor.schema.nodes.emoji.spec.attrs).toHaveProperty("url");
|
||||
});
|
||||
|
||||
it("should have source attribute", () => {
|
||||
editor = createEditor();
|
||||
expect(editor.schema.nodes.emoji.spec.attrs).toHaveProperty("source");
|
||||
});
|
||||
|
||||
it("should inherit mention attributes (id, label)", () => {
|
||||
editor = createEditor();
|
||||
const attrs = editor.schema.nodes.emoji.spec.attrs!;
|
||||
expect(attrs).toHaveProperty("id");
|
||||
expect(attrs).toHaveProperty("label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderText", () => {
|
||||
it("should return emoji character for unicode source", () => {
|
||||
editor = createEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
label: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
});
|
||||
expect(editor.getText()).toContain("🔥");
|
||||
});
|
||||
|
||||
it("should return :shortcode: for custom emoji", () => {
|
||||
editor = createEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
label: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
});
|
||||
expect(editor.getText()).toContain(":pepe:");
|
||||
});
|
||||
|
||||
it("should return empty string for unicode with no url", () => {
|
||||
editor = createEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "unknown",
|
||||
label: "unknown",
|
||||
url: null,
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
});
|
||||
expect(editor.getText()).not.toContain(":unknown:");
|
||||
expect(editor.getText().trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should handle multiple emoji in sequence", () => {
|
||||
editor = createEditor();
|
||||
editor
|
||||
.chain()
|
||||
.insertContent([
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
])
|
||||
.run();
|
||||
|
||||
const text = editor.getText();
|
||||
expect(text).toContain("🔥");
|
||||
expect(text).toContain(":pepe:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentionSuggestionChar", () => {
|
||||
it("should preserve mentionSuggestionChar when explicitly set to ':'", () => {
|
||||
editor = createEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
label: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
});
|
||||
|
||||
let emojiNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "emoji") {
|
||||
emojiNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(emojiNode).not.toBeNull();
|
||||
expect(emojiNode.attrs.mentionSuggestionChar).toBe(":");
|
||||
});
|
||||
|
||||
it("should default mentionSuggestionChar to '@' if not explicitly set", () => {
|
||||
editor = createEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
label: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
// NOT setting mentionSuggestionChar - tests the regression
|
||||
},
|
||||
});
|
||||
|
||||
let emojiNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "emoji") {
|
||||
emojiNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Mention extension defaults to "@" - this is why we must always
|
||||
// set mentionSuggestionChar: ":" when inserting emoji nodes
|
||||
expect(emojiNode?.attrs.mentionSuggestionChar).toBe("@");
|
||||
});
|
||||
});
|
||||
|
||||
describe("backspace behavior", () => {
|
||||
it("should replace emoji with ':' when backspacing (mentionSuggestionChar set)", () => {
|
||||
editor = createEditor("<p>hello </p>");
|
||||
// Move to end
|
||||
editor.commands.focus("end");
|
||||
// Insert emoji with correct mentionSuggestionChar
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
label: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
});
|
||||
|
||||
// Verify emoji is present
|
||||
expect(editor.getText()).toContain("🔥");
|
||||
|
||||
// Simulate backspace - Mention extension handles this
|
||||
editor.commands.keyboardShortcut("Backspace");
|
||||
|
||||
// After backspace, emoji should be replaced by ":"
|
||||
const text = editor.getText();
|
||||
expect(text).not.toContain("🔥");
|
||||
expect(text).toContain(":");
|
||||
expect(text).not.toContain("@");
|
||||
});
|
||||
|
||||
it("should replace emoji with '@' when mentionSuggestionChar was not set (regression)", () => {
|
||||
editor = createEditor("<p>hello </p>");
|
||||
editor.commands.focus("end");
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
label: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
// NOT setting mentionSuggestionChar - demonstrates the regression
|
||||
},
|
||||
});
|
||||
|
||||
editor.commands.keyboardShortcut("Backspace");
|
||||
|
||||
// Without the fix, this would be "@" instead of ":"
|
||||
const text = editor.getText();
|
||||
expect(text).toContain("@");
|
||||
});
|
||||
});
|
||||
});
|
||||
60
src/components/editor/extensions/emoji.ts
Normal file
60
src/components/editor/extensions/emoji.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { EmojiNodeView } from "../node-views/EmojiNodeView";
|
||||
|
||||
/**
|
||||
* Shared emoji extension for both RichEditor and MentionEditor
|
||||
*
|
||||
* Extends the Mention extension with emoji-specific attributes (url, source)
|
||||
* and uses a React node view for rendering.
|
||||
*/
|
||||
export const EmojiMention = Mention.extend({
|
||||
name: "emoji",
|
||||
|
||||
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 };
|
||||
},
|
||||
},
|
||||
address: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-address"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.address) return {};
|
||||
return { "data-address": attributes.address };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
// Return the emoji character for unicode, or shortcode for custom
|
||||
// This is what gets copied to clipboard
|
||||
if (node.attrs.source === "unicode") {
|
||||
return node.attrs.url || "";
|
||||
}
|
||||
return `:${node.attrs.id}:`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(EmojiNodeView, {
|
||||
// Render as inline span, not a block-level wrapper
|
||||
as: "span",
|
||||
className: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
229
src/components/editor/extensions/file-paste-handler.test.ts
Normal file
229
src/components/editor/extensions/file-paste-handler.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { FilePasteHandler } from "./file-paste-handler";
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
/**
|
||||
* Find the filePasteHandler plugin and extract its handlePaste function.
|
||||
* ProseMirror's PluginKey auto-increments keys (filePasteHandler$, filePasteHandler$1, etc.)
|
||||
* so we match by prefix. Cast to remove `this` context requirement.
|
||||
*/
|
||||
function getPasteHandler(editor: Editor) {
|
||||
const plugin = editor.state.plugins.find((p) =>
|
||||
(p as any).key?.startsWith("filePasteHandler$"),
|
||||
);
|
||||
return plugin?.props?.handlePaste as
|
||||
| ((view: any, event: any, slice: any) => boolean | void)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Create a mock ClipboardEvent with files */
|
||||
function mockPasteEvent(
|
||||
files: Array<{ name: string; type: string }>,
|
||||
): ClipboardEvent {
|
||||
const fileList = files.map(
|
||||
(f) => new File(["content"], f.name, { type: f.type }),
|
||||
);
|
||||
|
||||
return {
|
||||
clipboardData: {
|
||||
files: fileList,
|
||||
getData: () => "",
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent;
|
||||
}
|
||||
|
||||
describe("FilePasteHandler", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe("image files", () => {
|
||||
it("should call onFilePaste with image files", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([{ name: "photo.png", type: "image/png" }]);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onFilePaste).toHaveBeenCalledTimes(1);
|
||||
expect(onFilePaste.mock.calls[0][0]).toHaveLength(1);
|
||||
expect(onFilePaste.mock.calls[0][0][0].name).toBe("photo.png");
|
||||
});
|
||||
|
||||
it("should handle multiple image types", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([
|
||||
{ name: "photo.jpg", type: "image/jpeg" },
|
||||
{ name: "graphic.webp", type: "image/webp" },
|
||||
{ name: "icon.gif", type: "image/gif" },
|
||||
]);
|
||||
|
||||
handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(onFilePaste).toHaveBeenCalledTimes(1);
|
||||
expect(onFilePaste.mock.calls[0][0]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("video files", () => {
|
||||
it("should call onFilePaste with video files", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([{ name: "clip.mp4", type: "video/mp4" }]);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onFilePaste).toHaveBeenCalledTimes(1);
|
||||
expect(onFilePaste.mock.calls[0][0][0].type).toBe("video/mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio files", () => {
|
||||
it("should call onFilePaste with audio files", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([{ name: "song.mp3", type: "audio/mpeg" }]);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onFilePaste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-media files", () => {
|
||||
it("should ignore non-media files", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([
|
||||
{ name: "document.pdf", type: "application/pdf" },
|
||||
{ name: "data.json", type: "application/json" },
|
||||
]);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(onFilePaste).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should filter out non-media files from mixed paste", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([
|
||||
{ name: "photo.png", type: "image/png" },
|
||||
{ name: "readme.txt", type: "text/plain" },
|
||||
{ name: "clip.mp4", type: "video/mp4" },
|
||||
]);
|
||||
|
||||
handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(onFilePaste).toHaveBeenCalledTimes(1);
|
||||
// Should only receive the image and video, not the text file
|
||||
const files = onFilePaste.mock.calls[0][0];
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0].name).toBe("photo.png");
|
||||
expect(files[1].name).toBe("clip.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no files", () => {
|
||||
it("should return false when no files in clipboard", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = {
|
||||
clipboardData: { files: [], getData: () => "" },
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(onFilePaste).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false when clipboardData is null", () => {
|
||||
const onFilePaste = vi.fn();
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = {
|
||||
clipboardData: null,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no callback", () => {
|
||||
it("should return false when onFilePaste is not configured", () => {
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit, FilePasteHandler.configure({})],
|
||||
});
|
||||
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent([{ name: "photo.png", type: "image/png" }]);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { NostrEventPreviewInline } from "../node-views/NostrEventPreviewInline";
|
||||
|
||||
/**
|
||||
* Inline Nostr event preview node for MentionEditor (chat-style)
|
||||
*
|
||||
* Shows a compact badge with event type and truncated ID.
|
||||
* Uses ReactNodeViewRenderer for React-based rendering.
|
||||
*/
|
||||
export const NostrEventPreviewInlineNode = Node.create({
|
||||
name: "nostrEventPreview",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
type: { default: null }, // 'note' | 'nevent' | 'naddr'
|
||||
data: { default: null }, // Decoded bech32 data
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-nostr-preview="true"]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
const { type, data } = node.attrs;
|
||||
try {
|
||||
if (type === "note") return `nostr:${nip19.noteEncode(data)}`;
|
||||
if (type === "nevent") return `nostr:${nip19.neventEncode(data)}`;
|
||||
if (type === "naddr") return `nostr:${nip19.naddrEncode(data)}`;
|
||||
} catch (err) {
|
||||
console.error("[NostrEventPreviewInline] Failed to encode:", err);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(NostrEventPreviewInline, {
|
||||
as: "span",
|
||||
className: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
216
src/components/editor/extensions/nostr-event-preview.test.ts
Normal file
216
src/components/editor/extensions/nostr-event-preview.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
// Mock React node views
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
ReactNodeViewRenderer: () => () => null,
|
||||
}));
|
||||
vi.mock("../node-views/NostrEventPreviewRich", () => ({
|
||||
NostrEventPreviewRich: {},
|
||||
}));
|
||||
vi.mock("../node-views/NostrEventPreviewInline", () => ({
|
||||
NostrEventPreviewInline: {},
|
||||
}));
|
||||
|
||||
import { NostrEventPreviewRichNode } from "./nostr-event-preview-rich";
|
||||
import { NostrEventPreviewInlineNode } from "./nostr-event-preview-inline";
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
// Test data
|
||||
const TEST_EVENT_ID =
|
||||
"d7a9c9f8e7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0";
|
||||
const TEST_PUBKEY =
|
||||
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
|
||||
|
||||
describe("NostrEventPreviewRichNode (block-level)", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
function createRichEditor() {
|
||||
return new Editor({
|
||||
extensions: [StarterKit, NostrEventPreviewRichNode],
|
||||
});
|
||||
}
|
||||
|
||||
describe("schema", () => {
|
||||
it("should register nostrEventPreview node type", () => {
|
||||
editor = createRichEditor();
|
||||
expect(editor.schema.nodes.nostrEventPreview).toBeDefined();
|
||||
});
|
||||
|
||||
it("should be a block-level atom node", () => {
|
||||
editor = createRichEditor();
|
||||
const spec = editor.schema.nodes.nostrEventPreview.spec;
|
||||
expect(spec.group).toBe("block");
|
||||
expect(spec.inline).toBe(false);
|
||||
expect(spec.atom).toBe(true);
|
||||
});
|
||||
|
||||
it("should have type and data attributes", () => {
|
||||
editor = createRichEditor();
|
||||
const attrs = editor.schema.nodes.nostrEventPreview.spec.attrs!;
|
||||
expect(attrs).toHaveProperty("type");
|
||||
expect(attrs).toHaveProperty("data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderText", () => {
|
||||
it("should encode note type back to nostr: URI", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "note", data: TEST_EVENT_ID },
|
||||
});
|
||||
|
||||
const text = editor.getText();
|
||||
const expectedNote = nip19.noteEncode(TEST_EVENT_ID);
|
||||
expect(text).toContain(`nostr:${expectedNote}`);
|
||||
});
|
||||
|
||||
it("should encode nevent type back to nostr: URI", () => {
|
||||
editor = createRichEditor();
|
||||
const eventPointer = {
|
||||
id: TEST_EVENT_ID,
|
||||
relays: ["wss://relay.example.com"],
|
||||
};
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "nevent", data: eventPointer },
|
||||
});
|
||||
|
||||
const text = editor.getText();
|
||||
const expectedNevent = nip19.neventEncode(eventPointer);
|
||||
expect(text).toContain(`nostr:${expectedNevent}`);
|
||||
});
|
||||
|
||||
it("should encode naddr type back to nostr: URI", () => {
|
||||
editor = createRichEditor();
|
||||
const addrPointer = {
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "my-article",
|
||||
};
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "naddr", data: addrPointer },
|
||||
});
|
||||
|
||||
const text = editor.getText();
|
||||
const expectedNaddr = nip19.naddrEncode(addrPointer);
|
||||
expect(text).toContain(`nostr:${expectedNaddr}`);
|
||||
});
|
||||
|
||||
it("should return empty string for unknown type", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "unknown", data: null },
|
||||
});
|
||||
expect(editor.getText().trim()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHTML", () => {
|
||||
it("should parse div with data-nostr-preview attribute", () => {
|
||||
editor = createRichEditor();
|
||||
const parseRules = editor.schema.nodes.nostrEventPreview.spec.parseDOM;
|
||||
expect(parseRules![0].tag).toBe('div[data-nostr-preview="true"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NostrEventPreviewInlineNode", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
function createInlineEditor() {
|
||||
return new Editor({
|
||||
extensions: [StarterKit, NostrEventPreviewInlineNode],
|
||||
});
|
||||
}
|
||||
|
||||
describe("schema", () => {
|
||||
it("should be an inline atom node", () => {
|
||||
editor = createInlineEditor();
|
||||
const spec = editor.schema.nodes.nostrEventPreview.spec;
|
||||
expect(spec.group).toBe("inline");
|
||||
expect(spec.inline).toBe(true);
|
||||
expect(spec.atom).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderText", () => {
|
||||
it("should encode note back to nostr: URI", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "note", data: TEST_EVENT_ID },
|
||||
});
|
||||
|
||||
const expectedNote = nip19.noteEncode(TEST_EVENT_ID);
|
||||
expect(editor.getText()).toContain(`nostr:${expectedNote}`);
|
||||
});
|
||||
|
||||
it("should encode nevent back to nostr: URI", () => {
|
||||
editor = createInlineEditor();
|
||||
const eventPointer = { id: TEST_EVENT_ID };
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "nevent", data: eventPointer },
|
||||
});
|
||||
|
||||
const expectedNevent = nip19.neventEncode(eventPointer);
|
||||
expect(editor.getText()).toContain(`nostr:${expectedNevent}`);
|
||||
});
|
||||
|
||||
it("should encode naddr back to nostr: URI", () => {
|
||||
editor = createInlineEditor();
|
||||
const addrPointer = {
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "test",
|
||||
};
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "naddr", data: addrPointer },
|
||||
});
|
||||
|
||||
const expectedNaddr = nip19.naddrEncode(addrPointer);
|
||||
expect(editor.getText()).toContain(`nostr:${expectedNaddr}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHTML", () => {
|
||||
it("should parse span with data-nostr-preview attribute", () => {
|
||||
editor = createInlineEditor();
|
||||
const parseRules = editor.schema.nodes.nostrEventPreview.spec.parseDOM;
|
||||
expect(parseRules![0].tag).toBe('span[data-nostr-preview="true"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
509
src/components/editor/extensions/nostr-paste-handler.test.ts
Normal file
509
src/components/editor/extensions/nostr-paste-handler.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
// Mock React node views
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
ReactNodeViewRenderer: () => () => null,
|
||||
}));
|
||||
vi.mock("../node-views/NostrEventPreviewRich", () => ({
|
||||
NostrEventPreviewRich: {},
|
||||
}));
|
||||
|
||||
// Mock profile search service
|
||||
vi.mock("@/services/profile-search", () => ({
|
||||
default: {
|
||||
getByPubkey: () => null,
|
||||
},
|
||||
}));
|
||||
|
||||
import { NostrPasteHandler } from "./nostr-paste-handler";
|
||||
import { NostrEventPreviewRichNode } from "./nostr-event-preview-rich";
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
// Test data
|
||||
const TEST_PUBKEY =
|
||||
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
|
||||
const TEST_EVENT_ID =
|
||||
"d7a9c9f8e7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0";
|
||||
const TEST_NPUB = nip19.npubEncode(TEST_PUBKEY);
|
||||
const TEST_NOTE = nip19.noteEncode(TEST_EVENT_ID);
|
||||
const TEST_NPROFILE = nip19.nprofileEncode({ pubkey: TEST_PUBKEY });
|
||||
const TEST_NEVENT = nip19.neventEncode({ id: TEST_EVENT_ID });
|
||||
const TEST_NADDR = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "test-article",
|
||||
});
|
||||
|
||||
function createEditor() {
|
||||
return new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Mention.configure({ suggestion: { char: "@" } }),
|
||||
NostrEventPreviewRichNode,
|
||||
NostrPasteHandler,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nostrPasteHandler plugin and extract its handlePaste function.
|
||||
* ProseMirror's PluginKey auto-increments keys (nostrPasteHandler$, nostrPasteHandler$1, etc.)
|
||||
* so we match by prefix. Cast to remove `this` context requirement.
|
||||
*/
|
||||
function getPasteHandler(editor: Editor) {
|
||||
const plugin = editor.state.plugins.find((p) =>
|
||||
(p as any).key?.startsWith("nostrPasteHandler$"),
|
||||
);
|
||||
return plugin?.props?.handlePaste as
|
||||
| ((view: any, event: any, slice: any) => boolean | void)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** Create a mock ClipboardEvent with text */
|
||||
function mockPasteEvent(text: string): ClipboardEvent {
|
||||
return {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === "text/plain" ? text : ""),
|
||||
files: [],
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent;
|
||||
}
|
||||
|
||||
describe("NostrPasteHandler", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe("bech32 regex matching", () => {
|
||||
it("should detect npub with nostr: prefix", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent(`nostr:${TEST_NPUB}`);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect bare npub without nostr: prefix", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent(TEST_NPUB);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect note", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent(TEST_NOTE);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect nprofile", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent(TEST_NPROFILE);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect nevent", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent(TEST_NEVENT);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect naddr", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent(TEST_NADDR);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should NOT match bech32 inside URLs", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
// bech32 inside a URL should not be matched (no whitespace boundary)
|
||||
const event = mockPasteEvent(`https://njump.me/${TEST_NPUB}`);
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it("should pass through plain text without bech32", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent("just some regular text");
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it("should pass through empty clipboard", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const event = mockPasteEvent("");
|
||||
|
||||
const handled = handlePaste!(editor.view, event, null as any);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node creation", () => {
|
||||
it("should create mention node for npub", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(editor.view, mockPasteEvent(TEST_NPUB), null as any);
|
||||
|
||||
let mentionNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "mention") {
|
||||
mentionNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(mentionNode).not.toBeNull();
|
||||
expect(mentionNode.attrs.id).toBe(TEST_PUBKEY);
|
||||
});
|
||||
|
||||
it("should create mention node for nprofile", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(editor.view, mockPasteEvent(TEST_NPROFILE), null as any);
|
||||
|
||||
let mentionNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "mention") {
|
||||
mentionNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(mentionNode).not.toBeNull();
|
||||
expect(mentionNode.attrs.id).toBe(TEST_PUBKEY);
|
||||
});
|
||||
|
||||
it("should create nostrEventPreview node for note", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(editor.view, mockPasteEvent(TEST_NOTE), null as any);
|
||||
|
||||
let previewNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "nostrEventPreview") {
|
||||
previewNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(previewNode).not.toBeNull();
|
||||
expect(previewNode.attrs.type).toBe("note");
|
||||
expect(previewNode.attrs.data).toBe(TEST_EVENT_ID);
|
||||
});
|
||||
|
||||
it("should create nostrEventPreview node for nevent", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(editor.view, mockPasteEvent(TEST_NEVENT), null as any);
|
||||
|
||||
let previewNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "nostrEventPreview") {
|
||||
previewNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(previewNode).not.toBeNull();
|
||||
expect(previewNode.attrs.type).toBe("nevent");
|
||||
expect(previewNode.attrs.data).toHaveProperty("id", TEST_EVENT_ID);
|
||||
});
|
||||
|
||||
it("should create nostrEventPreview node for naddr", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(editor.view, mockPasteEvent(TEST_NADDR), null as any);
|
||||
|
||||
let previewNode: any = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "nostrEventPreview") {
|
||||
previewNode = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(previewNode).not.toBeNull();
|
||||
expect(previewNode.attrs.type).toBe("naddr");
|
||||
expect(previewNode.attrs.data).toHaveProperty("kind", 30023);
|
||||
expect(previewNode.attrs.data).toHaveProperty("pubkey", TEST_PUBKEY);
|
||||
expect(previewNode.attrs.data).toHaveProperty(
|
||||
"identifier",
|
||||
"test-article",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surrounding text preservation", () => {
|
||||
it("should preserve text before bech32", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`check this out ${TEST_NOTE}`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
// The editor should contain both text and the preview node
|
||||
let hasText = false;
|
||||
let hasPreview = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText && node.text?.includes("check this out")) {
|
||||
hasText = true;
|
||||
}
|
||||
if (node.type.name === "nostrEventPreview") {
|
||||
hasPreview = true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasText).toBe(true);
|
||||
expect(hasPreview).toBe(true);
|
||||
});
|
||||
|
||||
it("should preserve text after bech32", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`${TEST_NOTE} is really cool`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
let hasText = false;
|
||||
let hasPreview = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText && node.text?.includes("is really cool")) {
|
||||
hasText = true;
|
||||
}
|
||||
if (node.type.name === "nostrEventPreview") {
|
||||
hasPreview = true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasText).toBe(true);
|
||||
expect(hasPreview).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple bech32 entities in one paste", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`${TEST_NPUB} shared ${TEST_NOTE}`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
let mentionCount = 0;
|
||||
let previewCount = 0;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "mention") mentionCount++;
|
||||
if (node.type.name === "nostrEventPreview") previewCount++;
|
||||
});
|
||||
|
||||
expect(mentionCount).toBe(1);
|
||||
expect(previewCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should not introduce double spaces between entity and text", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`${TEST_NPUB} said hello`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
// Collect all text content from the doc
|
||||
const textNodes: string[] = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText) textNodes.push(node.text!);
|
||||
});
|
||||
const fullText = textNodes.join("");
|
||||
|
||||
// Should not have double spaces
|
||||
expect(fullText).not.toContain(" ");
|
||||
expect(fullText).toContain("said hello");
|
||||
});
|
||||
|
||||
it("should not add trailing space when entity is followed by more text", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`${TEST_NPUB} is cool`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
const textNodes: string[] = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText) textNodes.push(node.text!);
|
||||
});
|
||||
const fullText = textNodes.join("");
|
||||
|
||||
// Should have exactly one space before "is cool"
|
||||
expect(fullText).toMatch(/\sis cool$/);
|
||||
expect(fullText).not.toMatch(/\s\sis cool$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("punctuation handling", () => {
|
||||
it("should match bech32 followed by comma", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const handled = handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`${TEST_NPUB}, check this out`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
|
||||
let mentionCount = 0;
|
||||
let hasComma = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "mention") mentionCount++;
|
||||
if (node.isText && node.text?.includes(",")) hasComma = true;
|
||||
});
|
||||
expect(mentionCount).toBe(1);
|
||||
expect(hasComma).toBe(true);
|
||||
});
|
||||
|
||||
it("should match bech32 followed by period", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const handled = handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`See ${TEST_NPUB}.`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should match bech32 followed by exclamation mark", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const handled = handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`Look at ${TEST_NPUB}!`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it("should match bech32 followed by closing parenthesis", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const handled = handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(`(by ${TEST_NPUB})`),
|
||||
null as any,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed bech32", () => {
|
||||
it("should fall back to plain text for invalid bech32", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
// npub1 followed by lowercase chars (matches regex) but invalid checksum
|
||||
const fakeBech32 =
|
||||
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
|
||||
const handled = handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(fakeBech32),
|
||||
null as any,
|
||||
);
|
||||
|
||||
// Should match regex but fail decode — falls back to plain text insert
|
||||
// or returns false if the catch path just inserts text
|
||||
if (handled) {
|
||||
// The invalid bech32 was inserted as plain text
|
||||
let hasPlainText = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText && node.text?.includes("npub1")) {
|
||||
hasPlainText = true;
|
||||
}
|
||||
});
|
||||
expect(hasPlainText).toBe(true);
|
||||
}
|
||||
// Either way, no mention node should be created
|
||||
let mentionCount = 0;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "mention") mentionCount++;
|
||||
});
|
||||
expect(mentionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should not match uppercase bech32", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
const handled = handlePaste!(
|
||||
editor.view,
|
||||
mockPasteEvent(TEST_NPUB.toUpperCase()),
|
||||
null as any,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error resilience", () => {
|
||||
it("should not crash the editor on dispatch failure", () => {
|
||||
editor = createEditor();
|
||||
const handlePaste = getPasteHandler(editor);
|
||||
|
||||
// This should not throw, even if internal dispatch has issues
|
||||
expect(() => {
|
||||
handlePaste!(editor.view, mockPasteEvent(TEST_NPUB), null as any);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,11 +39,12 @@ export const NostrPasteHandler = Extension.create({
|
||||
if (!text) return false;
|
||||
|
||||
// Regex to detect nostr bech32 strings (with or without nostr: prefix)
|
||||
// Only match entities surrounded by whitespace or at string boundaries
|
||||
// Only match entities surrounded by whitespace/punctuation or at string boundaries
|
||||
// to avoid matching entities within URLs (e.g., https://njump.me/npub1...)
|
||||
// Note: Using (^|\s) capture group instead of lookbehind for Safari compatibility
|
||||
// Trailing lookahead allows common punctuation so "npub1..., cool" works
|
||||
const bech32Regex =
|
||||
/(^|\s)(?:nostr:)?(npub1[\w]{58,}|note1[\w]{58,}|nevent1[\w]+|naddr1[\w]+|nprofile1[\w]+)(?=$|\s)/g;
|
||||
/(^|\s)(?:nostr:)?(npub1[a-z0-9]{58,}|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+|nprofile1[a-z0-9]+)(?=$|\s|[.,!?;:)\]}>])/g;
|
||||
const matches = Array.from(text.matchAll(bech32Regex));
|
||||
|
||||
if (matches.length === 0) return false; // No bech32 found, use default paste
|
||||
@@ -113,8 +114,15 @@ export const NostrPasteHandler = Extension.create({
|
||||
);
|
||||
}
|
||||
|
||||
// Add space after preview node
|
||||
nodes.push(view.state.schema.text(" "));
|
||||
// Add trailing space only when entity is at the very end of the paste
|
||||
// (for cursor positioning). Don't add if there's more text coming,
|
||||
// since the boundary whitespace handling already preserves spacing.
|
||||
const isLastMatch = match === matches[matches.length - 1];
|
||||
const hasTrailingText =
|
||||
matchIndex + fullMatch.length < text.length;
|
||||
if (isLastMatch && !hasTrailingText) {
|
||||
nodes.push(view.state.schema.text(" "));
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid bech32, insert as plain text (entity portion without boundary)
|
||||
console.warn(
|
||||
@@ -139,21 +147,31 @@ export const NostrPasteHandler = Extension.create({
|
||||
|
||||
// Insert all nodes at cursor position
|
||||
if (nodes.length > 0) {
|
||||
const { tr } = view.state;
|
||||
const { from } = view.state.selection;
|
||||
try {
|
||||
const { tr } = view.state;
|
||||
const { from } = view.state.selection;
|
||||
|
||||
// Insert content and track position
|
||||
let insertPos = from;
|
||||
nodes.forEach((node) => {
|
||||
tr.insert(insertPos, node);
|
||||
insertPos += node.nodeSize;
|
||||
});
|
||||
// Insert content and track position
|
||||
let insertPos = from;
|
||||
nodes.forEach((node) => {
|
||||
tr.insert(insertPos, node);
|
||||
insertPos += node.nodeSize;
|
||||
});
|
||||
|
||||
// Move cursor to end of inserted content
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos)));
|
||||
// Move cursor to end of inserted content
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos)));
|
||||
|
||||
view.dispatch(tr);
|
||||
return true; // Prevent default paste
|
||||
view.dispatch(tr);
|
||||
return true; // Prevent default paste
|
||||
} catch (err) {
|
||||
// If insertion fails (e.g., block node at inline position),
|
||||
// fall through to default paste behavior
|
||||
console.warn(
|
||||
"[NostrPasteHandler] Failed to insert nodes:",
|
||||
err,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
137
src/components/editor/extensions/submit-shortcut.test.ts
Normal file
137
src/components/editor/extensions/submit-shortcut.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { SubmitShortcut } from "./submit-shortcut";
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
describe("SubmitShortcut", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe("Mod-Enter (always submits)", () => {
|
||||
it("should call submit handler on Mod-Enter", () => {
|
||||
const submitFn = vi.fn();
|
||||
const submitRef = { current: submitFn };
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
SubmitShortcut.configure({
|
||||
submitRef,
|
||||
enterSubmits: false,
|
||||
}),
|
||||
],
|
||||
content: "<p>Hello</p>",
|
||||
});
|
||||
|
||||
editor.commands.keyboardShortcut("Mod-Enter");
|
||||
expect(submitFn).toHaveBeenCalledTimes(1);
|
||||
expect(submitFn).toHaveBeenCalledWith(editor);
|
||||
});
|
||||
|
||||
it("should call submit handler on Mod-Enter even when enterSubmits is true", () => {
|
||||
const submitFn = vi.fn();
|
||||
const submitRef = { current: submitFn };
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
SubmitShortcut.configure({
|
||||
submitRef,
|
||||
enterSubmits: true,
|
||||
}),
|
||||
],
|
||||
content: "<p>Hello</p>",
|
||||
});
|
||||
|
||||
editor.commands.keyboardShortcut("Mod-Enter");
|
||||
expect(submitFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter behavior with enterSubmits: true", () => {
|
||||
it("should call submit handler on Enter", () => {
|
||||
const submitFn = vi.fn();
|
||||
const submitRef = { current: submitFn };
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
SubmitShortcut.configure({
|
||||
submitRef,
|
||||
enterSubmits: true,
|
||||
}),
|
||||
],
|
||||
content: "<p>Hello</p>",
|
||||
});
|
||||
|
||||
editor.commands.keyboardShortcut("Enter");
|
||||
expect(submitFn).toHaveBeenCalledTimes(1);
|
||||
expect(submitFn).toHaveBeenCalledWith(editor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter behavior with enterSubmits: false", () => {
|
||||
it("should NOT call submit handler on Enter", () => {
|
||||
const submitFn = vi.fn();
|
||||
const submitRef = { current: submitFn };
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
SubmitShortcut.configure({
|
||||
submitRef,
|
||||
enterSubmits: false,
|
||||
}),
|
||||
],
|
||||
content: "<p>Hello</p>",
|
||||
});
|
||||
|
||||
editor.commands.keyboardShortcut("Enter");
|
||||
expect(submitFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ref update", () => {
|
||||
it("should use the ref value that was current at configure time", () => {
|
||||
const submitFn = vi.fn();
|
||||
const submitRef = { current: submitFn };
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
SubmitShortcut.configure({
|
||||
submitRef,
|
||||
enterSubmits: false,
|
||||
}),
|
||||
],
|
||||
content: "<p>Hello</p>",
|
||||
});
|
||||
|
||||
// Trigger shortcut - should call the function from configure time
|
||||
editor.commands.keyboardShortcut("Mod-Enter");
|
||||
expect(submitFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/components/editor/extensions/submit-shortcut.ts
Normal file
49
src/components/editor/extensions/submit-shortcut.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
|
||||
interface SubmitShortcutOptions {
|
||||
/** Ref to the submit handler (uses ref to avoid stale closures) */
|
||||
submitRef: MutableRefObject<(editor: Editor) => void>;
|
||||
/** If true, plain Enter submits (desktop chat). If false, Enter creates newline (rich editor / mobile). */
|
||||
enterSubmits: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard shortcut extension for editor submission
|
||||
*
|
||||
* - Ctrl/Cmd+Enter always submits
|
||||
* - Plain Enter behavior depends on `enterSubmits` option:
|
||||
* - true (desktop chat): Enter submits, Shift+Enter inserts newline
|
||||
* - false (rich editor / mobile): Enter creates newline normally
|
||||
*/
|
||||
export const SubmitShortcut = Extension.create<SubmitShortcutOptions>({
|
||||
name: "submitShortcut",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
submitRef: { current: () => {} } as MutableRefObject<
|
||||
(editor: Editor) => void
|
||||
>,
|
||||
enterSubmits: false,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const shortcuts: Record<string, () => boolean> = {
|
||||
"Mod-Enter": () => {
|
||||
this.options.submitRef.current(this.editor);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
if (this.options.enterSubmits) {
|
||||
shortcuts["Enter"] = () => {
|
||||
this.options.submitRef.current(this.editor);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
123
src/components/editor/hooks/useSuggestionRenderer.tsx
Normal file
123
src/components/editor/hooks/useSuggestionRenderer.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type ComponentType,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import type {
|
||||
SuggestionOptions,
|
||||
SuggestionKeyDownProps,
|
||||
} from "@tiptap/suggestion";
|
||||
import { SuggestionPopover } from "../SuggestionPopover";
|
||||
|
||||
/** Handle interface that suggestion list components must expose via forwardRef */
|
||||
export interface SuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
/** Props that suggestion list components receive */
|
||||
export interface SuggestionListProps<T> {
|
||||
items: T[];
|
||||
command: (item: T) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface SuggestionState<T> {
|
||||
items: T[];
|
||||
command: (item: T) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
}
|
||||
|
||||
interface UseSuggestionRendererOptions {
|
||||
/** Floating-ui placement for the popover */
|
||||
placement?: "bottom-start" | "top-start";
|
||||
/** Called when Ctrl/Cmd+Enter is pressed while suggestion is open */
|
||||
onModEnter?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that bridges Tiptap's suggestion render callbacks to React state
|
||||
*
|
||||
* Returns:
|
||||
* - `render`: A stable function compatible with Tiptap's suggestion.render option
|
||||
* - `portal`: A ReactElement to include in the component tree (renders via portal)
|
||||
*
|
||||
* The render function is stable (never changes reference) so it's safe to use
|
||||
* as a useMemo dependency for extension configuration.
|
||||
*/
|
||||
export function useSuggestionRenderer<T>(
|
||||
Component: ComponentType<
|
||||
SuggestionListProps<T> & { ref?: RefObject<SuggestionListHandle | null> }
|
||||
>,
|
||||
options?: UseSuggestionRendererOptions,
|
||||
): {
|
||||
render: () => ReturnType<NonNullable<SuggestionOptions["render"]>>;
|
||||
portal: ReactElement | null;
|
||||
} {
|
||||
const [state, setState] = useState<SuggestionState<T> | null>(null);
|
||||
const componentRef = useRef<SuggestionListHandle>(null);
|
||||
const onModEnterRef = useRef(options?.onModEnter);
|
||||
onModEnterRef.current = options?.onModEnter;
|
||||
|
||||
// Stable render factory — uses setState which is guaranteed stable by React
|
||||
const render = useCallback(
|
||||
(): ReturnType<NonNullable<SuggestionOptions["render"]>> => ({
|
||||
onStart: (props) => {
|
||||
setState({
|
||||
items: props.items as T[],
|
||||
command: props.command as (item: T) => void,
|
||||
clientRect: props.clientRect as (() => DOMRect | null) | null,
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
setState({
|
||||
items: props.items as T[],
|
||||
command: props.command as (item: T) => void,
|
||||
clientRect: props.clientRect as (() => DOMRect | null) | null,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown: (props: SuggestionKeyDownProps) => {
|
||||
if (props.event.key === "Escape") {
|
||||
setState(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message even when suggestion is open
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
setState(null);
|
||||
onModEnterRef.current?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return componentRef.current?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
setState(null);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const placement = options?.placement ?? "bottom-start";
|
||||
|
||||
const portal = state ? (
|
||||
<SuggestionPopover clientRect={state.clientRect} placement={placement}>
|
||||
<Component
|
||||
ref={componentRef}
|
||||
items={state.items}
|
||||
command={state.command}
|
||||
onClose={() => setState(null)}
|
||||
/>
|
||||
</SuggestionPopover>
|
||||
) : null;
|
||||
|
||||
return { render, portal };
|
||||
}
|
||||
57
src/components/editor/node-views/BlobAttachmentInline.tsx
Normal file
57
src/components/editor/node-views/BlobAttachmentInline.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
import { formatBlobSize } from "../utils/serialize";
|
||||
|
||||
/**
|
||||
* Inline badge-style node view for blob attachments (used in MentionEditor)
|
||||
*
|
||||
* Shows a compact badge with media type icon, label, and size.
|
||||
* Replaces direct DOM manipulation with a React component.
|
||||
*/
|
||||
export function BlobAttachmentInline({ node }: ReactNodeViewProps) {
|
||||
const { url, mimeType, size } = node.attrs as {
|
||||
url: string;
|
||||
sha256: string;
|
||||
mimeType: string | null;
|
||||
size: number | null;
|
||||
server: string | null;
|
||||
};
|
||||
|
||||
const isImage = mimeType?.startsWith("image/");
|
||||
const isVideo = mimeType?.startsWith("video/");
|
||||
const isAudio = mimeType?.startsWith("audio/");
|
||||
|
||||
const typeLabel = isImage
|
||||
? "image"
|
||||
: isVideo
|
||||
? "video"
|
||||
: isAudio
|
||||
? "audio"
|
||||
: "file";
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="span"
|
||||
className="blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle"
|
||||
contentEditable={false}
|
||||
>
|
||||
{isImage && url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt="attachment"
|
||||
className="h-4 w-4 object-cover rounded"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{isVideo ? "\uD83C\uDFAC" : isAudio ? "\uD83C\uDFB5" : "\uD83D\uDCCE"}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground truncate max-w-[80px]">
|
||||
{typeLabel}
|
||||
</span>
|
||||
{size != null && (
|
||||
<span className="text-muted-foreground/70">{formatBlobSize(size)}</span>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
51
src/components/editor/node-views/EmojiNodeView.tsx
Normal file
51
src/components/editor/node-views/EmojiNodeView.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
|
||||
/**
|
||||
* React node view for emoji (both unicode and custom NIP-30 emoji)
|
||||
*
|
||||
* Replaces direct DOM manipulation with a React component.
|
||||
* Renders unicode emoji as text spans and custom emoji as images with error fallback.
|
||||
*/
|
||||
export function EmojiNodeView({ node }: ReactNodeViewProps) {
|
||||
const { url, source, id } = node.attrs as {
|
||||
url: string | null;
|
||||
source: string | null;
|
||||
id: string;
|
||||
};
|
||||
const isUnicode = source === "unicode";
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Fallback to shortcode text
|
||||
if (imgError || (!isUnicode && !url)) {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="emoji-node" data-emoji={id || ""}>
|
||||
{`:${id}:`}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUnicode && url) {
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="emoji-node" data-emoji={id || ""}>
|
||||
<span className="emoji-unicode" title={`:${id}:`}>
|
||||
{url}
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom emoji with image
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="emoji-node" data-emoji={id || ""}>
|
||||
<img
|
||||
src={url!}
|
||||
alt={`:${id}:`}
|
||||
title={`:${id}:`}
|
||||
className="emoji-image"
|
||||
draggable={false}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
42
src/components/editor/node-views/NostrEventPreviewInline.tsx
Normal file
42
src/components/editor/node-views/NostrEventPreviewInline.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
|
||||
/**
|
||||
* Inline badge-style node view for Nostr event previews (used in MentionEditor)
|
||||
*
|
||||
* Shows a compact badge with event type and truncated identifier.
|
||||
* Replaces direct DOM manipulation with a React component.
|
||||
*/
|
||||
export function NostrEventPreviewInline({ node }: ReactNodeViewProps) {
|
||||
const { type, data } = node.attrs as {
|
||||
type: "note" | "nevent" | "naddr";
|
||||
data: any;
|
||||
};
|
||||
|
||||
let typeLabel: string;
|
||||
let contentLabel: string;
|
||||
|
||||
if (type === "note" || type === "nevent") {
|
||||
typeLabel = "event";
|
||||
contentLabel =
|
||||
type === "note" ? data?.slice(0, 8) : data?.id?.slice(0, 8) || "";
|
||||
} else if (type === "naddr") {
|
||||
typeLabel = "address";
|
||||
contentLabel = data?.identifier || data?.pubkey?.slice(0, 8) || "";
|
||||
} else {
|
||||
typeLabel = "ref";
|
||||
contentLabel = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="span"
|
||||
className="nostr-event-preview inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/30 text-xs align-middle"
|
||||
contentEditable={false}
|
||||
>
|
||||
<span className="text-primary font-medium">{typeLabel}</span>
|
||||
<span className="text-muted-foreground truncate max-w-[140px]">
|
||||
{contentLabel}
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
45
src/components/editor/types.ts
Normal file
45
src/components/editor/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared types for editor components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents an emoji tag for NIP-30
|
||||
*/
|
||||
export interface EmojiTag {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
/** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */
|
||||
address?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a blob attachment for imeta tags (NIP-92)
|
||||
*/
|
||||
export interface BlobAttachment {
|
||||
/** The URL of the blob */
|
||||
url: string;
|
||||
/** SHA256 hash of the blob content */
|
||||
sha256: string;
|
||||
/** MIME type of the blob */
|
||||
mimeType?: string;
|
||||
/** Size in bytes */
|
||||
size?: number;
|
||||
/** Blossom server URL */
|
||||
server?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
* Note: mentions, event quotes, and hashtags are extracted automatically by applesauce
|
||||
* from the text content (nostr: URIs and #hashtags), so we don't need to extract them here.
|
||||
*/
|
||||
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[];
|
||||
/** Blob attachments for imeta tags (NIP-92) */
|
||||
blobAttachments: BlobAttachment[];
|
||||
/** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>;
|
||||
}
|
||||
663
src/components/editor/utils/serialize.test.ts
Normal file
663
src/components/editor/utils/serialize.test.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
// Mock React node views
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
ReactNodeViewRenderer: () => () => null,
|
||||
}));
|
||||
vi.mock("../node-views/EmojiNodeView", () => ({ EmojiNodeView: {} }));
|
||||
vi.mock("../node-views/BlobAttachmentRich", () => ({
|
||||
BlobAttachmentRich: {},
|
||||
}));
|
||||
vi.mock("../node-views/BlobAttachmentInline", () => ({
|
||||
BlobAttachmentInline: {},
|
||||
}));
|
||||
vi.mock("../node-views/NostrEventPreviewRich", () => ({
|
||||
NostrEventPreviewRich: {},
|
||||
}));
|
||||
vi.mock("../node-views/NostrEventPreviewInline", () => ({
|
||||
NostrEventPreviewInline: {},
|
||||
}));
|
||||
|
||||
import { EmojiMention } from "../extensions/emoji";
|
||||
import { BlobAttachmentRichNode } from "../extensions/blob-attachment-rich";
|
||||
import { BlobAttachmentInlineNode } from "../extensions/blob-attachment-inline";
|
||||
import { NostrEventPreviewRichNode } from "../extensions/nostr-event-preview-rich";
|
||||
import { NostrEventPreviewInlineNode } from "../extensions/nostr-event-preview-inline";
|
||||
import {
|
||||
serializeRichContent,
|
||||
serializeInlineContent,
|
||||
formatBlobSize,
|
||||
} from "./serialize";
|
||||
|
||||
beforeAll(() => {
|
||||
const rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
|
||||
Range.prototype.getClientRects = (() => []) as any;
|
||||
document.elementFromPoint = (() => null) as any;
|
||||
});
|
||||
|
||||
// Test data
|
||||
const TEST_PUBKEY =
|
||||
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
|
||||
const TEST_EVENT_ID =
|
||||
"d7a9c9f8e7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0";
|
||||
|
||||
/** Create a rich editor (block-level attachments, NostrEventPreview, emoji) */
|
||||
function createRichEditor(content?: string) {
|
||||
return new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Mention.configure({ suggestion: { char: "@" } }),
|
||||
EmojiMention.configure({ suggestion: { char: ":" } }),
|
||||
BlobAttachmentRichNode,
|
||||
NostrEventPreviewRichNode,
|
||||
],
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an inline editor (inline attachments, NostrEventPreview, emoji) */
|
||||
function createInlineEditor(content?: string) {
|
||||
return new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Mention.configure({ suggestion: { char: "@" } }),
|
||||
EmojiMention.configure({ suggestion: { char: ":" } }),
|
||||
BlobAttachmentInlineNode,
|
||||
NostrEventPreviewInlineNode,
|
||||
],
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
describe("formatBlobSize", () => {
|
||||
it("should format bytes", () => {
|
||||
expect(formatBlobSize(100)).toBe("100B");
|
||||
expect(formatBlobSize(0)).toBe("0B");
|
||||
expect(formatBlobSize(1023)).toBe("1023B");
|
||||
});
|
||||
|
||||
it("should format kilobytes", () => {
|
||||
expect(formatBlobSize(1024)).toBe("1KB");
|
||||
expect(formatBlobSize(2048)).toBe("2KB");
|
||||
expect(formatBlobSize(512 * 1024)).toBe("512KB");
|
||||
});
|
||||
|
||||
it("should format megabytes", () => {
|
||||
expect(formatBlobSize(1024 * 1024)).toBe("1.0MB");
|
||||
expect(formatBlobSize(1.5 * 1024 * 1024)).toBe("1.5MB");
|
||||
expect(formatBlobSize(10 * 1024 * 1024)).toBe("10.0MB");
|
||||
});
|
||||
|
||||
it("should handle boundary values", () => {
|
||||
// Just under 1KB
|
||||
expect(formatBlobSize(1023)).toBe("1023B");
|
||||
// Exactly 1KB
|
||||
expect(formatBlobSize(1024)).toBe("1KB");
|
||||
// Just under 1MB
|
||||
expect(formatBlobSize(1024 * 1024 - 1)).toBe("1024KB");
|
||||
// Exactly 1MB
|
||||
expect(formatBlobSize(1024 * 1024)).toBe("1.0MB");
|
||||
});
|
||||
|
||||
it("should handle zero", () => {
|
||||
expect(formatBlobSize(0)).toBe("0B");
|
||||
});
|
||||
|
||||
it("should handle very large values (no GB formatting)", () => {
|
||||
// 1 GB displayed as MB
|
||||
expect(formatBlobSize(1024 * 1024 * 1024)).toBe("1024.0MB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeRichContent", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe("text extraction", () => {
|
||||
it("should extract plain text", () => {
|
||||
editor = createRichEditor("<p>Hello world</p>");
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("should use single newline between blocks", () => {
|
||||
editor = createRichEditor("<p>Line 1</p><p>Line 2</p>");
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.text).toBe("Line 1\nLine 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emoji extraction", () => {
|
||||
it("should collect custom emoji tags", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.emojiTags).toHaveLength(1);
|
||||
expect(result.emojiTags[0]).toEqual({
|
||||
shortcode: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT collect unicode emoji tags", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.emojiTags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should deduplicate emoji tags", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent([
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.emojiTags).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blob attachment extraction", () => {
|
||||
it("should collect blob attachments", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: "abc123",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
server: "https://blossom.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.blobAttachments).toHaveLength(1);
|
||||
expect(result.blobAttachments[0]).toEqual({
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: "abc123",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
server: "https://blossom.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should deduplicate blob attachments by sha256", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "First" }],
|
||||
},
|
||||
{
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: "abc123",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Second" }],
|
||||
},
|
||||
{
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: "abc123",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.blobAttachments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("address reference extraction", () => {
|
||||
it("should collect naddr references", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: {
|
||||
type: "naddr",
|
||||
data: {
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "my-article",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.addressRefs).toHaveLength(1);
|
||||
expect(result.addressRefs[0]).toEqual({
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "my-article",
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT collect note or nevent as address refs", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Before" }],
|
||||
},
|
||||
{
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "note", data: TEST_EVENT_ID },
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "After" }],
|
||||
},
|
||||
{
|
||||
type: "nostrEventPreview",
|
||||
attrs: {
|
||||
type: "nevent",
|
||||
data: { id: TEST_EVENT_ID },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.addressRefs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should return empty result for empty editor", () => {
|
||||
editor = createRichEditor();
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.emojiTags).toHaveLength(0);
|
||||
expect(result.blobAttachments).toHaveLength(0);
|
||||
expect(result.addressRefs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should NOT collect blob attachments with null sha256", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: null,
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.blobAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should NOT collect blob attachments with null url", () => {
|
||||
editor = createRichEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: null,
|
||||
sha256: "abc123",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeRichContent(editor);
|
||||
expect(result.blobAttachments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeInlineContent", () => {
|
||||
let editor: Editor;
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe("text extraction", () => {
|
||||
it("should extract plain text", () => {
|
||||
editor = createInlineEditor("<p>Hello world</p>");
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mention serialization", () => {
|
||||
it("should serialize mentions as nostr: URIs", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { id: TEST_PUBKEY, label: "alice" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
const expectedNpub = nip19.npubEncode(TEST_PUBKEY);
|
||||
expect(result.text).toContain(`nostr:${expectedNpub}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emoji serialization", () => {
|
||||
it("should serialize unicode emoji as character", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain("🔥");
|
||||
expect(result.emojiTags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should serialize custom emoji as :shortcode: and collect tags", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain(":pepe:");
|
||||
expect(result.emojiTags).toHaveLength(1);
|
||||
expect(result.emojiTags[0]).toEqual({
|
||||
shortcode: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("blob attachment serialization", () => {
|
||||
it("should serialize blob attachments as URLs", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: "abc123",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain("https://cdn.example.com/image.png");
|
||||
expect(result.blobAttachments).toHaveLength(1);
|
||||
expect(result.blobAttachments[0].sha256).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nostr event preview serialization", () => {
|
||||
it("should serialize note as nostr: URI", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "note", data: TEST_EVENT_ID },
|
||||
});
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
const expectedNote = nip19.noteEncode(TEST_EVENT_ID);
|
||||
expect(result.text).toContain(`nostr:${expectedNote}`);
|
||||
});
|
||||
|
||||
it("should serialize nevent as nostr: URI", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: { type: "nevent", data: { id: TEST_EVENT_ID } },
|
||||
});
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
const expectedNevent = nip19.neventEncode({ id: TEST_EVENT_ID });
|
||||
expect(result.text).toContain(`nostr:${expectedNevent}`);
|
||||
});
|
||||
|
||||
it("should serialize naddr as nostr: URI and collect address ref", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "nostrEventPreview",
|
||||
attrs: {
|
||||
type: "naddr",
|
||||
data: {
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "my-article",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
const expectedNaddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: TEST_PUBKEY,
|
||||
identifier: "my-article",
|
||||
});
|
||||
expect(result.text).toContain(`nostr:${expectedNaddr}`);
|
||||
expect(result.addressRefs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should return empty result for empty editor", () => {
|
||||
editor = createInlineEditor();
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.emojiTags).toHaveLength(0);
|
||||
expect(result.blobAttachments).toHaveLength(0);
|
||||
expect(result.addressRefs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fall back to @label for invalid pubkey in mention", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { id: "not-a-valid-hex-pubkey", label: "broken" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain("@broken");
|
||||
});
|
||||
|
||||
it("should handle mention with missing pubkey", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { id: null, label: "ghost" },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
// Mention with null id should be silently dropped
|
||||
expect(result.text).not.toContain("nostr:");
|
||||
expect(result.text).not.toContain("@ghost");
|
||||
});
|
||||
|
||||
it("should handle blob attachment without sha256 (emit URL but not collect)", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: "https://cdn.example.com/image.png",
|
||||
sha256: null,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
});
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain("https://cdn.example.com/image.png");
|
||||
expect(result.blobAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle blob attachment without url (skip entirely)", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: { url: null, sha256: "abc123" },
|
||||
});
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text.trim()).toBe("");
|
||||
expect(result.blobAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should deduplicate inline emoji tags", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "pepe",
|
||||
url: "https://cdn.example.com/pepe.png",
|
||||
source: "custom",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.emojiTags).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should handle multiple paragraphs", () => {
|
||||
editor = createInlineEditor("<p>Line 1</p><p>Line 2</p><p>Line 3</p>");
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toBe("Line 1\nLine 2\nLine 3");
|
||||
});
|
||||
|
||||
it("should handle empty paragraphs", () => {
|
||||
editor = createInlineEditor("<p>Before</p><p></p><p>After</p>");
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain("Before");
|
||||
expect(result.text).toContain("After");
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined content", () => {
|
||||
it("should handle mixed content correctly", () => {
|
||||
editor = createInlineEditor();
|
||||
editor.commands.insertContent([
|
||||
{ type: "text", text: "Hello " },
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { id: TEST_PUBKEY, label: "alice" },
|
||||
},
|
||||
{ type: "text", text: " check this " },
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: "fire",
|
||||
url: "🔥",
|
||||
source: "unicode",
|
||||
mentionSuggestionChar: ":",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeInlineContent(editor);
|
||||
expect(result.text).toContain("Hello");
|
||||
expect(result.text).toContain(`nostr:${nip19.npubEncode(TEST_PUBKEY)}`);
|
||||
expect(result.text).toContain("check this");
|
||||
expect(result.text).toContain("🔥");
|
||||
});
|
||||
});
|
||||
});
|
||||
173
src/components/editor/utils/serialize.ts
Normal file
173
src/components/editor/utils/serialize.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { EmojiTag, BlobAttachment, SerializedContent } from "../types";
|
||||
|
||||
/**
|
||||
* Serialize RichEditor content to plain text with nostr: URIs
|
||||
*
|
||||
* Walks the ProseMirror document tree to extract emoji tags, blob attachments,
|
||||
* and address references. Mentions, event quotes, and hashtags are extracted
|
||||
* automatically by applesauce from the text content.
|
||||
*/
|
||||
export function serializeRichContent(editor: Editor): 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) => {
|
||||
if (node.type.name === "emoji") {
|
||||
const { id, url, source, address } = 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, address: address ?? undefined });
|
||||
}
|
||||
} else if (node.type.name === "blobAttachment") {
|
||||
const { url, sha256, mimeType, size, server } = node.attrs;
|
||||
if (url && sha256 && !seenBlobs.has(sha256)) {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({ url, sha256, mimeType, size, server });
|
||||
}
|
||||
} else if (node.type.name === "nostrEventPreview") {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize MentionEditor content by walking the JSON structure
|
||||
*
|
||||
* MentionEditor uses inline nodes (not block-level), so we walk the JSON
|
||||
* to reconstruct text with nostr: URIs for mentions and :shortcode: for custom emoji.
|
||||
*/
|
||||
export function serializeInlineContent(editor: Editor): SerializedContent {
|
||||
let text = "";
|
||||
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>();
|
||||
const json = editor.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 === "hardBreak") {
|
||||
text += "\n";
|
||||
} else if (child.type === "mention") {
|
||||
const pubkey = child.attrs?.id;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
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;
|
||||
const address = child.attrs?.address;
|
||||
|
||||
if (source === "unicode" && url) {
|
||||
text += url;
|
||||
} else if (shortcode) {
|
||||
text += `:${shortcode}:`;
|
||||
if (url && !seenEmojis.has(shortcode)) {
|
||||
seenEmojis.add(shortcode);
|
||||
emojiTags.push({ shortcode, url, address: address ?? undefined });
|
||||
}
|
||||
}
|
||||
} else if (child.type === "blobAttachment") {
|
||||
const { url, sha256, mimeType, size, server } = child.attrs;
|
||||
if (url) {
|
||||
text += url;
|
||||
if (sha256 && !seenBlobs.has(sha256)) {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({
|
||||
url,
|
||||
sha256,
|
||||
mimeType: mimeType || undefined,
|
||||
size: size || undefined,
|
||||
server: server || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (child.type === "nostrEventPreview") {
|
||||
const { type, data } = child.attrs;
|
||||
try {
|
||||
if (type === "note") {
|
||||
text += `nostr:${nip19.noteEncode(data)}`;
|
||||
} else if (type === "nevent") {
|
||||
text += `nostr:${nip19.neventEncode(data)}`;
|
||||
} else if (type === "naddr") {
|
||||
text += `nostr:${nip19.naddrEncode(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 || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[serializeInlineContent] Failed to serialize nostr preview:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format byte size to human-readable string */
|
||||
export 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user