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>
+
+
+
+
+
+
+
+ <: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),
+ },
},
});
}