still chasing my dreams
This commit is contained in:
@@ -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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user