diff --git a/packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx b/packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx index a5d7af765..cf3469fa5 100644 --- a/packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx +++ b/packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { useEditor } from '@tiptap/react'; import { EditableHtml } from '../components/EditableHtml'; +import { InlineDropdownNode } from '../extensions/responseArea'; // Mock TipTap dependencies jest.mock('@tiptap/react', () => ({ @@ -210,6 +211,39 @@ describe('EditableHtml', () => { expect(container).toBeInTheDocument(); }); + it('passes blur done behavior to inline dropdown toolbar close', async () => { + const onChange = jest.fn(); + const onDone = jest.fn(); + const html = '

from inline dropdown close

'; + + render( + , + ); + + await waitFor(() => { + expect(InlineDropdownNode.configure).toHaveBeenCalled(); + }); + + const configureCall = InlineDropdownNode.configure.mock.calls[InlineDropdownNode.configure.mock.calls.length - 1]; + const inlineDropdownOptions = configureCall[0]; + const editor = { + getHTML: jest.fn(() => html), + }; + + inlineDropdownOptions.onInlineDropdownToolbarClose(editor); + + expect(editor.getHTML).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(html); + expect(onDone).toHaveBeenCalledWith(html); + }); + it('accepts size props', () => { const sizeProps = { width: 500, diff --git a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx index 129aa50a9..b6d93144e 100644 --- a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx +++ b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx @@ -120,6 +120,30 @@ export const EditableHtml = (props) => { ...toolbarOpts, }; + const commitEditorContent = useCallback( + (editor) => { + const html = editor.getHTML(); + + if (props.markup !== html) { + props.onChange?.(html); + } + + if (toolbarOptsToUse.doneOn === 'blur') { + props.onDone?.(html); + } + }, + [props.markup, props.onChange, props.onDone, toolbarOptsToUse.doneOn], + ); + + const responseAreaPropsToUse = useMemo( + () => ({ + ...defaultResponseAreaProps, + ...props.responseAreaProps, + onInlineDropdownToolbarClose: commitEditorContent, + }), + [props.responseAreaProps, commitEditorContent], + ); + const activePluginsToUse = useMemo(() => { let { customPlugins, ...otherPluginProps } = props.pluginProps || {}; @@ -143,14 +167,14 @@ export const EditableHtml = (props) => { toolbar: {}, table: {}, responseArea: { - type: props.responseAreaProps?.type, + type: responseAreaPropsToUse.type, }, languageCharacters: props.languageCharactersProps, keyPadCharacterRef: {}, setKeypadInteraction: {}, media: {}, }); - }, [props]); + }, [props, responseAreaPropsToUse.type]); const extensions = [ TextAlign.configure({ @@ -183,11 +207,11 @@ export const EditableHtml = (props) => { TableRow, ExtendedTableHeader, ExtendedTableCell, - ResponseAreaExtension.configure(props.responseAreaProps), - ExplicitConstructedResponseNode.configure(props.responseAreaProps), - DragInTheBlankNode.configure(props.responseAreaProps), - InlineDropdownNode.configure(props.responseAreaProps), - MathTemplatedNode.configure(props.responseAreaProps), + ResponseAreaExtension.configure(responseAreaPropsToUse), + ExplicitConstructedResponseNode.configure(responseAreaPropsToUse), + DragInTheBlankNode.configure(responseAreaPropsToUse), + InlineDropdownNode.configure(responseAreaPropsToUse), + MathTemplatedNode.configure(responseAreaPropsToUse), MathNode.configure({ toolbarOpts: toolbarOptsToUse, math: props.pluginProps?.math || {}, @@ -303,15 +327,7 @@ export const EditableHtml = (props) => { return; } - const html = editor.getHTML(); - - if (props.markup !== html) { - props.onChange?.(html); - } - - if (toolbarOptsToUse.doneOn === 'blur') { - props.onDone?.(html); - } + commitEditorContent(editor); }, }, [props.charactersLimit], 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 b2419bfe4..cae359942 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 @@ -1,7 +1,12 @@ import React from 'react'; import { fireEvent, render, waitFor } from '@testing-library/react'; +import { renderMath } from '@pie-lib/math-rendering'; import InlineDropdown from '../respArea/InlineDropdown'; +jest.mock('@pie-lib/math-rendering', () => ({ + renderMath: jest.fn(), +})); + jest.mock('@tiptap/react', () => ({ NodeViewWrapper: ({ children, ...props }) => (
@@ -104,6 +109,32 @@ describe('InlineDropdown', () => { expect(valueDiv).toBeInTheDocument(); }); + it('renders math inside the value control on mount', () => { + const { container } = render(); + const valueDiv = container.querySelector('div[style*="border"]'); + + expect(renderMath).toHaveBeenCalledWith(valueDiv); + }); + + it('re-renders math when the value changes', () => { + const updatedNode = { + ...mockNode, + attrs: { + ...mockNode.attrs, + value: 'Updated math', + }, + }; + + const { container, rerender } = render(); + + renderMath.mockClear(); + + rerender(); + + const valueDiv = container.querySelector('div[style*="border"]'); + expect(renderMath).toHaveBeenCalledWith(valueDiv); + }); + it('uses 2px horizontal margin on the value control and no horizontal margin on the wrapper', () => { const { container, getByTestId } = render(); const valueDiv = container.querySelector('div[style*="border"]'); @@ -185,6 +216,28 @@ describe('InlineDropdown', () => { }); }); + it('calls close callback when toolbar closes on outside click', async () => { + const onInlineDropdownToolbarClose = jest.fn(); + const options = { + ...mockOptions, + onInlineDropdownToolbarClose, + }; + + const { queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument(); + }); + + fireEvent.mouseDown(document.body); + + await waitFor(() => { + expect(queryByTestId('inline-dropdown-toolbar')).not.toBeInTheDocument(); + }); + + expect(onInlineDropdownToolbarClose).toHaveBeenCalledWith(mockEditor); + }); + it('uses the current node when closing on outside click after the node prop changes', async () => { const onToolbarCloseRequest = jest.fn((_tuple, _editor, onConfirm) => onConfirm()); const options = { @@ -476,7 +529,7 @@ describe('InlineDropdown', () => { await waitFor(() => { expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument(); }); -}); + }); it('renders delete control on portaled custom toolbar when container el is set', async () => { const { findByLabelText } = render(); 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 da1031e6b..b7631e8fe 100644 --- a/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx +++ b/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx @@ -1,9 +1,10 @@ import React, { useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { renderMath } from '@pie-lib/math-rendering'; 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'; import { setToolbarOpened } from '../../utils/toolbar'; @@ -16,6 +17,7 @@ const InlineDropdown = (props) => { const toolbarRef = useRef(null); const toolbarEditor = useRef(null); const pendingCloseRequest = useRef(false); + const elementRef = useRef(null); const isHeld = () => editor._holdInlineDropdownToolbarIndex != null && @@ -30,6 +32,7 @@ const InlineDropdown = (props) => { } setShowToolbar(false); + options.onInlineDropdownToolbarClose?.(editor); }; const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, closeToolbar); @@ -93,13 +96,16 @@ const InlineDropdown = (props) => { } }, [editor, node, selected]); - - const isScrollbarClicked = (event) => event.clientX > document.documentElement.clientWidth || event.clientY > document.documentElement.clientHeight || event.target === document.documentElement; - + + useEffect(() => { + if (elementRef.current && typeof renderMath === 'function') { + renderMath(elementRef.current); + } + }, [value]); useEffect(() => { // Calculate position relative to selection @@ -113,10 +119,10 @@ const InlineDropdown = (props) => { }); const handleClickOutside = (event) => { - - if( isScrollbarClicked(event) ) { + if (isScrollbarClicked(event)) { return; } + const insideSomeEditor = event.target.closest('[data-toolbar-for]'); if ( @@ -151,6 +157,7 @@ const InlineDropdown = (props) => { }} >
{ attrs: { latex: 'x^2', }, + nodeSize: 1, }; let defaultProps; @@ -429,6 +430,7 @@ describe('MathNodeView', () => { editor: createMockEditor(), selected: false, options: {}, + getPos: jest.fn(() => 0), }; }); diff --git a/packages/editable-html-tip-tap/src/extensions/math.js b/packages/editable-html-tip-tap/src/extensions/math.js index 8b7cc2859..93bf45002 100644 --- a/packages/editable-html-tip-tap/src/extensions/math.js +++ b/packages/editable-html-tip-tap/src/extensions/math.js @@ -201,7 +201,7 @@ export const MathNode = Node.create({ }); export const MathNodeView = (props) => { - const { node, updateAttributes, editor, selected, options } = props; + const { node, updateAttributes, editor, selected, options, getPos } = props; const [showToolbar, setShowToolbar] = useState(selected); const toolbarRef = useRef(null); const nodeRef = useRef(null); @@ -222,16 +222,29 @@ export const MathNodeView = (props) => { updateAttributes({ latex: newLatex }); }; - const handleDone = (newLatex) => { + // moveCursorAfterNode is set explicitly by the caller (not inferred from + // editor.state.selection: every keystroke while editing already calls + // updateAttributes, which replaces this node's content and — per a + // ProseMirror mapping quirk — collapses any NodeSelection on it into a + // plain TextSelection right away, so the live selection can't be trusted + // to still describe this node by the time handleDone runs). + // + // - Check icon: always move the cursor to just after this node. + // - Clicking elsewhere in the editable content while the toolbar was + // open (see handleClickOutside): leave the cursor where the user + // actually clicked instead of overriding it. + const handleDone = (newLatex, { moveCursorAfterNode = true } = {}) => { updateAttributes({ latex: newLatex }); setShowToolbar(false); - const { selection, tr, doc } = editor.state; - const sel = TextSelection.create(doc, selection.from + 1); + if (moveCursorAfterNode && typeof getPos === 'function') { + const pos = getPos(); + const { doc } = editor.state; + const sel = TextSelection.create(doc, pos + node.nodeSize); + const tr = editor.state.tr.setSelection(sel); + editor.view.dispatch(tr); + } - // Build a fresh transaction from the current state and set the selection - tr.setSelection(sel); - editor.view.dispatch(tr); editor.commands.focus(); }; @@ -362,7 +375,16 @@ export const MathNodeView = (props) => { !clickedMathNode ) { setShowToolbar(false); - handleDone(node.attrs.latex); + + // If the click landed inside the editable content itself, respect + // it and leave the cursor where the user clicked. If it landed + // fully outside the editor (e.g. on other page UI), there's no + // click position to preserve — falling back to "leave selection + // untouched" there renders the browser's native NodeSelection + // fallback caret at the start of the node, so explicitly move the + // cursor after it instead, same as the check-icon path. + const clickedInsideEditableContent = !!editor?.view?.dom?.contains(target); + handleDone(node.attrs.latex, { moveCursorAfterNode: !clickedInsideEditableContent }); } }; diff --git a/packages/mask-markup/src/components/__tests__/blank.test.js b/packages/mask-markup/src/components/__tests__/blank.test.js index de20ad348..65954e66d 100644 --- a/packages/mask-markup/src/components/__tests__/blank.test.js +++ b/packages/mask-markup/src/components/__tests__/blank.test.js @@ -152,8 +152,8 @@ describe('Blank', () => { const chip = wrapper && wrapper.firstChild; // StyledChip (rootRef) // Width and height should include padding (24px) around measured content - expect(chip.style.width).toBe('124px'); - expect(chip.style.height).toBe('44px'); + expect(chip.style.width).toBe('129px'); + expect(chip.style.height).toBe('49px'); rectSpy.mockRestore(); jest.useRealTimers(); diff --git a/packages/mask-markup/src/components/blank.jsx b/packages/mask-markup/src/components/blank.jsx index 5413ded7c..5c42afae8 100644 --- a/packages/mask-markup/src/components/blank.jsx +++ b/packages/mask-markup/src/components/blank.jsx @@ -144,16 +144,21 @@ function BlankContent({ const measureNode = getMeasureNode(); const node = measureNode || spanRef.current; const rect = node.getBoundingClientRect(); - const width = rect.width || node.offsetWidth || 0; + const width = node.offsetWidth || rect.width || 0; const height = Math.max( - rect.height || 0, node.offsetHeight || 0, + rect.height || 0, node.scrollHeight || 0, spanRef.current.scrollHeight || 0, ); - const widthWithPadding = width + 24; // 12px padding on each side - const heightWithPadding = height + 24; // 12px padding on top and bottom + const PADDING = 12; + const BORDER_WIDTH = 2; + const ADDITIONAL_SPACE = 1; + // padding and border on each side + const widthWithPadding = width + 2 * PADDING + 2 * BORDER_WIDTH + ADDITIONAL_SPACE; + // padding and border on top and bottom + const heightWithPadding = height + 2 * PADDING + 2 * BORDER_WIDTH + ADDITIONAL_SPACE; const responseAreaWidth = parseFloat(emptyResponseAreaWidth) || 0; const responseAreaHeight = parseFloat(emptyResponseAreaHeight) || 0; @@ -161,6 +166,7 @@ function BlankContent({ const adjustedWidth = widthWithPadding <= responseAreaWidth ? responseAreaWidth : widthWithPadding; const adjustedHeight = heightWithPadding <= responseAreaHeight ? responseAreaHeight : heightWithPadding; + setDimensions((prevState) => ({ width: adjustedWidth > responseAreaWidth ? adjustedWidth : prevState.width, height: adjustedHeight > responseAreaHeight ? adjustedHeight : prevState.height,