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
This commit is contained in:
Penar Musaraj 2023-06-07 14:15:57 -04:00 committed by GitHub
parent e43ac00bf4
commit 987ec602ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 514 additions and 6 deletions

View File

@ -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

View File

@ -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")

View File

@ -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",

View File

@ -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;
}
}

View File

@ -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"
);
});
});

View File

@ -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]",
`<p>[grid]<br>
<img src="http://folksy.com/images/folksy-colour.png" alt role="presentation"><br>
[/grid]</p>`,
"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 } },
`<div class="d-image-grid">
<p><img src="http://folksy.com/images/folksy-colour.png" alt role="presentation"></p>
</div>`,
"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 } },
`<div class="d-image-grid">
<p><img src="http://folksy.com/images/folksy-colour.png" alt role="presentation"><br>
<img src="http://folksy.com/images/folksy-colour2.png" alt role="presentation"><br>
<img src="http://folksy.com/images/folksy-colour3.png" alt role="presentation"></p>
</div>`,
"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 } },
`<div class="d-image-grid">
<p><img src="http://folksy.com/images/folksy-colour.png" alt role="presentation"> <img src="http://folksy.com/images/folksy-colour2.png" alt role="presentation"><br>
<img src="http://folksy.com/images/folksy-colour3.png" alt role="presentation"></p>
</div>`,
"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 } },
`<div class="d-image-grid">
<p><img src="http://folksy.com/images/folksy-colour.png" alt role="presentation"> <img src="http://folksy.com/images/folksy-colour2.png" alt role="presentation"></p>
</div>`,
"image grid with inline images works"
);
});
});

View File

@ -106,6 +106,19 @@ function buildImageDeleteButton() {
</span>
`;
}
function buildImageGalleryControl(imageCount) {
return `
<span class="wrap-image-grid-button" title="${I18n.t(
"composer.toggle_image_grid"
)}" data-image-count="${imageCount}">
<svg class="fa d-icon d-icon-th svg-icon svg-string" xmlns="http://www.w3.org/2000/svg">
<use href="#th"></use>
</svg>
</span>
`;
}
// 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 += `<span class="button-wrapper" data-image-index="${index}">`;
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) => {

View File

@ -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);
});
}

View File

@ -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";

View File

@ -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;
}
}
}
}
}

View File

@ -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;
}

View File

@ -2525,6 +2525,7 @@ en:
aria_label: Alt text for image
delete_image_button: Delete Image
toggle_image_grid: Toggle image grid
notifications:
tooltip:

View File

@ -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"

View File

@ -203,6 +203,7 @@ module SvgSprite
tag
tags
tasks
th
thermometer-three-quarters
thumbs-down
thumbs-up