@atlaskit/editor-plugin-paste-options-toolbar 9.1.4 → 9.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @atlaskit/editor-plugin-paste-options-toolbar
2
2
 
3
+ ## 9.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [`fc5915138b437`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/fc5915138b437) -
8
+ EDITOR-6110 Ensure that paste actions menu appears in the correct position when the first item of
9
+ the paste is an inline node or mark
10
+ - Updated dependencies
11
+
12
+ ## 9.1.5
13
+
14
+ ### Patch Changes
15
+
16
+ - [`2e030e319c013`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/2e030e319c013) -
17
+ [EDITOR-5982] Fix AI paste menu not appearing when pasting heading + text
18
+ - [`e7032ec0e9287`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/e7032ec0e9287) -
19
+ [ux] EDITOR-5983 Ensure paste menu is in correct position when first pasted node is empty
20
+
3
21
  ## 9.1.4
4
22
 
5
23
  ### Patch Changes
@@ -157,7 +157,7 @@ function getMarkdownSlice(text, schema, selection) {
157
157
  for (var i = 0; i < textSplitByCodeBlock.length; i++) {
158
158
  if (i % 2 === 0) {
159
159
  // Ignored via go/ees005
160
- // eslint-disable-next-line require-unicode-regexp
160
+ // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace -- Ignored via go/ees017 (to be fixed)
161
161
  textSplitByCodeBlock[i] = textSplitByCodeBlock[i].replace(/\\/g, '\\\\');
162
162
  }
163
163
  }
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", {
6
6
  value: true
7
7
  });
8
8
  exports.PasteActionsMenu = void 0;
9
+ exports.findBlockAncestorDOM = findBlockAncestorDOM;
10
+ exports.getTargetElement = getTargetElement;
9
11
  exports.getVisualEndBottom = getVisualEndBottom;
10
12
  exports.onPositionCalculated = onPositionCalculated;
11
13
  exports.resolveTableAfterPos = resolveTableAfterPos;
@@ -29,10 +31,21 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
29
31
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
30
32
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
31
33
  var PopupWithListeners = (0, _uiReact.withReactEditorViewOuterListeners)(_ui.Popup);
