From 987ec602ecdba2de330f123904687fdb005871ed Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 7 Jun 2023 14:15:57 -0400 Subject: [PATCH] FEATURE: image grid in posts (experimental) (#21513) Adds a new `[grid]` tag that can arrange images (or other media) into a grid in posts. The grid defaults to a 3-column with a few exceptions: - if there are only 2 or 4 items, it defaults to a 2-column grid (because it generally looks better) - on mobile, it defaults to a 2-column grid - if there is only one item, the grid has no effect --- .../app/components/composer-editor.js | 36 +++++ .../discourse/app/components/d-editor.js | 7 + .../app/initializers/post-decorations.js | 20 +++ .../javascripts/discourse/app/lib/columns.js | 110 +++++++++++++ .../acceptance/composer-image-grid-test.js | 148 ++++++++++++++++++ .../tests/unit/lib/pretty-text-test.js | 56 +++++++ .../discourse-markdown/image-controls.js | 24 +++ .../engines/discourse-markdown/image-grid.js | 27 ++++ .../stylesheets/common/base/_index.scss | 1 + .../stylesheets/common/base/d-image-grid.scss | 66 ++++++++ app/assets/stylesheets/common/d-editor.scss | 20 ++- config/locales/client.en.yml | 1 + config/site_settings.yml | 3 + lib/svg_sprite.rb | 1 + 14 files changed, 514 insertions(+), 6 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/lib/columns.js create mode 100644 app/assets/javascripts/discourse/tests/acceptance/composer-image-grid-test.js create mode 100644 app/assets/javascripts/pretty-text/engines/discourse-markdown/image-grid.js create mode 100644 app/assets/stylesheets/common/base/d-image-grid.scss diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index b792f4c684f..c95a3e35469 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -742,12 +742,44 @@ export default Component.extend( ); }, + @bind + _handleImageGridButtonClick(event) { + if (!event.target.classList.contains("wrap-image-grid-button")) { + return; + } + + const index = parseInt( + event.target.closest(".button-wrapper").dataset.imageIndex, + 10 + ); + const reply = this.get("composer.reply"); + const matches = reply.match(IMAGE_MARKDOWN_REGEX); + const closingIndex = + index + parseInt(event.target.dataset.imageCount, 10) - 1; + + const textArea = this.element.querySelector(".d-editor-input"); + textArea.selectionStart = reply.indexOf(matches[index]); + textArea.selectionEnd = + reply.indexOf(matches[closingIndex]) + matches[closingIndex].length; + + this.appEvents.trigger( + `${this.composerEventPrefix}:apply-surround`, + "[grid]", + "[/grid]", + "grid_surround", + { useBlockMode: true } + ); + }, + _registerImageAltTextButtonClick(preview) { preview.addEventListener("click", this._handleAltTextEditButtonClick); preview.addEventListener("click", this._handleAltTextOkButtonClick); preview.addEventListener("click", this._handleAltTextCancelButtonClick); preview.addEventListener("click", this._handleImageDeleteButtonClick); preview.addEventListener("keypress", this._handleAltTextInputKeypress); + if (this.siteSettings.experimental_post_image_grid) { + preview.addEventListener("click", this._handleImageGridButtonClick); + } }, @on("willDestroyElement") @@ -773,6 +805,10 @@ export default Component.extend( preview?.removeEventListener("click", this._handleImageScaleButtonClick); preview?.removeEventListener("click", this._handleAltTextEditButtonClick); preview?.removeEventListener("click", this._handleAltTextOkButtonClick); + preview?.removeEventListener("click", this._handleImageDeleteButtonClick); + if (this.siteSettings.experimental_post_image_grid) { + preview?.removeEventListener("click", this._handleImageGridButtonClick); + } preview?.removeEventListener( "click", this._handleAltTextCancelButtonClick diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 995c98a827f..09a82105a83 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -309,6 +309,7 @@ export default Component.extend(TextareaTextManipulation, { this.appEvents.on("composer:insert-block", this, "insertBlock"); this.appEvents.on("composer:insert-text", this, "insertText"); this.appEvents.on("composer:replace-text", this, "replaceText"); + this.appEvents.on("composer:apply-surround", this, "_applySurround"); this.appEvents.on( "composer:indent-selected-text", this, @@ -349,6 +350,7 @@ export default Component.extend(TextareaTextManipulation, { this.appEvents.off("composer:insert-block", this, "insertBlock"); this.appEvents.off("composer:insert-text", this, "insertText"); this.appEvents.off("composer:replace-text", this, "replaceText"); + this.appEvents.off("composer:apply-surround", this, "_applySurround"); this.appEvents.off( "composer:indent-selected-text", this, @@ -646,6 +648,11 @@ export default Component.extend(TextareaTextManipulation, { } }, + _applySurround(head, tail, exampleKey, opts) { + const selected = this.getSelected(); + this.applySurround(selected, head, tail, exampleKey, opts); + }, + _toggleDirection() { let currentDir = this._$textarea.attr("dir") ? this._$textarea.attr("dir") diff --git a/app/assets/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js index 162eec7de61..13cdf632677 100644 --- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js +++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js @@ -3,6 +3,7 @@ import discourseLater from "discourse-common/lib/later"; import I18n from "I18n"; import highlightSyntax from "discourse/lib/highlight-syntax"; import lightbox from "discourse/lib/lightbox"; +import Columns from "discourse/lib/columns"; import { iconHTML, iconNode } from "discourse-common/lib/icon-library"; import { setTextDirections } from "discourse/lib/text-direction"; import { nativeLazyLoading } from "discourse/lib/lazy-load-images"; @@ -33,6 +34,25 @@ export default { { id: "discourse-lightbox" } ); + if (siteSettings.experimental_post_image_grid) { + api.decorateCookedElement( + (elem) => { + const grids = elem.querySelectorAll(".d-image-grid"); + + if (!grids.length) { + return; + } + + grids.forEach((grid) => { + return new Columns(grid, { + columns: site.mobileView ? 2 : 3, + }); + }); + }, + { id: "discourse-image-grid" } + ); + } + if (siteSettings.support_mixed_text_direction) { api.decorateCookedElement(setTextDirections, { id: "discourse-text-direction", diff --git a/app/assets/javascripts/discourse/app/lib/columns.js b/app/assets/javascripts/discourse/app/lib/columns.js new file mode 100644 index 00000000000..b88361894f5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/columns.js @@ -0,0 +1,110 @@ +/** + * Turns an element containing multiple children into a grid of columns. + * Can be used to arrange images or media in a grid. + * + * Inspired/adapted from https://github.com/mladenilic/columns.js + * + * TODO: Add unit tests + */ +export default class Columns { + constructor(container, options = {}) { + this.container = container; + + this.options = { + columns: 3, + columnClass: "d-image-grid-column", + minCount: 2, + ...options, + }; + + this.excluded = ["BR", "P"]; + + this.items = this._prepareItems(); + + if (this.items.length >= this.options.minCount) { + this.render(); + } else { + container.dataset.disabled = true; + } + } + + count() { + // a 2x2 grid looks better in most cases for 2 or 4 items + if (this.items.length === 4 || this.items.length === 2) { + return 2; + } + return this.options.columns; + } + + render() { + if (this.container.dataset.columns) { + return; + } + + this.container.dataset.columns = this.count(); + + const columns = this._distributeEvenly(); + + while (this.container.firstChild) { + this.container.removeChild(this.container.firstChild); + } + this.container.append(...columns); + return this; + } + + _prepareColumns(count) { + const columns = []; + [...Array(count)].forEach(() => { + const column = document.createElement("div"); + column.classList.add(this.options.columnClass); + columns.push(column); + }); + + return columns; + } + + _prepareItems() { + let targets = []; + + Array.from(this.container.children).forEach((child) => { + if (child.nodeName === "P" && child.children.length > 0) { + // sometimes children are wrapped in a paragraph + targets.push(...child.children); + } else { + targets.push(child); + } + }); + + return targets.filter((item) => { + return !this.excluded.includes(item.nodeName); + }); + } + + _distributeEvenly() { + const count = this.count(); + const columns = this._prepareColumns(count); + + const columnHeights = []; + for (let n = 0; n < count; n++) { + columnHeights[n] = 0; + } + this.items.forEach((item) => { + let shortest = 0; + + for (let j = 1; j < count; ++j) { + if (columnHeights[j] < columnHeights[shortest]) { + shortest = j; + } + } + + // use aspect ratio to compare heights and append to shortest column + // if element is not an image, assue ratio is 1:1 + const img = item.querySelector("img") || item; + const aR = img.nodeName === "IMG" ? img.height / img.width : 1; + columnHeights[shortest] += aR; + columns[shortest].append(item); + }); + + return columns; + } +} diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-image-grid-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-image-grid-test.js new file mode 100644 index 00000000000..db62b7ed704 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-image-grid-test.js @@ -0,0 +1,148 @@ +import { click, fillIn, visit } from "@ember/test-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Composer - Image Grid", function (needs) { + needs.user(); + needs.settings({ + experimental_post_image_grid: true, + allow_uncategorized_topics: true, + }); + + needs.pretender((server, helper) => { + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + }); + + test("Image Grid", async function (assert) { + await visit("/"); + + const uploads = [ + "![image_example_0|666x500](upload://q4iRxcuSAzfnbUaCsbjMXcGrpaK.jpeg)", + "![image_example_1|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg)", + "![image_example_3|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg)", + ]; + + await click("#create-topic"); + await fillIn(".d-editor-input", uploads.join("\n")); + + await click( + ".button-wrapper[data-image-index='0'] .wrap-image-grid-button" + ); + + assert.strictEqual( + query(".d-editor-input").value, + `[grid]\n${uploads.join("\n")}\n[/grid]`, + "Image grid toggles on" + ); + + await click( + ".button-wrapper[data-image-index='0'] .wrap-image-grid-button" + ); + + assert.strictEqual( + query(".d-editor-input").value, + uploads.join("\n"), + "Image grid toggles off" + ); + + const multipleImages = `![zorro|10x10](upload://zorro.png) ![z2|20x20](upload://zorrito.png)\nand a second group of images\n\n${uploads.join( + "\n" + )}`; + await fillIn(".d-editor-input", multipleImages); + + await click(".image-wrapper:first-child .wrap-image-grid-button"); + + assert.strictEqual( + query(".d-editor-input").value, + `[grid]![zorro|10x10](upload://zorro.png) ![z2|20x20](upload://zorrito.png)[/grid] +and a second group of images + +![image_example_0|666x500](upload://q4iRxcuSAzfnbUaCsbjMXcGrpaK.jpeg) +![image_example_1|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg) +![image_example_3|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg)`, + "First image grid toggles on" + ); + + await click(".image-wrapper:nth-of-type(1) .wrap-image-grid-button"); + + assert.strictEqual( + query(".d-editor-input").value, + multipleImages, + "First image grid toggles off" + ); + + // Second group of images is in paragraph 2 + assert.ok( + query( + ".d-editor-preview p:nth-child(2) .wrap-image-grid-button[data-image-count='3']" + ), + "Grid button has correct image count" + ); + + await click(".d-editor-preview p:nth-child(2) .wrap-image-grid-button"); + + assert.strictEqual( + query(".d-editor-input").value, + `![zorro|10x10](upload://zorro.png) ![z2|20x20](upload://zorrito.png) +and a second group of images + +[grid] +![image_example_0|666x500](upload://q4iRxcuSAzfnbUaCsbjMXcGrpaK.jpeg) +![image_example_1|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg) +![image_example_3|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg) +[/grid]`, + "Second image grid toggles on" + ); + }); + + test("Image Grid Preview", async function (assert) { + await visit("/"); + + const uploads = [ + "![image_example_0|666x500](upload://q4iRxcuSAzfnbUaCsbjMXcGrpaK.jpeg)", + "![image_example_1|481x480](upload://p1ijebM2iyQcUswBffKwMny3gxu.jpeg)", + ]; + + await click("#create-topic"); + await fillIn(".d-editor-input", uploads.join("\n")); + + assert.ok( + query( + ".image-wrapper:first-child .wrap-image-grid-button[data-image-count='2']" + ), + "Grid button has correct image count" + ); + + await click( + ".button-wrapper[data-image-index='0'] .wrap-image-grid-button" + ); + + assert.strictEqual( + document.querySelectorAll(".d-editor-preview .d-image-grid-column") + .length, + 2, + "Preview organizes images into two columns" + ); + + await fillIn(".d-editor-input", `[grid]\n${uploads[0]}\n[/grid]`); + + assert.ok( + query(".d-editor-preview .d-image-grid[data-disabled]"), + "Grid is disabled when there is only one image" + ); + + await fillIn( + ".d-editor-input", + `[grid]${uploads[0]} ${uploads[1]} ${uploads[0]} ${uploads[1]}[/grid]` + ); + + assert.ok( + document.querySelectorAll(".d-editor-preview .d-image-grid-column") + .length, + 2, + "Special case of two columns for 4 images" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js index 7f3cd646666..bb66218c681 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js @@ -1745,4 +1745,60 @@ var bar = 'bar'; "code block with html alias work" ); }); + + test("image grid", function (assert) { + assert.cooked( + "[grid]\n![](http://folksy.com/images/folksy-colour.png)\n[/grid]", + `

[grid]
+
+[/grid]

`, + "image grid without site setting does not work" + ); + + assert.cookedOptions( + "[grid]\n![](http://folksy.com/images/folksy-colour.png)\n[/grid]", + { siteSettings: { experimental_post_image_grid: true } }, + `
+

+
`, + "image grid with site setting works" + ); + + assert.cookedOptions( + `[grid] +![](http://folksy.com/images/folksy-colour.png) +![](http://folksy.com/images/folksy-colour2.png) +![](http://folksy.com/images/folksy-colour3.png) +[/grid]`, + { siteSettings: { experimental_post_image_grid: true } }, + `
+


+
+

+
`, + "image grid with 3 images works" + ); + + assert.cookedOptions( + `[grid] +![](http://folksy.com/images/folksy-colour.png) ![](http://folksy.com/images/folksy-colour2.png) +![](http://folksy.com/images/folksy-colour3.png) +[/grid]`, + { siteSettings: { experimental_post_image_grid: true } }, + `
+


+

+
`, + "image grid with mixed block and inline images works" + ); + + assert.cookedOptions( + "[grid]![](http://folksy.com/images/folksy-colour.png) ![](http://folksy.com/images/folksy-colour2.png)[/grid]", + { siteSettings: { experimental_post_image_grid: true } }, + `
+

+
`, + "image grid with inline images works" + ); + }); }); diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js index 7918ee3d79b..926c4f8ac2b 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js @@ -106,6 +106,19 @@ function buildImageDeleteButton() { `; } + +function buildImageGalleryControl(imageCount) { + return ` + + + + + + `; +} + // We need this to load after `upload-protocol` which is priority 0 export const priority = 1; @@ -124,6 +137,12 @@ function ruleWithImageControls(oldRule) { result += oldRule(tokens, idx, options, env, slf); result += ``; + if (idx === 0) { + const imageCount = tokens.filter((x) => x.type === "image").length; + if (imageCount > 1) { + result += buildImageGalleryControl(imageCount); + } + } result += buildImageShowAltTextControls( token.attrs[token.attrIndex("alt")][1] ); @@ -181,6 +200,11 @@ export function setup(helper) { "svg[class=fa d-icon d-icon-times svg-icon svg-string]", "svg[class=fa d-icon d-icon-trash-alt svg-icon svg-string]", "use[href=#times]", + + "span.wrap-image-grid-button", + "span.wrap-image-grid-button[data-image-count]", + "svg[class=fa d-icon d-icon-th svg-icon svg-string]", + "use[href=#th]", ]); helper.registerPlugin((md) => { diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-grid.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-grid.js new file mode 100644 index 00000000000..7e6b2626399 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-grid.js @@ -0,0 +1,27 @@ +const gridRule = { + tag: "grid", + before(state) { + let token = state.push("bbcode_open", "div", 1); + token.attrs = [["class", "d-image-grid"]]; + }, + + after(state) { + state.push("bbcode_close", "div", -1); + }, +}; + +export function setup(helper) { + helper.registerOptions((opts, siteSettings) => { + opts.enableGrid = !!siteSettings.experimental_post_image_grid; + }); + + helper.allowList(["div.d-image-grid"]); + + helper.registerPlugin((md) => { + if (!md.options.discourse.enableGrid) { + return; + } + + md.block.bbcode.ruler.push("grid", gridRule); + }); +} diff --git a/app/assets/stylesheets/common/base/_index.scss b/app/assets/stylesheets/common/base/_index.scss index aa16b49f50d..746802071b7 100644 --- a/app/assets/stylesheets/common/base/_index.scss +++ b/app/assets/stylesheets/common/base/_index.scss @@ -10,6 +10,7 @@ @import "compose"; @import "composer-user-selector"; @import "crawler_layout"; +@import "d-image-grid"; @import "d-icon"; @import "d-popover"; @import "dialog"; diff --git a/app/assets/stylesheets/common/base/d-image-grid.scss b/app/assets/stylesheets/common/base/d-image-grid.scss new file mode 100644 index 00000000000..e1f2181fb32 --- /dev/null +++ b/app/assets/stylesheets/common/base/d-image-grid.scss @@ -0,0 +1,66 @@ +.d-image-grid:not([data-disabled]) { + $grid-column-gap: 6px; + + &[data-columns] { + display: flex; + flex-wrap: wrap; + } + + &[data-columns="2"] > * { + flex-basis: calc(50% - ($grid-column-gap / 2)); + margin-right: $grid-column-gap; + } + + &[data-columns="3"] > * { + flex-basis: calc(33.33% - ($grid-column-gap * 0.667)); + margin-right: $grid-column-gap; + } + + .d-image-grid-column { + box-sizing: border-box; + + &:last-child { + margin-right: 0; + } + + > img { + margin-bottom: $grid-column-gap; + } + + // Forces images in the grid to fill each column + img, + > .lightbox-wrapper, + > .lightbox-wrapper > .lightbox { + width: 100%; + } + + .lightbox-wrapper { + .meta .informations { + display: none; + } + .meta .filename { + flex-grow: 3; + } + } + + // when staging edits + .image-wrapper { + display: block; + padding-bottom: $grid-column-gap; + margin-bottom: 0em; + } + } + + .desktop-view .d-editor-preview & { + .image-wrapper { + padding-bottom: $grid-column-gap; + margin-bottom: 0em; + .button-wrapper { + .scale-btn-container, + &[editing] .wrap-image-grid-button { + display: none; + } + } + } + } +} diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss index 55cb7be7234..e927f7f074d 100644 --- a/app/assets/stylesheets/common/d-editor.scss +++ b/app/assets/stylesheets/common/d-editor.scss @@ -183,11 +183,12 @@ width: 100%; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; gap: 0 0.5em; position: absolute; - height: var(--resizer-height); + height: calc(var(--resizer-height) + 0.5em); bottom: 0; left: 0; opacity: 0; @@ -234,15 +235,15 @@ } .alt-text-readonly-container { - flex: 1 1; - width: 100%; + flex: 1 1 auto; + // arbitrary min-width value allows for correct shrinking + min-width: 100px; .alt-text { margin-right: 0.5em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - max-width: 100%; } .alt-text-edit-btn { @@ -256,9 +257,9 @@ } .alt-text-edit-container { - margin-top: 0.25em; gap: 0 0.25em; flex: 1; + max-width: 100%; .alt-text-input, .alt-text-edit-ok, @@ -267,11 +268,13 @@ } .alt-text-input { + display: inline-flex; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; margin: 0; + padding-left: 0.25em; } .alt-text-edit-ok, @@ -294,6 +297,11 @@ } } + .wrap-image-grid-button { + cursor: pointer; + color: var(--tertiary); + } + svg { pointer-events: none; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 653b722f64a..4885006567a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2525,6 +2525,7 @@ en: aria_label: Alt text for image delete_image_button: Delete Image + toggle_image_grid: Toggle image grid notifications: tooltip: diff --git a/config/site_settings.yml b/config/site_settings.yml index 24252c72cef..1e439d88403 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -988,6 +988,9 @@ posting: autohighlight_all_code: client: true default: false + experimental_post_image_grid: + client: true + default: false highlighted_languages: default: "bash|c|cpp|csharp|css|diff|go|graphql|ini|java|javascript|json|kotlin|lua|makefile|markdown|objectivec|perl|php|php-template|plaintext|python|python-repl|r|ruby|rust|scss|shell|sql|swift|typescript|xml|yaml|wasm" choices: "HighlightJs.languages" diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb index 65a15861721..8dc470e11a2 100644 --- a/lib/svg_sprite.rb +++ b/lib/svg_sprite.rb @@ -203,6 +203,7 @@ module SvgSprite tag tags tasks + th thermometer-three-quarters thumbs-down thumbs-up