diff --git a/app/assets/javascripts/discourse-common/addon/utils/dom-utils.js b/app/assets/javascripts/discourse-common/addon/utils/dom-utils.js new file mode 100644 index 00000000000..20e83bf6ffb --- /dev/null +++ b/app/assets/javascripts/discourse-common/addon/utils/dom-utils.js @@ -0,0 +1,21 @@ +function offset(element) { + // note that getBoundingClientRect forces a reflow. + // When used in critical performance conditions + // you might want to move to more involved solution + // such as implementing an IntersectionObserver and + // using its boundingClientRect property + const rect = element.getBoundingClientRect(); + return { + top: rect.top + window.scrollY, + left: rect.left + window.scrollX, + }; +} + +function position(element) { + return { + top: element.offsetTop, + left: element.offsetLeft, + }; +} + +export default { offset, position }; diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js index 98ff4d775f4..261d01bc08d 100644 --- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js +++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js @@ -1,5 +1,5 @@ import { cloak, uncloak } from "discourse/widgets/post-stream"; -import { next, scheduleOnce } from "@ember/runloop"; +import { schedule, scheduleOnce } from "@ember/runloop"; import DiscourseURL from "discourse/lib/url"; import MountWidget from "discourse/components/mount-widget"; import discourseDebounce from "discourse-common/lib/debounce"; @@ -7,18 +7,20 @@ import { isWorkaroundActive } from "discourse/lib/safari-hacks"; import offsetCalculator from "discourse/lib/offset-calculator"; import { inject as service } from "@ember/service"; import { bind } from "discourse-common/utils/decorators"; +import domUtils from "discourse-common/utils/dom-utils"; const DEBOUNCE_DELAY = 50; -function findTopView($posts, viewportTop, postsWrapperTop, min, max) { +function findTopView(posts, viewportTop, postsWrapperTop, min, max) { if (max < min) { return min; } while (max > min) { const mid = Math.floor((min + max) / 2); - const $post = $($posts[mid]); - const viewBottom = $post.offset().top - postsWrapperTop + $post.height(); + const post = posts.item(mid); + const viewBottom = + domUtils.offset(post).top - postsWrapperTop + post.clientHeight; if (viewBottom > viewportTop) { max = mid - 1; @@ -57,20 +59,20 @@ export default MountWidget.extend({ }, beforePatch() { - const $body = $(document); - this.prevHeight = $body.height(); - this.prevScrollTop = $body.scrollTop(); + this.prevHeight = document.body.clientHeight; + this.prevScrollTop = document.body.scrollTop; }, afterPatch() { - const $body = $(document); - const height = $body.height(); - const scrollTop = $body.scrollTop(); + const height = document.body.clientHeight; // This hack is for when swapping out many cloaked views at once // when using keyboard navigation. It could suddenly move the scroll - if (this.prevHeight === height && scrollTop !== this.prevScrollTop) { - $body.scrollTop(this.prevScrollTop); + if ( + this.prevHeight === height && + document.body.scrollTop !== this.prevScrollTop + ) { + document.body.scroll({ left: 0, top: this.prevScrollTop }); } }, @@ -97,31 +99,30 @@ export default MountWidget.extend({ return; } - const $w = $(window); - const windowHeight = window.innerHeight ? window.innerHeight : $w.height(); + const windowHeight = window.innerHeight; const slack = Math.round(windowHeight * 5); const onscreen = []; const nearby = []; - - const windowTop = $w.scrollTop(); - - const postsWrapperTop = $(".posts-wrapper").offset().top; - const $posts = $( - this.element.querySelectorAll(".onscreen-post, .cloaked-post") + const windowTop = document.documentElement.scrollTop; + const postsWrapperTop = domUtils.offset( + document.querySelector(".posts-wrapper") + ).top; + const postsNodes = this.element.querySelectorAll( + ".onscreen-post, .cloaked-post" ); + const viewportTop = windowTop - slack; const topView = findTopView( - $posts, + postsNodes, viewportTop, postsWrapperTop, 0, - $posts.length - 1 + postsNodes.length - 1 ); let windowBottom = windowTop + windowHeight; let viewportBottom = windowBottom + slack; - - const bodyHeight = $("body").height(); + const bodyHeight = document.body.clientHeight; if (windowBottom > bodyHeight) { windowBottom = bodyHeight; } @@ -148,16 +149,15 @@ export default MountWidget.extend({ let allAbove = true; let bottomView = topView; let lastBottom = 0; - while (bottomView < $posts.length) { - const post = $posts[bottomView]; - const $post = $(post); + while (bottomView < postsNodes.length) { + const post = postsNodes.item(bottomView); - if (!$post) { + if (!post) { break; } - const viewTop = $post.offset().top; - const postHeight = $post.outerHeight(true); + const viewTop = domUtils.offset(post).top; + const postHeight = post.clientHeight; const viewBottom = Math.ceil(viewTop + postHeight); allAbove = allAbove && viewTop < topCheck; @@ -199,28 +199,30 @@ export default MountWidget.extend({ const first = posts.objectAt(onscreen[0]); if (this._topVisible !== first) { this._topVisible = first; - const $body = $("body"); - const elem = $posts[onscreen[0]]; + const elem = postsNodes.item(onscreen[0]); const elemId = elem.id; - const $elem = $(elem); - const elemPos = $elem.position(); - const distToElement = elemPos ? $body.scrollTop() - elemPos.top : 0; + const elemPos = domUtils.position(elem); + const distToElement = elemPos + ? document.body.scrollTop - elemPos.top + : 0; const topRefresh = () => { refresh(() => { - const $refreshedElem = $(`#${elemId}`); + const refreshedElem = document.getElementById(elemId); // Quickly going back might mean the element is destroyed - const position = $refreshedElem.position(); + const position = domUtils.position(refreshedElem); if (position && position.top) { let whereY = position.top + distToElement; - $("html, body").scrollTop(whereY); + document.documentElement.scroll({ top: whereY, left: 0 }); // This seems weird, but somewhat infrequently a rerender // will cause the browser to scroll to the top of the document // in Chrome. This makes sure the scroll works correctly if that // happens. - next(() => $("html, body").scrollTop(whereY)); + schedule("afterRender", () => { + document.documentElement.scroll({ top: whereY, left: 0 }); + }); } }); }; @@ -292,7 +294,7 @@ export default MountWidget.extend({ _posted(staged) { this.queueRerender(() => { if (staged) { - const postNumber = staged.get("post_number"); + const postNumber = staged.post_number; DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true }); } }); @@ -340,18 +342,17 @@ export default MountWidget.extend({ this.appEvents.on("post-stream:posted", this, "_posted"); - $(this.element).on( - "mouseenter.post-stream", - "button.widget-button", - (e) => { - $("button.widget-button").removeClass("d-hover"); - $(e.target).addClass("d-hover"); - } + this.element.addEventListener( + "mouseenter", + this._handleWidgetButtonHoverState, + true ); - $(this.element).on("mouseleave.post-stream", "button.widget-button", () => { - $("button.widget-button").removeClass("d-hover"); - }); + this.element.addEventListener( + "mouseleave", + this._removeWidgetButtonHoverState, + true + ); this.appEvents.on("post-stream:refresh", this, "_refresh"); @@ -365,12 +366,36 @@ export default MountWidget.extend({ willDestroyElement() { this._super(...arguments); + document.removeEventListener("touchmove", this._debouncedScroll); window.removeEventListener("scroll", this._debouncedScroll); this.appEvents.off("post-stream:refresh", this, "_debouncedScroll"); - $(this.element).off("mouseenter.post-stream"); - $(this.element).off("mouseleave.post-stream"); + this.element.removeEventListener( + "mouseenter", + this._handleWidgetButtonHoverState + ); + this.element.removeEventListener( + "mouseleave", + this._removeWidgetButtonHoverState + ); this.appEvents.off("post-stream:refresh", this, "_refresh"); this.appEvents.off("post-stream:posted", this, "_posted"); }, + + _handleWidgetButtonHoverState(event) { + if (event.target.classList.contains("widget-button")) { + document + .querySelectorAll("button.widget-button") + .forEach((widgetButton) => { + widgetButton.classList.remove("d-hover"); + }); + event.target.classList.add("d-hover"); + } + }, + + _removeWidgetButtonHoverState() { + document.querySelectorAll("button.widget-button").forEach((button) => { + button.classList.remove("d-hover"); + }); + }, }); diff --git a/app/assets/javascripts/discourse/tests/unit/utils/dom-utils-test.js b/app/assets/javascripts/discourse/tests/unit/utils/dom-utils-test.js new file mode 100644 index 00000000000..9817eebde85 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/utils/dom-utils-test.js @@ -0,0 +1,39 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import domUtils from "discourse-common/utils/dom-utils"; + +discourseModule("utils:dom-utils", function (hooks) { + setupRenderingTest(hooks); + + componentTest("offset", { + template: hbs`{{d-button translatedLabel="baz"}}`, + + async test(assert) { + const element = document.querySelector(".btn"); + const offset = domUtils.offset(element); + const rect = element.getBoundingClientRect(); + + assert.deepEqual(offset, { + top: rect.top + window.scrollY, + left: rect.left + window.scrollX, + }); + }, + }); + + componentTest("position", { + template: hbs`{{d-button translatedLabel="baz"}}`, + + async test(assert) { + const element = document.querySelector(".btn"); + const position = domUtils.position(element); + + assert.deepEqual(position, { + top: element.offsetTop, + left: element.offsetLeft, + }); + }, + }); +});