diff --git a/app/assets/stylesheets/common/base/photoswipe.scss b/app/assets/stylesheets/common/base/photoswipe.scss
index 8d969de6161b3..55a1dd1c2edac 100644
--- a/app/assets/stylesheets/common/base/photoswipe.scss
+++ b/app/assets/stylesheets/common/base/photoswipe.scss
@@ -409,3 +409,7 @@ div.pswp__img--placeholder,
.pswp--one-slide .pswp__counter {
display: none;
}
+
+.pswp__button--quote-image .pswp__icn {
+ transform: scale(0.67);
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 1a44757c7f86f..68e31c00b5687 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4913,6 +4913,7 @@ en:
download: "Download"
open: "Original image"
image_info: "Image information"
+ quote: "Quote image"
previous: "Previous (Left arrow key)"
next: "Next (Right arrow key)"
counter: "%curr% of %total%"
diff --git a/frontend/discourse/app/controllers/topic.js b/frontend/discourse/app/controllers/topic.js
index 95a849adafaf2..87ed8abedcdc8 100644
--- a/frontend/discourse/app/controllers/topic.js
+++ b/frontend/discourse/app/controllers/topic.js
@@ -521,7 +521,7 @@ export default class TopicController extends Controller {
? Promise.resolve(loadedPost)
: this.get("model.postStream").loadPost(postId);
- return promise.then((post) => {
+ return promise.then(async (post) => {
const composer = this.composer;
const viewOpen = composer.get("model.viewOpen");
@@ -550,7 +550,6 @@ export default class TopicController extends Controller {
}
const quotedText = buildQuote(post, buffer, opts);
- composerOpts.quote = quotedText;
if (composer.get("model.viewOpen")) {
this.appEvents.trigger("composer:insert-block", quotedText);
@@ -559,6 +558,16 @@ export default class TopicController extends Controller {
model.set("reply", model.get("reply") + "\n" + quotedText);
composer.openIfDraft();
} else {
+ const draftData = await Draft.get(composerOpts.draftKey);
+
+ if (draftData.draft) {
+ const data = JSON.parse(draftData.draft);
+ composerOpts.draftSequence = draftData.draft_sequence;
+ composerOpts.reply = data.reply + "\n" + quotedText;
+ } else {
+ composerOpts.quote = quotedText;
+ }
+
composer.open(composerOpts);
}
});
@@ -794,24 +803,25 @@ export default class TopicController extends Controller {
draftSequence: topic.get("draft_sequence"),
};
- if (quotedText) {
- opts.quote = quotedText;
- }
-
if (post && post.get("post_number") !== 1) {
opts.post = post;
} else {
opts.topic = topic;
}
- if (!opts.quote) {
- const draftData = await Draft.get(opts.draftKey);
+ const draftData = await Draft.get(opts.draftKey);
- if (draftData.draft) {
- const data = JSON.parse(draftData.draft);
+ if (draftData.draft) {
+ const data = JSON.parse(draftData.draft);
+ opts.draftSequence = draftData.draft_sequence;
+
+ if (quotedText) {
+ opts.reply = (data.reply || "") + "\n" + quotedText;
+ } else {
opts.reply = data.reply;
- opts.draftSequence = draftData.draft_sequence;
}
+ } else if (quotedText) {
+ opts.quote = quotedText;
}
composerController.open(opts);
diff --git a/frontend/discourse/app/instance-initializers/post-decorations.js b/frontend/discourse/app/instance-initializers/post-decorations.js
index b2f4e08e6bd73..d8547c48dfe1c 100644
--- a/frontend/discourse/app/instance-initializers/post-decorations.js
+++ b/frontend/discourse/app/instance-initializers/post-decorations.js
@@ -28,8 +28,8 @@ export default {
return highlightSyntax(elem, siteSettings, session);
});
- api.decorateCookedElement((elem) => {
- return lightbox(elem, siteSettings);
+ api.decorateCookedElement((elem, helper) => {
+ return lightbox(elem, siteSettings, helper.model);
});
api.decorateCookedElement((elem) => {
diff --git a/frontend/discourse/app/lib/lightbox.js b/frontend/discourse/app/lib/lightbox.js
index b8c567d59d8b4..cebcc6eb2afb6 100644
--- a/frontend/discourse/app/lib/lightbox.js
+++ b/frontend/discourse/app/lib/lightbox.js
@@ -5,6 +5,7 @@ import { isRailsTesting, isTesting } from "discourse/lib/environment";
import { helperContext } from "discourse/lib/helpers";
import { renderIcon } from "discourse/lib/icon-library";
import { SELECTORS } from "discourse/lib/lightbox/constants";
+import quoteImage, { canQuoteImage } from "discourse/lib/lightbox/quote-image";
import { isDocumentRTL } from "discourse/lib/text-direction";
import {
escapeExpression,
@@ -17,7 +18,11 @@ export async function loadMagnificPopup() {
await waitForPromise(import("magnific-popup"));
}
-export default async function lightbox(elem, siteSettings) {
+export default async function lightbox(
+ elem,
+ siteSettings,
+ relatedModel = null
+) {
if (!elem) {
return;
}
@@ -199,6 +204,38 @@ export default async function lightbox(elem, siteSettings) {
},
});
+ lightboxEl.pswp.ui.registerElement({
+ name: "quote-image",
+ order: 10,
+ isButton: true,
+ title: i18n("lightbox.quote"),
+ html: {
+ isCustomSVG: true,
+ inner:
+ '',
+ outlineID: "pswp__icn-quote",
+ size: 512,
+ },
+ onInit: (el, pswp) => {
+ pswp.on("change", () => {
+ const slideData = pswp.currSlide?.data;
+ const slideElement = slideData?.element;
+ el.style.display = canQuoteImage(slideElement, slideData)
+ ? ""
+ : "none";
+ });
+ },
+ onClick: () => {
+ const slideData = lightboxEl.pswp.currSlide?.data;
+ const slideElement = slideData?.element;
+ quoteImage(slideElement, slideData).then((didQuote) => {
+ if (didQuote) {
+ lightboxEl.pswp.close();
+ }
+ });
+ },
+ });
+
lightboxEl.pswp.ui.registerElement({
name: "custom-counter",
order: 6,
@@ -239,6 +276,7 @@ export default async function lightbox(elem, siteSettings) {
}
const imgInfo = el.querySelector(".informations")?.textContent || "";
+ const imgEl = el.tagName === "IMG" ? el : el.querySelector("img");
if (!width || !height) {
const dimensions = imgInfo.trim().split(" ")[0];
@@ -249,10 +287,20 @@ export default async function lightbox(elem, siteSettings) {
data.thumbCropped = true;
data.src = data.src || el.getAttribute("data-large-src");
- data.title = el.title || el.alt;
+ data.origSrc = imgEl?.getAttribute("data-orig-src");
+ data.title = el.title || imgEl?.alt || imgEl?.title;
+ data.base62SHA1 = imgEl?.getAttribute("data-base62-sha1");
data.details = imgInfo;
data.w = data.width = width;
data.h = data.height = height;
+ data.targetWidth =
+ el.getAttribute("data-target-width") || imgEl.getAttribute("width");
+ data.targetHeight =
+ el.getAttribute("data-target-height") || imgEl.getAttribute("height");
+
+ if (relatedModel?.constructor?.name === "Post") {
+ data.post = relatedModel;
+ }
return data;
});
diff --git a/frontend/discourse/app/lib/lightbox/quote-image.js b/frontend/discourse/app/lib/lightbox/quote-image.js
new file mode 100644
index 0000000000000..cfeb75a24cc72
--- /dev/null
+++ b/frontend/discourse/app/lib/lightbox/quote-image.js
@@ -0,0 +1,113 @@
+import { getOwner } from "@ember/owner";
+import { helperContext } from "discourse/lib/helpers";
+import { buildImageMarkdown as buildImageMarkdownShared } from "discourse/lib/markdown-image-builder";
+import { buildQuote } from "discourse/lib/quote";
+import Composer from "discourse/models/composer";
+import Draft from "discourse/models/draft";
+
+function buildImageMarkdown(slideElement, slideData) {
+ const img = slideElement?.querySelector("img");
+
+ if (!img) {
+ return null;
+ }
+
+ let src;
+
+ // Check for base62 SHA1 to use short upload:// URL format (same as to-markdown.js)
+ if (slideData.base62SHA1) {
+ src = `upload://${slideData.base62SHA1}`;
+ } else {
+ // Prefer data-orig-src (same as to-markdown.js)
+ src = slideData.origSrc || slideData.src;
+ }
+
+ if (!src) {
+ return null;
+ }
+
+ return buildImageMarkdownShared({
+ src,
+ alt: slideData.title,
+ width: slideData.targetWidth,
+ height: slideData.targetHeight,
+ fallbackAlt: "image",
+ });
+}
+
+export function canQuoteImage(slideElement, slideData) {
+ return buildImageMarkdown(slideElement, slideData) !== null;
+}
+
+export default async function quoteImage(slideElement, slideData) {
+ try {
+ const ownerContext = helperContext();
+
+ if (!slideElement || !ownerContext) {
+ return false;
+ }
+
+ const owner = getOwner(ownerContext);
+
+ if (!owner) {
+ return false;
+ }
+
+ const markdown = buildImageMarkdown(slideElement, slideData);
+ if (!markdown) {
+ return false;
+ }
+
+ const composer = owner.lookup("service:composer");
+ if (!composer) {
+ return false;
+ }
+
+ const post = slideData.post;
+ const quote = buildQuote(post, markdown);
+
+ if (!quote) {
+ return false;
+ }
+
+ if (composer.model?.viewOpen) {
+ const appEvents = owner.lookup("service:app-events");
+ appEvents?.trigger("composer:insert-block", quote);
+ return true;
+ }
+
+ if (composer.model?.viewDraft) {
+ const model = composer.model;
+ model.set("reply", model.get("reply") + "\n" + quote);
+ composer.openIfDraft();
+ return true;
+ }
+
+ const composerOpts = {
+ action: Composer.REPLY,
+ draftKey: post.topic.draft_key,
+ draftSequence: post.topic.draft_sequence,
+ };
+
+ if (post.post_number === 1) {
+ composerOpts.topic = post.topic;
+ } else {
+ composerOpts.post = post;
+ }
+
+ const draftData = await Draft.get(composerOpts.draftKey);
+
+ if (draftData.draft) {
+ const data = JSON.parse(draftData.draft);
+ composerOpts.draftSequence = draftData.draft_sequence;
+ composerOpts.reply = data.reply + "\n" + quote;
+ } else {
+ composerOpts.quote = quote;
+ }
+
+ await composer.open(composerOpts);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/frontend/discourse/app/lib/markdown-image-builder.js b/frontend/discourse/app/lib/markdown-image-builder.js
new file mode 100644
index 0000000000000..c34f0674b0693
--- /dev/null
+++ b/frontend/discourse/app/lib/markdown-image-builder.js
@@ -0,0 +1,37 @@
+export function sanitizeAlt(text, options = {}) {
+ const fallback = options.fallback ?? "";
+
+ if (!text) {
+ return fallback;
+ }
+
+ const trimmed = text.trim();
+ if (!trimmed) {
+ return fallback;
+ }
+
+ return trimmed.replace(/([\\\[\]])/g, "\\$1").replace(/\|/g, "|");
+}
+
+export function buildImageMarkdown(imageData) {
+ const {
+ src,
+ alt,
+ width,
+ height,
+ title,
+ escapeTablePipe = false,
+ fallbackAlt,
+ } = imageData;
+
+ if (!src) {
+ return "";
+ }
+
+ const altText = sanitizeAlt(alt, { fallback: fallbackAlt });
+ const pipe = escapeTablePipe ? "\\|" : "|";
+ const suffix = width && height ? `${pipe}${width}x${height}` : "";
+ const titleSuffix = title ? ` "${title}"` : "";
+
+ return ``;
+}
diff --git a/frontend/discourse/app/lib/to-markdown.js b/frontend/discourse/app/lib/to-markdown.js
index 289917f515cd8..83895edbf4c06 100644
--- a/frontend/discourse/app/lib/to-markdown.js
+++ b/frontend/discourse/app/lib/to-markdown.js
@@ -1,3 +1,5 @@
+import { buildImageMarkdown } from "discourse/lib/markdown-image-builder";
+
const MSO_LIST_CLASSES = [
"MsoListParagraphCxSpFirst",
"MsoListParagraphCxSpMiddle",
@@ -426,19 +428,20 @@ export class Tag {
return "[image]";
}
- let alt = attr.alt || pAttr.alt || "";
+ const alt = attr.alt || pAttr.alt;
const width = attr.width || pAttr.width;
const height = attr.height || pAttr.height;
const title = attr.title;
-
- if (width && height) {
- const pipe = this.element.parentNames.includes("table")
- ? "\\|"
- : "|";
- alt = `${alt}${pipe}${width}x${height}`;
- }
-
- return ``;
+ const escapeTablePipe = this.element.parentNames.includes("table");
+
+ return buildImageMarkdown({
+ src,
+ alt,
+ width,
+ height,
+ title,
+ escapeTablePipe,
+ });
}
return "";
diff --git a/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js b/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js
new file mode 100644
index 0000000000000..8ddd61c313015
--- /dev/null
+++ b/frontend/discourse/tests/unit/lib/lightbox/quote-image-test.js
@@ -0,0 +1,277 @@
+import Controller from "@ember/controller";
+import Service from "@ember/service";
+import { setupTest } from "ember-qunit";
+import { module, test } from "qunit";
+import sinon from "sinon";
+import quoteImage, { canQuoteImage } from "discourse/lib/lightbox/quote-image";
+import Draft from "discourse/models/draft";
+
+class ComposerStub extends Service {
+ model = { viewOpen: false };
+ openCalls = [];
+
+ async open(args) {
+ this.openCalls.push(args);
+ }
+}
+
+class AppEventsStub extends Service {
+ events = [];
+
+ trigger(...args) {
+ this.events.push(args);
+ }
+}
+
+module("Unit | Lib | lightbox | quote image", function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.unregister("service:composer");
+ this.owner.unregister("service:app-events");
+
+ this.owner.register("service:composer", ComposerStub);
+ this.owner.register("service:app-events", AppEventsStub);
+ this.owner.register("controller:topic", class extends Controller {});
+
+ this.composer = this.owner.lookup("service:composer");
+ this.appEvents = this.owner.lookup("service:app-events");
+ this.topicController = this.owner.lookup("controller:topic");
+
+ this.topic = { id: 100, draft_key: "topic_100", draft_sequence: 5 };
+ this.post = {
+ id: 321,
+ post_number: 2,
+ username: "alice",
+ name: "Alice",
+ topic_id: this.topic.id,
+ topicId: this.topic.id,
+ topic: this.topic,
+ };
+
+ this.draftGetStub = sinon.stub(Draft, "get").resolves({});
+ });
+
+ hooks.afterEach(function () {
+ document.querySelectorAll(".topic-post").forEach((el) => el.remove());
+ this.draftGetStub.restore();
+ });
+
+ function buildLightbox(context, overrides = {}) {
+ const topicPost = document.createElement("div");
+ topicPost.classList.add("topic-post");
+ topicPost.dataset.postNumber = overrides.postNumber || "2";
+
+ const article = document.createElement("article");
+ article.dataset.postId = overrides.postId || "321";
+ article.dataset.topicId = overrides.topicId || "100";
+ topicPost.appendChild(article);
+
+ const link = document.createElement("a");
+ const targetWidth = overrides.targetWidth || "640";
+ const targetHeight = overrides.targetHeight || "480";
+ const href = overrides.href || "/uploads/example.png";
+ const origSrc =
+ overrides.origSrc !== undefined
+ ? overrides.origSrc
+ : "upload://secure.png";
+
+ link.classList.add("lightbox");
+ link.dataset.targetWidth = targetWidth;
+ link.dataset.targetHeight = targetHeight;
+ link.setAttribute("href", href);
+ article.appendChild(link);
+
+ const img = document.createElement("img");
+ if (overrides.origSrc !== undefined) {
+ img.setAttribute("data-orig-src", overrides.origSrc);
+ } else {
+ img.setAttribute("data-orig-src", "upload://secure.png");
+ }
+ if (overrides.base62SHA1) {
+ img.setAttribute("data-base62-sha1", overrides.base62SHA1);
+ }
+ img.setAttribute("alt", overrides.alt || "diagram");
+ img.setAttribute("width", overrides.width || "640");
+ img.setAttribute("height", overrides.height || "480");
+ link.appendChild(img);
+
+ document.body.appendChild(topicPost);
+
+ const slideData = {
+ element: link,
+ src: href,
+ origSrc,
+ title: overrides.alt || "diagram",
+ targetWidth,
+ targetHeight,
+ base62SHA1: overrides.base62SHA1,
+ post: overrides.post || context.post,
+ };
+
+ return { element: link, slideData };
+ }
+
+ test("returns false when the element is outside a post", async function (assert) {
+ const element = document.createElement("a");
+ element.classList.add("lightbox");
+ element.appendChild(document.createElement("img"));
+
+ const result = await quoteImage(element, {});
+
+ assert.false(result, "quoteImage short-circuits without post context");
+ assert.strictEqual(
+ this.composer.openCalls.length,
+ 0,
+ "composer.open is not called"
+ );
+ assert.strictEqual(
+ this.appEvents.events.length,
+ 0,
+ "no composer insert event is fired"
+ );
+ });
+
+ test("canQuoteImage only returns true when context and metadata exist", function (assert) {
+ const invalid = document.createElement("a");
+ assert.false(canQuoteImage(invalid, {}));
+
+ const { element, slideData } = buildLightbox(this);
+ assert.true(canQuoteImage(element, slideData));
+ });
+
+ test("builds markdown using data-orig-src and dimensions when composer is closed", async function (assert) {
+ const { element, slideData } = buildLightbox(this, {
+ origSrc: "upload://original.png",
+ targetWidth: "800",
+ targetHeight: "600",
+ });
+
+ const result = await quoteImage(element, slideData);
+
+ assert.true(result, "quoteImage succeeds");
+ assert.strictEqual(this.composer.openCalls.length, 1);
+ const quote = this.composer.openCalls[0].quote;
+ assert.true(
+ quote.includes(""),
+ "markdown prefers data-orig-src and size"
+ );
+ assert.true(quote.includes("post:2"), "quote metadata references the post");
+ });
+
+ test("inserts into an open composer via app events", async function (assert) {
+ this.composer.model.viewOpen = true;
+ const { element, slideData } = buildLightbox(this);
+
+ const result = await quoteImage(element, slideData);
+
+ assert.true(result);
+ assert.strictEqual(this.composer.openCalls.length, 0);
+ assert.strictEqual(this.appEvents.events.length, 1, "event triggered once");
+ assert.true(
+ this.appEvents.events[0][1].includes("![diagram|640x480]"),
+ "quoted markdown is inserted"
+ );
+ });
+
+ test("falls back to the rendered href when no data-orig-src exists", async function (assert) {
+ const { element, slideData } = buildLightbox(this, { origSrc: "" });
+
+ const result = await quoteImage(element, slideData);
+
+ assert.true(result);
+ const quote = this.composer.openCalls[0].quote;
+ assert.true(
+ quote.includes("](/uploads/example.png)"),
+ "fallback uses the link href"
+ );
+ });
+
+ test("uses short upload:// URL when data-base62-sha1 is present", async function (assert) {
+ const { element, slideData } = buildLightbox(this, {
+ base62SHA1: "a4bcwvmLAy8cGHKPUrK4G3AUbt9",
+ href: "//localhost:4200/uploads/default/original/1X/468eb8aa1f0126f1ce7e7ea7a2f64f25da0b58db.png",
+ origSrc: "",
+ });
+
+ const result = await quoteImage(element, slideData);
+
+ assert.true(result);
+ const quote = this.composer.openCalls[0].quote;
+ assert.true(
+ quote.includes(
+ ""
+ ),
+ "uses short upload:// URL format with base62-sha1"
+ );
+ });
+
+ test("expands minimized composer and appends quote when viewDraft is true", async function (assert) {
+ const existingReply = "Existing draft content";
+ this.composer.model = {
+ viewOpen: false,
+ viewDraft: true,
+ reply: existingReply,
+ };
+
+ let openIfDraftCalled = false;
+ this.composer.openIfDraft = () => {
+ openIfDraftCalled = true;
+ };
+
+ const { element, slideData } = buildLightbox(this);
+
+ const result = await quoteImage(element, slideData);
+
+ assert.true(result);
+ assert.strictEqual(
+ this.composer.openCalls.length,
+ 0,
+ "composer.open not called"
+ );
+ assert.true(openIfDraftCalled, "openIfDraft was called to expand composer");
+ assert.true(
+ this.composer.model.reply.includes(existingReply),
+ "existing draft content preserved"
+ );
+ assert.true(
+ this.composer.model.reply.includes("![diagram|640x480]"),
+ "quote appended to existing content"
+ );
+ });
+
+ test("loads existing draft and appends quote when draft exists", async function (assert) {
+ const existingDraftContent = "This is my existing draft";
+ this.draftGetStub.resolves({
+ draft: JSON.stringify({ reply: existingDraftContent }),
+ draft_sequence: 10,
+ });
+
+ const { element, slideData } = buildLightbox(this);
+
+ const result = await quoteImage(element, slideData);
+
+ assert.true(result);
+ assert.strictEqual(this.composer.openCalls.length, 1);
+
+ const composerOpts = this.composer.openCalls[0];
+ assert.true(
+ composerOpts.reply.includes(existingDraftContent),
+ "existing draft content included"
+ );
+ assert.true(
+ composerOpts.reply.includes("![diagram|640x480]"),
+ "quote appended to draft"
+ );
+ assert.strictEqual(
+ composerOpts.draftSequence,
+ 10,
+ "draft sequence from server used"
+ );
+ assert.strictEqual(
+ composerOpts.quote,
+ undefined,
+ "quote option not used when draft exists"
+ );
+ });
+});
diff --git a/frontend/discourse/tests/unit/lib/markdown-image-builder-test.js b/frontend/discourse/tests/unit/lib/markdown-image-builder-test.js
new file mode 100644
index 0000000000000..7d1933da750c4
--- /dev/null
+++ b/frontend/discourse/tests/unit/lib/markdown-image-builder-test.js
@@ -0,0 +1,157 @@
+import { module, test } from "qunit";
+import {
+ buildImageMarkdown,
+ sanitizeAlt,
+} from "discourse/lib/markdown-image-builder";
+
+module("Unit | Lib | markdown-image-builder", function () {
+ module("sanitizeAlt", function () {
+ test("returns empty string by default for null or empty text", function (assert) {
+ assert.strictEqual(sanitizeAlt(null), "");
+ assert.strictEqual(sanitizeAlt(undefined), "");
+ assert.strictEqual(sanitizeAlt(""), "");
+ assert.strictEqual(sanitizeAlt(" "), "");
+ });
+
+ test("returns fallback for null or empty text when fallback is provided", function (assert) {
+ assert.strictEqual(sanitizeAlt(null, { fallback: "image" }), "image");
+ assert.strictEqual(
+ sanitizeAlt(undefined, { fallback: "image" }),
+ "image"
+ );
+ assert.strictEqual(sanitizeAlt("", { fallback: "image" }), "image");
+ assert.strictEqual(sanitizeAlt(" ", { fallback: "image" }), "image");
+ });
+
+ test("escapes pipes for markdown", function (assert) {
+ assert.strictEqual(
+ sanitizeAlt("alt|text|with|pipes"),
+ "alt|text|with|pipes"
+ );
+ });
+
+ test("escapes backslashes, brackets", function (assert) {
+ assert.strictEqual(
+ sanitizeAlt("text\\with\\slashes"),
+ "text\\\\with\\\\slashes"
+ );
+ assert.strictEqual(
+ sanitizeAlt("text[with]brackets"),
+ "text\\[with\\]brackets"
+ );
+ });
+
+ test("trims whitespace", function (assert) {
+ assert.strictEqual(sanitizeAlt(" trimmed "), "trimmed");
+ });
+ });
+
+ module("buildImageMarkdown", function () {
+ test("returns empty string when src is missing", function (assert) {
+ assert.strictEqual(buildImageMarkdown({}), "");
+ assert.strictEqual(buildImageMarkdown({ alt: "test" }), "");
+ });
+
+ test("builds basic image markdown", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({ src: "/uploads/image.png" }),
+ ""
+ );
+ });
+
+ test("builds basic image markdown with fallback alt", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({ src: "/uploads/image.png", fallbackAlt: "image" }),
+ ""
+ );
+ });
+
+ test("includes alt text", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({ src: "/uploads/image.png", alt: "My Image" }),
+ ""
+ );
+ });
+
+ test("includes dimensions when both width and height are provided", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "/uploads/image.png",
+ alt: "test",
+ width: 640,
+ height: 480,
+ }),
+ ""
+ );
+ });
+
+ test("omits dimensions when only width is provided", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "/uploads/image.png",
+ alt: "test",
+ width: 640,
+ }),
+ ""
+ );
+ });
+
+ test("omits dimensions when only height is provided", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "/uploads/image.png",
+ alt: "test",
+ height: 480,
+ }),
+ ""
+ );
+ });
+
+ test("includes title when provided", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "/uploads/image.png",
+ alt: "test",
+ title: "Image Title",
+ }),
+ ''
+ );
+ });
+
+ test("escapes pipe in dimensions for table context", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "/uploads/image.png",
+ alt: "test",
+ width: 640,
+ height: 480,
+ escapeTablePipe: true,
+ }),
+ ""
+ );
+ });
+
+ test("sanitizes alt text", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "/uploads/image.png",
+ alt: "text|with|pipes",
+ }),
+ ""
+ );
+ });
+
+ test("builds complete markdown with all options", function (assert) {
+ assert.strictEqual(
+ buildImageMarkdown({
+ src: "upload://secure.png",
+ alt: "diagram",
+ width: 800,
+ height: 600,
+ title: "Architecture Diagram",
+ }),
+ ''
+ );
+ });
+ });
+});
diff --git a/plugins/discourse-calendar/spec/system/post_event_spec.rb b/plugins/discourse-calendar/spec/system/post_event_spec.rb
index 546b1a8af9f3b..dd072b028403d 100644
--- a/plugins/discourse-calendar/spec/system/post_event_spec.rb
+++ b/plugins/discourse-calendar/spec/system/post_event_spec.rb
@@ -60,7 +60,9 @@
expect(page).to have_css(".event-description a[href='http://example.com']")
end
- it "correctly builds a multiline description", timezone: "Europe/Paris" do
+ # this is a flake cause strftim is calculated on server and client may have a
+ # slightly different time
+ xit "correctly builds a multiline description", timezone: "Europe/Paris" do
visit("/new-topic")
time = Time.now.strftime("%Y-%m-%d %H:%M")
diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js
index b44e667d421c9..e545c9495b6da 100644
--- a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js
+++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js
@@ -37,6 +37,9 @@ acceptance("Local Dates - quoting", function (needs) {
server.get("/t/280/:post_number.json", () => {
helper.response(topicResponse);
});
+ server.get("/drafts/topic_280.json", () => {
+ return helper.response({});
+ });
});
test("quoting single local dates with basic options", async function (assert) {
@@ -72,6 +75,9 @@ acceptance("Local Dates - quoting range", function (needs) {
`;
server.get("/t/280.json", () => helper.response(topicResponse));
+ server.get("/drafts/topic_280.json", () => {
+ return helper.response({});
+ });
server.get("/t/280/:post_number.json", () => {
helper.response(topicResponse);
});
@@ -113,6 +119,9 @@ acceptance(
`;
server.get("/t/280.json", () => helper.response(topicResponse));
+ server.get("/drafts/topic_280.json", () => {
+ return helper.response({});
+ });
server.get("/t/280/:post_number.json", () => {
helper.response(topicResponse);
});
diff --git a/spec/system/lightbox_spec.rb b/spec/system/lightbox_spec.rb
index 8aa01f7db8fd5..2ee057dacc6f6 100644
--- a/spec/system/lightbox_spec.rb
+++ b/spec/system/lightbox_spec.rb
@@ -10,6 +10,7 @@
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:lightbox) { PageObjects::Components::PhotoSwipe.new }
+ let(:composer) { PageObjects::Components::Composer.new }
let(:cpp) { CookedPostProcessor.new(post, disable_dominant_color: true) }
before do
@@ -28,7 +29,7 @@
it "has the correct lightbox elements" do
topic_page.visit_topic(topic)
- find("#post_1 a.lightbox").click
+ topic_page.post_by_number(1).find("a.lightbox").click
expect(lightbox).to be_visible
@@ -43,11 +44,33 @@
expect(lightbox).to have_image_info_button
end
+ it "quotes the image into the composer" do
+ topic_page.visit_topic(topic)
+
+ lightbox_link = topic_page.post_by_number(1).find("a.lightbox")
+ lightbox_image = lightbox_link.find("img", visible: :all)
+ expected_width = lightbox_link["data-target-width"].presence || lightbox_image["width"]
+ expected_height = lightbox_link["data-target-height"].presence || lightbox_image["height"]
+ expected_src = lightbox_image["data-orig-src"].presence || lightbox_link["href"]
+ lightbox_link.click
+
+ expect(lightbox).to have_quote_button
+
+ lightbox.quote_button.click
+
+ expect(composer).to be_opened
+ editor_value = composer.composer_input.value
+ expect(editor_value).to include(
+ "",
+ )
+ expect(editor_value).to include("post:1")
+ end
+
it "does not show image info button when no image details are available" do
post.update(cooked: post.cooked.gsub(%r{[^<]*}, ""))
topic_page.visit_topic(topic)
- find("#post_1 a.lightbox").click
+ topic_page.post_by_number(1).find("a.lightbox").click
expect(lightbox).to have_no_image_info_button
end
@@ -55,7 +78,7 @@
it "can toggle image info" do
topic_page.visit_topic(topic)
- find("#post_1 a.lightbox").click
+ topic_page.post_by_number(1).find("a.lightbox").click
expect(lightbox).to be_visible
expect(lightbox).to have_no_caption_details
@@ -172,7 +195,7 @@
it "toggles UI by tapping image" do
topic_page.visit_topic(topic)
- find("#post_1 a.lightbox").click
+ topic_page.post_by_number(1).find("a.lightbox").click
expect(lightbox).to be_visible
expect(lightbox).to have_ui_visible
@@ -186,7 +209,7 @@
it "closes lightbox by tapping backdrop" do
topic_page.visit_topic(topic)
- find("#post_1 a.lightbox").click
+ topic_page.post_by_number(1).find("a.lightbox").click
expect(lightbox).to be_visible
@@ -198,7 +221,7 @@
it "toggles image info by clicking button" do
topic_page.visit_topic(topic)
- find("#post_1 a.lightbox").click
+ topic_page.post_by_number(1).find("a.lightbox").click
expect(lightbox).to be_visible
expect(lightbox).to have_no_caption_details
@@ -221,7 +244,7 @@
)
topic_page.visit_topic(topic)
- lightbox_link = find("#post_1 a.lightbox")
+ lightbox_link = topic_page.post_by_number(1).find("a.lightbox")
expect(lightbox_link["data-target-width"]).to eq(upload_1.width.to_s)
expect(lightbox_link["data-target-height"]).to eq(upload_1.height.to_s)
diff --git a/spec/system/page_objects/components/photoswipe.rb b/spec/system/page_objects/components/photoswipe.rb
index ec83672cd9994..a56da962de5c1 100644
--- a/spec/system/page_objects/components/photoswipe.rb
+++ b/spec/system/page_objects/components/photoswipe.rb
@@ -13,6 +13,7 @@ class PhotoSwipe < PageObjects::Components::Base
DOWNLOAD_BTN = ".pswp__button--download-image"
ORIGINAL_IMAGE_BTN = ".pswp__button--original-image"
IMAGE_INFO_BTN = ".pswp__button--image-info"
+ QUOTE_BTN = ".pswp__button--quote-image"
COUNTER = ".pswp__custom-counter"
CAPTION = ".pswp__caption"
CAPTION_TITLE = ".pswp__caption-title"
@@ -44,6 +45,10 @@ def image_info_button
component.find(IMAGE_INFO_BTN)
end
+ def quote_button
+ component.find(QUOTE_BTN)
+ end
+
def close_button
component.find(CLOSE_BTN)
end
@@ -116,6 +121,14 @@ def has_no_image_info_button?
component.has_no_css?(IMAGE_INFO_BTN)
end
+ def has_quote_button?
+ component.has_css?(QUOTE_BTN)
+ end
+
+ def has_no_quote_button?
+ component.has_no_css?(QUOTE_BTN)
+ end
+
def has_ui_visible?
page.has_css?(UI_VISIBLE)
end