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. */