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 9d72abb09..5e6636452 100644
--- a/packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx
+++ b/packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx
@@ -266,4 +266,39 @@ describe('EditableHtml', () => {
const { container } = render();
expect(container).toBeInTheDocument();
});
+
+ it('calls editorRef callback when editor is initialized', async () => {
+ const editorRef = jest.fn();
+ render();
+
+ await waitFor(() => {
+ expect(editorRef).toHaveBeenCalled();
+ });
+ });
+
+ it('calls editorRef with the editor instance', async () => {
+ const editorRef = jest.fn();
+ render();
+
+ await waitFor(() => {
+ expect(editorRef).toHaveBeenCalled();
+ // Verify it was called with an object that has editor-like properties
+ const callArg = editorRef.mock.calls[0][0];
+ expect(callArg).toHaveProperty('getHTML');
+ expect(callArg).toHaveProperty('commands');
+ });
+ });
+
+ it('handles editorRef being undefined', () => {
+ const { container } = render();
+ expect(container).toBeInTheDocument();
+ });
+
+ it('applies flex display to StyledEditorContent', async () => {
+ const { getByTestId } = render();
+ await waitFor(() => {
+ const editorContent = getByTestId('editor-content');
+ expect(editorContent).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/editable-html-tip-tap/src/components/CharacterPicker.jsx b/packages/editable-html-tip-tap/src/components/CharacterPicker.jsx
index 572b71f36..482e540b2 100644
--- a/packages/editable-html-tip-tap/src/components/CharacterPicker.jsx
+++ b/packages/editable-html-tip-tap/src/components/CharacterPicker.jsx
@@ -121,6 +121,7 @@ export function CharacterPicker({ editor, opts, onClose }) {
{
[props.charactersLimit],
);
+ useEffect(() => {
+ if (props.editorRef) {
+ props.editorRef(editor);
+ }
+ }, [props.editorRef, editor]);
+
useEffect(() => {
editor?.setEditable(!props.disabled);
}, [props.disabled, editor]);
@@ -349,8 +355,10 @@ export const EditableHtml = (props) => {
const StyledEditorContent = styled(EditorContent, {
shouldForwardProp: (prop) => !['showParagraph', 'separateParagraph'].includes(prop),
})(({ showParagraph, separateParagraph }) => ({
+ display: 'flex',
outline: 'none !important',
'& .ProseMirror': {
+ flex: 1,
padding: '5px',
maxHeight: '500px',
outline: 'none !important',
diff --git a/packages/editable-html-tip-tap/src/components/MenuBar.jsx b/packages/editable-html-tip-tap/src/components/MenuBar.jsx
index c82acea0c..89a26e0f5 100644
--- a/packages/editable-html-tip-tap/src/components/MenuBar.jsx
+++ b/packages/editable-html-tip-tap/src/components/MenuBar.jsx
@@ -95,9 +95,12 @@ function MenuBar({
ctx.editor?.isActive('imageUploadNode') ||
ctx.editor?.isActive('drag_in_the_blank');
+ const hasTextSelectionInTable = selection && selection.empty === false && ctx.editor.isActive('table');
+
return {
currentNode,
hideDefaultToolbar,
+ hasTextSelectionInTable,
isFocused: ctx.editor?.isFocused,
isBold: ctx.editor.isActive('bold') ?? false,
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
@@ -162,35 +165,35 @@ function MenuBar({
{
icon:
,
onClick: (editor) => editor.chain().focus().addRowAfter().run(),
- hidden: (state) => !state.isTable,
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
isActive: (state) => state.isTable,
isDisabled: (state) => !state.canTable,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().deleteRow().run(),
- hidden: (state) => !state.isTable,
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
isActive: (state) => state.isTable,
isDisabled: (state) => !state.canTable,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().addColumnAfter().run(),
- hidden: (state) => !state.isTable,
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
isActive: (state) => state.isTable,
isDisabled: (state) => !state.canTable,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().deleteColumn().run(),
- hidden: (state) => !state.isTable,
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
isActive: (state) => state.isTable,
isDisabled: (state) => !state.canTable,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().deleteTable().run(),
- hidden: (state) => !state.isTable,
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
isActive: (state) => state.isTable,
isDisabled: (state) => !state.canTable,
},
@@ -206,54 +209,54 @@ function MenuBar({
editor.commands.updateAttributes('table', update);
},
- hidden: (state) => !state.isTable,
+ hidden: (state) => !(state.isTable && !state.hasTextSelectionInTable),
isActive: (state) => state.tableHasBorder,
isDisabled: (state) => !state.canTable,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleBold().run(),
- hidden: (state) => !activePlugins?.includes('bold') || state.isTable,
+ hidden: () => !activePlugins?.includes('bold'),
isActive: (state) => state.isBold,
isDisabled: (state) => !state.canBold,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleItalic().run(),
- hidden: (state) => !activePlugins?.includes('italic') || state.isTable,
+ hidden: () => !activePlugins?.includes('italic'),
isActive: (state) => state.isItalic,
isDisabled: (state) => !state.canItalic,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleStrike().run(),
- hidden: (state) => !activePlugins?.includes('strikethrough') || state.isTable,
+ hidden: () => !activePlugins?.includes('strikethrough'),
isActive: (state) => state.isStrike,
isDisabled: (state) => !state.canStrike,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleCode().run(),
- hidden: (state) => !activePlugins?.includes('code') || state.isTable,
+ hidden: () => !activePlugins?.includes('code'),
isActive: (state) => state.isCode,
isDisabled: (state) => !state.canCode,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleUnderline().run(),
- hidden: (state) => !activePlugins?.includes('underline') || state.isTable,
+ hidden: () => !activePlugins?.includes('underline'),
isActive: (state) => state.isUnderline,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleSubscript().run(),
- hidden: (state) => !activePlugins?.includes('subscript') || state.isTable,
+ hidden: () => !activePlugins?.includes('subscript'),
isActive: (state) => state.isSubScript,
},
{
icon:
,
onClick: (editor) => editor.chain().focus().toggleSuperscript().run(),
- hidden: (state) => !activePlugins?.includes('superscript') || state.isTable,
+ hidden: () => !activePlugins?.includes('superscript'),
isActive: (state) => state.isSuperScript,
},
{
@@ -263,22 +266,22 @@ function MenuBar({
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('video') || state.isTable,
+ hidden: () => !activePlugins?.includes('video'),
onClick: (editor) => editor.chain().focus().insertMedia({ type: 'video' }).run(),
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('audio') || state.isTable,
+ hidden: () => !activePlugins?.includes('audio'),
onClick: (editor) => editor.chain().focus().insertMedia({ type: 'audio', tag: 'audio' }).run(),
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('css') || state.isTable,
+ hidden: () => !activePlugins?.includes('css'),
onClick: (editor) => editor.commands.openCSSClassDialog(),
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('h3') || state.isTable,
+ hidden: () => !activePlugins?.includes('h3'),
onClick: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: (state) => state.isHeading3,
},
@@ -299,30 +302,30 @@ function MenuBar({
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('text-align') || state.isTable,
+ hidden: () => !activePlugins?.includes('text-align'),
onClick: () => {},
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('bulleted-list') || state.isTable,
+ hidden: () => !activePlugins?.includes('bulleted-list'),
onClick: (editor) => editor.chain().focus().toggleBulletList().run(),
isActive: (state) => state.isBulletList,
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('numbered-list') || state.isTable,
+ hidden: () => !activePlugins?.includes('numbered-list'),
onClick: (editor) => editor.chain().focus().toggleOrderedList().run(),
isActive: (state) => state.isOrderedList,
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('undo') || state.isTable,
+ hidden: () => !activePlugins?.includes('undo'),
onClick: (editor) => editor.chain().focus().undo().run(),
isDisabled: (state) => !state.canUndo,
},
{
icon:
,
- hidden: (state) => !activePlugins?.includes('redo') || state.isTable,
+ hidden: () => !activePlugins?.includes('redo'),
onClick: (editor) => editor.chain().focus().redo().run(),
isDisabled: (state) => !state.canRedo,
},
diff --git a/packages/editable-html-tip-tap/src/components/__tests__/CharacterPicker.test.jsx b/packages/editable-html-tip-tap/src/components/__tests__/CharacterPicker.test.jsx
index c18ca4952..4909b0b79 100644
--- a/packages/editable-html-tip-tap/src/components/__tests__/CharacterPicker.test.jsx
+++ b/packages/editable-html-tip-tap/src/components/__tests__/CharacterPicker.test.jsx
@@ -194,4 +194,26 @@ describe('CharacterPicker', () => {
const dialog = container.querySelector('.insert-character-dialog');
expect(dialog).toHaveStyle({ position: 'absolute' });
});
+
+ it('adds data-toolbar-for attribute with editor instanceId', () => {
+ const editorWithInstanceId = {
+ ...mockEditor,
+ instanceId: 'editor-123',
+ };
+ const opts = {
+ characters: [['á', 'é']],
+ };
+ const { container } = render(
);
+ const dialog = container.querySelector('.insert-character-dialog');
+ expect(dialog).toHaveAttribute('data-toolbar-for', 'editor-123');
+ });
+
+ it('renders without instanceId gracefully', () => {
+ const opts = {
+ characters: [['á', 'é']],
+ };
+ const { container } = render(
);
+ const dialog = container.querySelector('.insert-character-dialog');
+ expect(dialog).toBeInTheDocument();
+ });
});
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 f1051eef8..ec61ef4f0 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
@@ -184,4 +184,153 @@ describe('InlineDropdown', () => {
}
});
});
+
+ it('passes editorCallback to InlineDropdownToolbar', async () => {
+ const mockToolbarComponent = jest.fn(({ editorCallback }) => {
+ editorCallback?.({ instanceId: 'test-instance' });
+ return
Toolbar
;
+ });
+
+ const mockOptionsWithCallback = {
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
+ };
+
+ const { queryByTestId } = render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
+ });
+ });
+
+ it('stores toolbar editor instance in ref when editorCallback is called', async () => {
+ let capturedCallback;
+ const mockToolbarComponent = ({ editorCallback }) => {
+ capturedCallback = editorCallback;
+ return
Toolbar
;
+ };
+
+ const mockOptionsWithCallback = {
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
+ };
+
+ const { queryByTestId } = render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
+ });
+
+ // Verify callback exists
+ expect(capturedCallback).toBeDefined();
+ });
+
+ it('handles click outside logic with data-toolbar-for attribute', async () => {
+ const editorWithInstanceId = {
+ ...mockEditor,
+ instanceId: 'editor-123',
+ _toolbarOpened: false,
+ };
+
+ // Mock the toolbar callback to set the toolbar editor instance
+ let capturedCallback;
+ const mockToolbarComponent = ({ editorCallback }) => {
+ React.useEffect(() => {
+ capturedCallback = editorCallback;
+ if (editorCallback) {
+ editorCallback({ instanceId: 'editor-123' });
+ }
+ }, [editorCallback]);
+ return
Toolbar
;
+ };
+
+ const mockOptionsWithCallback = {
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
+ };
+
+ const { container, queryByTestId } = render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
+ });
+
+ // Create an element with data-toolbar-for attribute
+ const otherToolbar = document.createElement('div');
+ otherToolbar.setAttribute('data-toolbar-for', 'editor-456');
+ document.body.appendChild(otherToolbar);
+
+ await waitFor(() => {
+ fireEvent.mouseDown(otherToolbar);
+ });
+
+ // Cleanup
+ document.body.removeChild(otherToolbar);
+ expect(container).toBeInTheDocument();
+ });
+
+ it('does not close when clicking inside same editor toolbar', async () => {
+ const editorWithInstanceId = {
+ ...mockEditor,
+ instanceId: 'editor-123',
+ _toolbarOpened: false,
+ };
+
+ let toolbarEditorInstance;
+ const mockToolbarComponent = ({ editorCallback }) => {
+ React.useEffect(() => {
+ if (editorCallback) {
+ editorCallback({ instanceId: 'editor-123' });
+ toolbarEditorInstance = { instanceId: 'editor-123' };
+ }
+ }, [editorCallback]);
+ return
Toolbar
;
+ };
+
+ const mockOptionsWithCallback = {
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
+ };
+
+ render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(mockOptionsWithCallback.respAreaToolbar).toHaveBeenCalled();
+ });
+ });
+
+ it('checks editor._toolbarOpened in click outside handler', async () => {
+ const editorWithToolbarOpened = {
+ ...mockEditor,
+ _toolbarOpened: true,
+ };
+
+ const { queryByTestId } = render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
+ });
+
+ // When _toolbarOpened is true, clicking outside should not close
+ fireEvent.mouseDown(document.body);
+
+ // Toolbar should still be visible
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
+ });
});
diff --git a/packages/editable-html-tip-tap/src/components/__tests__/MenuBar.test.jsx b/packages/editable-html-tip-tap/src/components/__tests__/MenuBar.test.jsx
index 1c01fb465..ea229bbe7 100644
--- a/packages/editable-html-tip-tap/src/components/__tests__/MenuBar.test.jsx
+++ b/packages/editable-html-tip-tap/src/components/__tests__/MenuBar.test.jsx
@@ -214,4 +214,36 @@ describe('StyledMenuBar', () => {
toolbar?.dispatchEvent(event);
expect(preventDefaultSpy).toHaveBeenCalled();
});
+
+ it('calculates hasTextSelectionInTable correctly when selection is not empty in table', () => {
+ // This test verifies the hasTextSelectionInTable state computation
+ const { container } = render(
);
+ expect(container).toBeInTheDocument();
+ });
+
+ it('hides table manipulation buttons when text is selected in table', () => {
+ // When hasTextSelectionInTable is true, table row/column buttons should be hidden
+ const { container } = render(
);
+ // The component should render but table manipulation buttons should be conditional
+ expect(container).toBeInTheDocument();
+ });
+
+ it('shows table manipulation buttons when no text is selected in table', () => {
+ // When hasTextSelectionInTable is false, table row/column buttons should be visible
+ const { container } = render(
);
+ expect(container).toBeInTheDocument();
+ });
+
+ it('shows text formatting buttons regardless of table state', () => {
+ // Bold, italic, etc. should always be visible when their plugin is active
+ const { container } = render(
);
+ const buttons = container.querySelectorAll('button');
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+
+ it('does not hide text formatting buttons when in table', () => {
+ // Verify that the removal of "|| state.isTable" condition works correctly
+ const { container } = render(
);
+ expect(container).toBeInTheDocument();
+ });
});
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 662320dc6..af5813f27 100644
--- a/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx
+++ b/packages/editable-html-tip-tap/src/components/respArea/InlineDropdown.jsx
@@ -12,6 +12,7 @@ const InlineDropdown = (props) => {
// Needed because items with values inside have different positioning for some reason
const html = value || '
 
';
const toolbarRef = useRef(null);
+ const toolbarEditor = useRef(null);
const [showToolbar, setShowToolbar] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const InlineDropdownToolbar = options.respAreaToolbar(node, editor, () => {});
@@ -41,7 +42,11 @@ const InlineDropdown = (props) => {
});
const handleClickOutside = (event) => {
+ const insideSomeEditor = event.target.closest('[data-toolbar-for]');
+
if (
+ (!insideSomeEditor || insideSomeEditor.dataset.toolbarFor !== toolbarEditor.current.instanceId) &&
+ !editor._toolbarOpened &&
toolbarRef.current &&
!toolbarRef.current.contains(event.target) &&
!event.target.closest('[data-inline-node]')
@@ -116,7 +121,11 @@ const InlineDropdown = (props) => {
{showToolbar &&
ReactDOM.createPortal(
-
+ {
+ toolbarEditor.current = instance;
+ }}
+ />
,
document.body,
)}
diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js
new file mode 100644
index 000000000..a5f6d871f
--- /dev/null
+++ b/packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js
@@ -0,0 +1,327 @@
+import React from 'react';
+import { render, waitFor, fireEvent } from '@testing-library/react';
+import { MathNode, MathNodeView } from '../math';
+
+jest.mock('@tiptap/react', () => ({
+ NodeViewWrapper: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ ReactNodeViewRenderer: jest.fn((component) => component),
+}));
+
+jest.mock('react-dom', () => ({
+ ...jest.requireActual('react-dom'),
+ createPortal: (node) => node,
+}));
+
+jest.mock('@pie-lib/math-toolbar', () => {
+ const React = require('react');
+ return {
+ MathPreview: ({ latex }) =>
{latex}
,
+ MathToolbar: ({ latex, onChange, onDone }) => {
+ const [localLatex, setLocalLatex] = React.useState(latex);
+ return (
+
+ {
+ setLocalLatex(e.target.value);
+ onChange(e.target.value);
+ }}
+ />
+
+
+ );
+ },
+ };
+});
+
+jest.mock('@pie-lib/math-rendering', () => ({
+ wrapMath: (latex, wrapper) => latex,
+}));
+
+jest.mock('@tiptap/core', () => ({
+ Node: {
+ create: jest.fn((config) => config),
+ },
+}));
+
+jest.mock('prosemirror-state', () => ({
+ Plugin: jest.fn(function (config) {
+ return config;
+ }),
+ PluginKey: jest.fn(function (key) {
+ this.key = key;
+ }),
+ TextSelection: {
+ create: jest.fn((doc, pos) => ({ type: 'text', pos })),
+ },
+ NodeSelection: {
+ create: jest.fn((doc, pos) => ({ type: 'node', pos })),
+ },
+}));
+
+describe('MathNode', () => {
+ describe('configuration', () => {
+ it('has correct name', () => {
+ expect(MathNode.name).toBe('math');
+ });
+
+ it('is inline', () => {
+ expect(MathNode.inline).toBe(true);
+ });
+
+ it('is in inline group', () => {
+ expect(MathNode.group).toBe('inline');
+ });
+
+ it('is atomic', () => {
+ expect(MathNode.atom).toBe(true);
+ });
+ });
+
+ describe('addAttributes', () => {
+ it('returns required attributes', () => {
+ const attributes = MathNode.addAttributes();
+
+ expect(attributes).toHaveProperty('latex');
+ expect(attributes).toHaveProperty('wrapper');
+ expect(attributes).toHaveProperty('html');
+
+ expect(attributes.latex).toEqual({ default: '' });
+ expect(attributes.wrapper).toEqual({ default: null });
+ expect(attributes.html).toEqual({ default: null });
+ });
+ });
+
+ describe('parseHTML', () => {
+ it('returns parsing rules for latex', () => {
+ const rules = MathNode.parseHTML();
+
+ expect(Array.isArray(rules)).toBe(true);
+ expect(rules).toHaveLength(2);
+ expect(rules[0]).toHaveProperty('tag', 'span[data-latex]');
+ });
+
+ it('returns parsing rules for mathml', () => {
+ const rules = MathNode.parseHTML();
+ expect(rules[1]).toHaveProperty('tag', 'span[data-type="mathml"]');
+ });
+ });
+
+ describe('renderHTML', () => {
+ it('renders mathml when html attribute is present', () => {
+ const result = MathNode.renderHTML({
+ HTMLAttributes: {
+ html: '
',
+ },
+ });
+
+ expect(result[0]).toBe('span');
+ expect(result[1]).toHaveProperty('data-type', 'mathml');
+ });
+
+ it('renders latex when html attribute is not present', () => {
+ const result = MathNode.renderHTML({
+ HTMLAttributes: {
+ latex: 'x^2',
+ },
+ });
+
+ expect(result[0]).toBe('span');
+ expect(result[1]).toHaveProperty('data-latex', '');
+ expect(result[1]).toHaveProperty('data-raw', 'x^2');
+ });
+ });
+
+ describe('addCommands', () => {
+ it('returns insertMath command', () => {
+ const commands = MathNode.addCommands();
+
+ expect(commands).toHaveProperty('insertMath');
+ expect(typeof commands.insertMath).toBe('function');
+ });
+ });
+
+ describe('addNodeView', () => {
+ it('returns ReactNodeViewRenderer result', () => {
+ const result = MathNode.addNodeView();
+
+ expect(result).toBeDefined();
+ });
+ });
+});
+
+describe('MathNodeView', () => {
+ const createMockEditor = () => ({
+ state: {
+ selection: {
+ from: 0,
+ to: 1,
+ },
+ tr: {
+ setSelection: jest.fn().mockReturnThis(),
+ },
+ doc: {},
+ },
+ view: {
+ coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
+ dispatch: jest.fn(),
+ },
+ commands: {
+ focus: jest.fn(),
+ },
+ instanceId: 'editor-123',
+ _toolbarOpened: false,
+ });
+
+ const mockNode = {
+ attrs: {
+ latex: 'x^2',
+ },
+ };
+
+ let defaultProps;
+
+ beforeAll(() => {
+ Object.defineProperty(document.body, 'getBoundingClientRect', {
+ value: jest.fn(() => ({ top: 0, left: 0 })),
+ configurable: true,
+ });
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ defaultProps = {
+ node: mockNode,
+ updateAttributes: jest.fn(),
+ editor: createMockEditor(),
+ selected: false,
+ options: {},
+ };
+ });
+
+ it('renders without crashing', () => {
+ const { container } = render(
);
+ expect(container).toBeInTheDocument();
+ });
+
+ it('renders NodeViewWrapper', () => {
+ const { getByTestId } = render(
);
+ expect(getByTestId('node-view-wrapper')).toBeInTheDocument();
+ });
+
+ it('displays math preview', () => {
+ const { getByTestId } = render(
);
+ expect(getByTestId('math-preview')).toBeInTheDocument();
+ });
+
+ it('shows toolbar when selected', async () => {
+ const { getByTestId } = render(
);
+ await waitFor(() => {
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
+ });
+ });
+
+ it('does not show toolbar when not selected', () => {
+ const { queryByTestId } = render(
);
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
+ });
+
+ it('adds data-toolbar-for attribute with editor instanceId', async () => {
+ const { container } = render(
);
+ await waitFor(() => {
+ const toolbar = container.querySelector('[data-toolbar-for]');
+ expect(toolbar).toHaveAttribute('data-toolbar-for', 'editor-123');
+ });
+ });
+
+ it('renders toolbar with correct position', async () => {
+ const { container } = render(
);
+ await waitFor(() => {
+ const toolbar = container.querySelector('[data-toolbar-for]');
+ expect(toolbar).toHaveStyle({ position: 'absolute' });
+ });
+ });
+
+ it('calls updateAttributes when latex changes', async () => {
+ const { getByTestId } = render(
);
+ await waitFor(() => {
+ const input = getByTestId('math-input');
+ fireEvent.change(input, { target: { value: 'y^2' } });
+ });
+ expect(defaultProps.updateAttributes).toHaveBeenCalledWith({ latex: 'y^2' });
+ });
+
+ it('closes toolbar and updates attributes when done', async () => {
+ const updateAttributes = jest.fn();
+ const { getByTestId } = render(
+
,
+ );
+
+ await waitFor(() => {
+ expect(getByTestId('done-button')).toBeInTheDocument();
+ });
+
+ const doneButton = getByTestId('done-button');
+ fireEvent.click(doneButton);
+
+ await waitFor(() => {
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
+ });
+ });
+
+ it('sets editor._toolbarOpened when toolbar is shown', async () => {
+ const { getByTestId } = render(
);
+ await waitFor(() => {
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
+ });
+ });
+
+ it('unsets editor._toolbarOpened when toolbar is closed', async () => {
+ const { getByTestId } = render(
);
+
+ await waitFor(() => {
+ expect(getByTestId('done-button')).toBeInTheDocument();
+ });
+
+ const doneButton = getByTestId('done-button');
+ fireEvent.click(doneButton);
+
+ await waitFor(() => {
+ expect(defaultProps.editor._toolbarOpened).toBe(false);
+ });
+ });
+
+ it('closes toolbar on outside click', async () => {
+ const { queryByTestId } = render(
);
+
+ await waitFor(() => {
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
+ });
+
+ fireEvent.mouseDown(document.body);
+
+ await waitFor(() => {
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
+ });
+ });
+
+ it('renders with empty latex', () => {
+ const nodeWithEmptyLatex = { attrs: { latex: '' } };
+ const { getByTestId } = render(
);
+ expect(getByTestId('math-preview')).toBeInTheDocument();
+ });
+
+ it('has correct styling on NodeViewWrapper', () => {
+ const { getByTestId } = render(
);
+ const wrapper = getByTestId('node-view-wrapper');
+ expect(wrapper).toHaveStyle({ display: 'inline-flex', cursor: 'pointer' });
+ });
+});
diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js
index 6d9866566..f97ee2eaf 100644
--- a/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js
+++ b/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js
@@ -101,6 +101,163 @@ describe('ResponseAreaExtension', () => {
expect(commands).toHaveProperty('insertResponseArea');
expect(typeof commands.insertResponseArea).toBe('function');
});
+
+ it('returns refreshResponseArea command', () => {
+ const commands = ResponseAreaExtension.addCommands();
+
+ expect(commands).toHaveProperty('refreshResponseArea');
+ expect(typeof commands.refreshResponseArea).toBe('function');
+ });
+
+ it('refreshResponseArea handles node with attrs safely', () => {
+ const context = {
+ options: {
+ type: 'explicit-constructed-response',
+ maxResponseAreas: 5,
+ },
+ };
+
+ const commands = ResponseAreaExtension.addCommands.call(context);
+ const refreshCommand = commands.refreshResponseArea();
+
+ // Mock transaction and state
+ const mockNode = {
+ attrs: {
+ index: '0',
+ value: 'test',
+ },
+ };
+
+ const mockTr = {
+ setNodeMarkup: jest.fn(),
+ setSelection: jest.fn(),
+ };
+
+ const mockState = {
+ selection: {
+ from: 0,
+ $from: {
+ nodeAfter: mockNode,
+ },
+ },
+ tr: mockTr,
+ };
+
+ const mockCommands = {
+ focus: jest.fn(),
+ };
+
+ const mockDispatch = jest.fn();
+
+ refreshCommand({
+ tr: mockTr,
+ state: mockState,
+ commands: mockCommands,
+ dispatch: mockDispatch,
+ });
+
+ expect(mockTr.setNodeMarkup).toHaveBeenCalled();
+ });
+
+ it('refreshResponseArea handles node without attrs safely (optional chaining)', () => {
+ const context = {
+ options: {
+ type: 'explicit-constructed-response',
+ maxResponseAreas: 5,
+ },
+ };
+
+ const commands = ResponseAreaExtension.addCommands.call(context);
+ const refreshCommand = commands.refreshResponseArea();
+
+ // Mock transaction and state with node that has no attrs
+ const mockNode = null;
+
+ const mockTr = {
+ setNodeMarkup: jest.fn(),
+ setSelection: jest.fn(),
+ };
+
+ const mockState = {
+ selection: {
+ from: 0,
+ $from: {
+ nodeAfter: mockNode,
+ },
+ },
+ tr: mockTr,
+ };
+
+ const mockCommands = {
+ focus: jest.fn(),
+ };
+
+ const mockDispatch = jest.fn();
+
+ // This should not throw an error due to optional chaining on node?.attrs
+ expect(() => {
+ refreshCommand({
+ tr: mockTr,
+ state: mockState,
+ commands: mockCommands,
+ dispatch: mockDispatch,
+ });
+ }).not.toThrow();
+ });
+
+ it('refreshResponseArea updates timestamp in node attributes', () => {
+ const context = {
+ options: {
+ type: 'explicit-constructed-response',
+ maxResponseAreas: 5,
+ },
+ };
+
+ const commands = ResponseAreaExtension.addCommands.call(context);
+ const refreshCommand = commands.refreshResponseArea();
+
+ const mockNode = {
+ attrs: {
+ index: '0',
+ value: 'test',
+ updated: '1234567890',
+ },
+ };
+
+ const mockTr = {
+ setNodeMarkup: jest.fn((pos, type, attrs) => {
+ // Verify that updated timestamp is being set
+ expect(attrs.updated).toBeDefined();
+ expect(attrs.updated).not.toBe('1234567890');
+ }),
+ setSelection: jest.fn(),
+ };
+
+ const mockState = {
+ selection: {
+ from: 0,
+ $from: {
+ nodeAfter: mockNode,
+ },
+ },
+ tr: mockTr,
+ };
+
+ const mockCommands = {
+ focus: jest.fn(),
+ };
+
+ const mockDispatch = jest.fn();
+
+ refreshCommand({
+ tr: mockTr,
+ state: mockState,
+ commands: mockCommands,
+ dispatch: mockDispatch,
+ });
+
+ expect(mockTr.setNodeMarkup).toHaveBeenCalled();
+ });
});
});
diff --git a/packages/editable-html-tip-tap/src/extensions/math.js b/packages/editable-html-tip-tap/src/extensions/math.js
index 53f2dbff5..e7ca8db2d 100644
--- a/packages/editable-html-tip-tap/src/extensions/math.js
+++ b/packages/editable-html-tip-tap/src/extensions/math.js
@@ -253,6 +253,7 @@ export const MathNodeView = (props) => {
ReactDOM.createPortal(