@manuscripts/body-editor 3.9.11 → 3.9.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 (105) hide show
  1. package/dist/cjs/commands.js +5 -2
  2. package/dist/cjs/components/keywords/AddKeywordInline.js +12 -1
  3. package/dist/cjs/components/views/FigureDropdown.js +65 -6
  4. package/dist/cjs/configs/ManuscriptsEditor.js +1 -1
  5. package/dist/cjs/index.js +1 -0
  6. package/dist/cjs/keys/misc.js +2 -1
  7. package/dist/cjs/keys/title.js +0 -38
  8. package/dist/cjs/lib/comments.js +1 -0
  9. package/dist/cjs/lib/context-menu.js +74 -8
  10. package/dist/cjs/lib/media.js +27 -3
  11. package/dist/cjs/lib/navigation-utils.js +132 -0
  12. package/dist/cjs/lib/popper.js +25 -1
  13. package/dist/cjs/lib/position-menu.js +3 -0
  14. package/dist/cjs/lib/utils.js +7 -2
  15. package/dist/cjs/plugins/accessibility_element.js +10 -2
  16. package/dist/cjs/plugins/add-subtitle.js +8 -2
  17. package/dist/cjs/plugins/alt-titles.js +6 -1
  18. package/dist/cjs/plugins/comments.js +27 -15
  19. package/dist/cjs/plugins/persistent-cursor.js +4 -6
  20. package/dist/cjs/plugins/section_category.js +42 -9
  21. package/dist/cjs/plugins/translations.js +49 -13
  22. package/dist/cjs/versions.js +1 -1
  23. package/dist/cjs/views/accessibility_element.js +30 -0
  24. package/dist/cjs/views/alt_title.js +29 -0
  25. package/dist/cjs/views/alt_titles_section.js +9 -1
  26. package/dist/cjs/views/attachment.js +1 -1
  27. package/dist/cjs/views/bibliography_element.js +39 -17
  28. package/dist/cjs/views/citation.js +1 -0
  29. package/dist/cjs/views/citation_editable.js +4 -2
  30. package/dist/cjs/views/contributors.js +23 -2
  31. package/dist/cjs/views/cross_reference.js +3 -0
  32. package/dist/cjs/views/editable_block.js +37 -3
  33. package/dist/cjs/views/embed.js +3 -3
  34. package/dist/cjs/views/figure_editable.js +1 -1
  35. package/dist/cjs/views/figure_element.js +3 -0
  36. package/dist/cjs/views/footnote.js +3 -0
  37. package/dist/cjs/views/hero_image.js +4 -1
  38. package/dist/cjs/views/image_element.js +15 -7
  39. package/dist/cjs/views/inline_footnote.js +3 -0
  40. package/dist/cjs/views/keyword.js +15 -0
  41. package/dist/cjs/views/keyword_group.js +38 -0
  42. package/dist/cjs/views/quote_image_editable.js +1 -0
  43. package/dist/cjs/views/supplements.js +4 -1
  44. package/dist/es/commands.js +5 -2
  45. package/dist/es/components/keywords/AddKeywordInline.js +12 -1
  46. package/dist/es/components/views/FigureDropdown.js +66 -7
  47. package/dist/es/configs/ManuscriptsEditor.js +1 -1
  48. package/dist/es/index.js +1 -0
  49. package/dist/es/keys/misc.js +2 -1
  50. package/dist/es/keys/title.js +1 -39
  51. package/dist/es/lib/comments.js +1 -0
  52. package/dist/es/lib/context-menu.js +74 -8
  53. package/dist/es/lib/media.js +27 -3
  54. package/dist/es/lib/navigation-utils.js +122 -0
  55. package/dist/es/lib/popper.js +25 -1
  56. package/dist/es/lib/position-menu.js +3 -0
  57. package/dist/es/lib/utils.js +7 -2
  58. package/dist/es/plugins/accessibility_element.js +10 -2
  59. package/dist/es/plugins/add-subtitle.js +8 -2
  60. package/dist/es/plugins/alt-titles.js +6 -1
  61. package/dist/es/plugins/comments.js +27 -15
  62. package/dist/es/plugins/persistent-cursor.js +4 -6
  63. package/dist/es/plugins/section_category.js +42 -9
  64. package/dist/es/plugins/translations.js +49 -13
  65. package/dist/es/versions.js +1 -1
  66. package/dist/es/views/accessibility_element.js +30 -0
  67. package/dist/es/views/alt_title.js +29 -0
  68. package/dist/es/views/alt_titles_section.js +9 -1
  69. package/dist/es/views/attachment.js +1 -1
  70. package/dist/es/views/bibliography_element.js +39 -17
  71. package/dist/es/views/citation.js +1 -0
  72. package/dist/es/views/citation_editable.js +4 -2
  73. package/dist/es/views/contributors.js +23 -2
  74. package/dist/es/views/cross_reference.js +3 -0
  75. package/dist/es/views/editable_block.js +37 -3
  76. package/dist/es/views/embed.js +3 -3
  77. package/dist/es/views/figure_editable.js +1 -1
  78. package/dist/es/views/figure_element.js +3 -0
  79. package/dist/es/views/footnote.js +3 -0
  80. package/dist/es/views/hero_image.js +4 -1
  81. package/dist/es/views/image_element.js +15 -7
  82. package/dist/es/views/inline_footnote.js +3 -0
  83. package/dist/es/views/keyword.js +15 -0
  84. package/dist/es/views/keyword_group.js +38 -0
  85. package/dist/es/views/quote_image_editable.js +1 -0
  86. package/dist/es/views/supplements.js +4 -1
  87. package/dist/types/configs/ManuscriptsEditor.d.ts +1 -1
  88. package/dist/types/index.d.ts +1 -0
  89. package/dist/types/lib/context-menu.d.ts +1 -0
  90. package/dist/types/lib/media.d.ts +1 -1
  91. package/dist/types/lib/navigation-utils.d.ts +45 -0
  92. package/dist/types/lib/popper.d.ts +3 -0
  93. package/dist/types/lib/utils.d.ts +1 -1
  94. package/dist/types/versions.d.ts +1 -1
  95. package/dist/types/views/accessibility_element.d.ts +2 -0
  96. package/dist/types/views/alt_title.d.ts +2 -0
  97. package/dist/types/views/bibliography_element.d.ts +3 -0
  98. package/dist/types/views/citation_editable.d.ts +1 -1
  99. package/dist/types/views/contributors.d.ts +3 -1
  100. package/dist/types/views/keyword.d.ts +1 -0
  101. package/dist/types/views/keyword_group.d.ts +4 -0
  102. package/package.json +4 -4
  103. package/styles/AdvancedEditor.css +116 -10
  104. package/styles/Editor.css +61 -6
  105. package/styles/popper.css +3 -1
