@pie-lib/editable-html-tip-tap 2.1.4 → 2.1.6
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.
- package/CHANGELOG.md +17 -0
- package/lib/components/CharacterPicker.js +41 -16
- package/lib/components/CharacterPicker.js.map +1 -1
- package/lib/components/MenuBar.js +5 -4
- package/lib/components/MenuBar.js.map +1 -1
- package/lib/components/common/toolbar-buttons.js +5 -0
- package/lib/components/common/toolbar-buttons.js.map +1 -1
- package/lib/components/respArea/ExplicitConstructedResponse.js +2 -1
- package/lib/components/respArea/ExplicitConstructedResponse.js.map +1 -1
- package/lib/components/respArea/InlineDropdown.js +4 -6
- package/lib/components/respArea/InlineDropdown.js.map +1 -1
- package/lib/extensions/math.js +51 -37
- package/lib/extensions/math.js.map +1 -1
- package/lib/utils/toolbar.js +19 -0
- package/lib/utils/toolbar.js.map +1 -0
- package/package.json +6 -6
- package/src/components/CharacterPicker.jsx +46 -16
- package/src/components/MenuBar.jsx +5 -3
- package/src/components/__tests__/CharacterPicker.test.jsx +10 -1
- package/src/components/__tests__/ExplicitConstructedResponse.test.jsx +6 -1
- package/src/components/__tests__/InlineDropdown.test.jsx +31 -8
- package/src/components/common/toolbar-buttons.jsx +5 -0
- package/src/components/respArea/ExplicitConstructedResponse.jsx +2 -1
- package/src/components/respArea/InlineDropdown.jsx +4 -3
- package/src/extensions/__tests__/math.test.js +220 -64
- package/src/extensions/math.js +60 -39
- package/src/utils/__tests__/toolbar.test.js +43 -0
- package/src/utils/toolbar.js +15 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render, waitFor, fireEvent } from '@testing-library/react';
|
|
3
3
|
import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math';
|
|
4
|
+
import * as toolbarUtils from '../../utils/toolbar';
|
|
4
5
|
|
|
5
6
|
jest.mock('@tiptap/react', () => ({
|
|
6
7
|
NodeViewWrapper: ({ children, ...props }) => (
|
|
@@ -147,6 +148,35 @@ describe('MathNode', () => {
|
|
|
147
148
|
expect(commands).toHaveProperty('insertMath');
|
|
148
149
|
expect(typeof commands.insertMath).toBe('function');
|
|
149
150
|
});
|
|
151
|
+
|
|
152
|
+
it('insertMath opens the toolbar after inserting a math node', () => {
|
|
153
|
+
const setToolbarOpenedSpy = jest.spyOn(toolbarUtils, 'setToolbarOpened');
|
|
154
|
+
const mathNode = { type: { name: 'math' }, nodeSize: 1 };
|
|
155
|
+
const tr = {
|
|
156
|
+
insert: jest.fn().mockReturnThis(),
|
|
157
|
+
setSelection: jest.fn().mockReturnThis(),
|
|
158
|
+
doc: {},
|
|
159
|
+
};
|
|
160
|
+
const editor = {
|
|
161
|
+
view: {
|
|
162
|
+
state: {
|
|
163
|
+
schema: { nodes: { math: { create: jest.fn(() => mathNode) } } },
|
|
164
|
+
selection: { $from: { pos: 1 } },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const dispatch = jest.fn();
|
|
169
|
+
const insertMath = MathNode.addCommands().insertMath('x^2');
|
|
170
|
+
|
|
171
|
+
insertMath({ tr, editor, dispatch });
|
|
172
|
+
|
|
173
|
+
expect(tr.insert).toHaveBeenCalledWith(1, mathNode);
|
|
174
|
+
expect(tr.setSelection).toHaveBeenCalled();
|
|
175
|
+
expect(dispatch).toHaveBeenCalledWith(tr);
|
|
176
|
+
expect(setToolbarOpenedSpy).toHaveBeenCalledWith(editor, true);
|
|
177
|
+
|
|
178
|
+
setToolbarOpenedSpy.mockRestore();
|
|
179
|
+
});
|
|
150
180
|
});
|
|
151
181
|
|
|
152
182
|
describe('addNodeView', () => {
|
|
@@ -219,12 +249,32 @@ describe('EnsureTextAfterMathPlugin', () => {
|
|
|
219
249
|
});
|
|
220
250
|
|
|
221
251
|
describe('ZeroWidthSpaceHandlingPlugin', () => {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
resolve: jest.fn(() => ({
|
|
252
|
+
const createDocWithMathAndZwsp = (resolveOverrides = {}) => ({
|
|
253
|
+
resolve: jest.fn((pos) => ({
|
|
225
254
|
nodeAfter: null,
|
|
226
255
|
nodeBefore: null,
|
|
256
|
+
pos,
|
|
257
|
+
...resolveOverrides[pos],
|
|
227
258
|
})),
|
|
259
|
+
nodeAt: jest.fn((pos) => {
|
|
260
|
+
if (pos === 1) {
|
|
261
|
+
return { type: { name: 'text' }, textContent: '\u200b' };
|
|
262
|
+
}
|
|
263
|
+
if (pos === 0) {
|
|
264
|
+
return { type: { name: 'math' }, nodeSize: 1 };
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const createDocWithRegularTextBeforeCursor = () => ({
|
|
271
|
+
resolve: jest.fn((pos) => ({ nodeAfter: null, nodeBefore: null, pos })),
|
|
272
|
+
nodeAt: jest.fn((pos) => {
|
|
273
|
+
if (pos === 1) {
|
|
274
|
+
return { type: { name: 'text' }, textContent: 'a' };
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}),
|
|
228
278
|
});
|
|
229
279
|
|
|
230
280
|
const createView = ({ state: stateOverrides = {} } = {}) => {
|
|
@@ -237,66 +287,88 @@ describe('ZeroWidthSpaceHandlingPlugin', () => {
|
|
|
237
287
|
return {
|
|
238
288
|
state: {
|
|
239
289
|
selection: { from: 2, empty: true },
|
|
240
|
-
doc:
|
|
290
|
+
doc: createDocWithMathAndZwsp(),
|
|
241
291
|
tr,
|
|
242
292
|
...stateOverrides,
|
|
243
|
-
doc: { ...createDefaultDoc(), ...stateOverrides.doc },
|
|
244
293
|
},
|
|
245
294
|
dispatch,
|
|
246
295
|
};
|
|
247
296
|
};
|
|
248
297
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
298
|
+
describe('Backspace', () => {
|
|
299
|
+
it('deletes the inline node and zero-width space before the cursor', () => {
|
|
300
|
+
const view = createView();
|
|
301
|
+
const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Backspace' });
|
|
253
302
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
303
|
+
expect(handled).toBe(true);
|
|
304
|
+
expect(view.state.tr.delete).toHaveBeenCalledWith(0, 2);
|
|
305
|
+
expect(view.dispatch).toHaveBeenCalledWith(view.state.tr);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('returns false when regular text precedes the cursor', () => {
|
|
309
|
+
const view = createView({
|
|
310
|
+
state: {
|
|
311
|
+
doc: createDocWithRegularTextBeforeCursor(),
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Backspace' });
|
|
316
|
+
|
|
317
|
+
expect(handled).toBe(false);
|
|
318
|
+
expect(view.state.tr.delete).not.toHaveBeenCalled();
|
|
319
|
+
expect(view.dispatch).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
257
321
|
});
|
|
258
322
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
.mockReturnValueOnce({ pos: 4 }),
|
|
323
|
+
describe('ArrowLeft', () => {
|
|
324
|
+
it('selects the inline node before a zero-width space', () => {
|
|
325
|
+
const mathNode = { nodeSize: 3 };
|
|
326
|
+
const view = createView({
|
|
327
|
+
state: {
|
|
328
|
+
doc: createDocWithMathAndZwsp({
|
|
329
|
+
0: { nodeAfter: mathNode, nodeBefore: null, pos: 0 },
|
|
330
|
+
}),
|
|
268
331
|
},
|
|
269
|
-
}
|
|
332
|
+
});
|
|
333
|
+
const { NodeSelection } = require('prosemirror-state');
|
|
334
|
+
|
|
335
|
+
const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
|
|
336
|
+
|
|
337
|
+
expect(handled).toBe(true);
|
|
338
|
+
expect(view.state.doc.resolve).toHaveBeenCalledWith(0);
|
|
339
|
+
expect(NodeSelection.create).toHaveBeenCalledWith(view.state.doc, 0);
|
|
340
|
+
expect(view.dispatch).toHaveBeenCalled();
|
|
270
341
|
});
|
|
271
|
-
const { NodeSelection } = require('prosemirror-state');
|
|
272
342
|
|
|
273
|
-
|
|
343
|
+
it('moves the text cursor before the zero-width space when no inline node precedes it', () => {
|
|
344
|
+
const view = createView();
|
|
345
|
+
const { TextSelection } = require('prosemirror-state');
|
|
274
346
|
|
|
275
|
-
|
|
276
|
-
expect(NodeSelection.create).toHaveBeenCalledWith(view.state.doc, 4);
|
|
277
|
-
expect(view.dispatch).toHaveBeenCalled();
|
|
278
|
-
});
|
|
347
|
+
const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
|
|
279
348
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
349
|
+
expect(handled).toBe(true);
|
|
350
|
+
expect(view.state.doc.resolve).toHaveBeenCalledWith(0);
|
|
351
|
+
expect(TextSelection.create).toHaveBeenCalledWith(view.state.doc, 0);
|
|
352
|
+
expect(view.dispatch).toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('returns false when regular text precedes the cursor', () => {
|
|
356
|
+
const view = createView({
|
|
357
|
+
state: {
|
|
358
|
+
doc: createDocWithRegularTextBeforeCursor(),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
283
361
|
|
|
284
|
-
|
|
362
|
+
const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'ArrowLeft' });
|
|
285
363
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
364
|
+
expect(handled).toBe(false);
|
|
365
|
+
expect(view.state.tr.setSelection).not.toHaveBeenCalled();
|
|
366
|
+
expect(view.dispatch).not.toHaveBeenCalled();
|
|
367
|
+
});
|
|
289
368
|
});
|
|
290
369
|
|
|
291
370
|
it('returns false for unrelated keys', () => {
|
|
292
|
-
const view = createView(
|
|
293
|
-
state: {
|
|
294
|
-
doc: {
|
|
295
|
-
textBetween: jest.fn(() => 'a'),
|
|
296
|
-
resolve: jest.fn(),
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
});
|
|
371
|
+
const view = createView();
|
|
300
372
|
|
|
301
373
|
const handled = ZeroWidthSpaceHandlingPlugin.props.handleKeyDown(view, { key: 'Enter' });
|
|
302
374
|
expect(handled).toBe(false);
|
|
@@ -304,6 +376,15 @@ describe('ZeroWidthSpaceHandlingPlugin', () => {
|
|
|
304
376
|
});
|
|
305
377
|
|
|
306
378
|
describe('MathNodeView', () => {
|
|
379
|
+
const createEditorElement = (rect = { top: 0, left: 0, width: 600, height: 400 }) => {
|
|
380
|
+
const element = document.createElement('div');
|
|
381
|
+
Object.defineProperty(element, 'getBoundingClientRect', {
|
|
382
|
+
value: jest.fn(() => rect),
|
|
383
|
+
configurable: true,
|
|
384
|
+
});
|
|
385
|
+
return element;
|
|
386
|
+
};
|
|
387
|
+
|
|
307
388
|
const createMockEditor = () => ({
|
|
308
389
|
state: {
|
|
309
390
|
selection: {
|
|
@@ -312,6 +393,7 @@ describe('MathNodeView', () => {
|
|
|
312
393
|
},
|
|
313
394
|
tr: {
|
|
314
395
|
setSelection: jest.fn().mockReturnThis(),
|
|
396
|
+
setMeta: jest.fn().mockReturnThis(),
|
|
315
397
|
},
|
|
316
398
|
doc: {},
|
|
317
399
|
},
|
|
@@ -319,6 +401,9 @@ describe('MathNodeView', () => {
|
|
|
319
401
|
coordsAtPos: jest.fn(() => ({ top: 100, left: 50 })),
|
|
320
402
|
dispatch: jest.fn(),
|
|
321
403
|
},
|
|
404
|
+
options: {
|
|
405
|
+
element: createEditorElement(),
|
|
406
|
+
},
|
|
322
407
|
commands: {
|
|
323
408
|
focus: jest.fn(),
|
|
324
409
|
},
|
|
@@ -334,13 +419,6 @@ describe('MathNodeView', () => {
|
|
|
334
419
|
|
|
335
420
|
let defaultProps;
|
|
336
421
|
|
|
337
|
-
beforeAll(() => {
|
|
338
|
-
Object.defineProperty(document.body, 'getBoundingClientRect', {
|
|
339
|
-
value: jest.fn(() => ({ top: 0, left: 0 })),
|
|
340
|
-
configurable: true,
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
|
|
344
422
|
beforeEach(() => {
|
|
345
423
|
jest.clearAllMocks();
|
|
346
424
|
mockCreatePortal.mockImplementation((node) => node);
|
|
@@ -389,30 +467,29 @@ describe('MathNodeView', () => {
|
|
|
389
467
|
});
|
|
390
468
|
|
|
391
469
|
describe('toolbar positioning', () => {
|
|
392
|
-
it('
|
|
470
|
+
it('positions relative to the editor element using coordsAtPos', async () => {
|
|
393
471
|
const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
|
|
394
472
|
await waitFor(() => {
|
|
395
473
|
const toolbar = container.querySelector('[data-toolbar-for]');
|
|
396
474
|
expect(toolbar).toBeInTheDocument();
|
|
397
|
-
expect(toolbar.style.top).toBe('
|
|
475
|
+
expect(toolbar.style.top).toBe('140px');
|
|
398
476
|
expect(toolbar.style.left).toBe('50px');
|
|
399
477
|
});
|
|
400
478
|
});
|
|
401
479
|
|
|
402
|
-
it('
|
|
403
|
-
const
|
|
404
|
-
containerEl.getBoundingClientRect = jest.fn(() => ({ top: -200, left: 0, width: 600, height: 400 }));
|
|
480
|
+
it('accounts for editor scroll offset when calculating toolbar position', async () => {
|
|
481
|
+
const editorElement = createEditorElement({ top: -200, left: 0, width: 600, height: 400 });
|
|
405
482
|
|
|
406
483
|
const editor = {
|
|
407
484
|
...defaultProps.editor,
|
|
408
|
-
|
|
485
|
+
options: { element: editorElement },
|
|
409
486
|
};
|
|
410
487
|
|
|
411
488
|
const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
|
|
412
489
|
await waitFor(() => {
|
|
413
490
|
const toolbar = container.querySelector('[data-toolbar-for]');
|
|
414
491
|
expect(toolbar).toBeInTheDocument();
|
|
415
|
-
expect(toolbar.style.top).toBe('
|
|
492
|
+
expect(toolbar.style.top).toBe('340px');
|
|
416
493
|
expect(toolbar.style.left).toBe('50px');
|
|
417
494
|
});
|
|
418
495
|
});
|
|
@@ -426,7 +503,7 @@ describe('MathNodeView', () => {
|
|
|
426
503
|
});
|
|
427
504
|
});
|
|
428
505
|
|
|
429
|
-
it('updates
|
|
506
|
+
it('updates position from coordsAtPos when selection changes', async () => {
|
|
430
507
|
const editor = {
|
|
431
508
|
...defaultProps.editor,
|
|
432
509
|
view: {
|
|
@@ -440,7 +517,7 @@ describe('MathNodeView', () => {
|
|
|
440
517
|
await waitFor(() => {
|
|
441
518
|
const toolbar = container.querySelector('[data-toolbar-for]');
|
|
442
519
|
expect(toolbar).toBeInTheDocument();
|
|
443
|
-
expect(toolbar.style.top).toBe('
|
|
520
|
+
expect(toolbar.style.top).toBe('240px');
|
|
444
521
|
expect(toolbar.style.left).toBe('150px');
|
|
445
522
|
});
|
|
446
523
|
});
|
|
@@ -514,18 +591,35 @@ describe('MathNodeView', () => {
|
|
|
514
591
|
});
|
|
515
592
|
});
|
|
516
593
|
|
|
517
|
-
it('unsets editor._toolbarOpened when toolbar is closed', async () => {
|
|
594
|
+
it('unsets editor._toolbarOpened when toolbar is closed and node is not selected', async () => {
|
|
595
|
+
const { getByTestId } = render(<MathNodeView {...defaultProps} selected={false} />);
|
|
596
|
+
|
|
597
|
+
fireEvent.click(getByTestId('math-preview'));
|
|
598
|
+
|
|
599
|
+
await waitFor(() => {
|
|
600
|
+
expect(getByTestId('math-toolbar')).toBeInTheDocument();
|
|
601
|
+
expect(defaultProps.editor._toolbarOpened).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
fireEvent.click(getByTestId('done-button'));
|
|
605
|
+
|
|
606
|
+
await waitFor(() => {
|
|
607
|
+
expect(defaultProps.editor._toolbarOpened).toBe(false);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('keeps editor._toolbarOpened true while the math node remains selected', async () => {
|
|
518
612
|
const { getByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
|
|
519
613
|
|
|
520
614
|
await waitFor(() => {
|
|
521
615
|
expect(getByTestId('done-button')).toBeInTheDocument();
|
|
616
|
+
expect(defaultProps.editor._toolbarOpened).toBe(true);
|
|
522
617
|
});
|
|
523
618
|
|
|
524
|
-
|
|
525
|
-
fireEvent.click(doneButton);
|
|
619
|
+
fireEvent.click(getByTestId('done-button'));
|
|
526
620
|
|
|
527
621
|
await waitFor(() => {
|
|
528
|
-
expect(defaultProps.editor._toolbarOpened).toBe(
|
|
622
|
+
expect(defaultProps.editor._toolbarOpened).toBe(true);
|
|
529
623
|
});
|
|
530
624
|
});
|
|
531
625
|
|
|
@@ -550,7 +644,7 @@ describe('MathNodeView', () => {
|
|
|
550
644
|
expect(editor.state.tr.setSelection).toHaveBeenCalled();
|
|
551
645
|
expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
|
|
552
646
|
expect(editor.commands.focus).toHaveBeenCalled();
|
|
553
|
-
expect(editor._toolbarOpened).toBe(
|
|
647
|
+
expect(editor._toolbarOpened).toBe(true);
|
|
554
648
|
});
|
|
555
649
|
});
|
|
556
650
|
|
|
@@ -568,6 +662,68 @@ describe('MathNodeView', () => {
|
|
|
568
662
|
});
|
|
569
663
|
});
|
|
570
664
|
|
|
665
|
+
describe('unique math-node class per instance', () => {
|
|
666
|
+
let dateNowSpy;
|
|
667
|
+
|
|
668
|
+
beforeEach(() => {
|
|
669
|
+
let timestamp = 1000;
|
|
670
|
+
dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => timestamp++);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
afterEach(() => {
|
|
674
|
+
dateNowSpy.mockRestore();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('assigns a timestamp-based class to NodeViewWrapper', () => {
|
|
678
|
+
const { getByTestId } = render(<MathNodeView {...defaultProps} />);
|
|
679
|
+
const wrapper = getByTestId('node-view-wrapper');
|
|
680
|
+
|
|
681
|
+
expect(wrapper.className).toMatch(/^math-node-\d+$/);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('assigns a different class to each MathNodeView instance', () => {
|
|
685
|
+
const { getAllByTestId } = render(
|
|
686
|
+
<>
|
|
687
|
+
<MathNodeView {...defaultProps} />
|
|
688
|
+
<MathNodeView {...defaultProps} node={{ attrs: { latex: 'y^2' } }} />
|
|
689
|
+
</>,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const wrappers = getAllByTestId('node-view-wrapper');
|
|
693
|
+
expect(wrappers[0].className).toBe('math-node-1000');
|
|
694
|
+
expect(wrappers[1].className).toBe('math-node-1001');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('closes toolbar when clicking a different math node', async () => {
|
|
698
|
+
const updateAttributes = jest.fn();
|
|
699
|
+
const editorA = createMockEditor();
|
|
700
|
+
const editorB = createMockEditor();
|
|
701
|
+
|
|
702
|
+
const { getAllByTestId, queryAllByTestId } = render(
|
|
703
|
+
<>
|
|
704
|
+
<MathNodeView {...defaultProps} editor={editorA} updateAttributes={updateAttributes} selected={false} />
|
|
705
|
+
<MathNodeView {...defaultProps} editor={editorB} node={{ attrs: { latex: 'y^2' } }} selected={false} />
|
|
706
|
+
</>,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
fireEvent.click(getAllByTestId('math-preview')[0]);
|
|
710
|
+
|
|
711
|
+
await waitFor(() => {
|
|
712
|
+
expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const secondPreview = getAllByTestId('math-preview')[1];
|
|
716
|
+
fireEvent.click(secondPreview);
|
|
717
|
+
|
|
718
|
+
await waitFor(() => {
|
|
719
|
+
expect(queryAllByTestId('math-toolbar')).toHaveLength(1);
|
|
720
|
+
expect(updateAttributes).toHaveBeenCalledWith({ latex: 'x^2' });
|
|
721
|
+
expect(editorA._toolbarOpened).toBe(false);
|
|
722
|
+
expect(getAllByTestId('math-input')[0]).toHaveValue('y^2');
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
571
727
|
it('does not close toolbar when clicking equation editor dropdown', async () => {
|
|
572
728
|
const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);
|
|
573
729
|
|
package/src/extensions/math.js
CHANGED
|
@@ -5,6 +5,7 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
|
|
5
5
|
import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
|
|
6
6
|
import { MathPreview, MathToolbar } from '@pie-lib/math-toolbar';
|
|
7
7
|
import { wrapMath } from '@pie-lib/math-rendering';
|
|
8
|
+
import { setToolbarOpened } from '../utils/toolbar';
|
|
8
9
|
|
|
9
10
|
const ensureTextAfterMathPluginKey = new PluginKey('ensureTextAfterMath');
|
|
10
11
|
|
|
@@ -44,6 +45,26 @@ export const EnsureTextAfterMathPlugin = (mathNodeName) =>
|
|
|
44
45
|
},
|
|
45
46
|
});
|
|
46
47
|
|
|
48
|
+
const nodeBeforeZeroWidthSpace = (doc, from) => {
|
|
49
|
+
let i;
|
|
50
|
+
|
|
51
|
+
// finding if previous to the cursor there's a zero-width space
|
|
52
|
+
// and a non-text element, and deleting everything until the space
|
|
53
|
+
for (i = from; i > 0; i--) {
|
|
54
|
+
const currentDoc = doc.nodeAt(i);
|
|
55
|
+
|
|
56
|
+
if (currentDoc?.type?.name === 'text' && currentDoc.textContent !== '\u200b') {
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (currentDoc && currentDoc?.type?.name !== 'text') {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return i;
|
|
66
|
+
};
|
|
67
|
+
|
|
47
68
|
export const ZeroWidthSpaceHandlingPlugin = new Plugin({
|
|
48
69
|
key: new PluginKey('zeroWidthSpaceHandling'),
|
|
49
70
|
props: {
|
|
@@ -53,35 +74,38 @@ export const ZeroWidthSpaceHandlingPlugin = new Plugin({
|
|
|
53
74
|
const { from, empty } = selection;
|
|
54
75
|
|
|
55
76
|
if (empty && event.key === 'Backspace' && from > 0) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return true; // handled
|
|
77
|
+
const start = nodeBeforeZeroWidthSpace(doc, from);
|
|
78
|
+
|
|
79
|
+
if (start === -1) {
|
|
80
|
+
return false;
|
|
61
81
|
}
|
|
82
|
+
|
|
83
|
+
const tr = state.tr.delete(start, from);
|
|
84
|
+
dispatch(tr);
|
|
85
|
+
return true; // handled
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
if (empty && event.key === 'ArrowLeft' && from > 0) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
const start = nodeBeforeZeroWidthSpace(doc, from);
|
|
90
|
+
|
|
91
|
+
if (start === -1) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const resolved = state.doc.resolve(start);
|
|
96
|
+
const maybeNode = resolved.nodeAfter || resolved.nodeBefore;
|
|
97
|
+
|
|
98
|
+
// Check if there's an inline selectable node (e.g., your math node)
|
|
99
|
+
if (maybeNode) {
|
|
100
|
+
const nodeResolved = state.doc.resolve(start);
|
|
101
|
+
const tr = state.tr.setSelection(NodeSelection.create(state.doc, nodeResolved.pos));
|
|
102
|
+
dispatch(tr);
|
|
103
|
+
return true;
|
|
104
|
+
} else {
|
|
105
|
+
// Just move the text cursor before the zwsp
|
|
106
|
+
const tr = state.tr.setSelection(TextSelection.create(state.doc, from - 2));
|
|
107
|
+
dispatch(tr);
|
|
108
|
+
return true;
|
|
85
109
|
}
|
|
86
110
|
}
|
|
87
111
|
|
|
@@ -151,14 +175,9 @@ export const MathNode = Node.create({
|
|
|
151
175
|
|
|
152
176
|
dispatch(tr);
|
|
153
177
|
|
|
178
|
+
setToolbarOpened(editor, true);
|
|
154
179
|
return true;
|
|
155
180
|
},
|
|
156
|
-
// insertMath: (latex = '') => ({ commands }) => {
|
|
157
|
-
// return commands.insertContent({
|
|
158
|
-
// type: this.name,
|
|
159
|
-
// attrs: { latex },
|
|
160
|
-
// });
|
|
161
|
-
// },
|
|
162
181
|
};
|
|
163
182
|
},
|
|
164
183
|
|
|
@@ -183,6 +202,7 @@ export const MathNodeView = (props) => {
|
|
|
183
202
|
const { node, updateAttributes, editor, selected, options } = props;
|
|
184
203
|
const [showToolbar, setShowToolbar] = useState(selected);
|
|
185
204
|
const toolbarRef = useRef(null);
|
|
205
|
+
const timestamp = useRef(Date.now());
|
|
186
206
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
187
207
|
const { math: mathOptions = {} } = options || {};
|
|
188
208
|
const {
|
|
@@ -203,8 +223,6 @@ export const MathNodeView = (props) => {
|
|
|
203
223
|
updateAttributes({ latex: newLatex });
|
|
204
224
|
setShowToolbar(false);
|
|
205
225
|
|
|
206
|
-
editor._toolbarOpened = false;
|
|
207
|
-
|
|
208
226
|
const { selection, tr, doc } = editor.state;
|
|
209
227
|
const sel = TextSelection.create(doc, selection.from + 1);
|
|
210
228
|
|
|
@@ -221,16 +239,19 @@ export const MathNodeView = (props) => {
|
|
|
221
239
|
}, [selected]);
|
|
222
240
|
|
|
223
241
|
useEffect(() => {
|
|
224
|
-
editor
|
|
225
|
-
}, [showToolbar]);
|
|
242
|
+
setToolbarOpened(editor, selected || showToolbar);
|
|
243
|
+
}, [editor, showToolbar, selected]);
|
|
226
244
|
|
|
227
245
|
useEffect(() => {
|
|
228
246
|
// Calculate position relative to selection
|
|
229
247
|
const { from } = editor.state.selection;
|
|
230
248
|
const start = editor.view.coordsAtPos(from);
|
|
249
|
+
const editorDOM = editor.options.element;
|
|
250
|
+
const editorRect = editorDOM.getBoundingClientRect();
|
|
251
|
+
|
|
231
252
|
setPosition({
|
|
232
|
-
top: 40, // shift above
|
|
233
|
-
left: start.left,
|
|
253
|
+
top: start.top - editorRect.top + 40, // shift above
|
|
254
|
+
left: start.left - editorRect.left,
|
|
234
255
|
});
|
|
235
256
|
|
|
236
257
|
const handleClickOutside = (event) => {
|
|
@@ -253,7 +274,7 @@ export const MathNodeView = (props) => {
|
|
|
253
274
|
// will keep/re-open the toolbar. Without this guard, closing and then
|
|
254
275
|
// immediately clicking the math node would fire this listener in the same
|
|
255
276
|
// event cycle and close the toolbar before it could open.
|
|
256
|
-
const clickedMathNode = !!target?.closest?.(
|
|
277
|
+
const clickedMathNode = !!target?.closest?.(`.math-node-${timestamp.current}`);
|
|
257
278
|
|
|
258
279
|
if (
|
|
259
280
|
toolbarRef.current &&
|
|
@@ -281,7 +302,7 @@ export const MathNodeView = (props) => {
|
|
|
281
302
|
|
|
282
303
|
return (
|
|
283
304
|
<NodeViewWrapper
|
|
284
|
-
className=
|
|
305
|
+
className={`math-node-${timestamp.current}`}
|
|
285
306
|
style={{
|
|
286
307
|
display: 'inline-flex',
|
|
287
308
|
cursor: 'pointer',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { setToolbarOpened, TOOLBAR_OPENED_META_KEY } from '../toolbar';
|
|
2
|
+
|
|
3
|
+
describe('setToolbarOpened', () => {
|
|
4
|
+
const createEditor = (toolbarOpened = false) => {
|
|
5
|
+
const tr = {
|
|
6
|
+
setMeta: jest.fn().mockReturnThis(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
_toolbarOpened: toolbarOpened,
|
|
11
|
+
state: { tr },
|
|
12
|
+
view: {
|
|
13
|
+
dispatch: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
it('sets editor._toolbarOpened and dispatches a meta transaction', () => {
|
|
19
|
+
const editor = createEditor(false);
|
|
20
|
+
|
|
21
|
+
setToolbarOpened(editor, true);
|
|
22
|
+
|
|
23
|
+
expect(editor._toolbarOpened).toBe(true);
|
|
24
|
+
expect(editor.state.tr.setMeta).toHaveBeenCalledWith(TOOLBAR_OPENED_META_KEY, true);
|
|
25
|
+
expect(editor.view.dispatch).toHaveBeenCalledWith(editor.state.tr);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does nothing when the value is unchanged', () => {
|
|
29
|
+
const editor = createEditor(true);
|
|
30
|
+
|
|
31
|
+
setToolbarOpened(editor, true);
|
|
32
|
+
|
|
33
|
+
expect(editor.view.dispatch).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('coerces the opened value to a boolean', () => {
|
|
37
|
+
const editor = createEditor(false);
|
|
38
|
+
|
|
39
|
+
setToolbarOpened(editor, 1);
|
|
40
|
+
|
|
41
|
+
expect(editor._toolbarOpened).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const TOOLBAR_OPENED_META_KEY = 'toolbarOpenedChanged';
|
|
2
|
+
|
|
3
|
+
export const setToolbarOpened = (editor, opened) => {
|
|
4
|
+
const next = !!opened;
|
|
5
|
+
|
|
6
|
+
if (editor._toolbarOpened === next) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
editor._toolbarOpened = next;
|
|
11
|
+
|
|
12
|
+
if (editor?.view && editor?.state?.tr) {
|
|
13
|
+
editor.view.dispatch(editor.state.tr.setMeta(TOOLBAR_OPENED_META_KEY, true));
|
|
14
|
+
}
|
|
15
|
+
};
|