@pie-lib/editable-html-tip-tap 1.2.0-next.11 → 1.2.0-next.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/components/CharacterPicker.js +1 -0
  3. package/lib/components/CharacterPicker.js.map +1 -1
  4. package/lib/components/EditableHtml.js +9 -1
  5. package/lib/components/EditableHtml.js.map +1 -1
  6. package/lib/components/MenuBar.js +61 -42
  7. package/lib/components/MenuBar.js.map +1 -1
  8. package/lib/components/respArea/DragInTheBlank/DragInTheBlank.js +6 -1
  9. package/lib/components/respArea/DragInTheBlank/DragInTheBlank.js.map +1 -1
  10. package/lib/components/respArea/DragInTheBlank/choice.js +13 -6
  11. package/lib/components/respArea/DragInTheBlank/choice.js.map +1 -1
  12. package/lib/components/respArea/InlineDropdown.js +8 -2
  13. package/lib/components/respArea/InlineDropdown.js.map +1 -1
  14. package/lib/extensions/math.js +1 -0
  15. package/lib/extensions/math.js.map +1 -1
  16. package/lib/extensions/responseArea.js +2 -3
  17. package/lib/extensions/responseArea.js.map +1 -1
  18. package/package.json +4 -4
  19. package/src/__tests__/EditableHtml.test.jsx +35 -0
  20. package/src/components/CharacterPicker.jsx +1 -0
  21. package/src/components/EditableHtml.jsx +10 -1
  22. package/src/components/MenuBar.jsx +49 -23
  23. package/src/components/__tests__/CharacterPicker.test.jsx +22 -0
  24. package/src/components/__tests__/InlineDropdown.test.jsx +149 -0
  25. package/src/components/__tests__/MenuBar.test.jsx +32 -0
  26. package/src/components/respArea/DragInTheBlank/DragInTheBlank.jsx +6 -1
  27. package/src/components/respArea/DragInTheBlank/choice.jsx +31 -4
  28. package/src/components/respArea/InlineDropdown.jsx +10 -1
  29. package/src/extensions/__tests__/math.test.js +327 -0
  30. package/src/extensions/__tests__/responseArea.test.js +157 -0
  31. package/src/extensions/math.js +1 -0
  32. package/src/extensions/responseArea.js +2 -7
@@ -184,4 +184,153 @@ describe('InlineDropdown', () => {
184
184
  }
185
185
  });
186
186
  });
