Browse Source

fix address-import submenu-hover

fix lingering letter after pasting in
imwald
Silberengel 1 week ago
parent
commit
341f4d5cd4
  1. 7
      src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx
  2. 3
      src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx
  3. 17
      src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts
  4. 114
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  5. 2
      src/services/post-editor.service.ts

7
src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx

@ -9,6 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover @@ -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({ @@ -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({ @@ -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 + ' '))
}}
>
<FileSearch className="h-4 w-4" />
</Button>

3
src/components/PostEditor/PostTextarea/Mention/NeventPickerProvider.tsx

@ -1,3 +1,4 @@ @@ -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 } @@ -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 } @@ -40,6 +42,7 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode }
(link: string) => {
onSelectedRef?.(link)
setOnSelectedRef(null)
postEditor.closeSuggestionPopup()
},
[onSelectedRef]
)

17
src/components/PostEditor/PostTextarea/Mention/mention-range.test.ts

@ -0,0 +1,17 @@ @@ -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)
})
})

114
src/components/PostEditor/PostTextarea/Mention/suggestion.ts

@ -22,22 +22,56 @@ let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | undef @@ -22,22 +22,56 @@ let currentComponent: ReactRenderer<MentionListHandle, MentionListProps> | 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
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 < 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
}
return pos
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 end
}
function mountPopup(
@ -62,10 +96,18 @@ const suggestion = { @@ -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 = { @@ -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 = { @@ -125,17 +168,36 @@ const suggestion = {
render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps> | undefined
let popup: ReturnType<typeof createSuggestionPopup> | 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<MentionListItem>) => {
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 = { @@ -146,6 +208,7 @@ const suggestion = {
},
onUpdate(props: SuggestionProps<MentionListItem>) {
if (exited) return
component?.updateProps(props)
if (popup && component) {
mountPopup(popup, props, component)
@ -154,23 +217,14 @@ const suggestion = { @@ -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()
}
}
}

2
src/services/post-editor.service.ts

@ -28,11 +28,9 @@ class PostEditorService extends EventTarget { @@ -28,11 +28,9 @@ class PostEditorService extends EventTarget {
}
closeSuggestionPopup() {
if (this.isSuggestionPopupOpen) {
this.isSuggestionPopupOpen = false
this.dispatchEvent(new CustomEvent('closeSuggestionPopup'))
}
}
/** Opens the main “new note” composer (same as sidebar / write button). Listeners run login check. */
requestOpenNewPost() {

Loading…
Cancel
Save