diff --git a/chrome/content/zotero/contextMenuUtils.sys.mjs b/chrome/content/zotero/contextMenuUtils.sys.mjs index 3952cfa21d..a24ebfe688 100644 --- a/chrome/content/zotero/contextMenuUtils.sys.mjs +++ b/chrome/content/zotero/contextMenuUtils.sys.mjs @@ -22,6 +22,20 @@ export function getTargetElement(doc) { * @returns {{ clientX: number, clientY: number }} */ function getContextMenuPosition(targetElement) { + // In theory, we could find the caret position using some privileged-code + // magic: + // + // let x = {}, y = {}, w = {}, h = {}; + // Cc["@mozilla.org/accessibilityService;1"].getService(Ci.nsIAccessibilityService) + // .getAccessibleFor(targetElement) + // .QueryInterface(Ci.nsIAccessibleText) + // .getCaretRect(x, y, w, h); + // + // But this returns hardware screen units, doesn't work for selections, and + // doesn't *precisely* correspond to the position of the caret on screen + // without adjustments. + // Consider revisiting if the manual method gets too weighed down by edge cases. + let selection; if (targetElement.editor?.selection) { selection = targetElement.editor.selection; @@ -41,23 +55,53 @@ function getContextMenuPosition(targetElement) { let anchorToEnd; if (selection) { let range = selection.getRangeAt(0); + + // Easy case: We have a range with rects, so use its bounding rect if (range.getClientRects().length) { rect = range.getBoundingClientRect(); + + // ...Except an empty 's text node will be anchored + // to the bottom with height 0, so move the rect to the top + if (rect.height === 0 + && range.collapsed + && range.startContainer.isNativeAnonymous + && range.startContainer.parentElement?.getClientRects().length) { + // We know we have a mutable DOMRect here, so we can + // mutate without cloning + rect.y = range.startContainer.parentElement.getBoundingClientRect().y; + + anchorToBottom = false; + anchorToEnd = true; + } } - // If the selection is between lines in an editor, it'll be - // inside the editor's native anonymous text node and won't - // have any rects for some reason. + // If the caret is between lines in an editor, it'll be inside the + // editor's native anonymous text node and won't have any rects for + // some reason. // If that's the case, use the text node's bounds. - else if (range.startContainer === range.endContainer && range.startContainer.isNativeAnonymous + else if (range.collapsed && range.startContainer.isNativeAnonymous && range.startContainer.firstChild?.nodeType === Node.TEXT_NODE) { let quads = range.startContainer.firstChild.getBoxQuads(); rect = quads[quads.length - 1].getBounds(); } + // In a contenteditable (ProseMirror), when the selection is in an + // empty block or at the end of a block, it won't be within a text node + // and won't have any rects. Place at the end of the previous node. + else if (range.startContainer === range.endContainer + && range.startOffset === range.endOffset + && range.startOffset < range.startContainer.childNodes.length) { + range = range.cloneRange(); + range.selectNode(range.startContainer.childNodes[range.startOffset]); + rect = range.getBoundingClientRect(); + + anchorToBottom = false; + anchorToEnd = true; + } else { rect = range.commonAncestorContainer.getBoundingClientRect(); } - anchorToBottom = !range.collapsed; - anchorToEnd = range.collapsed; + + anchorToBottom ??= !range.collapsed; + anchorToEnd ??= range.collapsed; } else { rect = targetElement.getBoundingClientRect();