@pie-lib/editable-html-tip-tap 2.1.1 → 2.1.2-next.102

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 (52) hide show
  1. package/lib/components/CharacterPicker.js +51 -20
  2. package/lib/components/CharacterPicker.js.map +1 -1
  3. package/lib/components/EditableHtml.js +37 -24
  4. package/lib/components/EditableHtml.js.map +1 -1
  5. package/lib/components/MenuBar.js +15 -6
  6. package/lib/components/MenuBar.js.map +1 -1
  7. package/lib/components/TiptapContainer.js +9 -1
  8. package/lib/components/TiptapContainer.js.map +1 -1
  9. package/lib/components/common/toolbar-buttons.js +5 -0
  10. package/lib/components/common/toolbar-buttons.js.map +1 -1
  11. package/lib/components/respArea/ExplicitConstructedResponse.js +2 -1
  12. package/lib/components/respArea/ExplicitConstructedResponse.js.map +1 -1
  13. package/lib/components/respArea/InlineDropdown.js +74 -16
  14. package/lib/components/respArea/InlineDropdown.js.map +1 -1
  15. package/lib/components/respArea/inlineDropdownUtils.js +67 -0
  16. package/lib/components/respArea/inlineDropdownUtils.js.map +1 -0
  17. package/lib/extensions/css.js +17 -7
  18. package/lib/extensions/css.js.map +1 -1
  19. package/lib/extensions/image-component.js +9 -6
  20. package/lib/extensions/image-component.js.map +1 -1
  21. package/lib/extensions/math.js +179 -71
  22. package/lib/extensions/math.js.map +1 -1
  23. package/lib/extensions/responseArea.js +7 -1
  24. package/lib/extensions/responseArea.js.map +1 -1
  25. package/lib/index.js +7 -0
  26. package/lib/index.js.map +1 -1
  27. package/lib/utils/toolbar.js +19 -0
  28. package/lib/utils/toolbar.js.map +1 -0
  29. package/package.json +18 -18
  30. package/src/__tests__/EditableHtml.test.jsx +116 -2
  31. package/src/components/CharacterPicker.jsx +56 -19
  32. package/src/components/EditableHtml.jsx +43 -23
  33. package/src/components/MenuBar.jsx +15 -3
  34. package/src/components/TiptapContainer.jsx +18 -2
  35. package/src/components/__tests__/CharacterPicker.test.jsx +57 -6
  36. package/src/components/__tests__/ExplicitConstructedResponse.test.jsx +6 -1
  37. package/src/components/__tests__/InlineDropdown.test.jsx +185 -8
  38. package/src/components/__tests__/MenuBar.test.jsx +1 -0
  39. package/src/components/common/toolbar-buttons.jsx +5 -0
  40. package/src/components/respArea/ExplicitConstructedResponse.jsx +2 -1
  41. package/src/components/respArea/InlineDropdown.jsx +97 -24
  42. package/src/components/respArea/inlineDropdownUtils.js +79 -0
  43. package/src/extensions/__tests__/image-component.test.jsx +1 -0
  44. package/src/extensions/__tests__/math.test.js +481 -42
  45. package/src/extensions/__tests__/responseArea.test.js +57 -3
  46. package/src/extensions/css.js +16 -3
  47. package/src/extensions/image-component.jsx +8 -6
  48. package/src/extensions/math.js +188 -69
  49. package/src/extensions/responseArea.js +7 -1
  50. package/src/index.jsx +2 -2
  51. package/src/utils/__tests__/toolbar.test.js +43 -0
  52. package/src/utils/toolbar.js +15 -0
@@ -29,9 +29,12 @@ export function CharacterPicker({ editor, opts, onClose }) {
29
29
  }
30
30
 
31
31
  const containerRef = useRef(null);
32
+ const onCloseRef = useRef(onClose);
32
33
  const [position, setPosition] = useState({ top: 0, left: 0 });
33
34
  const [popover, setPopover] = useState(null);
34
35
 
