diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx index 608130e8..be7767fc 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx @@ -9,6 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import UserItem, { UserItemSkeleton } from '@/components/UserItem' import { useSearchProfiles } from '@/hooks' import { MENTION_NPUB_DROPDOWN_LIMIT } from '@/services/mention-event-search.service' +import postEditor from '@/services/post-editor.service' import { AtSign, FileSearch } from 'lucide-react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -42,6 +43,7 @@ export function MentionAndEventToolbarButtons({ const selectNpub = useCallback( (npub: string) => { insertAtCursor(`nostr:${npub} `) + postEditor.closeSuggestionPopup() closeMention() }, [insertAtCursor, closeMention] @@ -114,7 +116,10 @@ export function MentionAndEventToolbarButtons({ size="icon" title={t('Insert event or address')} className={btnClass} - onClick={() => neventPicker?.openNeventPicker((link) => insertAtCursor(link + ' '))} + onClick={() => { + postEditor.closeSuggestionPopup() + neventPicker?.openNeventPicker((link) => insertAtCursor(link + ' ')) + }} > diff --git a/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx b/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx index 7e5f88b7..8b0d8fe8 100644 --- a/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx @@ -1,3 +1,4 @@ +import postEditor from '@/services/post-editor.service' import { useCallback, useEffect, useMemo, useState } from 'react' import type { Editor } from '@tiptap/core' import type { PickerSearchMode } from '@/services/mention-event-search.service' @@ -22,6 +23,7 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode } const to = extendMentionRangeToEndOfWord(editor, range) setOnSelectedRef(() => (link: string) => { editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run() + postEditor.closeSuggestionPopup() }) setInitialMode(detailMode ?? 'nevent') setOpen(true) @@ -40,6 +42,7 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode } (link: string) => { onSelectedRef?.(link) setOnSelectedRef(null) + postEditor.closeSuggestionPopup() }, [onSelectedRef] ) diff --git a/src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts b/src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts new file mode 100644 index 00000000..b309b667 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { mentionQueryLengthInText } from './suggestion' + +describe('mentionQueryLengthInText', () => { + it('includes the full handle after @', () => { + expect(mentionQueryLengthInText('@Nusa')).toBe(5) + expect(mentionQueryLengthInText('@Nusa more')).toBe(5) + }) + + it('includes dotted NIP-05 style handles', () => { + expect(mentionQueryLengthInText('@user.name')).toBe(10) + }) + + it('supports query text without the leading @', () => { + expect(mentionQueryLengthInText('Nusa')).toBe(4) + }) +}) diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index e13af9cb..79b05725 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -22,22 +22,56 @@ let currentComponent: ReactRenderer | undef let currentQuery = '' let mentionSearchGeneration = 0 -/** Extend range.to to include any trailing word chars (handle, NIP-05) so the full @handle is replaced. Exported for nevent picker. */ +const MENTION_QUERY_CHAR = /[\w.-]/ + +/** Length of @query (including @) within `text` starting at index 0. */ +export function mentionQueryLengthInText(text: string, mentionChar = MENTION_CHAR): number { + if (text.startsWith(mentionChar)) { + let i = mentionChar.length + while (i < text.length && MENTION_QUERY_CHAR.test(text[i]!)) i++ + return i + } + let i = 0 + while (i < text.length && MENTION_QUERY_CHAR.test(text[i]!)) i++ + return i +} + +/** + * Extend range.to through the full @handle typed in the document. + * TipTap's range.to is often stale (especially on mouse pick); scanning the doc text avoids leaving a trailing letter. + */ export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number { - const { doc } = editor.state - let pos = range.to - while (pos < doc.content.size) { - const $pos = doc.resolve(pos) - const node = $pos.nodeAfter - if (!node || !node.isText) break - const text = node.text ?? '' - const offset = pos - $pos.start() - let i = offset - while (i < text.length && /[\w.-]/.test(text[i]!)) i++ - if (i === offset) break - pos += i - offset + const { doc, selection } = editor.state + const scanEnd = Math.min(doc.content.size, range.from + 300) + const prefix = doc.textBetween(range.from, scanEnd, '', '') + + let end = range.to + + const queryLen = mentionQueryLengthInText(prefix) + if (queryLen > 0) { + end = Math.max(end, range.from + queryLen) + } else { + let pos = range.to + while (pos < scanEnd) { + const ch = doc.textBetween(pos, pos + 1, '', '') + if (!ch || !MENTION_QUERY_CHAR.test(ch)) break + pos += 1 + } + end = Math.max(end, pos) + } + + // Click-to-select can leave the caret ahead of the last suggestion range update. + if (selection.empty && selection.to > end) { + let pos = selection.to + while (pos < scanEnd) { + const ch = doc.textBetween(pos, pos + 1, '', '') + if (!ch || !MENTION_QUERY_CHAR.test(ch)) break + pos += 1 + } + end = Math.max(end, pos) } - return pos + + return end } function mountPopup( @@ -62,10 +96,18 @@ const suggestion = { props: { id: string | null; label?: string | null; mode?: PickerSearchMode } }) => { if (props.id === NEVENT_NADDR_PICKER_ID) { + const to = extendMentionRangeToEndOfWord(editor, range) + const insertAt = range.from + // Drop @naddr / @nevent trigger text so the suggestion session ends before the dialog opens. + editor.chain().focus().deleteRange({ from: range.from, to }).run() postEditor.closeSuggestionPopup() window.dispatchEvent( new CustomEvent(OPEN_NEVENT_PICKER_EVENT, { - detail: { editor, range, initialMode: props.mode ?? 'nevent' } + detail: { + editor, + range: { from: insertAt, to: insertAt }, + initialMode: props.mode ?? 'nevent' + } }) ) return @@ -84,6 +126,7 @@ const suggestion = { ]) .run() editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd() + postEditor.closeSuggestionPopup() }, items: async ({ query }: { query: string }) => { @@ -125,17 +168,36 @@ const suggestion = { render: () => { let component: ReactRenderer | undefined let popup: ReturnType | undefined - let closePopup: () => void + let closePopup: (() => void) | undefined let exited = false + const exit = () => { + if (exited) return + exited = true + mentionSearchGeneration += 1 + postEditor.isSuggestionPopupOpen = false + currentComponent = undefined + currentQuery = '' + popup?.destroy() + popup = undefined + component?.destroy() + component = undefined + if (closePopup) { + postEditor.removeEventListener('closeSuggestionPopup', closePopup) + } + } + return { onBeforeStart: () => { - closePopup = () => { - popup?.hide() - } + closePopup = exit + postEditor.removeEventListener('closeSuggestionPopup', closePopup) postEditor.addEventListener('closeSuggestionPopup', closePopup) }, onStart: (props: SuggestionProps) => { + exited = false + closePopup = exit + postEditor.removeEventListener('closeSuggestionPopup', closePopup) + postEditor.addEventListener('closeSuggestionPopup', closePopup) popup = createSuggestionPopup(props.editor) component = new ReactRenderer(MentionList, { props: { ...props, loading: true }, @@ -146,6 +208,7 @@ const suggestion = { }, onUpdate(props: SuggestionProps) { + if (exited) return component?.updateProps(props) if (popup && component) { mountPopup(popup, props, component) @@ -154,23 +217,14 @@ const suggestion = { onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === 'Escape') { - popup?.hide() + exit() return true } return component?.ref?.onKeyDown(props) ?? false }, onExit() { - if (exited) return - exited = true - postEditor.isSuggestionPopupOpen = false - currentComponent = undefined - currentQuery = '' - popup?.destroy() - popup = undefined - component?.destroy() - component = undefined - postEditor.removeEventListener('closeSuggestionPopup', closePopup) + exit() } } } diff --git a/src/services/post-editor.service.ts b/src/services/post-editor.service.ts index e01eab2e..27aa687e 100644 --- a/src/services/post-editor.service.ts +++ b/src/services/post-editor.service.ts @@ -28,10 +28,8 @@ class PostEditorService extends EventTarget { } closeSuggestionPopup() { - if (this.isSuggestionPopupOpen) { - this.isSuggestionPopupOpen = false - this.dispatchEvent(new CustomEvent('closeSuggestionPopup')) - } + this.isSuggestionPopupOpen = false + this.dispatchEvent(new CustomEvent('closeSuggestionPopup')) } /** Opens the main “new note” composer (same as sidebar / write button). Listeners run login check. */