12 changed files with 379 additions and 203 deletions
@ -0,0 +1,68 @@ |
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
import type { Editor } from '@tiptap/core' |
||||||
|
import type { PickerSearchMode } from '@/services/mention-event-search.service' |
||||||
|
import NeventNaddrPickerDialog from './NeventNaddrPickerDialog' |
||||||
|
import { NeventPickerContext } from './nevent-picker-context' |
||||||
|
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion' |
||||||
|
|
||||||
|
export function NeventPickerProvider({ children }: { children: React.ReactNode }) { |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
const [onSelectedRef, setOnSelectedRef] = useState<((link: string) => void) | null>(null) |
||||||
|
const [initialMode, setInitialMode] = useState<PickerSearchMode>('nevent') |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const handler = (e: Event) => { |
||||||
|
const { editor, range, initialMode: detailMode } = ( |
||||||
|
e as CustomEvent<{ |
||||||
|
editor: Editor |
||||||
|
range: { from: number; to: number } |
||||||
|
initialMode?: PickerSearchMode |
||||||
|
}> |
||||||
|
).detail |
||||||
|
const to = extendMentionRangeToEndOfWord(editor, range) |
||||||
|
setOnSelectedRef(() => (link: string) => { |
||||||
|
editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run() |
||||||
|
}) |
||||||
|
setInitialMode(detailMode ?? 'nevent') |
||||||
|
setOpen(true) |
||||||
|
} |
||||||
|
window.addEventListener(OPEN_NEVENT_PICKER_EVENT, handler) |
||||||
|
return () => window.removeEventListener(OPEN_NEVENT_PICKER_EVENT, handler) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const openNeventPicker = useCallback((onSelected: (nostrLink: string) => void, mode?: PickerSearchMode) => { |
||||||
|
setOnSelectedRef(() => onSelected) |
||||||
|
setInitialMode(mode ?? 'nevent') |
||||||
|
setOpen(true) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleSelect = useCallback( |
||||||
|
(link: string) => { |
||||||
|
onSelectedRef?.(link) |
||||||
|
setOnSelectedRef(null) |
||||||
|
}, |
||||||
|
[onSelectedRef] |
||||||
|
) |
||||||
|
|
||||||
|
const handleOpenChange = useCallback((next: boolean) => { |
||||||
|
if (!next) { |
||||||
|
setOnSelectedRef(null) |
||||||
|
setInitialMode('nevent') |
||||||
|
} |
||||||
|
setOpen(next) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const value = useMemo(() => ({ openNeventPicker }), [openNeventPicker]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<NeventPickerContext.Provider value={value}> |
||||||
|
{children} |
||||||
|
<NeventNaddrPickerDialog |
||||||
|
open={open} |
||||||
|
onOpenChange={handleOpenChange} |
||||||
|
onSelect={handleSelect} |
||||||
|
initialMode={initialMode} |
||||||
|
/> |
||||||
|
</NeventPickerContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
import { createContext } from 'react' |
||||||
|
import type { PickerSearchMode } from '@/services/mention-event-search.service' |
||||||
|
|
||||||
|
export type NeventPickerContextValue = { |
||||||
|
openNeventPicker: (onSelected: (nostrLink: string) => void, initialMode?: PickerSearchMode) => void |
||||||
|
} |
||||||
|
|
||||||
|
export const NeventPickerContext = createContext<NeventPickerContextValue | null>(null) |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
import postEditor from '@/services/post-editor.service' |
||||||
|
import type { Editor } from '@tiptap/core' |
||||||
|
import tippy, { type GetReferenceClientRect, type Instance, type Props } from 'tippy.js' |
||||||
|
|
||||||
|
/** Above Radix Sheet/Dialog (`z-50`) so @-mention / emoji lists stay visible on mobile. */ |
||||||
|
export const SUGGESTION_POPUP_Z_INDEX = 350 |
||||||
|
|
||||||
|
export type SuggestionPopupController = { |
||||||
|
ensure: (props: { |
||||||
|
clientRect?: (() => DOMRect | null) | null |
||||||
|
content: Element |
||||||
|
}) => void |
||||||
|
hide: () => void |
||||||
|
destroy: () => void |
||||||
|
} |
||||||
|
|
||||||
|
export function createSuggestionPopup(editor: Editor): SuggestionPopupController { |
||||||
|
let popup: Instance | undefined |
||||||
|
let touchListener: ((e: TouchEvent) => void) | undefined |
||||||
|
|
||||||
|
const destroy = () => { |
||||||
|
if (touchListener) { |
||||||
|
document.removeEventListener('touchstart', touchListener) |
||||||
|
touchListener = undefined |
||||||
|
} |
||||||
|
if (popup) { |
||||||
|
popup.destroy() |
||||||
|
popup = undefined |
||||||
|
} |
||||||
|
postEditor.isSuggestionPopupOpen = false |
||||||
|
} |
||||||
|
|
||||||
|
const ensure = (props: { |
||||||
|
clientRect?: (() => DOMRect | null) | null |
||||||
|
content: Element |
||||||
|
}) => { |
||||||
|
if (!props.clientRect) return |
||||||
|
|
||||||
|
if (!touchListener) { |
||||||
|
touchListener = (e: TouchEvent) => { |
||||||
|
if (!popup || !postEditor.isSuggestionPopupOpen) return |
||||||
|
const target = e.target as Node |
||||||
|
if (popup.popper?.contains(target)) return |
||||||
|
const editorEl = editor.view?.dom |
||||||
|
if (editorEl?.contains(target)) return |
||||||
|
popup.hide() |
||||||
|
} |
||||||
|
document.addEventListener('touchstart', touchListener, { passive: true }) |
||||||
|
} |
||||||
|
|
||||||
|
const rectProps = { |
||||||
|
getReferenceClientRect: props.clientRect as GetReferenceClientRect |
||||||
|
} |
||||||
|
|
||||||
|
if (popup) { |
||||||
|
popup.setProps({ |
||||||
|
...rectProps, |
||||||
|
content: props.content |
||||||
|
} as Partial<Props>) |
||||||
|
if (!popup.state.isVisible) popup.show() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
popup = tippy(document.body, { |
||||||
|
...rectProps, |
||||||
|
appendTo: () => document.body, |
||||||
|
content: props.content, |
||||||
|
showOnCreate: true, |
||||||
|
interactive: true, |
||||||
|
trigger: 'manual', |
||||||
|
placement: 'bottom-start', |
||||||
|
hideOnClick: false, |
||||||
|
maxWidth: 'none', |
||||||
|
zIndex: SUGGESTION_POPUP_Z_INDEX, |
||||||
|
touch: true, |
||||||
|
onShow() { |
||||||
|
postEditor.isSuggestionPopupOpen = true |
||||||
|
}, |
||||||
|
onHide() { |
||||||
|
postEditor.isSuggestionPopupOpen = false |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
ensure, |
||||||
|
hide: () => popup?.hide(), |
||||||
|
destroy |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue