@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.
@@ -78,25 +78,52 @@ export function CharacterPicker({ editor, opts, onClose }) {
78
78
  useEffect(() => {
79
79
  if (!editor) return;
80
80
 
81
- // Calculate position relative to selection
82
81
  const editorDOM = editor.options.element;
83
- const editorRect = editorDOM.getBoundingClientRect();
84
- const bodyRect = document.body.getBoundingClientRect();
85
- const { from } = editor.state.selection;
86
- const start = editor.view.coordsAtPos(from);
82
+ const editorViewDom = editor.view.dom;
87
83
 
88
- 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;
89
90
 
90
- if (editorRect.y > containerRef.current.offsetHeight) {
91
- top = top - (containerRef.current.offsetHeight + editorRect.height) - 80;
92
- }
91
+ const editorRect = editorDOM.getBoundingClientRect();
92
+ const { from } = editor.state.selection;
93
+ const start = editor.view.coordsAtPos(from);
93
94
 
94
- setPosition({
95
- top: top,
96
- left: start.left,
97
- });
95
+ const dialogHeight = containerRef.current.offsetHeight;
96
+ const dialogWidth = containerRef.current.offsetWidth;
98
97
 
99
- const editorViewDom = editor.view.dom;
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);
100
127
 
101
128
  const handleClickOutside = (e) => {
102
129
  if (containerRef.current && !containerRef.current.contains(e.target) && !editorViewDom.contains(e.target)) {
@@ -110,6 +137,9 @@ export function CharacterPicker({ editor, opts, onClose }) {
110
137
 
111
138
  return () => {
112
139
  clearTimeout(timeoutId);
140
+ if (frame !== null) cancelAnimationFrame(frame);
141
+ window.removeEventListener('scroll', scheduleUpdate, true);
142
+ window.removeEventListener('resize', scheduleUpdate);
113
143
  document.removeEventListener('click', handleClickOutside);
114
144
  };
115
145
  }, [editor]);
@@ -131,11 +161,11 @@ export function CharacterPicker({ editor, opts, onClose }) {
131
161
  data-toolbar-for={editor.instanceId}
132
162
  style={{
133
163
  visibility: position.top === 0 && position.left === 0 ? 'hidden' : 'initial',
134
- position: 'absolute',
164
+ position: 'fixed',
135
165
  top: `${position.top}px`,
136
166
  left: `${position.left}px`,
137
167
  maxWidth: '500px',
138
- zIndex: 99,
168
+ zIndex: 1000,
139
169
  }}
140
170
  >
141
171
  <div>
@@ -107,6 +107,7 @@ function MenuBar({
107
107
  hideDefaultToolbar,
108
108
  hasTextSelectionInTable,
109
109
  isFocused: ctx.editor?.isFocused,
110
+ toolbarOpened: ctx.editor?._toolbarOpened ?? false,
110
111
  isBold: ctx.editor.isActive('bold') ?? false,
111
112
  canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
112
113
  isTable: ctx.editor.isActive('table') ?? false,
@@ -148,7 +149,8 @@ function MenuBar({
148
149
  [classes.toolbarTop]: toolbarOpts.position === 'top',
149
150
  [classes.toolbarRight]: toolbarOpts.alignment === 'right',
150
151
  [classes.focused]:
151
- toolbarOpts.alwaysVisible || (editorState.isFocused && !editor._toolbarOpened && !editorState.hideDefaultToolbar),
152
+ toolbarOpts.alwaysVisible ||
153
+ (editorState.isFocused && !editorState.toolbarOpened && !editorState.hideDefaultToolbar),
152
154
  [classes.autoWidth]: autoWidth,
153
155
  [classes.fullWidth]: !autoWidth,
154
156
  [classes.hidden]: toolbarOpts.isHidden === true,
@@ -436,7 +438,7 @@ const StyledMenuBar = (props) => {
436
438
  const classes = {
437
439
  defaultToolbar: 'defaultToolbar',
438
440
  buttonsContainer: 'buttonsContainer',
439
- button: 'button',
441
+ button: 'toolbarButton',
440
442
  active: 'active',
441
443
  disabled: 'disabled',
442
444
  isActive: 'isActive',
@@ -471,7 +473,7 @@ const StyledMenuBarRoot = styled('div')(({ theme }) => ({
471
473
  display: 'flex',
472
474
  width: '100%',
473
475
  },
474
- '& .button': {
476
+ '& .toolbarButton': {
475
477
  color: 'grey',
476
478
  display: 'inline-flex',
477
479
  padding: '2px',
@@ -234,7 +234,16 @@ describe('CharacterPicker', () => {
234
234
  };
235
235
  const { container } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);
236
236
  const dialog = container.querySelector('.insert-character-dialog');
237
- 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' });
238
247
  });
239
248
 
240
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
  });
@@ -10,9 +10,11 @@ jest.mock('@tiptap/react', () => ({
10
10
  ),
11
11
  }));
12
12
 
13
+ const mockCreatePortal = jest.fn((node) => node);
14
+
13
15
  jest.mock('react-dom', () => ({
14
16
  ...jest.requireActual('react-dom'),
15
- createPortal: (node) => node,
17
+ createPortal: (...args) => mockCreatePortal(...args),
16
18
  }));
17
19
 
18
20
  describe('InlineDropdown', () => {
@@ -26,6 +28,9 @@ describe('InlineDropdown', () => {
26
28
  delete: jest.fn(),
27
29
  doc: { nodeAt: mockNodeAt },
28
30
  setSelection: jest.fn(),
31
+ setMeta: jest.fn(function setMeta() {
32
+ return mockTr;
33
+ }),
29
34
  };
30
35
  return {
31
36
  state: {
@@ -74,6 +79,7 @@ describe('InlineDropdown', () => {
74
79
 
75
80
  beforeEach(() => {
76
81
  jest.clearAllMocks();
82
+ mockCreatePortal.mockClear();
77
83
  mockEditor = buildMockEditor();
78
84
  defaultProps.editor = mockEditor;
79
85
  Object.defineProperty(document.body, 'getBoundingClientRect', {
@@ -208,14 +214,29 @@ describe('InlineDropdown', () => {
208
214
  expect(textContainer).toHaveStyle({ textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
209
215
  });
210
216
 
211
- it('renders toolbar with z-index', async () => {
212
- const { container } = render(<InlineDropdown {...defaultProps} selected={true} />);
217
+ it('portals toolbar into editor container when _tiptapContainerEl is set', async () => {
218
+ const containerEl = document.createElement('div');
219
+ const editor = buildMockEditor({ _tiptapContainerEl: containerEl });
220
+
221
+ render(<InlineDropdown {...defaultProps} editor={editor} selected={true} />);
222
+
223
+ await waitFor(() => {
224
+ expect(mockCreatePortal).toHaveBeenCalled();
225
+ });
226
+
227
+ expect(mockCreatePortal.mock.calls[0][1]).toBe(containerEl);
228
+ });
229
+
230
+ it('portals toolbar into document.body when _tiptapContainerEl is missing', async () => {
231
+ const editor = buildMockEditor({ _tiptapContainerEl: undefined });
232
+
233
+ render(<InlineDropdown {...defaultProps} editor={editor} selected={true} />);
234
+
213
235
  await waitFor(() => {
214
- const toolbarContainer = container.querySelector('div[style*="zIndex"]');
215
- if (toolbarContainer) {
216
- expect(toolbarContainer).toHaveStyle({ zIndex: '1' });
217
- }
236
+ expect(mockCreatePortal).toHaveBeenCalled();
218
237
  });
238
+
239
+ expect(mockCreatePortal.mock.calls[0][1]).toBe(document.body);
219
240
  });
220
241
 
221
242
  it('passes editorCallback to InlineDropdownToolbar', async () => {
@@ -381,7 +402,9 @@ describe('InlineDropdown', () => {
381
402
  const { findByLabelText } = render(<InlineDropdown {...defaultProps} selected />);
382
403
  fireEvent.mouseDown(await findByLabelText('Delete'));
383
404
  expect(mockEditor.state.tr.delete).toHaveBeenCalledWith(5, 6);
384
- expect(mockEditor.view.dispatch).toHaveBeenCalledWith(mockEditor.state.tr);
405
+ expect(mockEditor.view.dispatch).toHaveBeenCalledTimes(2);
406
+ expect(mockEditor.view.dispatch).toHaveBeenNthCalledWith(1, mockEditor.state.tr);
407
+ expect(mockEditor.view.dispatch).toHaveBeenNthCalledWith(2, mockEditor.state.tr);
385
408
  expect(mockEditor._toolbarOpened).toBe(false);
386
409
  expect(mockEditor.commands.focus).toHaveBeenCalled();
387
410
  });
@@ -12,6 +12,11 @@ const StyledButton = styled('button', {
12
12
  background: 'none',
13
13
  border: 'none',
14
14
  cursor: disabled ? 'not-allowed' : 'pointer',
15
+ // 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
16
+ '& svg': {
17
+ width: '24px',
18
+ height: '24px',
19
+ },
15
20
  '&:hover': {
16
21
  color: disabled ? 'grey' : 'black',
17
22
  },
@@ -3,6 +3,7 @@ import { NodeViewWrapper } from '@tiptap/react';
3
3
  import ReactDOM from 'react-dom';
4
4
  import PropTypes from 'prop-types';
5
5
  import CustomToolbarWrapper from '../../extensions/custom-toolbar-wrapper';
6
+ import { setToolbarOpened } from '../../utils/toolbar';
6
7
 
7
8
  const ExplicitConstructedResponse = (props) => {
8
9
  const { editor, node, getPos, options, selected } = props;
@@ -110,7 +111,7 @@ const ExplicitConstructedResponse = (props) => {
110
111
  tr.delete(pos, pos + node.nodeSize);
111
112
  // Prevent the debounced onBlur/onDone from firing into the
112
113
  // now-deleted node's stale position
113
- editor._toolbarOpened = false;
114
+ setToolbarOpened(editor, false);
114
115
  editor.view.dispatch(tr);
115
116
  setShowToolbar(false);
116
117
  editor.commands.focus();
@@ -5,6 +5,7 @@ import { NodeSelection } from 'prosemirror-state';
5
5
  import { Chevron } from '../icons/RespArea';
6
6
  import ReactDOM from 'react-dom';
7
7
  import CustomToolbarWrapper from '../../extensions/custom-toolbar-wrapper';
8
+ import { setToolbarOpened } from '../../utils/toolbar';
8
9
 
9
10
  const InlineDropdown = (props) => {
10
11
  const { editor, node, getPos, options, selected } = props;
@@ -174,14 +175,14 @@ const InlineDropdown = (props) => {
174
175
  {showToolbar && (
175
176
  <React.Fragment>
176
177
  {ReactDOM.createPortal(
177
- <div ref={toolbarRef} style={{ zIndex: 1 }}>
178
+ <div ref={toolbarRef}>
178
179
  <InlineDropdownToolbar
179
180
  editorCallback={(instance) => {
180
181
  toolbarEditor.current = instance;
181
182
  }}
182
183
  />
183
184
  </div>,
184
- document.body,
185
+ editor?._tiptapContainerEl || document.body,
185
186
  )}
186
187
 
187
188
  {editor._tiptapContainerEl &&
@@ -196,7 +197,7 @@ const InlineDropdown = (props) => {
196
197
  tr.delete(pos, pos + node.nodeSize);
197
198
  // Prevent the debounced onBlur/onDone from firing into the
198
199
  // now-deleted node's stale position
199
- editor._toolbarOpened = false;
200
+ setToolbarOpened(editor, false);
200
201
  delete editor._holdInlineDropdownToolbarIndex;
201
202
  editor.view.dispatch(tr);
202
203
  setShowToolbar(false);