@pie-lib/editable-html-tip-tap 1.2.0-next.21 → 1.2.0-next.23

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.
@@ -258,6 +258,140 @@ describe('ResponseAreaExtension', () => {
258
258
 
259
259
  expect(mockTr.setNodeMarkup).toHaveBeenCalled();
260
260
  });
261
+
262
+ describe('insertResponseArea', () => {
263
+ beforeEach(() => {
264
+ jest.resetModules();
265
+ });
266
+
267
+ const buildInsertCommand = () => {
268
+ const { ResponseAreaExtension } = require('../responseArea');
269
+ const context = {
270
+ options: {
271
+ type: 'inline-dropdown',
272
+ maxResponseAreas: 5,
273
+ },
274
+ };
275
+ const commands = ResponseAreaExtension.addCommands.call(context);
276
+ return commands.insertResponseArea('inline-dropdown');
277
+ };
278
+
279
+ const createDoc = (existingCount, typeName = 'inline_dropdown') => ({
280
+ descendants: jest.fn((callback) => {
281
+ for (let i = 0; i < existingCount; i += 1) {
282
+ callback({ type: { name: typeName } }, i);
283
+ }
284
+ }),
285
+ content: { size: 50 },
286
+ });
287
+
288
+ it('assigns index 1 and id 1 on the first insert', () => {
289
+ const insert = buildInsertCommand();
290
+ const mockInlineNode = { nodeSize: 1 };
291
+ const create = jest.fn(() => mockInlineNode);
292
+ const mockDoc = createDoc(0);
293
+ const mockTr = {
294
+ insert: jest.fn(),
295
+ doc: mockDoc,
296
+ setSelection: jest.fn(),
297
+ };
298
+ const state = {
299
+ schema: {
300
+ nodes: {
301
+ inline_dropdown: { create },
302
+ },
303
+ },
304
+ doc: mockDoc,
305
+ selection: { from: 5 },
306
+ };
307
+ const mockDispatch = jest.fn();
308
+ const mockCommands = { focus: jest.fn() };
309
+
310
+ const result = insert({
311
+ tr: mockTr,
312
+ state,
313
+ dispatch: mockDispatch,
314
+ commands: mockCommands,
315
+ });
316
+
317
+ expect(result).toBe(true);
318
+ expect(create).toHaveBeenCalledWith({
319
+ index: '1',
320
+ id: '1',
321
+ value: '',
322
+ });
323
+ expect(mockTr.insert).toHaveBeenCalledWith(5, mockInlineNode);
324
+ expect(mockDispatch).toHaveBeenCalled();
325
+ });
326
+
327
+ it('assigns consecutive indices on repeated inserts', () => {
328
+ const insert = buildInsertCommand();
329
+ const mockInlineNode = { nodeSize: 1 };
330
+ const create = jest.fn(() => mockInlineNode);
331
+ const mockDoc = createDoc(0);
332
+
333
+ const runOnce = () => {
334
+ const mockTr = {
335
+ insert: jest.fn(),
336
+ doc: mockDoc,
337
+ setSelection: jest.fn(),
338
+ };
339
+ const state = {
340
+ schema: {
341
+ nodes: {
342
+ inline_dropdown: { create },
343
+ },
344
+ },
345
+ doc: mockDoc,
346
+ selection: { from: 1 },
347
+ };
348
+ insert({
349
+ tr: mockTr,
350
+ state,
351
+ dispatch: jest.fn(),
352
+ commands: { focus: jest.fn() },
353
+ });
354
+ };
355
+
356
+ runOnce();
357
+ runOnce();
358
+
359
+ expect(create.mock.calls[0][0]).toEqual({ index: '1', id: '1', value: '' });
360
+ expect(create.mock.calls[1][0]).toEqual({ index: '2', id: '2', value: '' });
361
+ });
362
+
363
+ it('returns false when maxResponseAreas is reached', () => {
364
+ const insert = buildInsertCommand();
365
+ const mockInlineNode = { nodeSize: 1 };
366
+ const create = jest.fn(() => mockInlineNode);
367
+ const mockDoc = createDoc(5);
368
+ const mockTr = {
369
+ insert: jest.fn(),
370
+ doc: mockDoc,
371
+ setSelection: jest.fn(),
372
+ };
373
+ const state = {
374
+ schema: {
375
+ nodes: {
376
+ inline_dropdown: { create },
377
+ },
378
+ },
379
+ doc: mockDoc,
380
+ selection: { from: 5 },
381
+ };
382
+
383
+ const result = insert({
384
+ tr: mockTr,
385
+ state,
386
+ dispatch: jest.fn(),
387
+ commands: { focus: jest.fn() },
388
+ });
389
+
390
+ expect(result).toBe(false);
391
+ expect(create).not.toHaveBeenCalled();
392
+ expect(mockTr.insert).not.toHaveBeenCalled();
393
+ });
394
+ });
261
395
  });