36
+ onCloseRef.current = onClose;
37
+
35
38
  const configToUse = useMemo(() => {
36
39
  if (!opts) return spanishConfig;
37
40
 
@@ -69,31 +72,62 @@ export function CharacterPicker({ editor, opts, onClose }) {
69
72
  [],
70
73
  );
71
74
 
75
+ // Keep `onClose` out of the dependency array — parents often pass a new callback each
76
+ // render (e.g. after each keystroke), which would re-run this effect constantly. Use a
77
+ // ref so click-outside always calls the latest close handler.
72
78
  useEffect(() => {
73
79
  if (!editor) return;
74
80
 
75
- // Calculate position relative to selection
76
81
  const editorDOM = editor.options.element;
77
- const editorRect = editorDOM.getBoundingClientRect();
78
- const bodyRect = document.body.getBoundingClientRect();
79
- const { from } = editor.state.selection;
80
- const start = editor.view.coordsAtPos(from);
82
+ const editorViewDom = editor.view.dom;
81
83
 
82
- let top = editorRect.top + Math.abs(bodyRect.top) + editorRect.height + 60;
84
+ // Position is computed in viewport coordinates (the dialog uses position: fixed),
85
+ // so coordsAtPos / getBoundingClientRect values can be used directly without
86
+ // adding scroll offsets. The dialog is then clamped to the viewport so it does
87
+ // not get cut off by fixed page headers/footers.
88
+ const updatePosition = () => {
89
+ if (!containerRef.current) return;
83
90
 
84
- if (editorRect.y > containerRef.current.offsetHeight) {
85
- top = top - (containerRef.current.offsetHeight + editorRect.height) - 80;
86
- }
91
+ const editorRect = editorDOM.getBoundingClientRect();
92
+ const { from } = editor.state.selection;
93
+ const start = editor.view.coordsAtPos(from);
87
94
 
88
- setPosition({
89
- // top: start.top + Math.abs(bodyRect.top) - containerRef.current.offsetHeight - 10 + additionalTopOffset, // shift above
90
- top: top,
91
- left: start.left,
92
- });
95
+ const dialogHeight = containerRef.current.offsetHeight;
96
+ const dialogWidth = containerRef.current.offsetWidth;
97
+
98
+ // prefer below the editor; flip above when there isn't room below.
99
+ const spaceBelow = window.innerHeight - (editorRect.bottom + 60);
100
+ let top =
101
+ spaceBelow >= dialogHeight || editorRect.top < dialogHeight + 80
102
+ ? editorRect.bottom + 60
103
+ : editorRect.top - dialogHeight - 20;
104
+
105
+ let left = start.left;
106
+
107
+ const margin = 8;
108
+ top = Math.max(margin, Math.min(top, window.innerHeight - dialogHeight - margin));
109
+ left = Math.max(margin, Math.min(left, window.innerWidth - dialogWidth - margin));
110
+
111
+ setPosition({ top, left });
112
+ };
113
+
114
+ updatePosition();
115
+
116
+ let frame = null;
117
+ const scheduleUpdate = () => {
118
+ if (frame !== null) return;
119
+ frame = requestAnimationFrame(() => {
120
+ frame = null;
121
+ updatePosition();
122
+ });
123
+ };
124
+
125
+ window.addEventListener('scroll', scheduleUpdate, true);
126
+ window.addEventListener('resize', scheduleUpdate);
93
127
 
94
128
  const handleClickOutside = (e) => {
95
- if (containerRef.current && !containerRef.current.contains(e.target) && !editor.view.dom.contains(e.target)) {
96
- onClose();
129
+ if (containerRef.current && !containerRef.current.contains(e.target) && !editorViewDom.contains(e.target)) {
130
+ onCloseRef.current();
97
131
  }
98
132
  };
99
133
 
@@ -103,9 +137,12 @@ export function CharacterPicker({ editor, opts, onClose }) {
103
137
 
104
138
  return () => {
105
139
  clearTimeout(timeoutId);
140
+ if (frame !== null) cancelAnimationFrame(frame);
141
+ window.removeEventListener('scroll', scheduleUpdate, true);
142
+ window.removeEventListener('resize', scheduleUpdate);
106
143
  document.removeEventListener('click', handleClickOutside);
107
144
  };
108
- }, [editor, onClose]);
145
+ }, [editor]);
109
146
 
110
147
  const renderPopOver = (event, el) => setPopover({ anchorEl: event.currentTarget, el });
111
148
 
@@ -124,11 +161,11 @@ export function CharacterPicker({ editor, opts, onClose }) {
124
161
  data-toolbar-for={editor.instanceId}
125
162
  style={{
126
163
  visibility: position.top === 0 && position.left === 0 ? 'hidden' : 'initial',
127
- position: 'absolute',
164
+ position: 'fixed',
128
165
  top: `${position.top}px`,
129
166
  left: `${position.left}px`,
130
167
  maxWidth: '500px',
131
- zIndex: 99,
168
+ zIndex: 1000,
132
169
  }}
133
170
  >
134
171
  <div>
@@ -120,6 +120,30 @@ export const EditableHtml = (props) => {
120
120
  ...toolbarOpts,
121
121
  };
122
122
 
123
+ const commitEditorContent = useCallback(
124
+ (editor) => {
125
+ const html = editor.getHTML();
126
+
127
+ if (props.markup !== html) {
128
+ props.onChange?.(html);
129
+ }
130
+
131
+ if (toolbarOptsToUse.doneOn === 'blur') {
132
+ props.onDone?.(html);
133
+ }
134
+ },
135
+ [props.markup, props.onChange, props.onDone, toolbarOptsToUse.doneOn],
136
+ );
137
+
138
+ const responseAreaPropsToUse = useMemo(
139
+ () => ({
140
+ ...defaultResponseAreaProps,
141
+ ...props.responseAreaProps,
142
+ onInlineDropdownToolbarClose: commitEditorContent,
143
+ }),
144
+ [props.responseAreaProps, commitEditorContent],
145
+ );
146
+
123
147
  const activePluginsToUse = useMemo(() => {
124
148
  let { customPlugins, ...otherPluginProps } = props.pluginProps || {};
125
149
 
@@ -143,14 +167,14 @@ export const EditableHtml = (props) => {
143
167
  toolbar: {},
144
168
  table: {},
145
169
  responseArea: {
146
- type: props.responseAreaProps?.type,
170
+ type: responseAreaPropsToUse.type,
147
171
  },
148
172
  languageCharacters: props.languageCharactersProps,
149
173
  keyPadCharacterRef: {},
150
174
  setKeypadInteraction: {},
151
175
  media: {},
152
176
  });
153
- }, [props]);
177
+ }, [props, responseAreaPropsToUse.type]);
154
178
 
