diff --git a/packages/editable-html-tip-tap/src/components/__tests__/InlineDropdown.test.jsx b/packages/editable-html-tip-tap/src/components/__tests__/InlineDropdown.test.jsx index 206716814..0a4345875 100644 --- a/packages/editable-html-tip-tap/src/components/__tests__/InlineDropdown.test.jsx +++ b/packages/editable-html-tip-tap/src/components/__tests__/InlineDropdown.test.jsx @@ -17,8 +17,15 @@ jest.mock('react-dom', () => ({ describe('InlineDropdown', () => { const buildMockEditor = (overrides = {}) => { + const mockNodeAt = jest.fn((pos) => (pos === 5 ? { nodeSize: 1 } : null)); + const mockDoc = { + descendants: jest.fn(), + nodeAt: mockNodeAt, + }; const mockTr = { delete: jest.fn(), + doc: { nodeAt: mockNodeAt }, + setSelection: jest.fn(), }; return { state: { @@ -27,6 +34,7 @@ describe('InlineDropdown', () => { to: 1, }, tr: mockTr, + doc: mockDoc, }, view: { coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })), diff --git a/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx b/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx index f1ba32087..0dd26838f 100644 --- a/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx +++ b/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { NodeViewWrapper } from '@tiptap/react'; +import { NodeSelection } from 'prosemirror-state'; import { Chevron } from '../icons/RespArea'; import ReactDOM from 'react-dom'; import CustomToolbarWrapper from '../../extensions/custom-toolbar-wrapper'; @@ -9,15 +10,74 @@ const InlineDropdown = (props) => { const { editor, node, getPos, options, selected } = props; const { attrs: attributes } = node; const { value, error } = attributes; - // TODO: Investigate - // Needed because items with values inside have different positioning for some reason const html = value || '
 
'; const pos = getPos(); const toolbarRef = useRef(null); const toolbarEditor = useRef(null); + const pendingCloseRequest = useRef(false); + + const isHeld = () => + editor._holdInlineDropdownToolbarIndex != null && + String(editor._holdInlineDropdownToolbarIndex) === String(node.attrs.index); + const [showToolbar, setShowToolbar] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); - const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, () => {}); + + const closeToolbar = () => { + if (isHeld()) { + return; + } + + setShowToolbar(false); + }; + + const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, closeToolbar); + + const reselectNode = () => { + const { tr } = editor.state; + const nodeAtPos = tr.doc.nodeAt(pos); + + if (!nodeAtPos) { + return; + } + + const { selection } = tr; + + if (selection.from === pos && selection.to === pos + nodeAtPos.nodeSize) { + return; + } + + tr.setSelection(NodeSelection.create(tr.doc, pos)); + editor.view.dispatch(tr); + }; + + const requestClose = () => { + if (pendingCloseRequest.current) { + return; + } + + if (options.onToolbarCloseRequest) { + pendingCloseRequest.current = true; + + options.onToolbarCloseRequest( + [node, pos], + editor, + () => { + pendingCloseRequest.current = false; + delete editor._holdInlineDropdownToolbarIndex; + closeToolbar(); + }, + () => { + pendingCloseRequest.current = false; + delete editor._holdInlineDropdownToolbarIndex; + setShowToolbar(true); + setTimeout(reselectNode, 0); + }, + ); + } else { + closeToolbar(); + } + }; useEffect(() => { const { selection } = editor.state; @@ -25,10 +85,10 @@ const InlineDropdown = (props) => { if (selected) { if (onlyThisNodeSelected) { - setShowToolbar(selected); + setShowToolbar(true); } - } else { - setShowToolbar(selected); + } else if (showToolbar) { + requestClose(); } }, [editor, node, selected]); @@ -47,13 +107,14 @@ const InlineDropdown = (props) => { const insideSomeEditor = event.target.closest('[data-toolbar-for]'); if ( - (!insideSomeEditor || insideSomeEditor.dataset.toolbarFor !== toolbarEditor.current.instanceId) && + !event.target.closest('[data-inline-dropdown-toolbar]') && + (!insideSomeEditor || insideSomeEditor.dataset.toolbarFor !== toolbarEditor.current?.instanceId) && !editor._toolbarOpened && toolbarRef.current && !toolbarRef.current.contains(event.target) && !event.target.closest('[data-inline-node]') ) { - setShowToolbar(false); + requestClose(); } }; @@ -105,19 +166,10 @@ const InlineDropdown = (props) => { display: 'inline-block', verticalAlign: 'middle', }} - dangerouslySetInnerHTML={{ - __html: html, - }} + dangerouslySetInnerHTML={{ __html: html }} /> - + {showToolbar && ( @@ -145,6 +197,7 @@ const InlineDropdown = (props) => { // Prevent the debounced onBlur/onDone from firing into the // now-deleted node's stale position editor._toolbarOpened = false; + delete editor._holdInlineDropdownToolbarIndex; editor.view.dispatch(tr); setShowToolbar(false); editor.commands.focus(); diff --git a/packages/editable-html-tip-tap/src/components/respArea/inlineDropdownUtils.js b/packages/editable-html-tip-tap/src/components/respArea/inlineDropdownUtils.js new file mode 100644 index 000000000..a0b8fbccf --- /dev/null +++ b/packages/editable-html-tip-tap/src/components/respArea/inlineDropdownUtils.js @@ -0,0 +1,79 @@ +import { NodeSelection } from 'prosemirror-state'; + +export const HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX = '_holdInlineDropdownToolbarIndex'; + +export const findInlineDropdownPos = (editor, index) => { + let foundPos = null; + + editor.state.doc.descendants((n, p) => { + if (n.type?.name === 'inline_dropdown' && String(n.attrs?.index) === String(index)) { + foundPos = p; + return false; + } + + return true; + }); + + return foundPos; +}; + +export const holdInlineDropdownToolbar = (editor, index) => { + editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX] = index; +}; + +export const releaseInlineDropdownToolbarHold = (editor) => { + delete editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX]; +}; + +export const isInlineDropdownToolbarHeld = (editor, index) => + editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX] != null && + String(editor[HOLD_INLINE_DROPDOWN_TOOLBAR_INDEX]) === String(index); + +export const selectInlineDropdownNode = (editor, index, fallbackPos) => { + const pos = findInlineDropdownPos(editor, index) ?? fallbackPos; + + if (pos == null) { + return null; + } + + const { tr } = editor.state; + const nodeAtPos = tr.doc.nodeAt(pos); + + if (!nodeAtPos) { + return null; + } + + const { selection } = tr; + + if (selection.from === pos && selection.to === pos + nodeAtPos.nodeSize) { + return pos; + } + + tr.setSelection(NodeSelection.create(tr.doc, pos)); + editor.view.dispatch(tr); + + return pos; +}; + +export const deleteInlineDropdownByIndex = (editor, index, fallbackPos) => { + const pos = findInlineDropdownPos(editor, index) ?? fallbackPos; + + if (pos == null) { + releaseInlineDropdownToolbarHold(editor); + return false; + } + + const { tr } = editor.state; + const nodeAtPos = tr.doc.nodeAt(pos); + + if (!nodeAtPos) { + releaseInlineDropdownToolbarHold(editor); + return false; + } + + tr.delete(pos, pos + nodeAtPos.nodeSize); + editor.view.dispatch(tr); + releaseInlineDropdownToolbarHold(editor); + + return true; +}; \ No newline at end of file diff --git a/packages/editable-html-tip-tap/src/index.jsx b/packages/editable-html-tip-tap/src/index.jsx index 7f51abfed..9f0018d4c 100644 --- a/packages/editable-html-tip-tap/src/index.jsx +++ b/packages/editable-html-tip-tap/src/index.jsx @@ -1,5 +1,5 @@ import StyledEditor, { EditableHtml } from './components/EditableHtml'; import { ALL_PLUGINS, DEFAULT_PLUGINS } from './extensions'; - -export { EditableHtml, ALL_PLUGINS, DEFAULT_PLUGINS }; +import { deleteInlineDropdownByIndex } from './components/respArea/inlineDropdownUtils'; +export { EditableHtml, ALL_PLUGINS, DEFAULT_PLUGINS, deleteInlineDropdownByIndex }; export default StyledEditor;