262
396
  });
263
397
 
@@ -48,7 +48,7 @@ const SharedContainer = styled('div')({
48
48
  });
49
49
 
50
50
  function CustomToolbarWrapper(props) {
51
- const { children, deletable, toolbarOpts, autoWidth, isFocused, doneButtonRef, onDelete, showDone, onDone } = props;
51
+ const { children, deletable, toolbarOpts, autoWidth, isFocused, doneButtonRef, onDelete, showDone, onDone, style } = props;
52
52
  const customStyles = toolbarOpts.minWidth !== undefined ? { minWidth: toolbarOpts.minWidth } : {};
53
53
 
54
54
  return (
@@ -59,7 +59,7 @@ function CustomToolbarWrapper(props) {
59
59
  isFocused={toolbarOpts.alwaysVisible || isFocused}
60
60
  autoWidth={autoWidth}
61
61
  isHidden={toolbarOpts.isHidden === true}
62
- style={{ ...customStyles }}
62
+ style={{ ...customStyles, ...style }}
63
63
  >
64
64
  {children}
65
65
 
@@ -31,6 +31,46 @@ export const DivNode = Node.create({
31
31
  .splitBlock() // create another <p>
32
32
  .run();
33
33
  },
34
+
35
+ // When the cursor is in a div and the user presses Backspace,
36
+ // ProseMirror's default handler may try to join/delete the block node
37
+ // once it becomes empty. That triggers the Enter shortcut above
38
+ // (div → p conversion + split), making it look like a new line is
39
+ // inserted instead of deleting.
40
+ // We handle two cases explicitly:
41
+ // 1. The div already IS empty → swallow the event (nothing to delete).
42
+ // 2. The div has exactly ONE character left → delete just that character
43
+ // using a precise transaction, then stop. This prevents ProseMirror
44
+ // from following up with a block-join that triggers the Enter handler.
45
+ Backspace: () => {
46
+ const { state } = this.editor;
47
+ const { $from, empty: selectionEmpty } = state.selection;
48
+
49
+ if ($from.parent.type.name !== 'div') {
50
+ return false;
51
+ }
52
+
53
+ if (!selectionEmpty) {
54
+ return false;
55
+ }
56
+
57
+ const parentText = $from.parent.textContent;
58
+
59
+ if (parentText.length === 0) {
60
+ return state.doc.childCount === 1 ? true : false;
61
+ }
62
+
63
+ // one character left and cursor is after it — delete
64
+ // only that character and stop, preventing the block-join that fires Enter.
65
+ if (parentText.length === 1 && $from.parentOffset === 1) {
66
+ const { tr } = state;
67
+ tr.delete($from.pos - 1, $from.pos);
68
+ this.editor.view.dispatch(tr);
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ },
34
74
  };
35
75
  },
36
76
  });
@@ -8,6 +8,15 @@ import { wrapMath } from '@pie-lib/math-rendering';
8
8
 
9
9
  const ensureTextAfterMathPluginKey = new PluginKey('ensureTextAfterMath');
10
10
 
11
+ const generateAdditionalKeys = (keyData = []) => {
12
+ return keyData.map((key) => ({
13
+ name: key,
14
+ latex: key,
15
+ write: key,
16
+ label: key,
17
+ }));
18
+ };
19
+
11
20
  export const EnsureTextAfterMathPlugin = (mathNodeName) =>
12
21
  new Plugin({
13
22
  key: ensureTextAfterMathPluginKey,
@@ -175,6 +184,14 @@ export const MathNodeView = (props) => {
175
184
  const [showToolbar, setShowToolbar] = useState(selected);
176
185
  const toolbarRef = useRef(null);
177
186
  const [position, setPosition] = useState({ top: 0, left: 0 });
187
+ const { math: mathOptions = {} } = options || {};
188
+ const {
189
+ keypadMode,
190
+ controlledKeypadMode = true,
191
+ customKeys = [],
192
+ keyPadCharacterRef,
193
+ setKeypadInteraction,
194
+ } = mathOptions;
178
195
 
179
196
  const latex = node.attrs.latex || '';
180
197
 
@@ -199,10 +216,26 @@ export const MathNodeView = (props) => {
199
216
  });
200
217
 
201
218
  const handleClickOutside = (event) => {
219
+ const target = event?.target;
220
+
221
+ // MUI's `Select` renders its dropdown options in a portal attached to `document.body`.
222
+ // Those clicks should not dismiss the math toolbar.
223
+ const equationEditorListboxes =
224
+ document.querySelectorAll?.(
225
+ '[id^="equation-editor-select"][id*="listbox"], [aria-labelledby="equation-editor-label"][role="listbox"]',
226
+ ) || [];
227
+
228
+ const equationEditorPopoverOpen = equationEditorListboxes.length > 0;
229
+ const clickedEquationEditorSelect =
230
+ !!(target?.id && target.id.includes('equation-editor-select')) ||
231
+ !!target?.closest?.('[id*="equation-editor-select"]');
232
+
202
233
  if (
203
234
  toolbarRef.current &&
204
- !toolbarRef.current.contains(event.target) &&
205
- !event.target.closest('[data-inline-node]')
235
+ !toolbarRef.current.contains(target) &&
236
+ !target?.closest?.('[data-inline-node]') &&
237
+ !equationEditorPopoverOpen &&
238
+ !clickedEquationEditorSelect
206
239
  ) {
207
240
  setShowToolbar(false);
208
241
  }
@@ -266,7 +299,17 @@ export const MathNodeView = (props) => {
266
299
  '0px 1px 5px 0px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.12)',
267
300
  }}
268
301
  >
269
- <MathToolbar latex={latex} autoFocus onChange={handleChange} onDone={handleDone} keypadMode="basic" />
302
+ <MathToolbar
303
+ latex={latex}
304
+ autoFocus
305
+ onChange={handleChange}
306
+ onDone={handleDone}
307
+ keypadMode={keypadMode}
308
+ controlledKeypadMode={controlledKeypadMode}
309
+ additionalKeys={generateAdditionalKeys(customKeys)}
310
+ keyPadCharacterRef={keyPadCharacterRef}
311
+ setKeypadInteraction={setKeypadInteraction}
312
+ />
270
313
  </div>,
271
314
  document.body,
272
315
  )}
@@ -142,10 +142,12 @@ export const ResponseAreaExtension = Extension.create({
142
142
  }
143
143
 
144
144
  // --- Slate: indexing logic (kept identical) ---
145
- if (lastIndexMap[typeName] === undefined) lastIndexMap[typeName] = 0;
145
+ if (lastIndexMap[typeName] === undefined) {
146
+ lastIndexMap[typeName] = 0;
147
+ }
146
148
 
147
149
  const prevIndex = lastIndexMap[typeName];
148
- const newIndex = prevIndex === 0 ? prevIndex : prevIndex + 1;
150
+ const newIndex = prevIndex + 1;
149
151
 
150
152
  // Slate increments map even if newIndex === 0
151
153
  lastIndexMap[typeName] += 1;
@@ -156,7 +158,9 @@ export const ResponseAreaExtension = Extension.create({
156
158
  index: newIndex,
157
159
  });
158
160
 
159
- if (!newInline) return false;
161
+ if (!newInline) {
162
+ return false;
163
+ }
160
164
 
161
165
  // --- Insert logic ---
162
166
  const { selection } = state;
@@ -182,7 +186,10 @@ export const ResponseAreaExtension = Extension.create({
182
186
  if (usedPos == null) {
183
187
  usedPos = tryInsertAt(tr.doc.content.size);
184
188
  }
185
- if (usedPos == null) return false;
189
+
190
+ if (usedPos == null) {
191
+ return false;
192
+ }
186
193
 
187
194
  // Optionally select the node you just inserted (like your original command)
188
195
  // tr.setSelection(NodeSelection.create(tr.doc, usedPos))