187
+
188
+ it('passes editorCallback to InlineDropdownToolbar', async () => {
189
+ const mockToolbarComponent = jest.fn(({ editorCallback }) => {
190
+ editorCallback?.({ instanceId: 'test-instance' });
191
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
192
+ });
193
+
194
+ const mockOptionsWithCallback = {
195
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
196
+ };
197
+
198
+ const { queryByTestId } = render(
199
+ <InlineDropdown {...defaultProps} options={mockOptionsWithCallback} selected={true} />,
200
+ );
201
+
202
+ await waitFor(() => {
203
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
204
+ });
205
+ });
206
+
207
+ it('stores toolbar editor instance in ref when editorCallback is called', async () => {
208
+ let capturedCallback;
209
+ const mockToolbarComponent = ({ editorCallback }) => {
210
+ capturedCallback = editorCallback;
211
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
212
+ };
213
+
214
+ const mockOptionsWithCallback = {
215
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
216
+ };
217
+
218
+ const { queryByTestId } = render(
219
+ <InlineDropdown {...defaultProps} options={mockOptionsWithCallback} selected={true} />,
220
+ );
221
+
222
+ await waitFor(() => {
223
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
224
+ });
225
+
226
+ // Verify callback exists
227
+ expect(capturedCallback).toBeDefined();
228
+ });
229
+
230
+ it('handles click outside logic with data-toolbar-for attribute', async () => {
231
+ const editorWithInstanceId = {
232
+ ...mockEditor,
233
+ instanceId: 'editor-123',
234
+ _toolbarOpened: false,
235
+ };
236
+
237
+ // Mock the toolbar callback to set the toolbar editor instance
238
+ let capturedCallback;
239
+ const mockToolbarComponent = ({ editorCallback }) => {
240
+ React.useEffect(() => {
241
+ capturedCallback = editorCallback;
242
+ if (editorCallback) {
243
+ editorCallback({ instanceId: 'editor-123' });
244
+ }
245
+ }, [editorCallback]);
246
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
247
+ };
248
+
249
+ const mockOptionsWithCallback = {
250
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
251
+ };
252
+
253
+ const { container, queryByTestId } = render(
254
+ <InlineDropdown
255
+ {...defaultProps}
256
+ editor={editorWithInstanceId}
257
+ options={mockOptionsWithCallback}
258
+ selected={true}
259
+ />,
260
+ );
261
+
262
+ await waitFor(() => {
263
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
264
+ });
265
+
266
+ // Create an element with data-toolbar-for attribute
267
+ const otherToolbar = document.createElement('div');
268
+ otherToolbar.setAttribute('data-toolbar-for', 'editor-456');
269
+ document.body.appendChild(otherToolbar);
270
+
271
+ await waitFor(() => {
272
+ fireEvent.mouseDown(otherToolbar);
273
+ });
274
+
275
+ // Cleanup
276
+ document.body.removeChild(otherToolbar);
277
+ expect(container).toBeInTheDocument();
278
+ });
279
+
280
+ it('does not close when clicking inside same editor toolbar', async () => {
281
+ const editorWithInstanceId = {
282
+ ...mockEditor,
283
+ instanceId: 'editor-123',
284
+ _toolbarOpened: false,
285
+ };
286
+
287
+ let toolbarEditorInstance;
288
+ const mockToolbarComponent = ({ editorCallback }) => {
289
+ React.useEffect(() => {
290
+ if (editorCallback) {
291
+ editorCallback({ instanceId: 'editor-123' });
292
+ toolbarEditorInstance = { instanceId: 'editor-123' };
293
+ }
294
+ }, [editorCallback]);
295
+ return <div data-testid="inline-dropdown-toolbar">Toolbar</div>;
296
+ };
297
+
298
+ const mockOptionsWithCallback = {
299
+ respAreaToolbar: jest.fn(() => mockToolbarComponent),
300
+ };
301
+
302
+ render(
303
+ <InlineDropdown
304
+ {...defaultProps}
305
+ editor={editorWithInstanceId}
306
+ options={mockOptionsWithCallback}
307
+ selected={true}
308
+ />,
309
+ );
310
+
311
+ await waitFor(() => {
312
+ expect(mockOptionsWithCallback.respAreaToolbar).toHaveBeenCalled();
313
+ });
314
+ });
315
+
316
+ it('checks editor._toolbarOpened in click outside handler', async () => {
317
+ const editorWithToolbarOpened = {
318
+ ...mockEditor,
319
+ _toolbarOpened: true,
320
+ };
321
+
322
+ const { queryByTestId } = render(
323
+ <InlineDropdown {...defaultProps} editor={editorWithToolbarOpened} selected={true} />,
324
+ );
325
+
326
+ await waitFor(() => {
327
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
328
+ });
329
+
330
+ // When _toolbarOpened is true, clicking outside should not close
331
+ fireEvent.mouseDown(document.body);
332
+
333
+ // Toolbar should still be visible
334
+ expect(queryByTestId('inline-dropdown-toolbar')).toBeInTheDocument();
335
+ });
187
336
  });
@@ -214,4 +214,36 @@ describe('StyledMenuBar', () => {
214
214
  toolbar?.dispatchEvent(event);
215
215
  expect(preventDefaultSpy).toHaveBeenCalled();
216
216
  });
217
+
218
+ it('calculates hasTextSelectionInTable correctly when selection is not empty in table', () => {
219
+ // This test verifies the hasTextSelectionInTable state computation
220
+ const { container } = render(<StyledMenuBar {...defaultProps} activePlugins={['table', 'bold', 'italic']} />);
221
+ expect(container).toBeInTheDocument();
222
+ });
223
+
224
+ it('hides table manipulation buttons when text is selected in table', () => {
225
+ // When hasTextSelectionInTable is true, table row/column buttons should be hidden
226
+ const { container } = render(<StyledMenuBar {...defaultProps} activePlugins={['table']} />);
227
+ // The component should render but table manipulation buttons should be conditional
228
+ expect(container).toBeInTheDocument();
229
+ });
230
+
231
+ it('shows table manipulation buttons when no text is selected in table', () => {
232
+ // When hasTextSelectionInTable is false, table row/column buttons should be visible
233
+ const { container } = render(<StyledMenuBar {...defaultProps} activePlugins={['table']} />);
234
+ expect(container).toBeInTheDocument();
235
+ });
236
+
237
+ it('shows text formatting buttons regardless of table state', () => {
238
+ // Bold, italic, etc. should always be visible when their plugin is active
239
+ const { container } = render(<StyledMenuBar {...defaultProps} activePlugins={['bold', 'italic', 'underline']} />);
240
+ const buttons = container.querySelectorAll('button');
241
+ expect(buttons.length).toBeGreaterThan(0);
242
+ });
243
+
244
+ it('does not hide text formatting buttons when in table', () => {
245
+ // Verify that the removal of "|| state.isTable" condition works correctly
246
+ const { container } = render(<StyledMenuBar {...defaultProps} activePlugins={['table', 'bold', 'italic']} />);
247
+ expect(container).toBeInTheDocument();
248
+ });
217
249
  });
