import type { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu' import type { EditorState, TextSelection } from '@tiptap/pm/state' import type { Component, JSX } from 'solid-js' import { isTextSelection } from '@tiptap/core' import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu' import { Logger } from 'besonders-logger' import { createSignal, onCleanup, onMount, Show } from 'solid-js' import { isNeedingSuggestionMenu } from '../data/tagMatcherForAutoComplete' import { autoCompleteCallback, AutoCompleteMenu, autoCompleteOptions, LinkOpenMenu, setAutoCompleteOptions } from './BlockTree' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.DEBUG) // eslint-disable-line unused-imports/no-unused-vars const defaultShouldShow: Exclude = ({ view, state, from, to, }) => { const { doc, selection } = state const { empty } = selection // Sometime check for `empty` is not enough. // Doubleclick an empty paragraph returns a node size of 2. // So we check also for an empty text size. const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection) // When clicking on a element inside the bubble menu the editor "blur" event // is called and the bubble menu item is focussed. In this case we should // consider the menu as part of the editor and keep showing the menu const isChildOfMenu = this.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() || isChildOfMenu if (!hasEditorFocus || empty || isEmptyTextBlock || !this.editor.isEditable) { return false } return true } const createMenuState = () => { const [selectionOrAutocomplete, setSelOrAC] = createSignal(null) const [linkUnderCursor, setLinkUnderCursor] = createSignal>(null) return { selectionOrAutocomplete, setSelOrAC, linkUnderCursor, setLinkUnderCursor, } } type MenuState = ReturnType const createShouldShowWhenSelectionOrAutoComplete = ({ selectionOrAutocomplete, setSelOrAC, linkUnderCursor, setLinkUnderCursor, }: MenuState): Exclude => ({ editor, view, state, from, to, }) => { // Guard against destroyed editor or missing view if (editor.isDestroyed || !view?.docView) { DEBUG('[TiptapMenuWrapper.shouldShow] Guard blocked:', { isDestroyed: editor.isDestroyed, hasDocView: !!view?.docView }) return false } const { doc, selection } = state const { empty } = selection // HACK: Reset state - otherwise state handling would get messy... but this way we might have double signal trigger 🤷 if (linkUnderCursor()) setLinkUnderCursor(null) if (!editor.isEditable) { return false } // Sometime check for `empty` is not enough. // Doubleclick an empty paragraph returns a node size of 2. // So we check also for an empty text size. const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection) // When clicking on a element inside the bubble menu the editor "blur" event // is called and the bubble menu item is focussed. In this case we should // consider the menu as part of the editor and keep showing the menu // const isChildOfMenu = this.element.contains(document.activeElement) const hasEditorFocus = view.hasFocus() // || isChildOfMenu if (!(!hasEditorFocus || empty || isEmptyTextBlock || !editor.isEditable)) { setSelOrAC('selection') return true } const { currentWordInfo } = editor.commands.getWordInfo() const currentWordStart = currentWordInfo?.word?.substring(0, 1) const needsSuggestions = isNeedingSuggestionMenu(null, currentWordStart) VERBOSE({ currentWordInfo, needsSuggestions }) if (needsSuggestions) { const options = autoCompleteOptions() DEBUG('[TiptapMenuWrapper] autocomplete trigger', { options: options.length, word: currentWordInfo?.word }) if (!options.length) { setSelOrAC(null) return false } setSelOrAC('autocomplete') return true } else { setAutoCompleteOptions([]) // when i was on a tag and then drag to select, this hides the autocomplete menu } const linksUnderCursor = tiptapGetLinksUnderCursor({ state }) if (linksUnderCursor.length) { setSelOrAC('link') setLinkUnderCursor(linksUnderCursor[0]) return true } setSelOrAC(null) return false } type TiptapMenuWrapperProps = & Omit< BubbleMenuPluginProps, 'element' | 'pluginKey' | 'shouldShow' > & { class?: string children?: JSX.Element shouldShow?: BubbleMenuPluginProps['shouldShow'] } const TiptapMenuWrapper: Component = (props) => { let ref const pluginKey = 'note3-menu' // ? nanoid() const menuState = createMenuState() const shouldShowWhenSelectionOrAutoComplete = createShouldShowWhenSelectionOrAutoComplete(menuState) onMount(() => { const { editor, shouldShow, tippyOptions } = props // if (ref) { editor.registerPlugin( BubbleMenuPlugin({ editor, pluginKey, shouldShow: shouldShowWhenSelectionOrAutoComplete, /* shouldShow, : (props) => { // TODO show when autoComplete is needed if (shouldShow) { return shouldShow(props) }} > return false } */ element: ref, tippyOptions, }), ) // } }) onCleanup(() => { const { editor } = props if (editor && !editor.isDestroyed) { editor.unregisterPlugin(pluginKey) } }) return (
{props.children}
) } export { TiptapMenuWrapper } export type { TiptapMenuWrapperProps } function tiptapGetLinksUnderCursor({ state }: { state: EditorState }) { const { selection, doc } = state const cursorPos = (selection as TextSelection).$cursor?.pos ?? null if (cursorPos !== null) { const node = doc.nodeAt(cursorPos) const linkMarks = node?.marks.filter(mark => mark.type.name === 'link') if (linkMarks?.length) { DEBUG(`[tiptapGetLinkUnderCursor] found`, linkMarks) return linkMarks.map(mark => mark.attrs) } } return [] }