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 = '
@@ -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,