FIX: `<QuoteButton/>` shifts when content is added to header (#20878)

This commit is contained in:
Keegan George 2023-04-05 12:08:38 -07:00 committed by GitHub
parent e1a5f36d52
commit 3d7833d67e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 77 additions and 114 deletions

View File

@ -17,12 +17,14 @@ import KeyEnterEscape from "discourse/mixins/key-enter-escape";
import Sharing from "discourse/lib/sharing"; import Sharing from "discourse/lib/sharing";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import { getAbsoluteURL } from "discourse-common/lib/get-url"; import { getAbsoluteURL } from "discourse-common/lib/get-url";
import { next, schedule } from "@ember/runloop"; import { next, schedule } from "@ember/runloop";
import toMarkdown from "discourse/lib/to-markdown"; import toMarkdown from "discourse/lib/to-markdown";
import escapeRegExp from "discourse-common/utils/escape-regexp"; import escapeRegExp from "discourse-common/utils/escape-regexp";
import { createPopper } from "@popperjs/core";
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
function getQuoteTitle(element) { function getQuoteTitle(element) {
const titleEl = element.querySelector(".title"); const titleEl = element.querySelector(".title");
@ -55,6 +57,8 @@ export default Component.extend(KeyEnterEscape, {
animated: false, animated: false,
privateCategory: alias("topic.category.read_restricted"), privateCategory: alias("topic.category.read_restricted"),
editPost: null, editPost: null,
_popper: null,
popperPlacement: "top-start",
_isFastEditable: false, _isFastEditable: false,
_displayFastEditInput: false, _displayFastEditInput: false,
@ -78,48 +82,12 @@ export default Component.extend(KeyEnterEscape, {
this.set("_displayFastEditInput", false); this.set("_displayFastEditInput", false);
this.set("_fastEditInitialSelection", null); this.set("_fastEditInitialSelection", null);
this.set("_fastEditNewSelection", null); this.set("_fastEditNewSelection", null);
}, this._teardownSelectionListeners();
_getRangeBoundaryRect(range, atEnd) {
// Don't mess with the original range as it results in weird behaviours
// where certain browsers will deselect the selection
const clone = range.cloneRange(range);
// create a marker element containing a single invisible character
const markerElement = document.createElement("span");
markerElement.appendChild(document.createTextNode("\ufeff"));
// on mobile, collapse the range at the end of the selection
if (atEnd) {
clone.collapse();
}
// insert the marker
clone.insertNode(markerElement);
// retrieve the position of the marker
const boundaryRect = markerElement.getBoundingClientRect();
boundaryRect.x += document.documentElement.scrollLeft;
boundaryRect.y += document.documentElement.scrollTop;
// remove the marker
const parent = markerElement.parentNode;
parent.removeChild(markerElement);
// merge back all text nodes so they don't get messed up
parent.normalize();
// work around Safari that would sometimes lose the selection
if (this.capabilities.isSafari) {
this._reselected = true;
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
return boundaryRect;
}, },
_selectionChanged() { _selectionChanged() {
if (this._displayFastEditInput) { if (this._displayFastEditInput) {
this.textRange = virtualElementFromTextRange();
return; return;
} }
@ -229,7 +197,9 @@ export default Component.extend(KeyEnterEscape, {
const { isIOS, isAndroid, isOpera } = this.capabilities; const { isIOS, isAndroid, isOpera } = this.capabilities;
const showAtEnd = isMobileDevice || isIOS || isAndroid || isOpera; const showAtEnd = isMobileDevice || isIOS || isAndroid || isOpera;
const boundaryPosition = this._getRangeBoundaryRect(firstRange, showAtEnd); if (showAtEnd) {
this.popperPlacement = "bottom-start";
}
// change the position of the button // change the position of the button
schedule("afterRender", () => { schedule("afterRender", () => {
@ -237,85 +207,26 @@ export default Component.extend(KeyEnterEscape, {
return; return;
} }
let top = 0; this.textRange = virtualElementFromTextRange();
let left = 0; this._setupSelectionListeners();
const pxFromSelection = 5;
if (showAtEnd) { this._popper = createPopper(this.textRange, this.element, {
// The selection-handles on iOS have a hit area of ~50px radius placement: this.popperPlacement,
// so we need to make sure our buttons are outside that radius modifiers: [
// Apply the same logic on all mobile devices for consistency
top = boundaryPosition.bottom + pxFromSelection;
left = boundaryPosition.left;
const safeRadius = 50;
const topicArea = document
.querySelector(".topic-area")
.getBoundingClientRect();
topicArea.x += document.documentElement.scrollLeft;
topicArea.y += document.documentElement.scrollTop;
const endHandlePosition = boundaryPosition;
const width = this.element.clientWidth;
const possiblePositions = [
{ {
// move to left name: "computeStyles",
top, options: {
left: left - width - safeRadius, adaptive: false,
},
}, },
{ {
// move to right name: "offset",
top, options: {
left: left + safeRadius, offset: [0, 3],
},
}, },
{ ],
// centered below end handle });
top: top + safeRadius,
left: left - width / 2,
},
];
for (const pos of possiblePositions) {
// Ensure buttons are entirely within the .topic-area
pos.left = Math.max(topicArea.left, pos.left);
pos.left = Math.min(topicArea.right - width, pos.left);
let clearOfStartHandle = true;
if (isAndroid) {
// On android, the start-selection handle extends below the line, so we need to avoid it as well:
const startHandlePosition = this._getRangeBoundaryRect(
firstRange,
false
);
clearOfStartHandle =
pos.top - startHandlePosition.bottom >= safeRadius ||
pos.left + width <= startHandlePosition.left - safeRadius ||
pos.left >= startHandlePosition.left + safeRadius;
}
const clearOfEndHandle =
pos.top - endHandlePosition.top >= safeRadius ||
pos.left + width <= endHandlePosition.left - safeRadius ||
pos.left >= endHandlePosition.left + safeRadius;
if (clearOfStartHandle && clearOfEndHandle) {
left = pos.left;
top = pos.top;
break;
}
}
} else {
// Desktop
top =
boundaryPosition.top - this.element.clientHeight - pxFromSelection;
left = boundaryPosition.left;
}
Object.assign(this.element.style, { top: `${top}px`, left: `${left}px` });
if (!this.animated) { if (!this.animated) {
// We only enable CSS transitions after the initial positioning // We only enable CSS transitions after the initial positioning
@ -325,6 +236,23 @@ export default Component.extend(KeyEnterEscape, {
}); });
}, },
@bind
_updateRect() {
this.textRange?.updateRect();
},
_setupSelectionListeners() {
document.body.addEventListener("mouseup", this._updateRect);
window.addEventListener("scroll", this._updateRect);
document.scrollingElement.addEventListener("scroll", this._updateRect);
},
_teardownSelectionListeners() {
document.body.removeEventListener("mouseup", this._updateRect);
window.removeEventListener("scroll", this._updateRect);
document.scrollingElement.removeEventListener("scroll", this._updateRect);
},
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
@ -372,12 +300,14 @@ export default Component.extend(KeyEnterEscape, {
}, },
willDestroyElement() { willDestroyElement() {
this._popper?.destroy();
$(document) $(document)
.off("mousedown.quote-button") .off("mousedown.quote-button")
.off("mouseup.quote-button") .off("mouseup.quote-button")
.off("selectionchange.quote-button"); .off("selectionchange.quote-button");
this.appEvents.off("quote-button:quote", this, "insertQuote"); this.appEvents.off("quote-button:quote", this, "insertQuote");
this.appEvents.off("quote-button:edit", this, "_toggleFastEditForm"); this.appEvents.off("quote-button:edit", this, "_toggleFastEditForm");
this._teardownSelectionListeners();
}, },
@discourseComputed("topic.{isPrivateMessage,invisible,category}") @discourseComputed("topic.{isPrivateMessage,invisible,category}")

View File

@ -0,0 +1,33 @@
class VirtualElementFromTextRange {
constructor() {
this.updateRect();
}
updateRect() {
const selection = document.getSelection();
this.range = selection && selection.rangeCount && selection.getRangeAt(0);
if (!this.range) {
return;
}
this.rect = this.range.getBoundingClientRect();
return this.rect;
}
getBoundingClientRect() {
return this.rect;
}
get clientWidth() {
return this.rect.width;
}
get clientHeight() {
return this.rect.height;
}
}
export default function virtualElementFromTextRange() {
return new VirtualElementFromTextRange();
}