FIX: `<QuoteButton/>` shifts when content is added to header (#20878)
This commit is contained in:
parent
e1a5f36d52
commit
3d7833d67e
|
@ -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}")
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
Loading…
Reference in New Issue