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