diff --git a/assets/javascripts/discourse/initializers/url-to-article.js b/assets/javascripts/discourse/initializers/url-to-article.js
index bf0ff90..3ae705b 100644
--- a/assets/javascripts/discourse/initializers/url-to-article.js
+++ b/assets/javascripts/discourse/initializers/url-to-article.js
@@ -1,86 +1,24 @@
import { apiInitializer } from "discourse/lib/api";
-import { debounce } from "@ember/runloop";
import { ajax } from "discourse/lib/ajax";
import I18n from "I18n";
const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i;
-const DEBOUNCE_MS = 600;
export default apiInitializer("1.8.0", (api) => {
if (!api.container.lookup("site-settings:main").url_to_article_enabled) {
return;
}
- // -----------------------------------------------------------------------
- // Intercept Discourse's native title URL lookup at the CLASS level.
- // Instance-patching is too late — Discourse's own titleChanged observer
- // may fire before ours. This wraps the prototype method so the flag wins
- // regardless of observer order.
- // -----------------------------------------------------------------------
- api.modifyClass("model:composer", {
- pluginId: "url-to-article",
-
- _titleLookup() {
- if (this._urlToArticleSuppressLookup) return;
- return this._super(...arguments);
- },
- });
-
- // -----------------------------------------------------------------------
- // Inject helper button + status banner into the composer
- // -----------------------------------------------------------------------
api.modifyClass("component:composer-editor", {
pluginId: "url-to-article",
didInsertElement() {
this._super(...arguments);
- this._setupUrlToArticle();
+ this._attachTitlePasteListener();
},
willDestroyElement() {
this._super(...arguments);
- this._teardownUrlToArticle();
- },
-
- _setupUrlToArticle() {
- const composer = this.get("composer");
- if (!composer) return;
-
- this._titleObserver = () => this._onTitleChanged();
- composer.addObserver("model.title", this, "_titleObserver");
-
- // Intercept paste on the title input BEFORE Ember's data-binding fires,
- // so the suppression flag is set before Discourse's titleChanged observer runs.
- this._attachTitlePasteListener();
- },
-
- _attachTitlePasteListener() {
- const tryAttach = (attempts = 0) => {
- const input = document.querySelector("#reply-title");
- if (input) {
- this._titlePasteHandler = (e) => {
- const text = (
- e.clipboardData || window.clipboardData
- ).getData("text/plain").trim();
- if (URL_REGEX.test(text)) {
- const model = this.get("composer.model");
- if (model) model._urlToArticleSuppressLookup = true;
- }
- };
- input.addEventListener("paste", this._titlePasteHandler, true);
- this._titleInputEl = input;
- } else if (attempts < 10) {
- setTimeout(() => tryAttach(attempts + 1), 200);
- }
- };
- tryAttach();
- },
-
- _teardownUrlToArticle() {
- const composer = this.get("composer");
- if (!composer) return;
- composer.removeObserver("model.title", this, "_titleObserver");
- this._clearSuppression();
if (this._titleInputEl && this._titlePasteHandler) {
this._titleInputEl.removeEventListener(
"paste",
@@ -90,48 +28,32 @@ export default apiInitializer("1.8.0", (api) => {
}
},
- _onTitleChanged() {
- const title = this.get("composer.model.title") || "";
- const match = title.trim().match(URL_REGEX);
+ // ---- Title paste interception -------------------------------------
+ // We use capture:true so our handler runs before Discourse's, then
+ // preventDefault + stopImmediatePropagation so Discourse never sees
+ // the paste event at all — no title lookup, no onebox race.
- if (!match) {
- this._clearSuppression();
- this._hideArticleBar();
- return;
- }
+ _attachTitlePasteListener(attempts = 0) {
+ const input = document.querySelector("#reply-title");
+ if (input) {
+ this._titleInputEl = input;
+ this._titlePasteHandler = (e) => {
+ const text = (e.clipboardData || window.clipboardData)
+ .getData("text/plain")
+ .trim();
+ if (!URL_REGEX.test(text)) return;
- const url = match[1];
- if (this._lastDetectedUrl === url) return;
- this._lastDetectedUrl = url;
+ e.preventDefault();
+ e.stopImmediatePropagation();
- // Belt-and-suspenders: ensure suppression is set even if paste listener
- // missed it (e.g. URL typed manually rather than pasted).
- const model = this.get("composer.model");
- if (model) model._urlToArticleSuppressLookup = true;
-
- const autoPopulate = api.container
- .lookup("site-settings:main")
- .url_to_article_auto_populate;
-
- if (autoPopulate) {
- debounce(this, "_fetchAndPopulate", url, DEBOUNCE_MS);
- } else {
- this._showArticleBar(url);
- }
- },
-
- // ---- Suppression helpers ------------------------------------------
-
- _clearSuppression() {
- const model = this.get("composer.model");
- if (model) delete model._urlToArticleSuppressLookup;
- },
-
- _triggerNativeLookup() {
- this._clearSuppression();
- const model = this.get("composer.model");
- if (model && typeof model._titleLookup === "function") {
- model._titleLookup();
+ // Show URL in the field visually without going through Ember binding
+ input.value = text;
+ this._pendingUrl = text;
+ this._showArticleBar(text);
+ };
+ input.addEventListener("paste", this._titlePasteHandler, true);
+ } else if (attempts < 15) {
+ setTimeout(() => this._attachTitlePasteListener(attempts + 1), 200);
}
},
@@ -142,7 +64,6 @@ export default apiInitializer("1.8.0", (api) => {
const bar = document.createElement("div");
bar.className = "url-to-article-bar";
- bar.dataset.url = url;
bar.innerHTML = `
📄
${I18n.t("url_to_article.bar_label")}
@@ -152,28 +73,28 @@ export default apiInitializer("1.8.0", (api) => {
-
+
`;
bar.querySelector(".url-to-article-btn").addEventListener("click", () => {
this._fetchAndPopulate(url);
});
+ // "Use Onebox" — commit URL into Ember model normally so Discourse takes over
bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => {
this._hideArticleBar();
- this._lastDetectedUrl = null;
- this._triggerNativeLookup();
+ this._commitUrlToModel();
});
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
this._hideArticleBar();
- this._clearSuppression();
- this._lastDetectedUrl = null;
+ this._pendingUrl = null;
});
- const toolbarEl = this.element.querySelector(".d-editor-container");
- if (toolbarEl) {
- toolbarEl.insertAdjacentElement("afterbegin", bar);
+ const container = this.element.querySelector(".d-editor-container");
+ if (container) {
+ container.insertAdjacentElement("afterbegin", bar);
}
},
@@ -186,7 +107,6 @@ export default apiInitializer("1.8.0", (api) => {
_setStatus(message, type = "info") {
const bar = this.element?.querySelector(".url-to-article-bar");
if (!bar) return;
-
let status = bar.querySelector(".url-to-article-status");
if (!status) {
status = document.createElement("span");
@@ -197,6 +117,16 @@ export default apiInitializer("1.8.0", (api) => {
status.className = `url-to-article-status url-to-article-status--${type}`;
},
+ // Release the URL into Ember's data-binding so Discourse handles it normally
+ _commitUrlToModel() {
+ const input = this._titleInputEl;
+ const url = this._pendingUrl;
+ if (!input || !url) return;
+ input.value = url;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ this._pendingUrl = null;
+ },
+
// ---- Fetch & populate ---------------------------------------------
async _fetchAndPopulate(url) {
@@ -215,14 +145,10 @@ export default apiInitializer("1.8.0", (api) => {
data: { url },
});
- if (data.error) {
- throw new Error(data.error);
- }
+ if (data.error) throw new Error(data.error);
this._populateComposer(data);
- this._clearSuppression();
this._setStatus(I18n.t("url_to_article.success"), "success");
-
setTimeout(() => this._hideArticleBar(), 3000);
} catch (err) {
const msg =
@@ -245,7 +171,6 @@ export default apiInitializer("1.8.0", (api) => {
if (!composerModel) return;
const lines = [];
-
const siteName = data.site_name ? `**${data.site_name}**` : "";
const byline = data.byline ? ` — *${data.byline}*` : "";
if (siteName || byline) {
@@ -266,14 +191,15 @@ export default apiInitializer("1.8.0", (api) => {
lines.push(data.markdown || "");
- const body = lines.join("\n");
+ // Set title directly on the model (bypasses the title-lookup trigger)
+ composerModel.set("title", data.title || this._pendingUrl || "");
+ composerModel.set("reply", lines.join("\n"));
+ this._pendingUrl = null;
- const currentTitle = composerModel.get("title") || "";
- if (currentTitle.trim() === data.url || currentTitle.trim() === "") {
- composerModel.set("title", data.title || data.url);
+ // Sync the title input visually
+ if (this._titleInputEl) {
+ this._titleInputEl.value = composerModel.get("title");
}
-
- composerModel.set("reply", body);
},
});
});