fix: refactor autocomplete refs pattern and add full JSON draft persistence

- Refactor NostrEditor to use a single refs object (SuggestionRefs) for all
  autocomplete functions instead of multiple individual refs
- Create dedicated suggestion config functions for mention, emoji, and slash
  commands that read from refs at call time
- Save drafts as full TipTap JSON to preserve emojis, mentions, and blob data
- Add resize-y CSS for full editor variant
- Add getJSON/setContent methods to NostrEditorHandle for draft persistence
This commit is contained in:
Claude
2026-01-20 16:15:59 +00:00
parent 3420d51cf0
commit cc7b0448ca
3 changed files with 295 additions and 108 deletions

View File

@@ -56,8 +56,8 @@ export function PostViewer() {
// Use pubkey as draft key - one draft per account, persists across reloads
const draftKey = pubkey ? `${DRAFT_STORAGE_PREFIX}${pubkey}` : null;
// Load draft from localStorage on mount
const [initialContent, setInitialContent] = useState<string | undefined>(
// Load draft from localStorage on mount (stores full TipTap JSON for rich content)
const [initialContent, setInitialContent] = useState<object | undefined>(
undefined,
);
const draftLoadedRef = useRef(false);
@@ -69,30 +69,30 @@ export function PostViewer() {
try {
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
setInitialContent(savedDraft);
const parsed = JSON.parse(savedDraft);
setInitialContent(parsed);
}
} catch (error) {
console.warn("[PostViewer] Failed to load draft:", error);
}
}, [draftKey]);
// Save draft to localStorage when content changes
const saveDraft = useCallback(
(content: SerializedContent) => {
if (!draftKey) return;
try {
if (content.text.trim()) {
localStorage.setItem(draftKey, content.text);
} else {
localStorage.removeItem(draftKey);
}
} catch (error) {
// localStorage might be full or disabled
console.warn("[PostViewer] Failed to save draft:", error);
// Save draft to localStorage when content changes (uses full TipTap JSON)
const saveDraft = useCallback(() => {
if (!draftKey || !editorRef.current) return;
try {
const json = editorRef.current.getJSON();
const text = editorRef.current.getContent();
if (text.trim()) {
localStorage.setItem(draftKey, JSON.stringify(json));
} else {
localStorage.removeItem(draftKey);
}
},
[draftKey],
);
} catch (error) {
// localStorage might be full or disabled
console.warn("[PostViewer] Failed to save draft:", error);
}
}, [draftKey]);
// Clear draft from localStorage
const clearDraft = useCallback(() => {
@@ -218,15 +218,12 @@ export function PostViewer() {
}, [handlePublish]);
// Handle content change - save draft and reset published state
const handleChange = useCallback(
(content: SerializedContent) => {
if (isPublished) {
setIsPublished(false);
}
saveDraft(content);
},
[isPublished, saveDraft],
);
const handleChange = useCallback(() => {
if (isPublished) {
setIsPublished(false);
}
saveDraft();
}, [isPublished, saveDraft]);
if (!canSign) {
return (

View File

@@ -34,6 +34,32 @@ import type {
SuggestionListHandle,
} from "./types";
/**
* Refs container for suggestion functions
* Allows updating search/select functions without recreating extensions
*/
interface SuggestionRefs {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mentionSearch: (query: string) => Promise<any[]>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mentionOnSelect: (item: any) => {
type: string;
attrs: Record<string, unknown>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emojiSearch: (query: string) => Promise<any[]>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emojiOnSelect: (item: any) => {
type: string;
attrs: Record<string, unknown>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slashSearch: (query: string) => Promise<any[]>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slashOnExecute?: (item: any) => Promise<void>;
handleSubmit: (editor: unknown) => void;
}
// Re-export handle type for consumers
export type { NostrEditorHandle };
@@ -41,7 +67,7 @@ export interface NostrEditorProps {
/** Placeholder text when editor is empty */
placeholder?: string;
/** Initial content (plain text) */
initialContent?: string;
initialContent?: string | object;
/** Called when content is submitted */
onSubmit?: (content: SerializedContent) => void;
/** Called when content changes */
@@ -311,31 +337,20 @@ function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) {
/**
* Create a TipTap suggestion configuration from our SuggestionConfig
* Uses a ref to always get the latest search function (since TipTap captures at mount)
* Uses a refs object to access the latest functions without recreating extensions
*/
function createSuggestionConfig<T>(
configRef: React.MutableRefObject<SuggestionConfig<T> | undefined>,
triggerChar: string,
handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>,
): Omit<SuggestionOptions<T>, "editor"> {
function createMentionSuggestionConfig(
config: SuggestionConfig<unknown>,
refsObj: React.MutableRefObject<SuggestionRefs>,
): Omit<SuggestionOptions<unknown>, "editor"> {
return {
char: triggerChar,
allowSpaces: configRef.current?.allowSpaces ?? false,
allow: configRef.current?.allow,
char: config.char,
allowSpaces: config.allowSpaces ?? false,
allow: config.allow,
items: async ({ query }) => {
// Always use the current config from ref to get fresh search function
const config = configRef.current;
if (!config) {
console.warn(
`[NostrEditor] Suggestion config for '${triggerChar}' is undefined`,
);
return [];
}
const results = await config.search(query);
console.log(
`[NostrEditor] Search '${triggerChar}' query="${query}" results=${results.length}`,
);
return results;
// Always use the current search function from refs
const searchFn = refsObj.current.mentionSearch;
return await searchFn(query);
},
render: () => {
let component: ReactRenderer<SuggestionListHandle>;
@@ -344,9 +359,6 @@ function createSuggestionConfig<T>(
return {
onStart: (props) => {
const config = configRef.current;
if (!config) return;
editorRef = props.editor;
component = new ReactRenderer(config.component as never, {
props: {
@@ -367,7 +379,6 @@ function createSuggestionConfig<T>(
interactive: true,
trigger: "manual",
placement: config.placement ?? "bottom-start",
// Ensure popup appears above dialogs (which use z-50)
zIndex: 100,
});
},
@@ -391,13 +402,167 @@ function createSuggestionConfig<T>(
return true;
}
// Ctrl/Cmd+Enter always submits
if (
props.event.key === "Enter" &&
(props.event.ctrlKey || props.event.metaKey)
) {
popup[0]?.hide();
handleSubmitRef.current(editorRef);
refsObj.current.handleSubmit(editorRef);
return true;
}
return component.ref?.onKeyDown(props.event) ?? false;
},
onExit() {
popup[0]?.destroy();
component.destroy();
},
};
},
};
}
function createEmojiSuggestionConfig(
config: SuggestionConfig<unknown>,
refsObj: React.MutableRefObject<SuggestionRefs>,
): Omit<SuggestionOptions<unknown>, "editor"> {
return {
char: config.char,
allowSpaces: config.allowSpaces ?? false,
allow: config.allow,
items: async ({ query }) => {
const searchFn = refsObj.current.emojiSearch;
return await searchFn(query);
},
render: () => {
let component: ReactRenderer<SuggestionListHandle>;
let popup: TippyInstance[];
let editorRef: unknown;
return {
onStart: (props) => {
editorRef = props.editor;
component = new ReactRenderer(config.component as never, {
props: {
items: props.items,
command: props.command,
onClose: () => popup[0]?.hide(),
},
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: config.placement ?? "bottom-start",
zIndex: 100,
});
},
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;
}
if (
props.event.key === "Enter" &&
(props.event.ctrlKey || props.event.metaKey)
) {
popup[0]?.hide();
refsObj.current.handleSubmit(editorRef);
return true;
}
return component.ref?.onKeyDown(props.event) ?? false;
},
onExit() {
popup[0]?.destroy();
component.destroy();
},
};
},
};
}
function createSlashSuggestionConfig(
config: SuggestionConfig<unknown>,
refsObj: React.MutableRefObject<SuggestionRefs>,
): Omit<SuggestionOptions<unknown>, "editor"> {
return {
char: config.char,
allowSpaces: config.allowSpaces ?? false,
allow: config.allow,
items: async ({ query }) => {
const searchFn = refsObj.current.slashSearch;
return await searchFn(query);
},
render: () => {
let component: ReactRenderer<SuggestionListHandle>;
let popup: TippyInstance[];
return {
onStart: (props) => {
component = new ReactRenderer(config.component as never, {
props: {
items: props.items,
command: props.command,
onClose: () => popup[0]?.hide(),
},
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: config.placement ?? "top-start",
zIndex: 100,
});
},
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;
}
@@ -431,33 +596,35 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
},
ref,
) => {
const handleSubmitRef = useRef<(editor: unknown) => void>(() => {});
// Find suggestion configs
const mentionConfig = suggestions.find((s) => s.char === "@");
const emojiConfig = suggestions.find((s) => s.char === ":");
const slashConfig = suggestions.find((s) => s.char === "/");
// Refs for suggestion configs - TipTap captures these at mount, so we use refs
// to always get the latest search functions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mentionConfigRef = useRef<SuggestionConfig<any> | undefined>(
undefined,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const emojiConfigRef = useRef<SuggestionConfig<any> | undefined>(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const slashConfigRef = useRef<SuggestionConfig<any> | undefined>(undefined);
// Single refs object for all suggestion functions - updated on every render
const suggestionRefsObj = useRef<SuggestionRefs>({
mentionSearch: async () => [],
mentionOnSelect: () => ({ type: "mention", attrs: {} }),
emojiSearch: async () => [],
emojiOnSelect: () => ({ type: "emoji", attrs: {} }),
slashSearch: async () => [],
slashOnExecute: undefined,
handleSubmit: () => {},
});
// Initialize refs immediately (for first render) and update when suggestions change
// This ensures the refs have values before extensions are created
mentionConfigRef.current = suggestions.find((s) => s.char === "@");
emojiConfigRef.current = suggestions.find((s) => s.char === ":");
slashConfigRef.current = suggestions.find((s) => s.char === "/");
// Debug: log suggestion config status
if (process.env.NODE_ENV === "development") {
console.log("[NostrEditor] Suggestions updated:", {
mention: !!mentionConfigRef.current,
emoji: !!emojiConfigRef.current,
slash: !!slashConfigRef.current,
suggestionsCount: suggestions.length,
});
// Update refs object with current search functions on every render
// This ensures TipTap extensions always call the latest functions
if (mentionConfig) {
suggestionRefsObj.current.mentionSearch = mentionConfig.search;
suggestionRefsObj.current.mentionOnSelect = mentionConfig.onSelect;
}
if (emojiConfig) {
suggestionRefsObj.current.emojiSearch = emojiConfig.search;
suggestionRefsObj.current.emojiOnSelect = emojiConfig.onSelect;
}
if (slashConfig) {
suggestionRefsObj.current.slashSearch = slashConfig.search;
suggestionRefsObj.current.slashOnExecute = slashConfig.onExecute;
}
// Helper function to serialize editor content
@@ -572,7 +739,8 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
[onSubmit, serializeContent],
);
handleSubmitRef.current = handleSubmit;
// Update handleSubmit in refs object
suggestionRefsObj.current.handleSubmit = handleSubmit;
// Build extensions array
const extensions = useMemo(() => {
@@ -584,7 +752,7 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
addKeyboardShortcuts() {
return {
"Mod-Enter": ({ editor }) => {
handleSubmitRef.current(editor);
suggestionRefsObj.current.handleSubmit(editor);
return true;
},
Enter: ({ editor }) => {
@@ -599,7 +767,7 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
if (isMobile) {
return editor.commands.setHardBreak();
} else {
handleSubmitRef.current(editor);
suggestionRefsObj.current.handleSubmit(editor);
return true;
}
}
@@ -617,13 +785,16 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
createBlobAttachmentNode(blobPreview),
];
// Add mention extension for @ mentions (uses ref for latest config)
if (mentionConfigRef.current) {
// Add mention extension for @ mentions
if (mentionConfig) {
exts.push(
Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: {
...createSuggestionConfig(mentionConfigRef, "@", handleSubmitRef),
...createMentionSuggestionConfig(
mentionConfig,
suggestionRefsObj,
),
command: ({
editor,
range,
@@ -633,9 +804,10 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
range: unknown;
props: unknown;
}) => {
const config = mentionConfigRef.current;
if (!config) return;
const result = config.onSelect(props as never);
// Use refs to get latest onSelect function
const result = suggestionRefsObj.current.mentionOnSelect(
props as never,
);
const ed = editor as {
chain: () => {
focus: () => {
@@ -662,13 +834,13 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
);
}
// Add emoji extension (uses ref for latest config)
if (emojiConfigRef.current) {
// Add emoji extension
if (emojiConfig) {
exts.push(
EmojiMention.configure({
HTMLAttributes: { class: "emoji" },
suggestion: {
...createSuggestionConfig(emojiConfigRef, ":", handleSubmitRef),
...createEmojiSuggestionConfig(emojiConfig, suggestionRefsObj),
command: ({
editor,
range,
@@ -678,9 +850,10 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
range: unknown;
props: unknown;
}) => {
const config = emojiConfigRef.current;
if (!config) return;
const result = config.onSelect(props as never);
// Use refs to get latest onSelect function
const result = suggestionRefsObj.current.emojiOnSelect(
props as never,
);
const ed = editor as {
chain: () => {
focus: () => {
@@ -704,14 +877,14 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
);
}
// Add slash command extension (uses ref for latest config)
if (slashConfigRef.current) {
// Add slash command extension
if (slashConfig) {
const SlashCommand = Mention.extend({ name: "slashCommand" });
exts.push(
SlashCommand.configure({
HTMLAttributes: { class: "slash-command" },
suggestion: {
...createSuggestionConfig(slashConfigRef, "/", handleSubmitRef),
...createSlashSuggestionConfig(slashConfig, suggestionRefsObj),
command: ({
editor,
props,
@@ -719,14 +892,14 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
editor: unknown;
props: unknown;
}) => {
const config = slashConfigRef.current;
if (!config) return;
const ed = editor as { commands: { clearContent: () => void } };
if (config.clearOnSelect !== false) {
if (slashConfig.clearOnSelect !== false) {
ed.commands.clearContent();
}
if (config.onExecute) {
config.onExecute(props as never).catch((error) => {
// Use refs to get latest onExecute function
const onExecute = suggestionRefsObj.current.slashOnExecute;
if (onExecute) {
onExecute(props as never).catch((error) => {
console.error(
"[NostrEditor] Command execution failed:",
error,
@@ -743,9 +916,15 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
}
return exts;
// Note: We don't depend on suggestions here - we use refs that are updated
// synchronously when suggestions change, ensuring the search functions are always fresh
}, [submitBehavior, placeholder, blobPreview]);
// Depend on whether configs exist (for extension creation) but not search functions (refs handle updates)
}, [
submitBehavior,
placeholder,
blobPreview,
!!mentionConfig,
!!emojiConfig,
!!slashConfig,
]);
const editor = useEditor({
extensions,
@@ -774,6 +953,12 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
if (!editor) return { text: "", emojiTags: [], blobAttachments: [] };
return serializeContent(editor);
},
getJSON: () => editor?.getJSON() || null,
setContent: (content: string | object) => {
if (editor) {
editor.commands.setContent(content);
}
},
isEmpty: () => editor?.isEmpty ?? true,
submit: () => {
if (editor) {
@@ -848,6 +1033,7 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
"rounded border bg-background transition-colors focus-within:border-primary px-2",
variant === "inline" && "h-7 flex items-center overflow-hidden",
variant !== "inline" && "py-2 overflow-y-auto",
variant === "full" && "resize-y min-h-[100px]",
className,
)}
style={getInlineStyles()}

View File

@@ -113,6 +113,10 @@ export interface NostrEditorHandle {
clear: () => void;
getContent: () => string;
getSerializedContent: () => SerializedContent;
/** Get the full TipTap JSON content (for draft persistence) */
getJSON: () => object | null;
/** Set content from string or TipTap JSON */
setContent: (content: string | object) => void;
isEmpty: () => boolean;
submit: () => void;
/** Insert text at the current cursor position */