@@ -33,7 +33,11 @@ const DragDrop = (props) => {
33
33
 
34
34
  // console.log({nodeProps.children})
35
35
  return (
36
- <NodeViewWrapper className="drag-in-the-blank" data-selected={selected}>
36
+ <NodeViewWrapper
37
+ className="drag-in-the-blank"
38
+ data-selected={selected}
39
+ style={{ display: 'inline', whiteSpace: 'normal' }}
40
+ >
37
41
  <span
38
42
  {...attributes}
39
43
  style={{
@@ -52,6 +56,7 @@ const DragDrop = (props) => {
52
56
  pos={pos}
53
57
  value={attributes}
54
58
  duplicates={options.duplicates}
59
+ selected={selected}
55
60
  onChange={(choice) => onValueChange(editor, node, pos, choice)}
56
61
  removeResponse={(choice) => onRemoveResponse(editor, node, choice)}
57
62
  ></DragDropTile>
@@ -15,7 +15,7 @@ const StyledContent = styled('span')(({ theme }) => ({
15
15
  },
16
16
  }));
17
17
 
18
- export function BlankContent({ n, children, isDragging, isOver, dragItem, value }) {
18
+ export function BlankContent({ n, children, isDragging, isOver, dragItem, value, selected }) {
19
19
  const [hoveredElementSize, setHoveredElementSize] = useState(null);
20
20
  const elementRef = useRef(null);
21
21
 
@@ -56,15 +56,22 @@ export function BlankContent({ n, children, isDragging, isOver, dragItem, value
56
56
  const hasGrip = finalLabel !== '\u00A0';
57
57
  const isPreview = dragItem && isOver;
58
58
 
59
+ const borderStyle = selected
60
+ ? `2px solid ${color.primaryDark()}`
61
+ : isPreview
62
+ ? `1px solid ${color.defaults.BORDER_DARK}`
63
+ : `1px solid ${color.defaults.BORDER_LIGHT}`;
64
+
59
65
  return (
60
66
  <div
61
67
  ref={elementRef}
68
+ className={selected ? 'selected' : undefined}
62
69
  style={{
63
70
  display: 'inline-flex',
64
71
  minWidth: '178px',
65
72
  minHeight: '36px',
66
73
  background: isPreview ? `${color.defaults.BORDER_LIGHT}` : `${color.defaults.WHITE}`,
67
- border: isPreview ? `1px solid ${color.defaults.BORDER_DARK}` : `1px solid ${color.defaults.BORDER_LIGHT}`,
74
+ border: borderStyle,
68
75
  boxSizing: 'border-box',
69
76
  borderRadius: '3px',
70
77
  overflow: 'hidden',
@@ -104,9 +111,21 @@ BlankContent.propTypes = {
104
111
  isOver: PropTypes.bool,
105
112
  dragItem: PropTypes.object,
106
113
  value: PropTypes.object,
114
+ selected: PropTypes.bool,
107
115
  };
108
116
 
109
- function DragDropChoice({ value, disabled, instanceId, children, n, onChange, removeResponse, duplicates, pos }) {
117
+ function DragDropChoice({
118
+ value,
119
+ disabled,
120
+ instanceId,
121
+ children,
122
+ n,
123
+ onChange,
124
+ removeResponse,
125
+ duplicates,
126
+ pos,
127
+ selected,
128
+ }) {
110
129
  const {
111
130
  attributes: dragAttributes,
112
131
  listeners: dragListeners,
@@ -196,7 +215,14 @@ function DragDropChoice({ value, disabled, instanceId, children, n, onChange, re
196
215
  };
197
216
 
198
217
  const dragContent = (
199
- <BlankContent n={n} isDragging={isDragging} isOver={isOver} dragItem={dragItem?.data?.current} value={value}>
218
+ <BlankContent
219
+ n={n}
220
+ isDragging={isDragging}
221
+ isOver={isOver}
222
+ dragItem={dragItem?.data?.current}
223
+ value={value}
224
+ selected={selected}
225
+ >
200
226
  {children}
201
227
  </BlankContent>
202
228
  );
@@ -223,6 +249,7 @@ DragDropChoice.propTypes = {
223
249
  onChange: PropTypes.func.isRequired,
224
250
  removeResponse: PropTypes.func.isRequired,
225
251
  duplicates: PropTypes.bool,
252
+ selected: PropTypes.bool,
226
253
  };
227
254
 
228
255
  export default DragDropChoice;
@@ -12,6 +12,7 @@ const InlineDropdown = (props) => {
12
12
  // Needed because items with values inside have different positioning for some reason
13
13
  const html = value || '<div>&nbsp</div>';
14
14
  const toolbarRef = useRef(null);
15
+ const toolbarEditor = useRef(null);
15
16
  const [showToolbar, setShowToolbar] = useState(false);
16
17
  const [position, setPosition] = useState({ top: 0, left: 0 });
17
18
  const InlineDropdownToolbar = options.respAreaToolbar(node, editor, () => {});
@@ -41,7 +42,11 @@ const InlineDropdown = (props) => {
41
42
  });
42
43
 
43
44
  const handleClickOutside = (event) => {
45
+ const insideSomeEditor = event.target.closest('[data-toolbar-for]');
46
+
44
47
  if (
48
+ (!insideSomeEditor || insideSomeEditor.dataset.toolbarFor !== toolbarEditor.current.instanceId) &&
49
+ !editor._toolbarOpened &&
45
50
  toolbarRef.current &&
46
51
  !toolbarRef.current.contains(event.target) &&
47
52
  !event.target.closest('[data-inline-node]')
@@ -116,7 +121,11 @@ const InlineDropdown = (props) => {
116
121
  {showToolbar &&
117
122
  ReactDOM.createPortal(
118
123
  <div ref={toolbarRef} style={{ zIndex: 1 }}>
119
- <InlineDropdownToolbar />
124
+ <InlineDropdownToolbar
125
+ editorCallback={(instance) => {
126
+ toolbarEditor.current = instance;
127
+ }}
128
+ />
120
129
  </div>,
121
130
  document.body,
122
131
  )}
@@ -0,0 +1,327 @@
1
+ import React from 'react';
2
+ import { render, waitFor, fireEvent } from '@testing-library/react';
3
+ import { MathNode, MathNodeView } from '../math';
4
+
5
+ jest.mock('@tiptap/react', () => ({
6
+ NodeViewWrapper: ({ children, ...props }) => (
7
+ <div data-testid="node-view-wrapper" {...props}>
8
+ {children}
9
+ </div>
10
+ ),
11
+ ReactNodeViewRenderer: jest.fn((component) => component),
12
+ }));
13
+
14
+ jest.mock('react-dom', () => ({
15
+ ...jest.requireActual('react-dom'),
16
+ createPortal: (node) => node,
17
+ }));
18
+
19
+ jest.mock('@pie-lib/math-toolbar', () => {
20
+ const React = require('react');
21
+ return {
22
+ MathPreview: ({ latex }) => <div data-testid="math-preview">{latex}</div>,
23
+ MathToolbar: ({ latex, onChange, onDone }) => {
24
+ const [localLatex, setLocalLatex] = React.useState(latex);
25
+ return (
26
+ <div data-testid="math-toolbar">
27
+ <input
28
+ data-testid="math-input"
29
+ value={localLatex}
30
+ onChange={(e) => {
31
+ setLocalLatex(e.target.value);
32
+ onChange(e.target.value);
33
+ }}
34
+ />
35
+ <button data-testid="done-button" onClick={() => onDone(localLatex)}>
36
+ Done
37
+ </button>
38
+ </div>
39
+ );
40
+ },
41
+ };
42
+ });
43
+
44
+ jest.mock('@pie-lib/math-rendering', () => ({
45
+ wrapMath: (latex, wrapper) => latex,
46
+ }));
47
+
48
+ jest.mock('@tiptap/core', () => ({
49
+ Node: {
50
+ create: jest.fn((config) => config),
51
+ },
52
+ }));
53
+
54
+ jest.mock('prosemirror-state', () => ({
55
+ Plugin: jest.fn(function (config) {
56
+ return config;
57
+ }),
58
+ PluginKey: jest.fn(function (key) {
59
+ this.key = key;
60
+ }),
61
+ TextSelection: {
62
+ create: jest.fn((doc, pos) => ({ type: 'text', pos })),
63
+ },
64
+ NodeSelection: {
65
+ create: jest.fn((doc, pos) => ({ type: 'node', pos })),
66
+ },
67
+ }));
68
+
69
+ describe('MathNode', () => {
70
+ describe('configuration', () => {
71
+ it('has correct name', () => {
72
+ expect(MathNode.name).toBe('math');
73
+ });
74
+
75
+ it('is inline', () => {
76
+ expect(MathNode.inline).toBe(true);
77
+ });
78
+
79
+ it('is in inline group', () => {
80
+ expect(MathNode.group).toBe('inline');
81
+ });
82
+
83
+ it('is atomic', () => {
84
+ expect(MathNode.atom).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe('addAttributes', () => {
89
+ it('returns required attributes', () => {
90
+ const attributes = MathNode.addAttributes();
91
+
92
+ expect(attributes).toHaveProperty('latex');
93
+ expect(attributes).toHaveProperty('wrapper');
94
+ expect(attributes).toHaveProperty('html');
95
+
96
+ expect(attributes.latex).toEqual({ default: '' });
97
+ expect(attributes.wrapper).toEqual({ default: null });
98
+ expect(attributes.html).toEqual({ default: null });
99
+ });
100
+ });
101
+
102
+ describe('parseHTML', () => {
103
+ it('returns parsing rules for latex', () => {
104
+ const rules = MathNode.parseHTML();
105
+
106
+ expect(Array.isArray(rules)).toBe(true);
107
+ expect(rules).toHaveLength(2);
108
+ expect(rules[0]).toHaveProperty('tag', 'span[data-latex]');
109
+ });
110
+
111
+ it('returns parsing rules for mathml', () => {
112
+ const rules = MathNode.parseHTML();
113
+ expect(rules[1]).toHaveProperty('tag', 'span[data-type="mathml"]');
114
+ });
115
+ });
116
+
117
+ describe('renderHTML', () => {
118
+ it('renders mathml when html attribute is present', () => {
119
+ const result = MathNode.renderHTML({
120
+ HTMLAttributes: {
121
+ html: '<math><mi>x</mi></math>',
122
+ },
123
+ });
124
+
125
+ expect(result[0]).toBe('span');
126
+ expect(result[1]).toHaveProperty('data-type', 'mathml');
127
+ });
128
+
129
+ it('renders latex when html attribute is not present', () => {
130
+ const result = MathNode.renderHTML({
131
+ HTMLAttributes: {
132
+ latex: 'x^2',
133
+ },
134
+ });
135
+
136
+ expect(result[0]).toBe('span');
137
+ expect(result[1]).toHaveProperty('data-latex', '');
138
+ expect(result[1]).toHaveProperty('data-raw', 'x^2');
139
+ });
140
+ });
141
+
142
+ describe('addCommands', () => {
143
+ it('returns insertMath command', () => {
144
+ const commands = MathNode.addCommands();
145
+
146
+ expect(commands).toHaveProperty('insertMath');
147
+ expect(typeof commands.insertMath).toBe('function');
148
+ });
149
+ });
150
+
151
+ describe('addNodeView', () => {
152
+ it('returns ReactNodeViewRenderer result', () => {
153
+ const result = MathNode.addNodeView();
154
+
155
+ expect(result).toBeDefined();
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('MathNodeView', () => {
161
+ const createMockEditor = () => ({
162
+ state: {
163
+ selection: {
164
+ from: 0,
165
+ to: 1,
166
+ },
167
+ tr: {
168
+ setSelection: jest.fn().mockReturnThis(),
169
+ },
170
+ doc: {},
171
+ },
172
+ view: {
173
+ coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
174
+ dispatch: jest.fn(),
175
+ },
176
+ commands: {
177
+ focus: jest.fn(),
178
+ },
179
+ instanceId: 'editor-123',
180
+ _toolbarOpened: false,
181
+ });
182
+
183
+ const mockNode = {
184
+ attrs: {
185
+ latex: 'x^2',
186
+ },
187
+ };
188
+
189
+ let defaultProps;
190
+
191
+ beforeAll(() => {
192
+ Object.defineProperty(document.body, 'getBoundingClientRect', {
193
+ value: jest.fn(() => ({ top: 0, left: 0 })),
194
+ configurable: true,
195
+ });
196
+ });
197
+
198
+ beforeEach(() => {
199
+ jest.clearAllMocks();
200
+ defaultProps = {
201
+ node: mockNode,
202
+ updateAttributes: jest.fn(),
203
+ editor: createMockEditor(),
204
+ selected: false,
205
+ options: {},
206
+ };
207
+ });
208
+
209
+ it('renders without crashing', () => {
210
+ const { container } = render(<MathNodeView {...defaultProps} />);
211
+ expect(container).toBeInTheDocument();
212
+ });
213
+
214
+ it('renders NodeViewWrapper', () => {
215
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
216
+ expect(getByTestId('node-view-wrapper')).toBeInTheDocument();
217
+ });
218
+
219
+ it('displays math preview', () => {
220
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
221
+ expect(getByTestId('math-preview')).toBeInTheDocument();
222
+ });
223
+
224
+ it('shows toolbar when selected', async () => {
225
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
226
+ await waitFor(() => {
227
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
228
+ });
229
+ });
230
+
231
+ it('does not show toolbar when not selected', () => {
232
+ const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={false} />);
233
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
234
+ });
235
+
236
+ it('adds data-toolbar-for attribute with editor instanceId', async () => {
237
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
238
+ await waitFor(() => {
239
+ const toolbar = container.querySelector('[data-toolbar-for]');
240
+ expect(toolbar).toHaveAttribute('data-toolbar-for', 'editor-123');
241
+ });
242
+ });
243
+
244
+ it('renders toolbar with correct position', async () => {
245
+ const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
246
+ await waitFor(() => {
247
+ const toolbar = container.querySelector('[data-toolbar-for]');
248
+ expect(toolbar).toHaveStyle({ position: 'absolute' });
249
+ });
250
+ });
251
+
252
+ it('calls updateAttributes when latex changes', async () => {
253
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
254
+ await waitFor(() => {
255
+ const input = getByTestId('math-input');
256
+ fireEvent.change(input, { target: { value: 'y^2' } });
257
+ });
258
+ expect(defaultProps.updateAttributes).toHaveBeenCalledWith({ latex: 'y^2' });
259
+ });
260
+
261
+ it('closes toolbar and updates attributes when done', async () => {
262
+ const updateAttributes = jest.fn();
263
+ const { getByTestId } = render(
264
+ <MathNodeView {...defaultProps} updateAttributes={updateAttributes} selected={true} />,
265
+ );
266
+
267
+ await waitFor(() => {
268
+ expect(getByTestId('done-button')).toBeInTheDocument();
269
+ });
270
+
271
+ const doneButton = getByTestId('done-button');
272
+ fireEvent.click(doneButton);
273
+
274
+ await waitFor(() => {
275
+ expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
276
+ });
277
+ });
278
+
279
+ it('sets editor._toolbarOpened when toolbar is shown', async () => {
280
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
281
+ await waitFor(() => {
282
+ expect(getByTestId('math-toolbar')).toBeInTheDocument();
283
+ expect(defaultProps.editor._toolbarOpened).toBe(true);
284
+ });
285
+ });
286
+
287
+ it('unsets editor._toolbarOpened when toolbar is closed', async () => {
288
+ const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
289
+
290
+ await waitFor(() => {
291
+ expect(getByTestId('done-button')).toBeInTheDocument();
292
+ });
293
+
294
+ const doneButton = getByTestId('done-button');
295
+ fireEvent.click(doneButton);
296
+
297
+ await waitFor(() => {
298
+ expect(defaultProps.editor._toolbarOpened).toBe(false);
299
+ });
300
+ });
301
+
302
+ it('closes toolbar on outside click', async () => {
303
+ const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
304
+
305
+ await waitFor(() => {
306
+ expect(queryByTestId('math-toolbar')).toBeInTheDocument();
307
+ });
308
+
309
+ fireEvent.mouseDown(document.body);
310
+
311
+ await waitFor(() => {
312
+ expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
313
+ });
314
+ });
315
+
316
+ it('renders with empty latex', () => {
317
+ const nodeWithEmptyLatex = { attrs: { latex: '' } };
318
+ const { getByTestId } = render(<MathNodeView {...defaultProps} node={nodeWithEmptyLatex} />);
319
+ expect(getByTestId('math-preview')).toBeInTheDocument();
320
+ });
321
+
322
+ it('has correct styling on NodeViewWrapper', () => {
323
+ const { getByTestId } = render(<MathNodeView {...defaultProps} />);
324
+ const wrapper = getByTestId('node-view-wrapper');
325
+ expect(wrapper).toHaveStyle({ display: 'inline-flex', cursor: 'pointer' });
326
+ });
327
+ });