diff --git a/app/assets/javascripts/discourse/app/components/composer-body.js b/app/assets/javascripts/discourse/app/components/composer-body.js index 035da9505f1..fb8bf92456b 100644 --- a/app/assets/javascripts/discourse/app/components/composer-body.js +++ b/app/assets/javascripts/discourse/app/components/composer-body.js @@ -11,9 +11,9 @@ import discourseDebounce from "discourse-common/lib/debounce"; import { headerHeight } from "discourse/components/site-header"; import positioningWorkaround from "discourse/lib/safari-hacks"; -const START_EVENTS = "touchstart mousedown"; -const DRAG_EVENTS = "touchmove mousemove"; -const END_EVENTS = "touchend mouseup"; +const START_DRAG_EVENTS = ["touchstart", "mousedown"]; +const DRAG_EVENTS = ["touchmove", "mousemove"]; +const END_DRAG_EVENTS = ["touchend", "mouseup"]; const THROTTLE_RATE = 20; @@ -54,17 +54,15 @@ export default Component.extend(KeyEnterEscape, { }, movePanels(size) { - $("#main-outlet").css("padding-bottom", size ? size : ""); + document.querySelector("#main-outlet").style.paddingBottom = size + ? `${size}px` + : ""; // signal the progress bar it should move! this.appEvents.trigger("composer:resized"); }, - @observes( - "composeState", - "composer.action", - "composer.canEditTopicFeaturedLink" - ) + @observes("composeState", "composer.{action,canEditTopicFeaturedLink}") resize() { schedule("afterRender", () => { if (!this.element || this.isDestroying || this.isDestroyed) { @@ -76,8 +74,11 @@ export default Component.extend(KeyEnterEscape, { }, debounceMove() { - const h = $("#reply-control:not(.saving)").height() || 0; - this.movePanels(h); + let height = 0; + if (!this.element.classList.contains("saving")) { + height = this.element.offsetHeight; + } + this.movePanels(height); }, keyUp() { @@ -105,45 +106,13 @@ export default Component.extend(KeyEnterEscape, { }, setupComposerResizeEvents() { - const $composer = $(this.element); - const $grippie = $(this.element.querySelector(".grippie")); - const $document = $(document); - let origComposerSize = 0; - let lastMousePos = 0; + this.origComposerSize = 0; + this.lastMousePos = 0; - const performDrag = (event) => { - $composer.trigger("div-resizing"); - this.appEvents.trigger("composer:div-resizing"); - $composer.addClass("clear-transitions"); - const currentMousePos = mouseYPos(event); - let size = origComposerSize + (lastMousePos - currentMousePos); - - const winHeight = $(window).height(); - size = Math.min(size, winHeight - headerHeight()); - this.movePanels(size); - $composer.height(size); - }; - - const throttledPerformDrag = ((event) => { - event.preventDefault(); - throttle(this, performDrag, event, THROTTLE_RATE); - }).bind(this); - - const endDrag = (() => { - this.appEvents.trigger("composer:resize-ended"); - $document.off(DRAG_EVENTS, throttledPerformDrag); - $document.off(END_EVENTS, endDrag); - $composer.removeClass("clear-transitions"); - $composer.focus(); - }).bind(this); - - $grippie.on(START_EVENTS, (event) => { - event.preventDefault(); - origComposerSize = $composer.height(); - lastMousePos = mouseYPos(event); - $document.on(DRAG_EVENTS, throttledPerformDrag); - $document.on(END_EVENTS, endDrag); - this.appEvents.trigger("composer:resize-started"); + START_DRAG_EVENTS.forEach((startDragEvent) => { + this.element + .querySelector(".grippie") + ?.addEventListener(startDragEvent, this.startDragHandler); }); if (this._visualViewportResizing()) { @@ -152,6 +121,58 @@ export default Component.extend(KeyEnterEscape, { } }, + @bind + performDragHandler() { + this.appEvents.trigger("composer:div-resizing"); + this.element.classList.add("clear-transitions"); + const currentMousePos = mouseYPos(event); + let size = this.origComposerSize + (this.lastMousePos - currentMousePos); + + size = Math.min(size, window.innerHeight - headerHeight()); + this.movePanels(size); + this.element.style.height = size ? `${size}px` : ""; + }, + + @bind + startDragHandler(event) { + event.preventDefault(); + + this.origComposerSize = this.element.offsetHeight; + this.lastMousePos = mouseYPos(event); + + DRAG_EVENTS.forEach((dragEvent) => { + document.addEventListener(dragEvent, this.throttledPerformDrag); + }); + + END_DRAG_EVENTS.forEach((endDragEvent) => { + document.addEventListener(endDragEvent, this.endDragHandler); + }); + + this.appEvents.trigger("composer:resize-started"); + }, + + @bind + endDragHandler() { + this.appEvents.trigger("composer:resize-ended"); + + DRAG_EVENTS.forEach((dragEvent) => { + document.removeEventListener(dragEvent, this.throttledPerformDrag); + }); + + END_DRAG_EVENTS.forEach((endDragEvent) => { + document.removeEventListener(endDragEvent, this.endDragHandler); + }); + + this.element.classList.remove("clear-transitions"); + this.element.focus(); + }, + + @bind + throttledPerformDrag(event) { + event.preventDefault(); + throttle(this, this.performDragHandler, event, THROTTLE_RATE); + }, + @bind viewportResize() { const composerVH = window.visualViewport.height * 0.01, @@ -207,10 +228,17 @@ export default Component.extend(KeyEnterEscape, { willDestroyElement() { this._super(...arguments); + if (this._visualViewportResizing()) { window.visualViewport.removeEventListener("resize", this.viewportResize); } + START_DRAG_EVENTS.forEach((startDragEvent) => { + this.element + .querySelector(".grippie") + ?.removeEventListener(startDragEvent, this.startDragHandler); + }); + cancel(this._lastKeyTimeout); }, diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index a98df73ad18..fe511fd6f2f 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -12,6 +12,7 @@ import { tinyAvatar, } from "discourse/lib/utilities"; import discourseComputed, { + bind, observes, on, } from "discourse-common/utils/decorators"; @@ -138,9 +139,7 @@ export default Component.extend(ComposerUpload, { @discourseComputed showLink() { - return ( - this.currentUser && this.currentUser.get("link_posting_access") !== "none" - ); + return this.currentUser && this.currentUser.link_posting_access !== "none"; }, @observes("focusTarget") @@ -189,7 +188,8 @@ export default Component.extend(ComposerUpload, { }; }, - userSearchTerm(term) { + @bind + _userSearchTerm(term) { const topicId = this.get("topic.id"); // maybe this is a brand new topic, so grab category from composer const categoryId = @@ -218,34 +218,42 @@ export default Component.extend(ComposerUpload, { return extensions.map((ext) => `.${ext}`).join(); }, + @bind + _afterMentionComplete(value) { + this.composer.set("reply", value); + + // ensures textarea scroll position is correct + schedule("afterRender", () => { + const input = this.element.querySelector(".d-editor-input"); + input?.blur(); + input?.focus(); + }); + }, + @on("didInsertElement") _composerEditorInit() { const $input = $(this.element.querySelector(".d-editor-input")); - const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); if (this.siteSettings.enable_mentions) { $input.autocomplete({ template: findRawTemplate("user-selector-autocomplete"), - dataSource: (term) => this.userSearchTerm.call(this, term), + dataSource: this._userSearchTerm, key: "@", transformComplete: (v) => v.username || v.name, - afterComplete: (value) => { - this.composer.set("reply", value); - - // ensures textarea scroll position is correct - schedule("afterRender", () => $input.blur().focus()); - }, + afterComplete: this._afterMentionComplete, triggerRule: (textarea) => !inCodeBlock(textarea.value, caretPosition(textarea)), }); } if (this._enableAdvancedEditorPreviewSync()) { - this._initInputPreviewSync($input, $preview); + const input = this.element.querySelector(".d-editor-input"); + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + this._initInputPreviewSync(input, preview); } else { - $input.on("scroll", () => - throttle(this, this._syncEditorAndPreviewScroll, $input, $preview, 20) - ); + this.element + .querySelector(".d-editor-input") + ?.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll); } // Focus on the body unless we have a title @@ -316,30 +324,47 @@ export default Component.extend(ComposerUpload, { this.set("shouldBuildScrollMap", true); }, - _initInputPreviewSync($input, $preview) { + @bind + _handleInputInteraction(event) { + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + + if (!$(preview).is(":visible")) { + return; + } + + preview.removeEventListener("scroll", this._handleInputOrPreviewScroll); + event.target.addEventListener("scroll", this._handleInputOrPreviewScroll); + }, + + @bind + _handleInputOrPreviewScroll(event) { + this._syncScroll( + this._syncEditorAndPreviewScroll, + $(event.target), + $(this.element.querySelector(".d-editor-preview-wrapper")) + ); + }, + + @bind + _handlePreviewInteraction(event) { + this.element + .querySelector(".d-editor-input") + ?.removeEventListener("scroll", this._handleInputOrPreviewScroll); + + event.target?.addEventListener("scroll", this._handleInputOrPreviewScroll); + }, + + _initInputPreviewSync(input, preview) { REBUILD_SCROLL_MAP_EVENTS.forEach((event) => { this.appEvents.on(event, this, this._resetShouldBuildScrollMap); }); schedule("afterRender", () => { - $input.on("touchstart mouseenter", () => { - if (!$preview.is(":visible")) { - return; - } - $preview.off("scroll"); + input?.addEventListener("touchstart", this._handleInputInteraction); + input?.addEventListener("mouseenter", this._handleInputInteraction); - $input.on("scroll", () => { - this._syncScroll(this._syncEditorAndPreviewScroll, $input, $preview); - }); - }); - - $preview.on("touchstart mouseenter", () => { - $input.off("scroll"); - - $preview.on("scroll", () => { - this._syncScroll(this._syncPreviewAndEditorScroll, $input, $preview); - }); - }); + preview?.addEventListener("touchstart", this._handlePreviewInteraction); + preview?.addEventListener("mouseenter", this._handlePreviewInteraction); }); }, @@ -353,13 +378,15 @@ export default Component.extend(ComposerUpload, { }, _teardownInputPreviewSync() { - [ - $(this.element.querySelector(".d-editor-input")), - $(this.element.querySelector(".d-editor-preview-wrapper")), - ].forEach(($element) => { - $element.off("mouseenter touchstart"); - $element.off("scroll"); - }); + const input = this.element.querySelector(".d-editor-input"); + input?.removeEventListener("mouseEnter", this._handleInputInteraction); + input?.removeEventListener("touchstart", this._handleInputInteraction); + input?.removeEventListener("scroll", this._handleInputOrPreviewScroll); + + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + preview?.removeEventListener("mouseEnter", this._handlePreviewInteraction); + preview?.removeEventListener("touchstart", this._handlePreviewInteraction); + preview?.removeEventListener("scroll", this._handleInputOrPreviewScroll); REBUILD_SCROLL_MAP_EVENTS.forEach((event) => { this.appEvents.off(event, this, this._resetShouldBuildScrollMap); @@ -453,6 +480,19 @@ export default Component.extend(ComposerUpload, { return scrollMap; }, + @bind + _throttledSyncEditorAndPreviewScroll(event) { + const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); + + throttle( + this, + this._syncEditorAndPreviewScroll, + $(event.target), + $preview, + 20 + ); + }, + _syncEditorAndPreviewScroll($input, $preview, scrollMap) { if (this._enableAdvancedEditorPreviewSync()) { let scrollTop; @@ -599,91 +639,103 @@ export default Component.extend(ComposerUpload, { }); }, - _registerImageScaleButtonClick($preview) { - $preview.off("click", ".scale-btn").on("click", ".scale-btn", (e) => { + @bind + _handleImageScaleButtonClick(event) { + if (!event.target.classList.contains("scale-btn")) { + return; + } + + const index = parseInt( + event.target.closest(".button-wrapper").dataset.imageIndex, + 10 + ); + + const scale = event.target.dataset.scale; + const matchingPlaceholder = this.get("composer.reply").match( + IMAGE_MARKDOWN_REGEX + ); + + if (matchingPlaceholder) { + const match = matchingPlaceholder[index]; + + if (match) { + const replacement = match.replace( + IMAGE_MARKDOWN_REGEX, + `![$1|$2, ${scale}%$4]($5)` + ); + + this.appEvents.trigger( + "composer:replace-text", + matchingPlaceholder[index], + replacement, + { regex: IMAGE_MARKDOWN_REGEX, index } + ); + } + } + + event.preventDefault(); + return; + }, + + @bind + _handleAltTextInputKeypress(event) { + if (!event.target.classList.contains("alt-text-input")) { + return; + } + + if (event.key === "[" || event.key === "]") { + event.preventDefault(); + } + + if (event.key === "Enter") { const index = parseInt( - $(e.target).closest(".button-wrapper").attr("data-image-index"), + $(event.target).closest(".button-wrapper").attr("data-image-index"), 10 ); - - const scale = e.target.attributes["data-scale"].value; const matchingPlaceholder = this.get("composer.reply").match( IMAGE_MARKDOWN_REGEX ); + const match = matchingPlaceholder[index]; + const replacement = match.replace( + IMAGE_MARKDOWN_REGEX, + `![${$(event.target).val()}|$2$3$4]($5)` + ); - if (matchingPlaceholder) { - const match = matchingPlaceholder[index]; + this.appEvents.trigger("composer:replace-text", match, replacement); - if (match) { - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![$1|$2, ${scale}%$4]($5)` - ); - - this.appEvents.trigger( - "composer:replace-text", - matchingPlaceholder[index], - replacement, - { regex: IMAGE_MARKDOWN_REGEX, index } - ); - } - } - - e.preventDefault(); - return; - }); + const parentContainer = $(event.target).closest( + ".alt-text-readonly-container" + ); + const altText = parentContainer.find(".alt-text"); + const altTextButton = parentContainer.find(".alt-text-edit-btn"); + altText.show(); + altTextButton.show(); + $(event.target).hide(); + } }, - _registerImageAltTextButtonClick($preview) { - $preview - .off("click", ".alt-text-edit-btn") - .on("click", ".alt-text-edit-btn", (e) => { - const parentContainer = $(e.target).closest( - ".alt-text-readonly-container" - ); - const altText = parentContainer.find(".alt-text"); - const correspondingInput = parentContainer.find(".alt-text-input"); + @bind + _handleAltTextEditButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-btn")) { + return; + } - $(e.target).hide(); - altText.hide(); - correspondingInput.val(altText.text()); - correspondingInput.show(); - e.preventDefault(); - }); + const parentContainer = $(event.target).closest( + ".alt-text-readonly-container" + ); + const altText = parentContainer.find(".alt-text"); + const correspondingInput = parentContainer.find(".alt-text-input"); - $preview - .off("keypress", ".alt-text-input") - .on("keypress", ".alt-text-input", (e) => { - if (e.key === "[" || e.key === "]") { - e.preventDefault(); - } + $(event.target).hide(); + altText.hide(); + correspondingInput.val(altText.text()); + correspondingInput.show(); + event.preventDefault(); + }, - if (e.key === "Enter") { - const index = parseInt( - $(e.target).closest(".button-wrapper").attr("data-image-index"), - 10 - ); - const matchingPlaceholder = this.get("composer.reply").match( - IMAGE_MARKDOWN_REGEX - ); - const match = matchingPlaceholder[index]; - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![${$(e.target).val()}|$2$3$4]($5)` - ); - - this.appEvents.trigger("composer:replace-text", match, replacement); - - const parentContainer = $(e.target).closest( - ".alt-text-readonly-container" - ); - const altText = parentContainer.find(".alt-text"); - const altTextButton = parentContainer.find(".alt-text-edit-btn"); - altText.show(); - altTextButton.show(); - $(e.target).hide(); - } - }); + _registerImageAltTextButtonClick(preview) { + preview.addEventListener("click", this._handleAltTextEditButtonClick); + preview.addEventListener("keypress", this._handleAltTextInputKeypress); }, @on("willDestroyElement") @@ -701,6 +753,20 @@ export default Component.extend(ComposerUpload, { if (this._enableAdvancedEditorPreviewSync()) { this._teardownInputPreviewSync(); } + + if (!this._enableAdvancedEditorPreviewSync()) { + this.element + .querySelector(".d-editor-input") + ?.removeEventListener( + "scroll", + this._throttledSyncEditorAndPreviewScroll + ); + } + + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + preview?.removeEventListener("click", this._handleImageScaleButtonClick); + preview?.removeEventListener("click", this._handleAltTextEditButtonClick); + preview?.removeEventListener("keypress", this._handleAltTextInputKeypress); }, onExpandPopupMenuOptions(toolbarEvent) { @@ -863,8 +929,8 @@ export default Component.extend(ComposerUpload, { ); } - this._registerImageScaleButtonClick($preview); - this._registerImageAltTextButtonClick($preview); + preview.addEventListener("click", this._handleImageScaleButtonClick); + this._registerImageAltTextButtonClick(preview); this.trigger("previewRefreshed", preview); this.afterRefresh($preview); diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 2c64f015707..13f589fba46 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -5,6 +5,7 @@ import { translateModKey, } from "discourse/lib/utilities"; import discourseComputed, { + bind, observes, on, } from "discourse-common/utils/decorators"; @@ -286,31 +287,9 @@ export default Component.extend(TextareaTextManipulation, { }); // disable clicking on links in the preview - $(this.element.querySelector(".d-editor-preview")).on( - "click.preview", - (e) => { - if (wantsNewWindow(e)) { - return; - } - const $target = $(e.target); - if ($target.is("a.mention")) { - this.appEvents.trigger( - "click.discourse-preview-user-card-mention", - $target - ); - } - if ($target.is("a.mention-group")) { - this.appEvents.trigger( - "click.discourse-preview-group-card-mention-group", - $target - ); - } - if ($target.is("a")) { - e.preventDefault(); - return false; - } - } - ); + this.element + .querySelector(".d-editor-preview") + .addEventListener("click", this._handlePreviewLinkClick); if (this.composerEvents) { this.appEvents.on("composer:insert-block", this, "_insertBlock"); @@ -323,6 +302,32 @@ export default Component.extend(TextareaTextManipulation, { } }, + @bind + _handlePreviewLinkClick(event) { + if (wantsNewWindow(event)) { + return; + } + + if (event.target.tagName === "A") { + if (event.target.classList.contains("mention")) { + this.appEvents.trigger( + "click.discourse-preview-user-card-mention", + $(event.target) + ); + } + + if (event.target.classList.contains("mention-group")) { + this.appEvents.trigger( + "click.discourse-preview-group-card-mention-group", + $(event.target) + ); + } + + event.preventDefault(); + return false; + } + }, + @on("willDestroyElement") _shutDown() { if (this.composerEvents) { @@ -334,7 +339,9 @@ export default Component.extend(TextareaTextManipulation, { this._itsatrap?.destroy(); this._itsatrap = null; - $(this.element.querySelector(".d-editor-preview")).off("click.preview"); + this.element + .querySelector(".d-editor-preview") + ?.removeEventListener("click", this._handlePreviewLinkClick); this._previewMutationObserver?.disconnect(); diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 99b77405a97..30d4aa519de 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -46,8 +46,6 @@ const keys = { let inputTimeout; export default function (options) { - const autocompletePlugin = this; - if (this.length === 0) { return; } @@ -55,13 +53,11 @@ export default function (options) { if (options === "destroy" || options.updateData) { cancel(inputTimeout); - $(this) - .off("keyup.autocomplete") - .off("keydown.autocomplete") - .off("paste.autocomplete") - .off("click.autocomplete"); - - $(window).off("click.autocomplete"); + this[0].removeEventListener("keydown", handleKeyDown); + this[0].removeEventListener("keyup", handleKeyUp); + this[0].removeEventListener("paste", handlePaste); + this[0].removeEventListener("click", closeAutocomplete); + window.removeEventListener("click", closeAutocomplete); if (options === "destroy") { return; @@ -116,8 +112,12 @@ export default function (options) { const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea; let inputSelectedItems = []; + function handlePaste() { + later(() => me.trigger("keydown"), 50); + } + function closeAutocomplete() { - _autoCompletePopper && _autoCompletePopper.destroy(); + _autoCompletePopper?.destroy(); if (div) { div.hide().remove(); @@ -276,7 +276,7 @@ export default function (options) { this.val(""); completeStart = 0; wrap.click(function () { - autocompletePlugin.focus(); + this.focus(); return true; }); } @@ -447,24 +447,17 @@ export default function (options) { closeAutocomplete(); }); - $(window).on("click.autocomplete", () => closeAutocomplete()); - $(this).on("click.autocomplete", () => closeAutocomplete()); - - $(this).on("paste.autocomplete", () => { - later(() => me.trigger("keydown"), 50); - }); - function checkTriggerRule(opts) { return options.triggerRule ? options.triggerRule(me[0], opts) : true; } - $(this).on("keyup.autocomplete", function (e) { + function handleKeyUp(e) { if (options.debounced) { discourseDebounce(this, performAutocomplete, e, INPUT_DELAY); } else { performAutocomplete(e); } - }); + } function performAutocomplete(e) { if ([keys.esc, keys.enter].indexOf(e.which) !== -1) { @@ -503,7 +496,7 @@ export default function (options) { } } - $(this).on("keydown.autocomplete", function (e) { + function handleKeyDown(e) { let c, i, initial, prev, prevIsGood, stopFound, term, total, userToComplete; let cp; @@ -602,7 +595,9 @@ export default function (options) { // We're cancelling it, really. return true; } + e.stopImmediatePropagation(); + e.preventDefault(); return false; case keys.upArrow: selectedOption = selectedOption - 1; @@ -652,7 +647,13 @@ export default function (options) { return true; } } - }); + } + + window.addEventListener("click", closeAutocomplete); + this[0].addEventListener("click", closeAutocomplete); + this[0].addEventListener("paste", handlePaste); + this[0].addEventListener("keyup", handleKeyUp); + this[0].addEventListener("keydown", handleKeyDown); return this; } diff --git a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js index 5f229ace6b1..b8253943d9d 100644 --- a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js @@ -1,3 +1,4 @@ +import { bind } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; import toMarkdown from "discourse/lib/to-markdown"; import { isTesting } from "discourse-common/config/environment"; @@ -6,7 +7,6 @@ import { determinePostReplaceSelection, safariHacksDisabled, } from "discourse/lib/utilities"; -import { bind } from "discourse-common/utils/decorators"; import { next, schedule } from "@ember/runloop"; const isInside = (text, regex) => {