still chasing my dreams

This commit is contained in:
2026-03-18 12:59:09 -04:00
parent f5b9d1a1f7
commit 9af9c3e62e

View File

@@ -1,86 +1,24 @@
import { apiInitializer } from "discourse/lib/api"; import { apiInitializer } from "discourse/lib/api";
import { debounce } from "@ember/runloop";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import I18n from "I18n"; import I18n from "I18n";
const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i; const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i;
const DEBOUNCE_MS = 600;
export default apiInitializer("1.8.0", (api) => { export default apiInitializer("1.8.0", (api) => {
if (!api.container.lookup("site-settings:main").url_to_article_enabled) { if (!api.container.lookup("site-settings:main").url_to_article_enabled) {
return; 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", { api.modifyClass("component:composer-editor", {
pluginId: "url-to-article", pluginId: "url-to-article",
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
this._setupUrlToArticle(); this._attachTitlePasteListener();
}, },
willDestroyElement() { willDestroyElement() {
this._super(...arguments); 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) { if (this._titleInputEl && this._titlePasteHandler) {
this._titleInputEl.removeEventListener( this._titleInputEl.removeEventListener(
"paste", "paste",
@@ -90,48 +28,32 @@ export default apiInitializer("1.8.0", (api) => {
} }
}, },
_onTitleChanged() { // ---- Title paste interception -------------------------------------
const title = this.get("composer.model.title") || ""; // We use capture:true so our handler runs before Discourse's, then
const match = title.trim().match(URL_REGEX); // preventDefault + stopImmediatePropagation so Discourse never sees
// the paste event at all — no title lookup, no onebox race.
if (!match) { _attachTitlePasteListener(attempts = 0) {
this._clearSuppression(); const input = document.querySelector("#reply-title");
this._hideArticleBar(); if (input) {
return; 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]; e.preventDefault();
if (this._lastDetectedUrl === url) return; e.stopImmediatePropagation();
this._lastDetectedUrl = url;
// Belt-and-suspenders: ensure suppression is set even if paste listener // Show URL in the field visually without going through Ember binding
// missed it (e.g. URL typed manually rather than pasted). input.value = text;
const model = this.get("composer.model"); this._pendingUrl = text;
if (model) model._urlToArticleSuppressLookup = true; this._showArticleBar(text);
};
const autoPopulate = api.container input.addEventListener("paste", this._titlePasteHandler, true);
.lookup("site-settings:main") } else if (attempts < 15) {
.url_to_article_auto_populate; setTimeout(() => this._attachTitlePasteListener(attempts + 1), 200);
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();
} }
}, },
@@ -142,7 +64,6 @@ export default apiInitializer("1.8.0", (api) => {
const bar = document.createElement("div"); const bar = document.createElement("div");
bar.className = "url-to-article-bar"; bar.className = "url-to-article-bar";
bar.dataset.url = url;
bar.innerHTML = ` bar.innerHTML = `
<span class="url-to-article-icon">📄</span> <span class="url-to-article-icon">📄</span>
<span class="url-to-article-label">${I18n.t("url_to_article.bar_label")}</span> <span class="url-to-article-label">${I18n.t("url_to_article.bar_label")}</span>
@@ -152,28 +73,28 @@ export default apiInitializer("1.8.0", (api) => {
<button class="btn btn-small btn-default url-to-article-onebox-btn"> <button class="btn btn-small btn-default url-to-article-onebox-btn">
${I18n.t("url_to_article.onebox_button")} ${I18n.t("url_to_article.onebox_button")}
</button> </button>
<button class="btn btn-small btn-flat url-to-article-dismiss" aria-label="${I18n.t("url_to_article.dismiss")}">✕</button> <button class="btn btn-small btn-flat url-to-article-dismiss"
aria-label="${I18n.t("url_to_article.dismiss")}">✕</button>
`; `;
bar.querySelector(".url-to-article-btn").addEventListener("click", () => { bar.querySelector(".url-to-article-btn").addEventListener("click", () => {
this._fetchAndPopulate(url); this._fetchAndPopulate(url);
}); });
// "Use Onebox" — commit URL into Ember model normally so Discourse takes over
bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => { bar.querySelector(".url-to-article-onebox-btn").addEventListener("click", () => {
this._hideArticleBar(); this._hideArticleBar();
this._lastDetectedUrl = null; this._commitUrlToModel();
this._triggerNativeLookup();
}); });
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => { bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
this._hideArticleBar(); this._hideArticleBar();
this._clearSuppression(); this._pendingUrl = null;
this._lastDetectedUrl = null;
}); });
const toolbarEl = this.element.querySelector(".d-editor-container"); const container = this.element.querySelector(".d-editor-container");
if (toolbarEl) { if (container) {
toolbarEl.insertAdjacentElement("afterbegin", bar); container.insertAdjacentElement("afterbegin", bar);
} }
}, },
@@ -186,7 +107,6 @@ export default apiInitializer("1.8.0", (api) => {
_setStatus(message, type = "info") { _setStatus(message, type = "info") {
const bar = this.element?.querySelector(".url-to-article-bar"); const bar = this.element?.querySelector(".url-to-article-bar");
if (!bar) return; if (!bar) return;
let status = bar.querySelector(".url-to-article-status"); let status = bar.querySelector(".url-to-article-status");
if (!status) { if (!status) {
status = document.createElement("span"); 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}`; 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 --------------------------------------------- // ---- Fetch & populate ---------------------------------------------
async _fetchAndPopulate(url) { async _fetchAndPopulate(url) {
@@ -215,14 +145,10 @@ export default apiInitializer("1.8.0", (api) => {
data: { url }, data: { url },
}); });
if (data.error) { if (data.error) throw new Error(data.error);
throw new Error(data.error);
}
this._populateComposer(data); this._populateComposer(data);
this._clearSuppression();
this._setStatus(I18n.t("url_to_article.success"), "success"); this._setStatus(I18n.t("url_to_article.success"), "success");
setTimeout(() => this._hideArticleBar(), 3000); setTimeout(() => this._hideArticleBar(), 3000);
} catch (err) { } catch (err) {
const msg = const msg =
@@ -245,7 +171,6 @@ export default apiInitializer("1.8.0", (api) => {
if (!composerModel) return; if (!composerModel) return;
const lines = []; const lines = [];
const siteName = data.site_name ? `**${data.site_name}**` : ""; const siteName = data.site_name ? `**${data.site_name}**` : "";
const byline = data.byline ? ` — *${data.byline}*` : ""; const byline = data.byline ? ` — *${data.byline}*` : "";
if (siteName || byline) { if (siteName || byline) {
@@ -266,14 +191,15 @@ export default apiInitializer("1.8.0", (api) => {
lines.push(data.markdown || ""); 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") || ""; // Sync the title input visually
if (currentTitle.trim() === data.url || currentTitle.trim() === "") { if (this._titleInputEl) {
composerModel.set("title", data.title || data.url); this._titleInputEl.value = composerModel.get("title");
} }
composerModel.set("reply", body);
}, },
}); });
}); });