34
+ /**
35
+ * Returns the DOM element at the given document position for use as a Popup anchor.
36
+ * For empty blocks (BR elements), returns the parent element to ensure correct positioning.
37
+ */
32
38
  function getTargetElement(editorView, pos) {
33
39
  try {
34
40
  var domRef = (0, _utils.findDomRefAtPos)(pos, editorView.domAtPos.bind(editorView));
35
41
  if (domRef instanceof HTMLElement) {
42
+ // Empty blocks render a <br> placeholder whose bounding rect has no
43
+ // meaningful dimensions (height ≈ 0). Using it as the Popup anchor
44
+ // causes the menu to appear at an unexpected position. Walk up to the
45
+ // parent block element so the Popup anchors correctly.
46
+ if (domRef.nodeName === 'BR' && domRef.parentElement) {
47
+ return domRef.parentElement;
48
+ }
36
49
  return domRef;
37
50
  }
38
51
  return null;
@@ -70,10 +83,47 @@ function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) {
70
83
  }
71
84
 
72
85
  /**
73
- * Adjusts the vertical position of the paste menu to align with the top of the
74
- * pasted content using the exact coordinates at the paste start position,
75
- * and sticks the menu to the top of the scroll container when the pasted
76
- * content scrolls above the visible area.
86
+ * Finds the DOM element for the nearest block-level ProseMirror ancestor of
87
+ * the given document position. Uses ProseMirror's schema (`node.isBlock`)
88
+ * rather than CSS display properties, so the check is always in sync with the
89
+ * document model.
90
+ *
91
+ * Returns `null` if no block ancestor can be resolved to a DOM element.
92
+ */
93
+ function findBlockAncestorDOM(editorView, pos) {
94
+ try {
95
+ var $pos = editorView.state.doc.resolve(pos);
96
+ // Walk up the document tree from the resolved position's innermost
97
+ // node towards the root. $pos.node(depth) gives the ancestor at each
98
+ // depth; $pos.start(depth) gives the position just inside that ancestor,
99
+ // so `$pos.start(depth) - 1` is the position of the ancestor node itself
100
+ // (which is what nodeDOM expects).
101
+ for (var depth = $pos.depth; depth >= 0; depth--) {
102
+ var node = $pos.node(depth);
103
+ if (node.isBlock) {
104
+ var domNode = editorView.nodeDOM($pos.start(depth) - 1);
105
+ if (domNode instanceof HTMLElement) {
106
+ return domNode;
107
+ }
108
+ // depth 0 is the doc node — nodeDOM(–1) won't work, so try
109
+ // the editor's own DOM element as a fallback.
110
+ if (depth === 0 && editorView.dom instanceof HTMLElement) {
111
+ return editorView.dom;
112
+ }
113
+ }
114
+ }
115
+ } catch (_unused2) {
116
+ // Position may be out of range after a concurrent edit — fall through.
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Adjusts the position of the paste menu so that:
123
+ *
124
+ * **Vertical:** The menu aligns with the top of the pasted content using the
125
+ * exact coordinates at the paste start position, and sticks to the top of the
126
+ * scroll container when the pasted content scrolls above the visible area.
77
127
  *
78
128
  * The Popup uses alignY="bottom", which positions the popup below the target
79
129
  * element's bottom edge. This override:
@@ -84,16 +134,26 @@ function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) {
84
134
  * to the scroll container's top edge (sticky-top).
85
135
  * 3. Stops sticking once the entire pasted range (pasteEndPos) has scrolled
86
136
  * above the visible area.
137
+ *
138
+ * **Horizontal:** When the target element is an inline element (e.g. a mark
139
+ * wrapper like `<strong>`, or an inline node like an emoji), the Popup's
140
+ * `alignX="end"` would place the menu at the right edge of that narrow
141
+ * element. This override resolves the nearest block-level ProseMirror
142
+ * ancestor (using `node.isBlock` from the document schema) and re-anchors
143
+ * the horizontal position to its right edge, so the menu consistently
144
+ * appears at the right side of the content area.
87
145
  */
88
146
  function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, targetElement, scrollableElement) {
89
147
  // Pre-compute once per render to avoid doc.resolve() on every scroll frame.
90
148
  var tableAfterPos = resolveTableAfterPos(editorView, pasteEndPos);
149
+ var blockAncestorDOM = findBlockAncestorDOM(editorView, pasteStartPos);
91
150
  return function (position) {
92
151
  var _position$top;
93
152
  var startCoords = editorView.coordsAtPos(pasteStartPos);
94
153
  var endBottom = getVisualEndBottom(editorView, pasteEndPos, tableAfterPos);
95
154
  var targetRect = targetElement.getBoundingClientRect();
96
155
 
156
+ // ── Vertical adjustment ──────────────────────────────────────────
97
157
  // The Popup places the menu at the target's bottom edge by default.
98
158
  // We shift it up so it aligns with the paste start position.
99
159
  // Both coordinates are in viewport space, so the delta is offset-parent agnostic.
@@ -109,8 +169,28 @@ function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, targetElem
109
169
  adjustedTop += scrollContainerTop - startCoords.top + _constants.PASTE_MENU_GAP_TOP;
110
170
  }
111
171
  }
172
+
173
+ // ── Horizontal adjustment ────────────────────────────────────────
174
+ // When pasted content starts with a mark (bold, italic, link …) or
175
+ // an inline node (emoji, smart link, inline image …),
176
+ // findDomRefAtPos returns the narrow inline wrapper element. The
177
+ // Popup's alignX="end" then places the menu at that element's right
178
+ // edge instead of the content area's right edge. We correct this by
179
+ // resolving the nearest block-level ProseMirror ancestor and
180
+ // re-anchoring to its right edge.
181
+ var adjustedLeft = position.left;
182
+ if (blockAncestorDOM && blockAncestorDOM !== targetElement) {
183
+ var _position$left;
184
+ var blockRect = blockAncestorDOM.getBoundingClientRect();
185
+ // Shift left by the difference between the block's right edge and
186
+ // the inline target's right edge. This mirrors what alignX="end"
187
+ // would have computed if the target were the block element.
188
+ var leftDelta = blockRect.right - targetRect.right;
189
+ adjustedLeft = ((_position$left = position.left) !== null && _position$left !== void 0 ? _position$left : 0) + leftDelta;
190
+ }
112
191
  return _objectSpread(_objectSpread({}, position), {}, {
113
- top: adjustedTop
192
+ top: adjustedTop,
193
+ left: adjustedLeft
114
194
  });
115
195
  };
116
196
  }