@@ -1,8 +1,42 @@
1
1
  import { DotsIcon, DropdownList, getFileIcon, IconButton, IconTextButton, TriangleCollapsedIcon, UploadIcon, useDropdown, } from '@manuscripts/style-guide';
2
- import React, { useEffect } from 'react';
2
+ import React, { useCallback, useEffect, useRef } from 'react';
3
3
  import styled from 'styled-components';
4
4
  import { memoGroupFiles, } from '../../lib/files';
5
5
  import { getMediaTypeInfo } from '../../lib/get-media-type';
6
+ import { createKeyboardInteraction } from '../../lib/navigation-utils';
7
+ function useDropdownKeyboardNav(isOpen, containerRef, onEscape, onArrowLeft) {
8
+ useEffect(() => {
9
+ const container = containerRef.current;
10
+ if (!isOpen || !container) {
11
+ return;
12
+ }
13
+ const buttons = Array.from(container.querySelectorAll('button:not([disabled])'));
14
+ if (buttons.length === 0) {
15
+ return;
16
+ }
17
+ const removeKeydownListener = createKeyboardInteraction({
18
+ container: container,
19
+ navigation: {
20
+ getItems: () => Array.from(container.querySelectorAll('button:not([disabled])')),
21
+ arrowKeys: {
22
+ forward: 'ArrowDown',
23
+ backward: 'ArrowUp',
24
+ },
25
+ },
26
+ additionalKeys: {
27
+ Enter: (e) => e.target.click(),
28
+ Escape: () => onEscape(),
29
+ ...(onArrowLeft ? { ArrowLeft: () => onArrowLeft() } : {}),
30
+ },
31
+ });
32
+ window.requestAnimationFrame(() => {
33
+ buttons[0]?.focus();
34
+ });
35
+ return () => {
36
+ removeKeydownListener();
37
+ };
38
+ }, [isOpen, containerRef, onEscape, onArrowLeft]);
39
+ }
6
40
  function getSupplements(getFiles, getDoc, groupFiles, isEmbed) {
7
41
  return groupFiles(getDoc(), getFiles())
8
42
  .supplements.map((s) => s.file)
@@ -21,6 +55,7 @@ function getOtherFiles(getFiles, getDoc, groupFiles, isEmbed) {
21
55
  }
22
56
  export const FigureOptions = ({ can, getDoc, getFiles, onDownload, onUpload, onDetach, onReplace, onReplaceEmbed, onDelete, isEmbed, hasSiblings, container, }) => {
23
57
  const { isOpen, toggleOpen, wrapperRef } = useDropdown();
58
+ const dropdownRef = useRef(null);
24
59
  const showDownload = onDownload && can.downloadFiles;
25
60
  const showUpload = onUpload && can.uploadFile;
26
61
  const showDetach = onDetach && can.detachFile;
@@ -44,12 +79,18 @@ export const FigureOptions = ({ can, getDoc, getFiles, onDownload, onUpload, onD
44
79
  container.classList.remove(activeClass);
45
80
  }
46
81
  }, [isOpen, container.classList]);
82
+ useDropdownKeyboardNav(isOpen, dropdownRef, toggleOpen);
47
83
  const isEmbedMode = !!onReplaceEmbed;
48
84
  const groupFiles = memoGroupFiles();
49
85
  return (React.createElement(DropdownWrapper, { ref: wrapperRef },
50
- React.createElement(OptionsButton, { className: 'options-button', onClick: toggleOpen },
86
+ React.createElement(OptionsButton, { className: 'options-button', onClick: toggleOpen, onKeyDown: (e) => {
87
+ if (e.key === 'Enter') {
88
+ e.preventDefault();
89
+ toggleOpen();
90
+ }
91
+ } },
51
92
  React.createElement(DotsIcon, null)),
52
- isOpen && (React.createElement(OptionsDropdownList, { direction: 'right', width: 128, top: 5 },
93
+ isOpen && (React.createElement(OptionsDropdownList, { direction: 'right', width: 128, top: 5, ref: dropdownRef },
53
94
  showReplace && isEmbedMode && (React.createElement(ListItemButton, { onClick: () => onReplaceEmbed && onReplaceEmbed() }, "Edit Link")),
54
95
  showReplace && !isEmbedMode && (React.createElement(NestedDropdown, { disabled: !showReplace, parentToggleOpen: toggleOpen, buttonText: replaceBtnText, moveLeft: true, list: React.createElement(React.Fragment, null,
55
96
  getSupplements(getFiles, getDoc, groupFiles, isEmbed).map((file, index) => (React.createElement(ListItemButton, { key: file.id, id: index.toString(), onClick: () => onReplace && onReplace(file, true) },
@@ -67,11 +108,23 @@ export const FigureOptions = ({ can, getDoc, getFiles, onDownload, onUpload, onD
67
108
  };
68
109
  const NestedDropdown = ({ parentToggleOpen, buttonText, disabled, list, moveLeft }) => {
69
110
  const { isOpen, toggleOpen, wrapperRef } = useDropdown();
111
+ const nestedListRef = useRef(null);
112
+ const handleArrowLeft = useCallback(() => {
113
+ toggleOpen();
114
+ const parentButton = wrapperRef.current?.querySelector('.nested-list-button');
115
+ parentButton?.focus();
116
+ }, [toggleOpen, wrapperRef]);
117
+ useDropdownKeyboardNav(isOpen, nestedListRef, handleArrowLeft, handleArrowLeft);
70
118
  return (React.createElement(DropdownWrapper, { ref: wrapperRef },
71
- React.createElement(NestedListButton, { onClick: toggleOpen, disabled: disabled },
119
+ React.createElement(NestedListButton, { className: "nested-list-button", onClick: toggleOpen, disabled: disabled, onKeyDown: (e) => {
120
+ if (e.key === 'ArrowRight') {
121
+ e.preventDefault();
122
+ toggleOpen();
123
+ }
124
+ } },
72
125
  React.createElement("div", null, buttonText),
73
126
  React.createElement(TriangleCollapsedIcon, null)),
74
- isOpen && (React.createElement(NestedListDropdownList, { direction: 'right', moveLeft: moveLeft, width: 192, onClick: (e) => {
127
+ isOpen && (React.createElement(NestedListDropdownList, { direction: 'right', moveLeft: moveLeft, width: 192, ref: nestedListRef, onClick: (e) => {
75
128
  toggleOpen();
76
129
  parentToggleOpen(e);
77
130
  } }, list))));
@@ -114,7 +167,8 @@ const ListItemButton = styled(IconTextButton) `
114
167
  justify-content: space-between;
115
168
  align-items: center;
116
169
 
117
- :hover {
170
+ :hover,
171
+ :focus-visible {
118
172
  background: #f2fbfc;
119
173
  }
120
174
 
@@ -133,7 +187,8 @@ const ListItemText = styled.div `
133
187
  const NestedListButton = styled(ListItemButton) `
134
188
  width: 100%;
135
189
  &:active,
136
- &:focus {
190
+ &:focus,
191
+ &:focus-visible {
137
192
  background: #f2fbfc;
138
193
  }
139
194
  svg {
@@ -148,4 +203,8 @@ const UploadButton = styled(IconTextButton) `
148
203
  border-top: 1px solid #f2f2f2;
149
204
  padding: ${(props) => props.theme.grid.unit * 4}px;
150
205
  justify-content: flex-start;
206
+
207
+ &:focus-visible {
208
+ background: #f2fbfc;
209
+ }
151
210
  `;
@@ -49,7 +49,7 @@ export const createEditorView = (props, root, state, dispatch) => {
49
49
  handleScrollToSelection: handleScrollToSelectedTarget,
50
50
  transformCopied,
51
51
  handleClickOn: (view, pos, node, nodePos, event) => {
52
- props.onEditorClick(pos, node, nodePos, event);
52
+ props.onEditorClick(event);
53
53
  if (event?.target &&
54
54
  event.target.classList.contains('table-context-menu-button')) {
55
55
  return true;
package/dist/es/index.js CHANGED
@@ -25,6 +25,7 @@ export { CollabProvider } from './classes/collabProvider';
25
25
  export { PopperManager } from './lib/popper';
26
26
  export * from './toolbar';
27
27
  export * from './lib/capabilities';
28
+ export * from './lib/navigation-utils';
28
29
  export * from './lib/comments';
29
30
  export * from './lib/files';
30
31
  export * from './lib/footnotes';
@@ -20,10 +20,11 @@ import { undoInputRule } from 'prosemirror-inputrules';
20
20
  import { goToNextCell } from 'prosemirror-tables';
21
21
  import { addToStart, autoComplete, exitEditorToContainer, ignoreAtomBlockNodeBackward, ignoreAtomBlockNodeForward, ignoreEnterInSubtitles, ignoreMetaNodeBackspaceCommand, insertBlock, insertBoxElement, insertBreak, insertCrossReference, insertInlineFootnote, insertInlineCitation, insertInlineEquation, insertLink, insertSection, selectAllIsolating, } from '../commands';
22
22
  import { skipCommandTracking } from './list';
23
+ import { focusNearestElement } from '../lib/navigation-utils';
23
24
  const customKeymap = {
24
25
  Backspace: chainCommands(undoInputRule, ignoreAtomBlockNodeBackward, ignoreMetaNodeBackspaceCommand, skipCommandTracking(joinBackward)),
25
26
  Delete: ignoreAtomBlockNodeForward,
26
- Tab: goToNextCell(1),
27
+ Tab: chainCommands(goToNextCell(1), focusNearestElement),
27
28
  Escape: exitEditorToContainer,
28
29
  'Mod-z': undo,
29
30
  'Mod-y': redo,
@@ -16,7 +16,7 @@
16
16
  import { isInGraphicalAbstractSection, schema, } from '@manuscripts/transform';
17
17
  import { chainCommands } from 'prosemirror-commands';
18
18
  import { Fragment, Slice } from 'prosemirror-model';
19
- import { Selection, TextSelection } from 'prosemirror-state';
19
+ import { TextSelection } from 'prosemirror-state';
20
20
  import { autoComplete, isAtEndOfTextBlock, isAtStartOfTextBlock, isTextSelection, } from '../commands';
21
21
  const insertParagraph = (dispatch, state, $anchor) => {
22
22
  const { tr, schema: { nodes }, } = state;
@@ -31,42 +31,6 @@ const insertParagraph = (dispatch, state, $anchor) => {
31
31
  tr.setSelection(TextSelection.create(tr.doc, pos + 1)).scrollIntoView();
32
32
  dispatch(tr);
33
33
  };
34
- const enterNextBlock = (dispatch, state, $anchor, create) => {
35
- const { schema: { nodes }, tr, } = state;
36
- const pos = $anchor.after($anchor.depth - 1);
37
- let selection = Selection.findFrom(tr.doc.resolve(pos), 1, true);
38
- if (!selection && create) {
39
- tr.insert(pos, nodes.paragraph.create());
40
- selection = Selection.findFrom(tr.doc.resolve(pos), 1, true);
41
- }
42
- if (!selection) {
43
- return false;
44
- }
45
- tr.setSelection(selection).scrollIntoView();
46
- dispatch(tr);
47
- return true;
48
- };
49
- const enterPreviousBlock = (dispatch, state, $anchor) => {
50
- const { tr } = state;
51
- const offset = $anchor.nodeBefore ? $anchor.nodeBefore.nodeSize : 0;
52
- const $pos = tr.doc.resolve($anchor.pos - offset - 1);
53
- const previous = Selection.findFrom($pos, -1, true);
54
- if (!previous) {
55
- return false;
56
- }
57
- tr.setSelection(TextSelection.create(tr.doc, previous.from)).scrollIntoView();
58
- dispatch(tr);
59
- return true;
60
- };
61
- const exitBlock = (direction) => (state, dispatch) => {
62
- const { selection: { $anchor }, } = state;
63
- if (dispatch) {
64
- return direction === 1
65
- ? enterNextBlock(dispatch, state, $anchor)
66
- : enterPreviousBlock(dispatch, state, $anchor);
67
- }
68
- return true;
69
- };
70
34
  const leaveTitle = (state, dispatch, view) => {
71
35
  const { selection } = state;
72
36
  if (!isTextSelection(selection)) {
@@ -150,8 +114,6 @@ const keepCaption = (state) => {
150
114
  const titleKeymap = {
151
115
  Backspace: chainCommands(protectTitles, protectReferencesTitle, protectCaption),
152
116
  Enter: chainCommands(autoComplete, leaveTitle, leaveFigcaption),
153
- Tab: exitBlock(1),
154
117
  Delete: chainCommands(keepCaption, protectReferencesTitle),
155
- 'Shift-Tab': exitBlock(-1),
156
118
  };
157
119
  export default titleKeymap;
@@ -28,6 +28,7 @@ export const createCommentMarker = (tagName, key, count) => {
28
28
  element.id = getMarkerID(key);
29
29
  element.dataset.key = key;
30
30
  element.classList.add('comment-marker');
31
+ element.tabIndex = 0;
31
32
  if (count && count > 1) {
32
33
  element.dataset.count = String(count);
33
34
  }
@@ -16,6 +16,7 @@
16
16
  import { TriangleCollapsedIcon } from '@manuscripts/style-guide';
17
17
  import { isInGraphicalAbstractSection, isSectionTitleNode, nodeNames, schema, } from '@manuscripts/transform';
18
18
  import { TextSelection } from 'prosemirror-state';
19
+ import { handleEnterKey, createKeyboardInteraction } from './navigation-utils';
19
20
  import { findChildrenByType } from 'prosemirror-utils';
20
21
  import { createElement } from 'react';
21
22
  import { renderToStaticMarkup } from 'react-dom/server';
@@ -48,7 +49,9 @@ export const contextMenuBtnClass = 'btn-context-menu';
48
49
  const contextSubmenuBtnClass = 'context-submenu-trigger';
49
50
  export class ContextMenu {
50
51
  constructor(node, view, getPos) {
52
+ this.menuItems = [];
51
53
  this.showAddMenu = (target) => {
54
+ this.menuItems = [];
52
55
  const menu = document.createElement('div');
53
56
  menu.className = 'menu';
54
57
  const $pos = this.resolvePos();
@@ -146,6 +149,7 @@ export class ContextMenu {
146
149
  this.addPopperEventListeners();
147
150
  };
148
151
  this.showEditMenu = (target) => {
152
+ this.menuItems = [];
149
153
  const menu = document.createElement('div');
150
154
  menu.className = 'menu';
151
155
  const $pos = this.resolvePos();
@@ -156,7 +160,7 @@ export class ContextMenu {
156
160
  const figure = getMatchingChild(this.node, (node) => node.type === schema.nodes.figure);
157
161
  if (figure) {
158
162
  const attrType = figure.attrs.type;
159
- const submenuOptions = createPositionOptions(schema.nodes.figure, figure, attrType, this.view);
163
+ const submenuOptions = createPositionOptions(schema.nodes.figure, figure, attrType, this.view, () => popper.destroy());
160
164
  const submenuLabel = 'Position';
161
165
  const submenu = this.createSubmenu(submenuLabel, submenuOptions);
162
166
  menu.appendChild(submenu);
@@ -274,16 +278,28 @@ export class ContextMenu {
274
278
  this.createSubmenuTrigger = (contents) => {
275
279
  const item = document.createElement('div');
276
280
  item.className = 'menu-item';
281
+ item.tabIndex = 0;
277
282
  const textNode = document.createTextNode(contents);
278
283
  item.innerHTML = renderToStaticMarkup(createElement(TriangleCollapsedIcon));
279
284
  item.prepend(textNode);
280
285
  item.classList.add(contextSubmenuBtnClass);
281
286
  item.addEventListener('mousedown', this.toggleSubmenu);
287
+ item.addEventListener('keydown', handleEnterKey((e) => {
288
+ const target = e.target;
289
+ this.toggleSubmenu(e);
290
+ const submenuContent = target.nextElementSibling;
291
+ if (submenuContent?.classList.contains('show')) {
292
+ const firstItem = submenuContent.querySelector('.menu-item');
293
+ firstItem?.focus();
294
+ }
295
+ }));
296
+ this.menuItems.push(item);
282
297
  return item;
283
298
  };
284
299
  this.createMenuItem = (contents, handler, IconComponent = null, selected = false) => {
285
300
  const item = document.createElement('div');
286
301
  item.className = 'menu-item';
302
+ item.setAttribute('tabindex', '0');
287
303
  selected && item.classList.add('selected');
288
304
  if (IconComponent) {
289
305
  if (typeof IconComponent === 'string') {
@@ -299,6 +315,8 @@ export class ContextMenu {
299
315
  event.preventDefault();
300
316
  handler(event);
301
317
  });
318
+ item.addEventListener('keydown', handleEnterKey(handler));
319
+ this.menuItems.push(item);
302
320
  return item;
303
321
  };
304
322
  this.createMenuSection = (createMenuItems, isSubmenu = false) => {
@@ -395,14 +413,62 @@ export class ContextMenu {
395
413
  popper.destroy();
396
414
  });
397
415
  };
398
- const keyListener = (event) => {
399
- if (event.key === 'Escape') {
400
- window.removeEventListener('keydown', keyListener);
401
- popper.destroy();
402
- }
403
- };
404
416
  window.addEventListener('mousedown', mouseListener);
405
- window.addEventListener('keydown', keyListener);
417
+ window.requestAnimationFrame(() => {
418
+ const popperContainer = popper.getContainer();
419
+ if (popperContainer) {
420
+ createKeyboardInteraction({
421
+ container: popperContainer,
422
+ navigation: {
423
+ getItems: () => {
424
+ const activeElement = document.activeElement;
425
+ const openSubmenu = activeElement?.closest('.menu-section.menu.show');
426
+ if (openSubmenu) {
427
+ return this.menuItems.filter((item) => openSubmenu.contains(item));
428
+ }
429
+ return this.menuItems.filter((item) => {
430
+ const menuSection = item.closest('.menu-section');
431
+ if (menuSection && menuSection.classList.contains('menu')) {
432
+ return menuSection.classList.contains('show');
433
+ }
434
+ return true;
435
+ });
436
+ },
437
+ arrowKeys: {
438
+ forward: 'ArrowDown',
439
+ backward: 'ArrowUp',
440
+ },
441
+ getCurrentElement: () => document.activeElement,
442
+ },
443
+ additionalKeys: {
444
+ ArrowRight: (event) => {
445
+ const target = event.target;
446
+ if (target.classList.contains(contextSubmenuBtnClass)) {
447
+ this.toggleSubmenu(event);
448
+ const submenuContent = target.nextElementSibling;
449
+ if (submenuContent?.classList.contains('show')) {
450
+ const firstItem = submenuContent.querySelector('.menu-item');
451
+ firstItem?.focus();
452
+ }
453
+ }
454
+ },
455
+ ArrowLeft: (event) => {
456
+ const target = event.target;
457
+ const submenu = target.closest('.context-submenu');
458
+ if (submenu) {
459
+ const trigger = submenu.querySelector(`.${contextSubmenuBtnClass}`);
460
+ if (trigger) {
461
+ const submenuContent = trigger.nextElementSibling;
462
+ submenuContent?.classList.toggle('show');
463
+ trigger.focus();
464
+ }
465
+ }
466
+ },
467
+ },
468
+ });
469
+ }
470
+ this.menuItems[0]?.focus();
471
+ });
406
472
  };
407
473
  this.trimTitle = (title, max) => {
408
474
  return title.length > max ? title.substring(0, max) + '…' : title;
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { schema, } from '@manuscripts/transform';
17
17
  import { openEmbedDialog } from '../components/toolbar/InsertEmbedDialog';
18
+ import { handleEnterKey } from './navigation-utils';
18
19
  import { FigureOptions, } from '../components/views/FigureDropdown';
19
20
  import { fileCorruptedIcon } from '../icons';
20
21
  import ReactSubView from '../views/ReactSubView';
@@ -50,9 +51,10 @@ const MediaLabels = {
50
51
  [MediaType.Figure]: 'figure',
51
52
  [MediaType.ExternalLink]: 'a file to link',
52
53
  };
53
- export const createMediaPlaceholder = (mediaType = MediaType.Media, view, getPos) => {
54
+ export const createMediaPlaceholder = (mediaType = MediaType.Media, view, getPos, props) => {
54
55
  const element = document.createElement('div');
55
56
  element.classList.add('figure', 'placeholder');
57
+ element.tabIndex = 0;
56
58
  const instructions = document.createElement('div');
57
59
  instructions.classList.add('instructions');
58
60
  instructions.innerHTML = `
@@ -72,14 +74,29 @@ export const createMediaPlaceholder = (mediaType = MediaType.Media, view, getPos
72
74
  `;
73
75
  if (mediaType === MediaType.Media && view && getPos) {
74
76
  const embedLink = instructions.querySelector("[data-action='add-external-link']");
77
+ embedLink.tabIndex = 0;
75
78
  if (embedLink) {
76
79
  embedLink.addEventListener('click', (e) => {
77
80
  e.stopPropagation();
78
81
  e.preventDefault();
79
82
  openEmbedDialog(view, getPos());
80
83
  });
84
+ embedLink.addEventListener('keydown', handleEnterKey((e) => {
85
+ e.stopPropagation();
86
+ e.preventDefault();
87
+ openEmbedDialog(view, getPos());
88
+ }));
81
89
  }
82
90
  }
91
+ const links = instructions.querySelectorAll(`[data-action='open-other-files'], [data-action='open-supplement-files'] `);
92
+ links.forEach((link) => {
93
+ link.tabIndex = 0;
94
+ link.addEventListener('keydown', handleEnterKey((event) => {
95
+ event.preventDefault();
96
+ event.stopPropagation();
97
+ props?.onEditorClick(event);
98
+ }));
99
+ });
83
100
  element.appendChild(instructions);
84
101
  return element;
85
102
  };
@@ -154,8 +171,7 @@ export const createFileUploader = (handler, accept = '*/*') => {
154
171
  return () => input.click();
155
172
  };
156
173
  export const addInteractionHandlers = (element, uploadFn, accept = '*/*') => {
157
- const handlePlaceholderClick = (event) => {
158
- const target = event.target;
174
+ const handlePlaceholderInteraction = (target) => {
159
175
  if (target.dataset && target.dataset.action) {
160
176
  return;
161
177
  }
@@ -170,7 +186,15 @@ export const addInteractionHandlers = (element, uploadFn, accept = '*/*') => {
170
186
  });
171
187
  input.click();
172
188
  };
189
+ const handlePlaceholderClick = (event) => {
190
+ const target = event.target;
191
+ handlePlaceholderInteraction(target);
192
+ };
173
193
  element.addEventListener('click', handlePlaceholderClick);
194
+ element.addEventListener('keydown', handleEnterKey(() => {
195
+ const target = document.activeElement;
196
+ handlePlaceholderInteraction(target);
197
+ }));
174
198
  element.addEventListener('mouseenter', () => {
175
199
  element.classList.toggle('over', true);
176
200
  });
@@ -0,0 +1,122 @@
1
+ /*!
2
+ * © 2025 Atypon Systems LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { findParentNodeWithIdValue } from './utils';
17
+ export function focusNextElement(elements, currentIndex, direction) {
18
+ const nextIndex = direction === 'forward'
19
+ ? (currentIndex + 1) % elements.length
20
+ : (currentIndex - 1 + elements.length) % elements.length;
21
+ const nextElement = elements[nextIndex];
22
+ nextElement?.focus();
23
+ }
24
+ export function handleEnterKey(action) {
25
+ return (event) => {
26
+ if (event.key === 'Enter') {
27
+ event.preventDefault();
28
+ action(event);
29
+ }
30
+ };
31
+ }
32
+ export function handleArrowNavigation(event, elements, currentElement, keys) {
33
+ const { forward, backward } = keys;
34
+ if (event.key !== forward && event.key !== backward) {
35
+ return;
36
+ }
37
+ event.preventDefault();
38
+ if (elements.length === 0) {
39
+ return;
40
+ }
41
+ const currentIndex = elements.indexOf(currentElement);
42
+ if (currentIndex === -1) {
43
+ elements[0]?.focus();
44
+ return;
45
+ }
46
+ const direction = event.key === forward ? 'forward' : 'backward';
47
+ focusNextElement(elements, currentIndex, direction);
48
+ }
49
+ export function createKeyboardInteraction(options) {
50
+ const { container, additionalKeys, navigation } = options;
51
+ const handleKeydown = (event) => {
52
+ const e = event;
53
+ const key = e.key;
54
+ const handler = additionalKeys?.[key];
55
+ if (handler) {
56
+ e.preventDefault();
57
+ handler(e);
58
+ return;
59
+ }
60
+ if (!navigation) {
61
+ return;
62
+ }
63
+ const { getItems, arrowKeys, getCurrentElement } = navigation;
64
+ const currentElement = getCurrentElement
65
+ ? getCurrentElement(e)
66
+ : e.target;
67
+ if (!currentElement) {
68
+ return;
69
+ }
70
+ const list = getItems();
71
+ if (!list.length) {
72
+ return;
73
+ }
74
+ handleArrowNavigation(e, list, currentElement, arrowKeys);
75
+ };
76
+ container.addEventListener('keydown', handleKeydown);
77
+ return () => {
78
+ container.removeEventListener('keydown', handleKeydown);
79
+ };
80
+ }
81
+ export const focusNearestElement = (state, dispatch, view) => {
82
+ if (!view) {
83
+ return false;
84
+ }
85
+ const active = document.activeElement;
86
+ if (!active || !active.classList.contains('manuscript-editor')) {
87
+ return false;
88
+ }
89
+ const { from } = view.state.selection;
90
+ const coords = view.coordsAtPos(from);
91
+ const container = getCursorContainer(view);
92
+ const target = findNearestTabbable(container, coords.top);
93
+ if (!target) {
94
+ return false;
95
+ }
96
+ target.focus();
97
+ return true;
98
+ };
99
+ export function getCursorContainer(view) {
100
+ const scoped = findParentNodeWithIdValue(view.state.selection);
101
+ if (scoped) {
102
+ const dom = view.nodeDOM(scoped.pos);
103
+ if (dom instanceof HTMLElement) {
104
+ return dom;
105
+ }
106
+ }
107
+ return view.dom;
108
+ }
109
+ export function findNearestTabbable(container, verticalPosition) {
110
+ const tabbables = container.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])');
111
+ let target = null;
112
+ let minDistance = null;
113
+ tabbables.forEach((el) => {
114
+ const rect = el.getBoundingClientRect();
115
+ const distance = Math.abs(rect.top - verticalPosition);
116
+ if (minDistance === null || distance < minDistance) {
117
+ minDistance = distance;
118
+ target = el;
119
+ }
120
+ });
121
+ return target;
122
+ }
@@ -14,18 +14,36 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { createPopper, } from '@popperjs/core';
17
+ import { createKeyboardInteraction } from './navigation-utils';
17
18
  export class PopperManager {
18
19
  constructor() {
19
20
  this.isActive = () => !!this.activePopper;
20
21
  }
21
22
  show(target, contents, placement = 'bottom', showArrow = true, modifiers = []) {
22
23
  this.destroy();
24
+ this.triggerElement = target;
23
25
  window.requestAnimationFrame(() => {
24
26
  const container = document.createElement('div');
25
27
  container.className = 'popper';
28
+ this.container = container;
26
29
  container.addEventListener('click', (e) => {
27
30
  e.stopPropagation();
28
31
  });
32
+ const closeAndRestoreFocus = (e) => {
33
+ e.preventDefault();
34
+ e.stopPropagation();
35
+ this.destroy();
36
+ if (this.triggerElement instanceof HTMLElement) {
37
+ this.triggerElement.focus();
38
+ }
39
+ };
40
+ createKeyboardInteraction({
41
+ container,
42
+ additionalKeys: {
43
+ Escape: closeAndRestoreFocus,
44
+ Tab: closeAndRestoreFocus,
45
+ },
46
+ });
29
47
  if (showArrow) {
30
48
  const arrow = document.createElement('div');
31
49
  arrow.className = 'popper-arrow';
@@ -77,15 +95,21 @@ export class PopperManager {
77
95
  window.removeEventListener('click', this.handleDocumentClick);
78
96
  }
79
97
  delete this.activePopper;
98
+ delete this.container;
80
99
  }
81
100
  }
101
+ getContainer() {
102
+ return this.container;
103
+ }
82
104
  update() {
83
105
  if (this.activePopper) {
84
106
  this.activePopper.update();
85
107
  }
86
108
  }
87
109
  focusInput(container) {
88
- const element = container.querySelector('input');
110
+ const input = container.querySelector('input');
111
+ const button = container.querySelector('button:not([disabled])');
112
+ const element = input || button;
89
113
  if (element) {
90
114
  element.focus();
91
115
  }
@@ -17,6 +17,7 @@ import { ContextMenu } from '@manuscripts/style-guide';
17
17
  import { imageDefaultIcon, imageLeftIcon, imageRightIcon } from '../icons';
18
18
  import { figurePositions } from '../views/image_element';
19
19
  import ReactSubView from '../views/ReactSubView';
20
+ import { handleEnterKey } from './navigation-utils';
20
21
  import { updateNodeAttrs } from './view';
21
22
  export const createPositionOptions = (nodeType, node, currentPosition, view, onComplete) => {
22
23
  const createAction = (position) => () => {
@@ -104,7 +105,9 @@ export const createPositionMenuWrapper = (currentPosition, onClick, props) => {
104
105
  positionMenuButton.innerHTML = icon;
105
106
  }
106
107
  if (can.editArticle) {
108
+ positionMenuButton.tabIndex = 0;
107
109
  positionMenuButton.addEventListener('click', onClick);
110
+ positionMenuButton.addEventListener('keydown', handleEnterKey(onClick));
108
111
  }
109
112
  positionMenuWrapper.appendChild(positionMenuButton);
110
113
  return positionMenuWrapper;
@@ -18,6 +18,7 @@ import { Fragment, } from 'prosemirror-model';
18
18
  import { findChildrenByType, findParentNode, findParentNodeOfTypeClosestToPos, } from 'prosemirror-utils';
19
19
  import { fieldConfigMap } from '../components/references/ReferenceForm/config';
20
20
  import { arrowDown } from '../icons';
21
+ import { handleEnterKey } from './navigation-utils';
21
22
  import { getEditorProps } from '../plugins/editor-props';
22
23
  export function* iterateChildren(node, recurse = false) {
23
24
  for (let i = 0; i < node.childCount; i++) {
@@ -140,10 +141,14 @@ export const createToggleButton = (listener, what) => {
140
141
  altTitlesButton.classList.add('toggle-button-open', 'button-reset');
141
142
  altTitlesButton.setAttribute('aria-label', `Expand ${what}`);
142
143
  altTitlesButton.innerHTML = arrowDown;
143
- altTitlesButton.addEventListener('click', (e) => {
144
+ const activate = (e) => {
144
145
  e.preventDefault();
145
- listener();
146
+ listener(e);
147
+ };
148
+ altTitlesButton.addEventListener('click', (e) => {
149
+ activate(e);
146
150
  });
151
+ altTitlesButton.addEventListener('keydown', handleEnterKey(activate));
147
152
  return altTitlesButton;
148
153
  };
149
154
  export const getInsertPos = (type, parent, pos) => {