diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 22a685cd204..3bf43ea737d 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -32,7 +32,6 @@ import { loadOneboxes } from "discourse/lib/load-oneboxes"; import loadScript from "discourse/lib/load-script"; import { resolveCachedShortUrls } from "pretty-text/upload-short-url"; import { inject as service } from "@ember/service"; -import showModal from "discourse/lib/show-modal"; import { siteDir } from "discourse/lib/text-direction"; import { translations } from "pretty-text/emoji/data"; import { wantsNewWindow } from "discourse/lib/intercept-click"; @@ -40,6 +39,7 @@ import { action, computed } from "@ember/object"; import TextareaTextManipulation, { getHead, } from "discourse/mixins/textarea-text-manipulation"; +import InsertHyperlink from "discourse/components/modal/insert-hyperlink"; function getButtonLabel(labelKey, defaultLabel) { // use the Font Awesome icon if the label matches the default @@ -218,6 +218,9 @@ export function onToolbarCreate(func) { } export default Component.extend(TextareaTextManipulation, { + emojiStore: service("emoji-store"), + modal: service(), + classNames: ["d-editor"], ready: false, lastSel: null, @@ -225,7 +228,6 @@ export default Component.extend(TextareaTextManipulation, { showLink: true, emojiPickerIsActive: false, emojiFilter: "", - emojiStore: service("emoji-store"), isEditorFocused: false, processPreview: true, composerFocusSelector: "#reply-control .d-editor-input", @@ -770,9 +772,11 @@ export default Component.extend(TextareaTextManipulation, { linkText = this._lastSel.value; } - showModal("insert-hyperlink").setProperties({ - linkText, - toolbarEvent, + this.modal.show(InsertHyperlink, { + model: { + linkText, + toolbarEvent, + }, }); }, diff --git a/app/assets/javascripts/discourse/app/components/modal/insert-hyperlink.hbs b/app/assets/javascripts/discourse/app/components/modal/insert-hyperlink.hbs new file mode 100644 index 00000000000..3baf7d762e7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/insert-hyperlink.hbs @@ -0,0 +1,74 @@ +{{! template-lint-disable no-pointer-down-event-binding }} + + <:body> +
+ + + {{#if this.searchLoading}} + {{loading-spinner}} + {{/if}} + + {{#if this.searchResults}} + + {{/if}} +
+ +
+ +
+ + + <:footer> + + + + +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/insert-hyperlink.js b/app/assets/javascripts/discourse/app/components/modal/insert-hyperlink.js new file mode 100644 index 00000000000..02c5c8c09b6 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/insert-hyperlink.js @@ -0,0 +1,164 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { cancel } from "@ember/runloop"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { isEmpty } from "@ember/utils"; +import { prefixProtocol } from "discourse/lib/url"; +import { searchForTerm } from "discourse/lib/search"; + +export default class InsertHyperlink extends Component { + @tracked linkText = this.args.model.linkText; + @tracked linkUrl = ""; + @tracked selectedRow = -1; + @tracked searchResults = []; + @tracked searchLoading = false; + _debounced; + _activeSearch; + + willDestroy() { + super.willDestroy(...arguments); + cancel(this._debounced); + } + + highlightRow(e, direction) { + const index = + direction === "down" ? this.selectedRow + 1 : this.selectedRow - 1; + + if (index > -1 && index < this.searchResults.length) { + document + .querySelectorAll(".internal-link-results .search-link") + [index].focus(); + this.selectedRow = index; + } else { + this.selectedRow = -1; + document.querySelector("input.link-url").focus(); + } + + e.preventDefault(); + } + + selectLink(el) { + this.searchResults = []; + this.linkUrl = el.href; + this.selectedRow = -1; + + if (!this.linkText && el.dataset.title) { + this.linkText = el.dataset.title; + } + + document.querySelector("input.link-text").focus(); + } + + async triggerSearch() { + if (this.linkUrl.length < 4 || this.linkUrl.startsWith("http")) { + this.abortSearch(); + return; + } + + this.searchLoading = true; + this._activeSearch = searchForTerm(this.linkUrl, { + typeFilter: "topic", + }); + + try { + const results = await this._activeSearch; + this.searchResults = results?.topics || []; + } finally { + this.searchLoading = false; + this._activeSearch = null; + } + } + + abortSearch() { + this._activeSearch?.abort(); + + this.searchResults = []; + this.searchLoading = false; + } + + @action + keyDown(event) { + switch (event.key) { + case "ArrowDown": + this.highlightRow(event, "down"); + break; + case "ArrowUp": + this.highlightRow(event, "up"); + break; + case "Enter": + // override Enter behavior when a row is selected + if (this.selectedRow > -1) { + const selected = document.querySelectorAll( + ".internal-link-results .search-link" + )[this.selectedRow]; + this.selectLink(selected); + event.preventDefault(); + event.stopPropagation(); + } + break; + case "Escape": + // Esc should cancel dropdown first + if (this.searchResults.length) { + this.searchResults = []; + event.preventDefault(); + event.stopPropagation(); + } else { + this.args.closeModal(); + document.querySelector(".d-editor-input")?.focus(); + } + break; + } + } + + @action + mouseDown(event) { + if (!event.target.closest(".inputs")) { + this.searchResults = []; + } + } + + @action + ok() { + const origLink = this.linkUrl; + const linkUrl = prefixProtocol(origLink); + const sel = this.args.model.toolbarEvent.selected; + + if (isEmpty(linkUrl)) { + return; + } + + const linkText = this.linkText || ""; + + if (linkText.length) { + this.args.model.toolbarEvent.addText(`[${linkText}](${linkUrl})`); + } else if (sel.value) { + this.args.model.toolbarEvent.addText(`[${sel.value}](${linkUrl})`); + } else { + this.args.model.toolbarEvent.addText(`[${origLink}](${linkUrl})`); + this.args.model.toolbarEvent.selectText(sel.start + 1, origLink.length); + } + + this.args.closeModal(); + } + + @action + linkClick(e) { + if (!e.metaKey && !e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + this.selectLink(e.target.closest(".search-link")); + } + } + + @action + updateLinkText(event) { + this.linkText = event.target.value; + } + + @action + search(event) { + this.linkUrl = event.target.value; + this._debounced = discourseDebounce(this, this.triggerSearch, 400); + } +} diff --git a/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js b/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js deleted file mode 100644 index dab1a788b57..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/insert-hyperlink.js +++ /dev/null @@ -1,186 +0,0 @@ -import { cancel, schedule } from "@ember/runloop"; -import Controller from "@ember/controller"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -import { bind } from "discourse-common/utils/decorators"; -import discourseDebounce from "discourse-common/lib/debounce"; -import { isEmpty } from "@ember/utils"; -import { prefixProtocol } from "discourse/lib/url"; -import { searchForTerm } from "discourse/lib/search"; - -export default Controller.extend(ModalFunctionality, { - _debounced: null, - _activeSearch: null, - - onShow() { - this.setProperties({ - linkUrl: "", - linkText: "", - searchResults: [], - searchLoading: false, - selectedRow: -1, - }); - - schedule("afterRender", () => { - const element = document.querySelector(".insert-link"); - element.addEventListener("keydown", this.keyDown); - - element - .closest(".modal-inner-container") - .addEventListener("mousedown", this.mouseDown); - }); - }, - - @bind - keyDown(event) { - switch (event.which) { - case 40: - this.highlightRow(event, "down"); - break; - case 38: - this.highlightRow(event, "up"); - break; - case 13: - // override Enter behaviour when a row is selected - if (this.selectedRow > -1) { - const selected = document.querySelectorAll( - ".internal-link-results .search-link" - )[this.selectedRow]; - this.selectLink(selected); - event.preventDefault(); - event.stopPropagation(); - } - break; - case 27: - // Esc should cancel dropdown first - if (this.searchResults.length) { - this.set("searchResults", []); - event.preventDefault(); - event.stopPropagation(); - } else { - this.send("closeModal"); - document.querySelector(".d-editor-input")?.focus(); - } - break; - } - }, - - @bind - mouseDown(event) { - if (!event.target.closest(".inputs")) { - this.set("searchResults", []); - } - }, - - highlightRow(e, direction) { - const index = - direction === "down" ? this.selectedRow + 1 : this.selectedRow - 1; - - if (index > -1 && index < this.searchResults.length) { - document - .querySelectorAll(".internal-link-results .search-link") - [index].focus(); - this.set("selectedRow", index); - } else { - this.set("selectedRow", -1); - document.querySelector("input.link-url").focus(); - } - - e.preventDefault(); - }, - - selectLink(el) { - this.setProperties({ - linkUrl: el.href, - searchResults: [], - selectedRow: -1, - }); - - if (!this.linkText && el.dataset.title) { - this.set("linkText", el.dataset.title); - } - - document.querySelector("input.link-text").focus(); - }, - - triggerSearch() { - if (this.linkUrl.length > 3 && !this.linkUrl.startsWith("http")) { - this.set("searchLoading", true); - this._activeSearch = searchForTerm(this.linkUrl, { - typeFilter: "topic", - }); - this._activeSearch - .then((results) => { - if (results && results.topics && results.topics.length > 0) { - this.set("searchResults", results.topics); - } else { - this.set("searchResults", []); - } - }) - .finally(() => { - this.set("searchLoading", false); - this._activeSearch = null; - }); - } else { - this.abortSearch(); - } - }, - - abortSearch() { - if (this._activeSearch) { - this._activeSearch.abort(); - } - this.setProperties({ - searchResults: [], - searchLoading: false, - }); - }, - - onClose() { - const element = document.querySelector(".insert-link"); - element.removeEventListener("keydown", this.keyDown); - element - .closest(".modal-inner-container") - .removeEventListener("mousedown", this.mouseDown); - - cancel(this._debounced); - }, - - actions: { - ok() { - const origLink = this.linkUrl; - const linkUrl = prefixProtocol(origLink); - const sel = this.toolbarEvent.selected; - - if (isEmpty(linkUrl)) { - return; - } - - const linkText = this.linkText || ""; - - if (linkText.length) { - this.toolbarEvent.addText(`[${linkText}](${linkUrl})`); - } else { - if (sel.value) { - this.toolbarEvent.addText(`[${sel.value}](${linkUrl})`); - } else { - this.toolbarEvent.addText(`[${origLink}](${linkUrl})`); - this.toolbarEvent.selectText(sel.start + 1, origLink.length); - } - } - this.send("closeModal"); - }, - cancel() { - this.send("closeModal"); - }, - linkClick(e) { - if (!e.metaKey && !e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - this.selectLink(e.target.closest(".search-link")); - } - }, - search() { - this._debounced = discourseDebounce(this, this.triggerSearch, 400); - }, - }, -}); diff --git a/app/assets/javascripts/discourse/app/services/modal.js b/app/assets/javascripts/discourse/app/services/modal.js index 396192afe40..a0f69fd1b62 100644 --- a/app/assets/javascripts/discourse/app/services/modal.js +++ b/app/assets/javascripts/discourse/app/services/modal.js @@ -33,7 +33,6 @@ const KNOWN_LEGACY_MODALS = [ "history", "ignore-duration-with-username", "ignore-duration", - "insert-hyperlink", "jump-to-post", "login", "move-to-topic", diff --git a/app/assets/javascripts/discourse/app/templates/modal/insert-hyperlink.hbs b/app/assets/javascripts/discourse/app/templates/modal/insert-hyperlink.hbs deleted file mode 100644 index a69187ed8fb..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/insert-hyperlink.hbs +++ /dev/null @@ -1,61 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js index 852e548dae1..8b65695a40b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js @@ -1,8 +1,4 @@ -import { - acceptance, - exists, - query, -} from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -14,28 +10,27 @@ acceptance("Composer - Hyperlink", function (needs) { await click(".topic-post:first-child button.reply"); await fillIn(".d-editor-input", "This is a link to "); - assert.ok( - !exists(".insert-link.modal-body"), - "no hyperlink modal by default" - ); + assert + .dom(".insert-link.modal-body") + .doesNotExist("no hyperlink modal by default"); await click(".d-editor button.link"); - assert.ok(exists(".insert-link.modal-body"), "hyperlink modal visible"); + assert.dom(".insert-link.modal-body").exists("hyperlink modal visible"); await fillIn(".modal-body .link-url", "google.com"); await fillIn(".modal-body .link-text", "Google"); await click(".modal-footer button.btn-primary"); - assert.strictEqual( - query(".d-editor-input").value, - "This is a link to [Google](https://google.com)", - "adds link with url and text, prepends 'https://'" - ); + assert + .dom(".d-editor-input") + .hasValue( + "This is a link to [Google](https://google.com)", + "adds link with url and text, prepends 'https://'" + ); - assert.ok( - !exists(".insert-link.modal-body"), - "modal dismissed after submitting link" - ); + assert + .dom(".insert-link.modal-body") + .doesNotExist("modal dismissed after submitting link"); await fillIn(".d-editor-input", "Reset textarea contents."); @@ -44,16 +39,16 @@ acceptance("Composer - Hyperlink", function (needs) { await fillIn(".modal-body .link-text", "Google"); await click(".modal-footer button.btn-danger"); - assert.strictEqual( - query(".d-editor-input").value, - "Reset textarea contents.", - "doesn’t insert anything after cancelling" - ); + assert + .dom(".d-editor-input") + .hasValue( + "Reset textarea contents.", + "doesn’t insert anything after cancelling" + ); - assert.ok( - !exists(".insert-link.modal-body"), - "modal dismissed after cancelling" - ); + assert + .dom(".insert-link.modal-body") + .doesNotExist("modal dismissed after cancelling"); const textarea = query("#reply-control .d-editor-input"); textarea.selectionStart = 0; @@ -63,48 +58,49 @@ acceptance("Composer - Hyperlink", function (needs) { await fillIn(".modal-body .link-url", "somelink.com"); await click(".modal-footer button.btn-primary"); - assert.strictEqual( - query(".d-editor-input").value, - "[Reset](https://somelink.com) textarea contents.", - "adds link to a selected text" - ); + assert + .dom(".d-editor-input") + .hasValue( + "[Reset](https://somelink.com) textarea contents.", + "adds link to a selected text" + ); await fillIn(".d-editor-input", ""); await click(".d-editor button.link"); await fillIn(".modal-body .link-url", "http://google.com"); await triggerKeyEvent(".modal-body .link-url", "keyup", "Space"); - assert.ok( - !exists(".internal-link-results"), - "does not show internal links search dropdown when inputting a url" - ); + assert + .dom(".internal-link-results") + .doesNotExist( + "does not show internal links search dropdown when inputting a url" + ); await fillIn(".modal-body .link-url", "local"); await triggerKeyEvent(".modal-body .link-url", "keyup", "Space"); - assert.ok( - exists(".internal-link-results"), - "shows internal links search dropdown when entering keywords" - ); + assert + .dom(".internal-link-results") + .exists("shows internal links search dropdown when entering keywords"); await triggerKeyEvent(".insert-link", "keydown", "ArrowDown"); await triggerKeyEvent(".insert-link", "keydown", "Enter"); - assert.ok( - !exists(".internal-link-results"), - "search dropdown dismissed after selecting an internal link" - ); + assert + .dom(".internal-link-results") + .doesNotExist( + "search dropdown dismissed after selecting an internal link" + ); - assert.ok( - query(".link-url").value.includes("http"), - "replaces link url field with internal link" - ); + assert + .dom(".link-url") + .hasValue(/http/, "replaces link url field with internal link"); await triggerKeyEvent(".insert-link", "keydown", "Escape"); - assert.strictEqual( - document.activeElement.classList.contains("d-editor-input"), - true, - "focus stays on composer after dismissing modal using Esc key" - ); + assert + .dom(".d-editor-input") + .isFocused( + "focus stays on composer after dismissing modal using Esc key" + ); }); }); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index 2a22d869978..f57672e0aeb 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -26,6 +26,7 @@ import { renderUserStatusHtml, } from "discourse/lib/user-status-on-autocomplete"; import ChatModalChannelSummary from "discourse/plugins/chat/discourse/components/chat/modal/channel-summary"; +import InsertHyperlink from "discourse/components/modal/insert-hyperlink"; export default class ChatComposer extends Component { @service capabilities; @@ -347,10 +348,12 @@ export default class ChatComposer extends Component { const selected = this.composer.textarea.getSelected("", { lineVal: true }); const linkText = selected?.value; - showModal("insert-hyperlink").setProperties({ - linkText, - toolbarEvent: { - addText: (text) => this.composer.textarea.addText(selected, text), + this.modal.show(InsertHyperlink, { + model: { + linkText, + toolbarEvent: { + addText: (text) => this.composer.textarea.addText(selected, text), + }, }, }); }