@@ -147,7 +227,12 @@ var PasteActionsMenu = exports.PasteActionsMenu = function PasteActionsMenu(_ref
147
227
  var $pos = editorView.state.doc.resolve(lastContentPasted.pasteStartPos);
148
228
  var pasteAncestorNodeNames = [];
149
229
  for (var depth = $pos.depth; depth > 0; depth--) {
150
- pasteAncestorNodeNames.push($pos.node(depth).type.name);
230
+ // Only include an ancestor if the entire pasted range is contained within it.
231
+ // This prevents nodes like 'heading' from being flagged as ancestors when the
232
+ // pasted content starts in a heading but extends beyond it (e.g. heading + paragraph).
233
+ if (lastContentPasted.pasteEndPos <= $pos.end(depth)) {
234
+ pasteAncestorNodeNames.push($pos.node(depth).type.name);
235
+ }
151
236
  }
152
237
  var legacyVisible = (0, _toolbar2.isToolbarVisible)(editorView.state, lastContentPasted) && ((_lastContentPasted$te = (_lastContentPasted$te2 = lastContentPasted.text) === null || _lastContentPasted$te2 === void 0 ? void 0 : _lastContentPasted$te2.length) !== null && _lastContentPasted$te !== void 0 ? _lastContentPasted$te : 0) >= 100;
153
238
  (0, _commands.showToolbar)(lastContentPasted, selectedOption, legacyVisible, pasteAncestorNodeNames)(editorView.state, editorView.dispatch);
@@ -231,7 +316,8 @@ var PasteActionsMenu = exports.PasteActionsMenu = function PasteActionsMenu(_ref
231
316
  minPopupMargin: _constants.PASTE_MENU_GAP_HORIZONTAL,
232
317
  zIndex: _editorSharedStyles.akEditorFloatingPanelZIndex,
233
318
  alignX: "end",
234
- alignY: "bottom",
319
+ alignY: "bottom"
320
+ /* eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) */,
235
321
  offset: [_constants.PASTE_MENU_GAP_HORIZONTAL, 0],
236
322
  onPositionCalculated: onPositionCalculated(editorView, pasteStartPos, pasteEndPos, target, effectiveScrollableElement),
237
323
  handleClickOutside: handleClickOutside,
@@ -152,7 +152,7 @@ export function getMarkdownSlice(text, schema, selection) {
152
152
  for (let i = 0; i < textSplitByCodeBlock.length; i++) {
153
153
  if (i % 2 === 0) {
154
154
  // Ignored via go/ees005
155
- // eslint-disable-next-line require-unicode-regexp
155
+ // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace -- Ignored via go/ees017 (to be fixed)
156
156
  textSplitByCodeBlock[i] = textSplitByCodeBlock[i].replace(/\\/g, '\\\\');
157
157
  }
158
158
  }
@@ -14,10 +14,21 @@ import { isToolbarVisible } from '../toolbar';
14
14
  import { getVisibleKeys, hasVisibleButton } from './hasVisibleButton';
15
15
  import { PasteActionsMenuContent } from './PasteActionsMenuContent';
16
16
  const PopupWithListeners = withReactEditorViewOuterListeners(Popup);
17
- function getTargetElement(editorView, pos) {
17
+ /**
18
+ * Returns the DOM element at the given document position for use as a Popup anchor.
19
+ * For empty blocks (BR elements), returns the parent element to ensure correct positioning.
20
+ */
21
+ export function getTargetElement(editorView, pos) {
18
22
  try {
19
23
  const domRef = findDomRefAtPos(pos, editorView.domAtPos.bind(editorView));
20
24
  if (domRef instanceof HTMLElement) {
25
+ // Empty blocks render a <br> placeholder whose bounding rect has no
26
+ // meaningful dimensions (height ≈ 0). Using it as the Popup anchor
27
+ // causes the menu to appear at an unexpected position. Walk up to the
28
+ // parent block element so the Popup anchors correctly.
29
+ if (domRef.nodeName === 'BR' && domRef.parentElement) {
30
+ return domRef.parentElement;
31
+ }
21
32
  return domRef;
22
33
  }
23
34
  return null;
@@ -55,10 +66,47 @@ export function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) {
55
66
  }
56
67
 
57
68
  /**
58
- * Adjusts the vertical position of the paste menu to align with the top of the
59
- * pasted content using the exact coordinates at the paste start position,
60
- * and sticks the menu to the top of the scroll container when the pasted
61
- * content scrolls above the visible area.
69
+ * Finds the DOM element for the nearest block-level ProseMirror ancestor of
70
+ * the given document position. Uses ProseMirror's schema (`node.isBlock`)
71
+ * rather than CSS display properties, so the check is always in sync with the
72
+ * document model.
73
+ *
74
+ * Returns `null` if no block ancestor can be resolved to a DOM element.
75
+ */
76
+ export function findBlockAncestorDOM(editorView, pos) {
77
+ try {
78
+ const $pos = editorView.state.doc.resolve(pos);
79
+ // Walk up the document tree from the resolved position's innermost
80
+ // node towards the root. $pos.node(depth) gives the ancestor at each
81
+ // depth; $pos.start(depth) gives the position just inside that ancestor,
82
+ // so `$pos.start(depth) - 1` is the position of the ancestor node itself
83
+ // (which is what nodeDOM expects).
84
+ for (let depth = $pos.depth; depth >= 0; depth--) {
85
+ const node = $pos.node(depth);
86
+ if (node.isBlock) {
87
+ const domNode = editorView.nodeDOM($pos.start(depth) - 1);
88
+ if (domNode instanceof HTMLElement) {
89
+ return domNode;
90
+ }
91
+ // depth 0 is the doc node — nodeDOM(–1) won't work, so try
92
+ // the editor's own DOM element as a fallback.
93
+ if (depth === 0 && editorView.dom instanceof HTMLElement) {
94
+ return editorView.dom;
95
+ }
96
+ }
97
+ }
98
+ } catch {
99
+ // Position may be out of range after a concurrent edit — fall through.
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Adjusts the position of the paste menu so that:
106
+ *
107
+ * **Vertical:** The menu aligns with the top of the pasted content using the
108
+ * exact coordinates at the paste start position, and sticks to the top of the
109
+ * scroll container when the pasted content scrolls above the visible area.
62
110
  *
63
111
  * The Popup uses alignY="bottom", which positions the popup below the target
64
112
  * element's bottom edge. This override:
@@ -69,16 +117,26 @@ export function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) {
69
117
  * to the scroll container's top edge (sticky-top).
70
118
  * 3. Stops sticking once the entire pasted range (pasteEndPos) has scrolled
71
119
  * above the visible area.
120
+ *
121
+ * **Horizontal:** When the target element is an inline element (e.g. a mark
122
+ * wrapper like `<strong>`, or an inline node like an emoji), the Popup's
123
+ * `alignX="end"` would place the menu at the right edge of that narrow
124
+ * element. This override resolves the nearest block-level ProseMirror
125
+ * ancestor (using `node.isBlock` from the document schema) and re-anchors
126
+ * the horizontal position to its right edge, so the menu consistently
127
+ * appears at the right side of the content area.
72
128
  */
73
129
  export function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, targetElement, scrollableElement) {
74
130
  // Pre-compute once per render to avoid doc.resolve() on every scroll frame.
75
131
  const tableAfterPos = resolveTableAfterPos(editorView, pasteEndPos);
132
+ const blockAncestorDOM = findBlockAncestorDOM(editorView, pasteStartPos);
76
133
  return position => {
77
134
  var _position$top;
78
135
  const startCoords = editorView.coordsAtPos(pasteStartPos);
79
136
  const endBottom = getVisualEndBottom(editorView, pasteEndPos, tableAfterPos);
80
137
  const targetRect = targetElement.getBoundingClientRect();
81
138
 
139
+ // ── Vertical adjustment ──────────────────────────────────────────
82
140
  // The Popup places the menu at the target's bottom edge by default.
83
141
  // We shift it up so it aligns with the paste start position.
84
142
  // Both coordinates are in viewport space, so the delta is offset-parent agnostic.
@@ -94,9 +152,29 @@ export function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, tar
94
152
  adjustedTop += scrollContainerTop - startCoords.top + PASTE_MENU_GAP_TOP;
95
153
  }
96
154
  }
155
+
156
+ // ── Horizontal adjustment ────────────────────────────────────────
157
+ // When pasted content starts with a mark (bold, italic, link …) or
158
+ // an inline node (emoji, smart link, inline image …),
159
+ // findDomRefAtPos returns the narrow inline wrapper element. The
160
+ // Popup's alignX="end" then places the menu at that element's right
161
+ // edge instead of the content area's right edge. We correct this by
162
+ // resolving the nearest block-level ProseMirror ancestor and
163
+ // re-anchoring to its right edge.
164
+ let adjustedLeft = position.left;
165
+ if (blockAncestorDOM && blockAncestorDOM !== targetElement) {
166
+ var _position$left;
167
+ const blockRect = blockAncestorDOM.getBoundingClientRect();
168
+ // Shift left by the difference between the block's right edge and
169
+ // the inline target's right edge. This mirrors what alignX="end"
170
+ // would have computed if the target were the block element.
171
+ const leftDelta = blockRect.right - targetRect.right;
172
+ adjustedLeft = ((_position$left = position.left) !== null && _position$left !== void 0 ? _position$left : 0) + leftDelta;
173
+ }
97
174
  return {
98
175
  ...position,
99
- top: adjustedTop
176
+ top: adjustedTop,
177
+ left: adjustedLeft
100
178
  };
101
179
  };
102
180
  }
@@ -135,7 +213,12 @@ export const PasteActionsMenu = ({
135
213
  const $pos = editorView.state.doc.resolve(lastContentPasted.pasteStartPos);
136
214
  const pasteAncestorNodeNames = [];
137
215
  for (let depth = $pos.depth; depth > 0; depth--) {
138
- pasteAncestorNodeNames.push($pos.node(depth).type.name);
216
+ // Only include an ancestor if the entire pasted range is contained within it.
217
+ // This prevents nodes like 'heading' from being flagged as ancestors when the
218
+ // pasted content starts in a heading but extends beyond it (e.g. heading + paragraph).
219
+ if (lastContentPasted.pasteEndPos <= $pos.end(depth)) {
220
+ pasteAncestorNodeNames.push($pos.node(depth).type.name);
221
+ }
139
222
  }
140
223
  const legacyVisible = isToolbarVisible(editorView.state, lastContentPasted) && ((_lastContentPasted$te = (_lastContentPasted$te2 = lastContentPasted.text) === null || _lastContentPasted$te2 === void 0 ? void 0 : _lastContentPasted$te2.length) !== null && _lastContentPasted$te !== void 0 ? _lastContentPasted$te : 0) >= 100;
141
224
  showToolbar(lastContentPasted, selectedOption, legacyVisible, pasteAncestorNodeNames)(editorView.state, editorView.dispatch);
@@ -220,7 +303,8 @@ export const PasteActionsMenu = ({
220
303
  minPopupMargin: PASTE_MENU_GAP_HORIZONTAL,
221
304
  zIndex: akEditorFloatingPanelZIndex,
222
305
  alignX: "end",
223
- alignY: "bottom",
306
+ alignY: "bottom"
307
+ /* eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) */,
224
308
  offset: [PASTE_MENU_GAP_HORIZONTAL, 0],
225
309
  onPositionCalculated: onPositionCalculated(editorView, pasteStartPos, pasteEndPos, target, effectiveScrollableElement),
226
310
  handleClickOutside: handleClickOutside,
@@ -150,7 +150,7 @@ export function getMarkdownSlice(text, schema, selection) {
150
150
  for (var i = 0; i < textSplitByCodeBlock.length; i++) {
151
151
  if (i % 2 === 0) {
152
152
  // Ignored via go/ees005
153
- // eslint-disable-next-line require-unicode-regexp
153
+ // eslint-disable-next-line require-unicode-regexp, @atlassian/perf-linting/no-expensive-split-replace -- Ignored via go/ees017 (to be fixed)
154
154
  textSplitByCodeBlock[i] = textSplitByCodeBlock[i].replace(/\\/g, '\\\\');
155
155
  }
156
156
  }
@@ -17,10 +17,21 @@ import { isToolbarVisible } from '../toolbar';
17
17
  import { getVisibleKeys, hasVisibleButton } from './hasVisibleButton';
18
18
  import { PasteActionsMenuContent } from './PasteActionsMenuContent';
19
19
  var PopupWithListeners = withReactEditorViewOuterListeners(Popup);
20
- function getTargetElement(editorView, pos) {
20
+ /**
21
+ * Returns the DOM element at the given document position for use as a Popup anchor.
22
+ * For empty blocks (BR elements), returns the parent element to ensure correct positioning.
23
+ */
24
+ export function getTargetElement(editorView, pos) {
21
25
  try {
22
26
  var domRef = findDomRefAtPos(pos, editorView.domAtPos.bind(editorView));
23
27
  if (domRef instanceof HTMLElement) {
28
+ // Empty blocks render a <br> placeholder whose bounding rect has no
29
+ // meaningful dimensions (height ≈ 0). Using it as the Popup anchor
30
+ // causes the menu to appear at an unexpected position. Walk up to the
31
+ // parent block element so the Popup anchors correctly.
32
+ if (domRef.nodeName === 'BR' && domRef.parentElement) {
33
+ return domRef.parentElement;
34
+ }
24
35
  return domRef;
25
36
  }
26
37
  return null;
@@ -58,10 +69,47 @@ export function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) {
58
69
  }
59
70
 
60
71
  /**
61
- * Adjusts the vertical position of the paste menu to align with the top of the
62
- * pasted content using the exact coordinates at the paste start position,
63
- * and sticks the menu to the top of the scroll container when the pasted
64
- * content scrolls above the visible area.
72
+ * Finds the DOM element for the nearest block-level ProseMirror ancestor of
73
+ * the given document position. Uses ProseMirror's schema (`node.isBlock`)
74
+ * rather than CSS display properties, so the check is always in sync with the
75
+ * document model.
76
+ *
77
+ * Returns `null` if no block ancestor can be resolved to a DOM element.
78
+ */
79
+ export function findBlockAncestorDOM(editorView, pos) {
80
+ try {
81
+ var $pos = editorView.state.doc.resolve(pos);
82
+ // Walk up the document tree from the resolved position's innermost
83
+ // node towards the root. $pos.node(depth) gives the ancestor at each
84
+ // depth; $pos.start(depth) gives the position just inside that ancestor,
85
+ // so `$pos.start(depth) - 1` is the position of the ancestor node itself
86
+ // (which is what nodeDOM expects).
87
+ for (var depth = $pos.depth; depth >= 0; depth--) {
88
+ var node = $pos.node(depth);
89
+ if (node.isBlock) {
90
+ var domNode = editorView.nodeDOM($pos.start(depth) - 1);
91
+ if (domNode instanceof HTMLElement) {
92
+ return domNode;
93
+ }
94
+ // depth 0 is the doc node — nodeDOM(–1) won't work, so try
95
+ // the editor's own DOM element as a fallback.
96
+ if (depth === 0 && editorView.dom instanceof HTMLElement) {
97
+ return editorView.dom;
98
+ }
99
+ }
100
+ }
101
+ } catch (_unused2) {
102
+ // Position may be out of range after a concurrent edit — fall through.
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Adjusts the position of the paste menu so that:
109
+ *
110
+ * **Vertical:** The menu aligns with the top of the pasted content using the
111
+ * exact coordinates at the paste start position, and sticks to the top of the
112
+ * scroll container when the pasted content scrolls above the visible area.
65
113
  *
66
114
  * The Popup uses alignY="bottom", which positions the popup below the target
67
115
  * element's bottom edge. This override:
@@ -72,16 +120,26 @@ export function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) {
72
120
  * to the scroll container's top edge (sticky-top).
73
121
  * 3. Stops sticking once the entire pasted range (pasteEndPos) has scrolled
74
122
  * above the visible area.
123
+ *
124
+ * **Horizontal:** When the target element is an inline element (e.g. a mark
125
+ * wrapper like `<strong>`, or an inline node like an emoji), the Popup's
126
+ * `alignX="end"` would place the menu at the right edge of that narrow
127
+ * element. This override resolves the nearest block-level ProseMirror
128
+ * ancestor (using `node.isBlock` from the document schema) and re-anchors
129
+ * the horizontal position to its right edge, so the menu consistently
130
+ * appears at the right side of the content area.
75
131
  */
76
132
  export function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, targetElement, scrollableElement) {
77
133
  // Pre-compute once per render to avoid doc.resolve() on every scroll frame.
78
134
  var tableAfterPos = resolveTableAfterPos(editorView, pasteEndPos);
135
+ var blockAncestorDOM = findBlockAncestorDOM(editorView, pasteStartPos);
79
136
  return function (position) {
80
137
  var _position$top;
81
138
  var startCoords = editorView.coordsAtPos(pasteStartPos);
82
139
  var endBottom = getVisualEndBottom(editorView, pasteEndPos, tableAfterPos);
83
140
  var targetRect = targetElement.getBoundingClientRect();
84
141
 
142
+ // ── Vertical adjustment ──────────────────────────────────────────
85
143
  // The Popup places the menu at the target's bottom edge by default.
86
144
  // We shift it up so it aligns with the paste start position.
87
145
  // Both coordinates are in viewport space, so the delta is offset-parent agnostic.
@@ -97,8 +155,28 @@ export function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, tar
97
155
  adjustedTop += scrollContainerTop - startCoords.top + PASTE_MENU_GAP_TOP;
98
156
  }
99
157
  }
158
+
159
+ // ── Horizontal adjustment ────────────────────────────────────────
160
+ // When pasted content starts with a mark (bold, italic, link …) or
161
+ // an inline node (emoji, smart link, inline image …),
162
+ // findDomRefAtPos returns the narrow inline wrapper element. The
163
+ // Popup's alignX="end" then places the menu at that element's right
164
+ // edge instead of the content area's right edge. We correct this by
165
+ // resolving the nearest block-level ProseMirror ancestor and
166
+ // re-anchoring to its right edge.
167
+ var adjustedLeft = position.left;
168
+ if (blockAncestorDOM && blockAncestorDOM !== targetElement) {
169
+ var _position$left;
170
+ var blockRect = blockAncestorDOM.getBoundingClientRect();
171
+ // Shift left by the difference between the block's right edge and
172
+ // the inline target's right edge. This mirrors what alignX="end"
173
+ // would have computed if the target were the block element.
174
+ var leftDelta = blockRect.right - targetRect.right;
175
+ adjustedLeft = ((_position$left = position.left) !== null && _position$left !== void 0 ? _position$left : 0) + leftDelta;
176
+ }
100
177
  return _objectSpread(_objectSpread({}, position), {}, {
101
- top: adjustedTop
178
+ top: adjustedTop,
179
+ left: adjustedLeft
102
180
  });
103
181
  };
104
182
  }
@@ -135,7 +213,12 @@ export var PasteActionsMenu = function PasteActionsMenu(_ref) {
135
213
  var $pos = editorView.state.doc.resolve(lastContentPasted.pasteStartPos);
136
214
  var pasteAncestorNodeNames = [];
137
215
  for (var depth = $pos.depth; depth > 0; depth--) {
138
- pasteAncestorNodeNames.push($pos.node(depth).type.name);
216
+ // Only include an ancestor if the entire pasted range is contained within it.
217
+ // This prevents nodes like 'heading' from being flagged as ancestors when the
218
+ // pasted content starts in a heading but extends beyond it (e.g. heading + paragraph).
219
+ if (lastContentPasted.pasteEndPos <= $pos.end(depth)) {
220
+ pasteAncestorNodeNames.push($pos.node(depth).type.name);
221
+ }
139
222
  }
140
223
  var legacyVisible = isToolbarVisible(editorView.state, lastContentPasted) && ((_lastContentPasted$te = (_lastContentPasted$te2 = lastContentPasted.text) === null || _lastContentPasted$te2 === void 0 ? void 0 : _lastContentPasted$te2.length) !== null && _lastContentPasted$te !== void 0 ? _lastContentPasted$te : 0) >= 100;
141
224
  showToolbar(lastContentPasted, selectedOption, legacyVisible, pasteAncestorNodeNames)(editorView.state, editorView.dispatch);
@@ -219,7 +302,8 @@ export var PasteActionsMenu = function PasteActionsMenu(_ref) {
219
302
  minPopupMargin: PASTE_MENU_GAP_HORIZONTAL,
220
303
  zIndex: akEditorFloatingPanelZIndex,
221
304
  alignX: "end",
222
- alignY: "bottom",
305
+ alignY: "bottom"
306
+ /* eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) */,
223
307
  offset: [PASTE_MENU_GAP_HORIZONTAL, 0],
224
308
  onPositionCalculated: onPositionCalculated(editorView, pasteStartPos, pasteEndPos, target, effectiveScrollableElement),
225
309
  handleClickOutside: handleClickOutside,
@@ -9,6 +9,11 @@ interface PasteActionsMenuProps {
9
9
  mountTo?: HTMLElement;
10
10
  scrollableElement?: HTMLElement;
11
11
  }
12
+ /**
13
+ * Returns the DOM element at the given document position for use as a Popup anchor.
14
+ * For empty blocks (BR elements), returns the parent element to ensure correct positioning.
15
+ */
16
+ export declare function getTargetElement(editorView: EditorView, pos: number): HTMLElement | null;
12
17
  /**
13
18
  * Returns the position immediately after a table ancestor of `pos`, or
14
19
  * `undefined` if not inside a table. Safe to cache per document version.
@@ -20,10 +25,20 @@ export declare function resolveTableAfterPos(editorView: EditorView, pos: number
20
25
  */
21
26
  export declare function getVisualEndBottom(editorView: EditorView, pasteEndPos: number, tableAfterPos?: number): number;
22
27
  /**
23
- * Adjusts the vertical position of the paste menu to align with the top of the
24
- * pasted content using the exact coordinates at the paste start position,
25
- * and sticks the menu to the top of the scroll container when the pasted
26
- * content scrolls above the visible area.
28
+ * Finds the DOM element for the nearest block-level ProseMirror ancestor of
29
+ * the given document position. Uses ProseMirror's schema (`node.isBlock`)
30
+ * rather than CSS display properties, so the check is always in sync with the
31
+ * document model.
32
+ *
33
+ * Returns `null` if no block ancestor can be resolved to a DOM element.
34
+ */
35
+ export declare function findBlockAncestorDOM(editorView: EditorView, pos: number): HTMLElement | null;
36
+ /**
37
+ * Adjusts the position of the paste menu so that:
38
+ *
39
+ * **Vertical:** The menu aligns with the top of the pasted content using the
40
+ * exact coordinates at the paste start position, and sticks to the top of the
41
+ * scroll container when the pasted content scrolls above the visible area.
27
42
  *
28
43
  * The Popup uses alignY="bottom", which positions the popup below the target
29
44
  * element's bottom edge. This override:
@@ -34,6 +49,14 @@ export declare function getVisualEndBottom(editorView: EditorView, pasteEndPos:
34
49
  * to the scroll container's top edge (sticky-top).
35
50
  * 3. Stops sticking once the entire pasted range (pasteEndPos) has scrolled
36
51
  * above the visible area.
52
+ *
53
+ * **Horizontal:** When the target element is an inline element (e.g. a mark
54
+ * wrapper like `<strong>`, or an inline node like an emoji), the Popup's
55
+ * `alignX="end"` would place the menu at the right edge of that narrow
56
+ * element. This override resolves the nearest block-level ProseMirror
57
+ * ancestor (using `node.isBlock` from the document schema) and re-anchors
58
+ * the horizontal position to its right edge, so the menu consistently
59
+ * appears at the right side of the content area.
37
60
  */
38
61
  export declare function onPositionCalculated(editorView: EditorView, pasteStartPos: number, pasteEndPos: number, targetElement: HTMLElement, scrollableElement?: HTMLElement | false): (position: {
39
62
  bottom?: number;
@@ -9,6 +9,11 @@ interface PasteActionsMenuProps {
9
9
  mountTo?: HTMLElement;
10
10
  scrollableElement?: HTMLElement;
11
11
  }
12
+ /**
13
+ * Returns the DOM element at the given document position for use as a Popup anchor.
14
+ * For empty blocks (BR elements), returns the parent element to ensure correct positioning.
15
+ */
16
+ export declare function getTargetElement(editorView: EditorView, pos: number): HTMLElement | null;
12
17
  /**
13
18
  * Returns the position immediately after a table ancestor of `pos`, or
14
19
  * `undefined` if not inside a table. Safe to cache per document version.
@@ -20,10 +25,20 @@ export declare function resolveTableAfterPos(editorView: EditorView, pos: number
20
25
  */
21
26
  export declare function getVisualEndBottom(editorView: EditorView, pasteEndPos: number, tableAfterPos?: number): number;
22
27
  /**
23
- * Adjusts the vertical position of the paste menu to align with the top of the
24
- * pasted content using the exact coordinates at the paste start position,
25
- * and sticks the menu to the top of the scroll container when the pasted
26
- * content scrolls above the visible area.
28
+ * Finds the DOM element for the nearest block-level ProseMirror ancestor of
29
+ * the given document position. Uses ProseMirror's schema (`node.isBlock`)
30
+ * rather than CSS display properties, so the check is always in sync with the
31
+ * document model.
32
+ *
33
+ * Returns `null` if no block ancestor can be resolved to a DOM element.
34
+ */
35
+ export declare function findBlockAncestorDOM(editorView: EditorView, pos: number): HTMLElement | null;
36
+ /**
37
+ * Adjusts the position of the paste menu so that:
38
+ *
39
+ * **Vertical:** The menu aligns with the top of the pasted content using the
40
+ * exact coordinates at the paste start position, and sticks to the top of the
41
+ * scroll container when the pasted content scrolls above the visible area.
27
42
  *
28
43
  * The Popup uses alignY="bottom", which positions the popup below the target
29
44
  * element's bottom edge. This override:
@@ -34,6 +49,14 @@ export declare function getVisualEndBottom(editorView: EditorView, pasteEndPos:
34
49
  * to the scroll container's top edge (sticky-top).
35
50
  * 3. Stops sticking once the entire pasted range (pasteEndPos) has scrolled
36
51
  * above the visible area.
52
+ *
53
+ * **Horizontal:** When the target element is an inline element (e.g. a mark
54
+ * wrapper like `<strong>`, or an inline node like an emoji), the Popup's
55
+ * `alignX="end"` would place the menu at the right edge of that narrow
56
+ * element. This override resolves the nearest block-level ProseMirror
57
+ * ancestor (using `node.isBlock` from the document schema) and re-anchors
58
+ * the horizontal position to its right edge, so the menu consistently
59
+ * appears at the right side of the content area.
37
60
  */
38
61
  export declare function onPositionCalculated(editorView: EditorView, pasteStartPos: number, pasteEndPos: number, targetElement: HTMLElement, scrollableElement?: HTMLElement | false): (position: {
39
62
  bottom?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-paste-options-toolbar",
3
- "version": "9.1.4",
3
+ "version": "9.1.6",
4
4
  "description": "Paste options toolbar for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -48,7 +48,7 @@
48
48
  "react-intl-next": "npm:react-intl@^5.18.1"
49
49
  },
50
50
  "peerDependencies": {
51
- "@atlaskit/editor-common": "^112.5.0",
51
+ "@atlaskit/editor-common": "^112.8.0",
52
52
  "react": "^18.2.0",
53
53
  "react-dom": "^18.2.0"
54
54
  },