155
179
  const extensions = [
156
180
  TextAlign.configure({
@@ -183,11 +207,11 @@ export const EditableHtml = (props) => {
183
207
  TableRow,
184
208
  ExtendedTableHeader,
185
209
  ExtendedTableCell,
186
- ResponseAreaExtension.configure(props.responseAreaProps),
187
- ExplicitConstructedResponseNode.configure(props.responseAreaProps),
188
- DragInTheBlankNode.configure(props.responseAreaProps),
189
- InlineDropdownNode.configure(props.responseAreaProps),
190
- MathTemplatedNode.configure(props.responseAreaProps),
210
+ ResponseAreaExtension.configure(responseAreaPropsToUse),
211
+ ExplicitConstructedResponseNode.configure(responseAreaPropsToUse),
212
+ DragInTheBlankNode.configure(responseAreaPropsToUse),
213
+ InlineDropdownNode.configure(responseAreaPropsToUse),
214
+ MathTemplatedNode.configure(responseAreaPropsToUse),
191
215
  MathNode.configure({
192
216
  toolbarOpts: toolbarOptsToUse,
193
217
  math: props.pluginProps?.math || {},
@@ -219,7 +243,7 @@ export const EditableHtml = (props) => {
219
243
  let cb;
220
244
 
221
245
  if (scheduled && result) {
222
- // finish editing only on success
246
+ // finish editing only on success
223
247
  cb = props.onChange;
224
248
  }
225
249
 
@@ -288,29 +312,23 @@ export const EditableHtml = (props) => {
288
312
  editable: !props.disabled,
289
313
  content: normalizeInitialMarkup(props.markup),
290
314
  onUpdate: ({ editor, transaction }) => {
291
- if (transaction.isDone || props.markup !== editor.getHTML()) {
315
+ if (transaction.isDone) {
292
316
  props.onChange?.(editor.getHTML());
293
317
  }
294
318
  },
295
- onBlur: debounce(({ editor }) => {
319
+ onBlur: ({ editor }) => {
296
320
  const otherToolbarOpened =
297
321
  editor._insertingImage ||
298
322
  editor._toolbarOpened ||
299
323
  editor.isActive('inline_dropdown') ||
300
324
  editor.isActive('explicit_constructed_response');
301
325
 
302
- if (otherToolbarOpened) {
326
+ if (otherToolbarOpened || !editor.schema) {
303
327
  return;
304
328
  }
305
329
 
306
- if (props.markup !== editor.getHTML()) {
307
- props.onChange?.(editor.getHTML());
308
- }
309
-
310
- if (toolbarOptsToUse.doneOn === 'blur') {
311
- props.onDone?.(editor.getHTML());
312
- }
313
- }, 200),
330
+ commitEditorContent(editor);
331
+ },
314
332
  },
315
333
  [props.charactersLimit],
316
334
  );
@@ -332,7 +350,7 @@ export const EditableHtml = (props) => {
332
350
  const nextMarkup = normalizeInitialMarkup(props.markup);
333
351
 
334
352
  if (nextMarkup !== editor.getHTML()) {
335
- editor.commands.setContent(nextMarkup, false);
353
+ editor.commands.setContent(nextMarkup, { emitUpdate: false });
336
354
  }
337
355
  }, [props.markup, editor]);
338
356
 
@@ -406,10 +424,12 @@ const StyledEditorContent = styled(EditorContent, {
406
424
  },
407
425
 
408
426
  // Out of flow so the caret stays at the start of the block; in-flow ::before pushes the caret after the hint text.
409
- '& p.is-editor-empty, & div.is-editor-empty': {
427
+ // :only-child ensures the placeholder is hidden whenever the editor has other content (images, upload nodes, etc.)
428
+ // and covers the type+backspace edge case where Tiptap only adds is-empty (not is-editor-empty).
429
+ '& p[data-placeholder].is-empty:only-child, & div[data-placeholder].is-empty:only-child': {
410
430
  position: 'relative',
411
431
  },
412
- '& p.is-editor-empty::before, & div.is-editor-empty::before': {
432
+ '& p[data-placeholder].is-empty:only-child::before, & div[data-placeholder].is-empty:only-child::before': {
413
433
  content: 'attr(data-placeholder)',
414
434
  position: 'absolute',
415
435
  left: 0,
@@ -428,7 +448,7 @@ const StyledEditorContent = styled(EditorContent, {
428
448
  },
429
449
  }),
430
450
  ...(separateParagraph && {
431
- '& > div:has(+ div)': {
451
+ '& > p:has(+ p)': {
432
452
  marginBottom: '1em',
433
453
  },
434
454
  }),
@@ -86,6 +86,10 @@ function MenuBar({
86
86
 
87
87
  let currentNode;
88
88
 
89
+ if (!ctx.editor?.commandManager) {
90
+ return {};
91
+ }
92
+
89
93
  if (selection instanceof NodeSelection) {
90
94
  currentNode = selection.node; // the selected node
91
95
  }
@@ -103,6 +107,7 @@ function MenuBar({
103
107
  hideDefaultToolbar,
104
108
  hasTextSelectionInTable,
105
109
  isFocused: ctx.editor?.isFocused,
110
+ toolbarOpened: ctx.editor?._toolbarOpened ?? false,
106
111
  isBold: ctx.editor.isActive('bold') ?? false,
107
112
  canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
108
113
  isTable: ctx.editor.isActive('table') ?? false,
@@ -143,7 +148,9 @@ function MenuBar({
143
148
  [classes.toolbarWithNoDone]: !hasDoneButton,
144
149
  [classes.toolbarTop]: toolbarOpts.position === 'top',
145
150
  [classes.toolbarRight]: toolbarOpts.alignment === 'right',
146
- [classes.focused]: toolbarOpts.alwaysVisible || (editorState.isFocused && !editor._toolbarOpened && !editorState.hideDefaultToolbar),
151
+ [classes.focused]:
152
+ toolbarOpts.alwaysVisible ||
153
+ (editorState.isFocused && !editorState.toolbarOpened && !editorState.hideDefaultToolbar),
147
154
  [classes.autoWidth]: autoWidth,
148
155
  [classes.fullWidth]: !autoWidth,
149
156
  [classes.hidden]: toolbarOpts.isHidden === true,
@@ -431,7 +438,7 @@ const StyledMenuBar = (props) => {
431
438
  const classes = {
432
439
  defaultToolbar: 'defaultToolbar',
433
440
  buttonsContainer: 'buttonsContainer',
434
- button: 'button',
441
+ button: 'toolbarButton',
435
442
  active: 'active',
436
443
  disabled: 'disabled',
437
444
  isActive: 'isActive',
@@ -466,13 +473,18 @@ const StyledMenuBarRoot = styled('div')(({ theme }) => ({
466
473
  display: 'flex',
467
474
  width: '100%',
468
475
  },
469
- '& .button': {
476
+ '& .toolbarButton': {
470
477
  color: 'grey',
471
478
  display: 'inline-flex',
472
479
  padding: '2px',
473
480
  background: 'none',
474
481
  border: 'none',
475
482
  cursor: 'pointer',
483
+ // previously we had implicit 24×24 icon rendering for mui svg icons, but now we need to explicitly set the size to 24×24 to match the previous behavior
484
+ '& svg': {
485
+ width: '24px',
486
+ height: '24px',
487
+ },
476
488
  '&:hover': {
477
489
  color: 'black',
478
490
  },
@@ -168,12 +168,28 @@ function TiptapContainer(props) {
168
168
 
169
169
  el.style.visibility = 'hidden';
170
170
  el.style.position = 'absolute';
171
+ el.style.whiteSpace = 'nowrap';
171
172
  el.textContent = 'W'.repeat(props.charactersLimit);
172
173
 
173
- rootRef.current.appendChild(el);
174
+ // Copy font styles from the editor container so the width measurement reflects the actual font
175
+ const computedStyles = window.getComputedStyle(rootRef.current);
176
+ const fontStyleProps = [
177
+ 'fontFamily',
178
+ 'fontSize',
179
+ 'fontWeight',
180
+ 'fontStyle',
181
+ 'letterSpacing',
182
+ 'wordSpacing',
183
+ 'textTransform',
184
+ 'lineHeight',
185
+ ];
186
+
187
+ fontStyleProps.forEach((prop) => {
188
+ el.style[prop] = computedStyles[prop];
189
+ });
174
190
 
191
+ document.body.appendChild(el);
175
192
  setAdjustedWidth(`${el.offsetWidth + 27}px`);
176
-
177
193
  el.remove();
178
194
  }
179
195
  }, [props.adjustWidthForLimit, props.charactersLimit]);
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { fireEvent, render, waitFor } from '@testing-library/react';
2
+ import { act, fireEvent, render, waitFor } from '@testing-library/react';
3
3
  import { CharacterIcon, CharacterPicker } from '../CharacterPicker';
4
4
 
5
5
  jest.mock('react-dom', () => ({
@@ -128,13 +128,55 @@ describe('CharacterPicker', () => {
128
128
  };
129
129
  render(<CharacterPicker editor={mockEditor} opts={opts} onClose={onClose} />);
130
130
 
131
+ await act(async () => {
132
+ await new Promise((r) => setTimeout(r, 0));
133
+ });
134
+ fireEvent.click(document.body);
135
+
131
136
  await waitFor(() => {
132
- setTimeout(() => {
133
- fireEvent.click(document.body);
134
- }, 0);
137
+ expect(onClose).toHaveBeenCalled();
135
138
  });
139
+ });
136
140
 
137
- expect(onClose).toHaveBeenCalled();
141
+ it('does not re-run positioning when only onClose reference changes', async () => {
142
+ const opts = {
143
+ characters: [['á', 'é']],
144
+ };
145
+ const getRect = mockEditor.options.element.getBoundingClientRect;
146
+ const { rerender } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);
147
+
148
+ await act(async () => {
149
+ await new Promise((r) => setTimeout(r, 0));
150
+ });
151
+
152
+ const callsAfterMount = getRect.mock.calls.length;
153
+ expect(callsAfterMount).toBeGreaterThan(0);
154
+
155
+ rerender(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);
156
+
157
+ expect(getRect.mock.calls.length).toBe(callsAfterMount);
158
+ });
159
+
160
+ it('outside click invokes the latest onClose after rerender', async () => {
161
+ const opts = {
162
+ characters: [['á', 'é']],
163
+ };
164
+ const onCloseFirst = jest.fn();
165
+ const { rerender } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={onCloseFirst} />);
166
+
167
+ await act(async () => {
168
+ await new Promise((r) => setTimeout(r, 0));
169
+ });
170
+
171
+ const onCloseSecond = jest.fn();
172
+ rerender(<CharacterPicker editor={mockEditor} opts={opts} onClose={onCloseSecond} />);
173
+
174
+ fireEvent.click(document.body);
175
+
176
+ await waitFor(() => {
177
+ expect(onCloseSecond).toHaveBeenCalled();
178
+ });
179
+ expect(onCloseFirst).not.toHaveBeenCalled();
138
180
  });
139
181
 
140
182
  it('does not close when clicking inside picker', async () => {
@@ -192,7 +234,16 @@ describe('CharacterPicker', () => {
192
234
  };
193
235
  const { container } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);
194
236
  const dialog = container.querySelector('.insert-character-dialog');
195
- expect(dialog).toHaveStyle({ position: 'absolute' });
237
+ expect(dialog).toHaveStyle({ position: 'fixed' });
238
+ });
239
+
240
+ it('renders above other editor overlays with a high z-index', () => {
241
+ const opts = {
242
+ characters: [['á']],
243
+ };
244
+ const { container } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);
245
+ const dialog = container.querySelector('.insert-character-dialog');
246
+ expect(dialog).toHaveStyle({ zIndex: '1000' });
196
247
  });
197
248
 
198
249
  it('adds data-toolbar-for attribute with editor instanceId', () => {
@@ -19,6 +19,9 @@ describe('ExplicitConstructedResponse', () => {
19
19
  const buildMockEditor = (overrides = {}) => {
20
20
  const mockTr = {
21
21
  delete: jest.fn(),
22
+ setMeta: jest.fn(function setMeta() {
23
+ return mockTr;
24
+ }),
22
25
  };
23
26
  return {
24
27
  state: {
@@ -197,7 +200,9 @@ describe('ExplicitConstructedResponse', () => {
197
200
  const { findByLabelText } = render(<ExplicitConstructedResponse {...defaultProps} selected />);
198
201
  fireEvent.mouseDown(await findByLabelText('Delete'));
199
202
  expect(mockEditor.state.tr.delete).toHaveBeenCalledWith(5, 6);
200
- expect(mockEditor.view.dispatch).toHaveBeenCalledWith(mockEditor.state.tr);
203
+ expect(mockEditor.view.dispatch).toHaveBeenCalledTimes(2);
204
+ expect(mockEditor.view.dispatch).toHaveBeenNthCalledWith(1, mockEditor.state.tr);
205
+ expect(mockEditor.view.dispatch).toHaveBeenNthCalledWith(2, mockEditor.state.tr);
201
206
  expect(mockEditor._toolbarOpened).toBe(false);
202
207
  expect(mockEditor.commands.focus).toHaveBeenCalled();
203
208
  });