From 10b33bc60111a3fc242747c372a747aa75329324 Mon Sep 17 00:00:00 2001
From: Keegan George <kgeorge13@gmail.com>
Date: Wed, 14 Feb 2024 12:20:53 -0800
Subject: [PATCH] DEV: API extra markup to image wrapper (#25575)

---
 .../src/features/image-controls.js            | 28 ++++++++++
 .../app/components/composer-editor.js         | 18 +++++++
 .../discourse/app/lib/plugin-api.js           | 24 ++++++++-
 .../acceptance/composer-image-preview-test.js | 51 +++++++++++++++++++
 docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md       |  4 ++
 5 files changed, 124 insertions(+), 1 deletion(-)

diff --git a/app/assets/javascripts/discourse-markdown-it/src/features/image-controls.js b/app/assets/javascripts/discourse-markdown-it/src/features/image-controls.js
index d6bb2bd1fe7..b63a165f795 100644
--- a/app/assets/javascripts/discourse-markdown-it/src/features/image-controls.js
+++ b/app/assets/javascripts/discourse-markdown-it/src/features/image-controls.js
@@ -2,6 +2,30 @@ import I18n from "discourse-i18n";
 
 const SCALES = ["100", "75", "50"];
 
+let apiExtraButton = [];
+let apiExtraButtonAllowList = [];
+
+export function addImageWrapperButton(label, btnClass, icon = null) {
+  const markup = [];
+  markup.push(`<span class="${btnClass}">`);
+  if (icon) {
+    markup.push(`
+      <svg class="fa d-icon d-icon-${icon} svg-icon svg-string" xmlns="http://www.w3.org/2000/svg">
+        <use href="#${icon}"></use>
+      </svg>
+    `);
+  }
+  markup.push(label);
+  markup.push("</span>");
+
+  apiExtraButton.push(markup.join(""));
+  apiExtraButtonAllowList.push(`span.${btnClass}`);
+  apiExtraButtonAllowList.push(
+    `svg[class=fa d-icon d-icon-${icon} svg-icon svg-string]`
+  );
+  apiExtraButtonAllowList.push(`use[href=#${icon}]`);
+}
+
 function isUpload(token) {
   return token.content.includes("upload://");
 }
@@ -157,6 +181,8 @@ function ruleWithImageControls(oldRule) {
       result += `</span>`;
       result += buildImageDeleteButton();
 
+      result += apiExtraButton.join("");
+
       result += "</span></span>";
 
       return result;
@@ -205,6 +231,8 @@ export function setup(helper) {
       "span.wrap-image-grid-button[data-image-count]",
       "svg[class=fa d-icon d-icon-th svg-icon svg-string]",
       "use[href=#th]",
+
+      ...apiExtraButtonAllowList,
     ]);
 
     helper.registerPlugin((md) => {
diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js
index 41918025cc5..9c27f80aaf9 100644
--- a/app/assets/javascripts/discourse/app/components/composer-editor.js
+++ b/app/assets/javascripts/discourse/app/components/composer-editor.js
@@ -101,6 +101,12 @@ export function addComposerUploadMarkdownResolver(resolver) {
 export function cleanUpComposerUploadMarkdownResolver() {
   uploadMarkdownResolvers = [];
 }
+
+let apiImageWrapperBtnEvents = [];
+export function addApiImageWrapperButtonClickEvent(fn) {
+  apiImageWrapperBtnEvents.push(fn);
+}
+
 export default Component.extend(ComposerUploadUppy, {
   classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
 
@@ -773,6 +779,12 @@ export default Component.extend(ComposerUploadUppy, {
     preview.addEventListener("click", this._handleImageDeleteButtonClick);
     preview.addEventListener("keypress", this._handleAltTextInputKeypress);
     preview.addEventListener("click", this._handleImageGridButtonClick);
+
+    if (apiImageWrapperBtnEvents.length > 0) {
+      apiImageWrapperBtnEvents.forEach((fn) => {
+        preview.addEventListener("click", fn);
+      });
+    }
   },
 
   @on("willDestroyElement")
@@ -802,6 +814,12 @@ export default Component.extend(ComposerUploadUppy, {
     preview?.removeEventListener("click", this._handleImageGridButtonClick);
     preview?.removeEventListener("click", this._handleAltTextCancelButtonClick);
     preview?.removeEventListener("keypress", this._handleAltTextInputKeypress);
+
+    if (apiImageWrapperBtnEvents.length > 0) {
+      apiImageWrapperBtnEvents.forEach((fn) => {
+        preview?.removeEventListener("click", fn);
+      });
+    }
   },
 
   onExpandPopupMenuOptions(toolbarEvent) {
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index ca592cb76f3..715aee9c39f 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -1,6 +1,7 @@
 import $ from "jquery";
 import { h } from "virtual-dom";
 import {
+  addApiImageWrapperButtonClickEvent,
   addComposerUploadHandler,
   addComposerUploadMarkdownResolver,
   addComposerUploadPreProcessor,
@@ -132,6 +133,7 @@ import {
   registerIconRenderer,
   replaceIcon,
 } from "discourse-common/lib/icon-library";
+import { addImageWrapperButton } from "discourse-markdown-it/features/image-controls";
 import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser";
 import { modifySelectKit } from "select-kit/mixins/plugin-api";
 
@@ -140,7 +142,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api";
 // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
 // using the format described at https://keepachangelog.com/en/1.0.0/.
 
-export const PLUGIN_API_VERSION = "1.24.0";
+export const PLUGIN_API_VERSION = "1.25.0";
 
 // This helper prevents us from applying the same `modifyClass` over and over in test mode.
 function canModify(klass, type, resolverName, changes) {
@@ -2687,6 +2689,26 @@ class PluginApi {
       .lookup("service:admin-custom-user-fields")
       .addProperty(userFieldProperty);
   }
+
+  /**
+   * Adds a custom button to the composer preview's image wrapper
+   *
+   *
+   * ```
+   * api.addComposerImageWrapperButton(
+   *   "My Custom Button",
+   *   "custom-button-class"
+   *   "lock"
+   *   (event) => { console.log("Custom button clicked", event)
+   * });
+   *
+   * ```
+   *
+   */
+  addComposerImageWrapperButton(label, btnClass, icon, fn) {
+    addImageWrapperButton(label, btnClass, icon);
+    addApiImageWrapperButtonClickEvent(fn);
+  }
 }
 
 // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js
index 3be52a4ba90..ca591126352 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js
@@ -1,5 +1,6 @@
 import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
 import { test } from "qunit";
+import { withPluginApi } from "discourse/lib/plugin-api";
 import {
   acceptance,
   count,
@@ -376,3 +377,53 @@ acceptance("Composer - Image Preview", function (needs) {
     );
   });
 });
+
+acceptance("Composer - Image Preview - Plugin API", function (needs) {
+  needs.user({});
+  needs.settings({ allow_uncategorized_topics: true });
+  needs.site({ can_tag_topics: true });
+  needs.pretender((server, helper) => {
+    server.post("/uploads/lookup-urls", () => {
+      return helper.response([]);
+    });
+  });
+
+  needs.hooks.beforeEach(() => {
+    withPluginApi("1.25.0", (api) => {
+      api.addComposerImageWrapperButton(
+        "My Custom Button",
+        "custom-button-class",
+        "lock",
+        (event) => {
+          if (event.target.classList.contains("custom-button-class")) {
+            document.querySelector(".d-editor-input").value =
+              "custom button change";
+          }
+        }
+      );
+    });
+  });
+
+  test("image wrapper includes extra API button and is functional", async function (assert) {
+    await visit("/");
+    await click("#create-topic");
+
+    await fillIn(
+      ".d-editor-input",
+      "![image_example_0|666x500](upload://q4iRxcuSAzfnbUaCsbjMXcGrpaK.jpeg)"
+    );
+
+    assert.ok(
+      exists(".image-wrapper .custom-button-class"),
+      "The custom button is added to the image preview wrapper"
+    );
+
+    await click(".custom-button-class");
+
+    assert.strictEqual(
+      query(".d-editor-input").value,
+      "custom button change",
+      "The custom button changes the editor input"
+    );
+  });
+});
diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
index f0382b6803c..9e826ff51e6 100644
--- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
+++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md
@@ -7,6 +7,10 @@ in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.25.0] - 2024-02-05
+
+- Added `addComposerImageWrapperButton` which is used to add a custom button to the composer preview's image wrapper that appears on hover of an uploaded image.
+
 ## [1.24.0] - 2024-01-08
 
 - Added `addAdminSidebarSectionLink` which is used to add a link to a specific admin sidebar section, as a replacement for the `admin-menu` plugin outlet. This only has an effect if the `admin_sidebar_enabled_groups` site setting is in use, which enables the new admin nav sidebar.