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 `![${altText}${suffix}](${src}${titleSuffix})`; +} 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 `![${alt}](${src}${title ? ` "${title}"` : ""})`; + 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("![diagram|800x600](upload://original.png)"), + "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( + "![diagram|640x480](upload://a4bcwvmLAy8cGHKPUrK4G3AUbt9)" + ), + "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" }), + "![](/uploads/image.png)" + ); + }); + + test("builds basic image markdown with fallback alt", function (assert) { + assert.strictEqual( + buildImageMarkdown({ src: "/uploads/image.png", fallbackAlt: "image" }), + "![image](/uploads/image.png)" + ); + }); + + test("includes alt text", function (assert) { + assert.strictEqual( + buildImageMarkdown({ src: "/uploads/image.png", alt: "My Image" }), + "![My Image](/uploads/image.png)" + ); + }); + + 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|640x480](/uploads/image.png)" + ); + }); + + test("omits dimensions when only width is provided", function (assert) { + assert.strictEqual( + buildImageMarkdown({ + src: "/uploads/image.png", + alt: "test", + width: 640, + }), + "![test](/uploads/image.png)" + ); + }); + + test("omits dimensions when only height is provided", function (assert) { + assert.strictEqual( + buildImageMarkdown({ + src: "/uploads/image.png", + alt: "test", + height: 480, + }), + "![test](/uploads/image.png)" + ); + }); + + test("includes title when provided", function (assert) { + assert.strictEqual( + buildImageMarkdown({ + src: "/uploads/image.png", + alt: "test", + title: "Image Title", + }), + '![test](/uploads/image.png "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\\|640x480](/uploads/image.png)" + ); + }); + + test("sanitizes alt text", function (assert) { + assert.strictEqual( + buildImageMarkdown({ + src: "/uploads/image.png", + alt: "text|with|pipes", + }), + "![text|with|pipes](/uploads/image.png)" + ); + }); + + 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", + }), + '![diagram|800x600](upload://secure.png "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( + "![first image|#{expected_width}x#{expected_height}](#{expected_src})", + ) + 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