12 changed files with 379 additions and 203 deletions
@ -0,0 +1,68 @@
@@ -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 @@
@@ -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 @@
@@ -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