206 lines
6.6 KiB
JavaScript
206 lines
6.6 KiB
JavaScript
import { apiInitializer } from "discourse/lib/api";
|
|
import { ajax } from "discourse/lib/ajax";
|
|
import I18n from "I18n";
|
|
|
|
const URL_REGEX = /^(https?:\/\/[^\s/$.?#][^\s]*)$/i;
|
|
|
|
export default apiInitializer("1.8.0", (api) => {
|
|
if (!api.container.lookup("site-settings:main").url_to_article_enabled) {
|
|
return;
|
|
}
|
|
|
|
api.modifyClass("component:composer-editor", {
|
|
pluginId: "url-to-article",
|
|
|
|
didInsertElement() {
|
|
this._super(...arguments);
|
|
this._attachTitlePasteListener();
|
|
},
|
|
|
|
willDestroyElement() {
|
|
this._super(...arguments);
|
|
if (this._titleInputEl && this._titlePasteHandler) {
|
|
this._titleInputEl.removeEventListener(
|
|
"paste",
|
|
this._titlePasteHandler,
|
|
true
|
|
);
|
|
}
|
|
},
|
|
|
|
// ---- 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.
|
|
|
|
_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;
|
|
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
|
|
// 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);
|
|
}
|
|
},
|
|
|
|
// ---- Bar UI -------------------------------------------------------
|
|
|
|
_showArticleBar(url) {
|
|
this._hideArticleBar();
|
|
|
|
const bar = document.createElement("div");
|
|
bar.className = "url-to-article-bar";
|
|
bar.innerHTML = `
|
|
<span class="url-to-article-icon">📄</span>
|
|
<span class="url-to-article-label">${I18n.t("url_to_article.bar_label")}</span>
|
|
<button class="btn btn-small btn-primary url-to-article-btn">
|
|
${I18n.t("url_to_article.fetch_button")}
|
|
</button>
|
|
<button class="btn btn-small btn-default url-to-article-onebox-btn">
|
|
${I18n.t("url_to_article.onebox_button")}
|
|
</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", () => {
|
|
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._commitUrlToModel();
|
|
});
|
|
|
|
bar.querySelector(".url-to-article-dismiss").addEventListener("click", () => {
|
|
this._hideArticleBar();
|
|
this._pendingUrl = null;
|
|
});
|
|
|
|
const container = this.element.querySelector(".d-editor-container");
|
|
if (container) {
|
|
container.insertAdjacentElement("afterbegin", bar);
|
|
}
|
|
},
|
|
|
|
_hideArticleBar() {
|
|
this.element
|
|
?.querySelectorAll(".url-to-article-bar")
|
|
.forEach((el) => el.remove());
|
|
},
|
|
|
|
_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");
|
|
status.className = "url-to-article-status";
|
|
bar.appendChild(status);
|
|
}
|
|
status.textContent = message;
|
|
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) {
|
|
const bar = this.element?.querySelector(".url-to-article-bar");
|
|
const btn = bar?.querySelector(".url-to-article-btn");
|
|
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = I18n.t("url_to_article.fetching");
|
|
}
|
|
this._setStatus(I18n.t("url_to_article.fetching"), "info");
|
|
|
|
try {
|
|
const data = await ajax("/url-to-article/extract", {
|
|
type: "POST",
|
|
data: { url },
|
|
});
|
|
|
|
if (data.error) throw new Error(data.error);
|
|
|
|
this._populateComposer(data);
|
|
this._setStatus(I18n.t("url_to_article.success"), "success");
|
|
setTimeout(() => this._hideArticleBar(), 3000);
|
|
} catch (err) {
|
|
const msg =
|
|
err.jqXHR?.responseJSON?.error ||
|
|
err.message ||
|
|
I18n.t("url_to_article.error_generic");
|
|
this._setStatus(
|
|
`${I18n.t("url_to_article.error_prefix")} ${msg}`,
|
|
"error"
|
|
);
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = I18n.t("url_to_article.retry_button");
|
|
}
|
|
}
|
|
},
|
|
|
|
_populateComposer(data) {
|
|
const composerModel = this.get("composer.model");
|
|
if (!composerModel) return;
|
|
|
|
const lines = [];
|
|
const siteName = data.site_name ? `**${data.site_name}**` : "";
|
|
const byline = data.byline ? ` — *${data.byline}*` : "";
|
|
if (siteName || byline) {
|
|
lines.push(`> ${siteName}${byline}`);
|
|
lines.push(`> ${I18n.t("url_to_article.source_label")}: <${data.url}>`);
|
|
lines.push("");
|
|
} else {
|
|
lines.push(`> ${I18n.t("url_to_article.source_label")}: <${data.url}>`);
|
|
lines.push("");
|
|
}
|
|
|
|
if (data.description) {
|
|
lines.push(`*${data.description}*`);
|
|
lines.push("");
|
|
lines.push("---");
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push(data.markdown || "");
|
|
|
|
// 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;
|
|
|
|
// Sync the title input visually
|
|
if (this._titleInputEl) {
|
|
this._titleInputEl.value = composerModel.get("title");
|
|
}
|
|
},
|
|
});
|
|
});
|