Skip to content
Merged
34 changes: 34 additions & 0 deletions packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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 = '<p>from inline dropdown close</p>';

render(
<EditableHtml
{...defaultProps}
markup="<p>Hello World</p>"
onChange={onChange}
onDone={onDone}
toolbarOpts={{ doneOn: 'blur' }}
responseAreaProps={{ type: 'inline-dropdown' }}
/>,
);

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,
Expand Down
48 changes: 32 additions & 16 deletions packages/editable-html-tip-tap/src/components/EditableHtml.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};

Expand All @@ -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({
Expand Down Expand Up @@ -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 || {},
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="node-view-wrapper" {...props}>
Expand Down Expand Up @@ -104,6 +109,32 @@ describe('InlineDropdown', () => {
expect(valueDiv).toBeInTheDocument();
});

it('renders math inside the value control on mount', () => {
const { container } = render(<InlineDropdown {...defaultProps} />);
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: '<span>Updated math</span>',
},
};

const { container, rerender } = render(<InlineDropdown {...defaultProps} />);

renderMath.mockClear();

rerender(<InlineDropdown {...defaultProps} node={updatedNode} />);

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(<InlineDropdown {...defaultProps} />);
const valueDiv = container.querySelector('div[style*="border"]');
Expand Down Expand Up @@ -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(<InlineDropdown {...defaultProps} options={options} selected={true} />);

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 = {
Expand Down Expand Up @@ -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(<InlineDropdown {...defaultProps} selected />);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 &&
Expand All @@ -30,6 +32,7 @@ const InlineDropdown = (props) => {
}

setShowToolbar(false);
options.onInlineDropdownToolbarClose?.(editor);
};

const InlineDropdownToolbar = options.respAreaToolbar([node, pos], editor, closeToolbar);
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -151,6 +157,7 @@ const InlineDropdown = (props) => {
}}
>
<div
ref={elementRef}
style={{
display: 'inline-flex',
minWidth: '178px',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ describe('MathNodeView', () => {
attrs: {
latex: 'x^2',
},
nodeSize: 1,
};

let defaultProps;
Expand All @@ -429,6 +430,7 @@ describe('MathNodeView', () => {
editor: createMockEditor(),
selected: false,
options: {},
getPos: jest.fn(() => 0),
};
});

Expand Down
38 changes: 30 additions & 8 deletions packages/editable-html-tip-tap/src/extensions/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
};

Expand Down Expand Up @@ -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 });
}
};

Expand Down
4 changes: 2 additions & 2 deletions packages/mask-markup/src/components/__tests__/blank.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading