From 1c179177e7c84362572481e61b18597f7d2d5050 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 12 Nov 2019 17:32:37 -0300 Subject: [PATCH] REFACTOR: Attach resize controls to images from the markdown pipeline (#8314) --- .../components/composer-editor.js.es6 | 99 ++--------- app/assets/javascripts/markdown-it-bundle.js | 1 + .../discourse-markdown/resize-controls.js.es6 | 157 ++++++++++++++++++ .../discourse-markdown/upload-protocol.js.es6 | 9 +- .../acceptance/composer-test.js.es6 | 24 ++- 5 files changed, 192 insertions(+), 98 deletions(-) create mode 100644 app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 1f9c2e08f14..0a5345e490e 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -770,54 +770,20 @@ export default Component.extend({ } }, - _appendImageScaleButtons($images, imageScaleRegex) { - const buttonScales = [100, 75, 50]; - const imageWrapperTemplate = `
`; - const buttonWrapperTemplate = `
`; - const scaleButtonTemplate = ``; + _registerImageScaleButtonClick($preview) { + // original string `![image|690x220, 50%](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")` + // group 1 `image` + // group 2 `690x220` + // group 3 `, 50%` + // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png' + // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title"' - $images.each((i, e) => { - const $e = $(e); + // Notes: + // Group 3 is optional. group 4 can match images with or without a markdown title. + // All matches are whitespace tolerant as long it's still valid markdown. + // If the image is inside a code block, we'll ignore it `(?!(.*`))`. + const imageScaleRegex = /!\[(.*?)\|(\d{1,4}x\d{1,4})(,\s*\d{1,3}%)?\]\((upload:\/\/.*?)\)(?!(.*`))/g; - const matches = this.get("composer.reply").match(imageScaleRegex); - - // ignore previewed upload markdown in codeblock - if (!matches || $e.hasClass("codeblock-image")) return; - - if (!$e.parent().hasClass("image-wrapper")) { - const match = matches[i]; - const matchingPlaceholder = imageScaleRegex.exec(match); - - if (!matchingPlaceholder) return; - - const currentScale = matchingPlaceholder[2] || 100; - - $e.data("index", i).wrap(imageWrapperTemplate); - $e.parent().append( - $(buttonWrapperTemplate).attr("data-image-index", i) - ); - - buttonScales.forEach((buttonScale, buttonIndex) => { - const activeClass = - parseInt(currentScale, 10) === buttonScale ? "active" : ""; - - const $scaleButton = $(scaleButtonTemplate) - .addClass(activeClass) - .attr("data-scale", buttonScale) - .text(`${buttonScale}%`); - - const $buttonWrapper = $e.parent().find(".button-wrapper"); - $buttonWrapper.append($scaleButton); - - if (buttonIndex !== buttonScales.length - 1) { - $buttonWrapper.append(``); - } - }); - } - }); - }, - - _registerImageScaleButtonClick($preview, imageScaleRegex) { $preview.off("click", ".scale-btn").on("click", ".scale-btn", e => { const index = parseInt( $(e.target) @@ -852,45 +818,6 @@ export default Component.extend({ }); }, - _placeImageScaleButtons($preview) { - // regex matches only upload placeholders with size defined, - // which is required for resizing - - // original string `![image|690x220, 50%](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")` - // group 1 `image` - // group 2 `690x220` - // group 3 `, 50%` - // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png' - // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title"' - - // Notes: - // Group 3 is optional. group 4 can match images with or without a markdown title. - // All matches are whitespace tolerant as long it's still valid markdown - - const imageScaleRegex = /!\[(.*?)\|(\d{1,4}x\d{1,4})(,\s*\d{1,3}%)?\]\((upload:\/\/.*?)\)/g; - - // wraps previewed upload markdown in a codeblock in its own class to keep a track - // of indexes later on to replace the correct upload placeholder in the composer - if ($preview.find(".codeblock-image").length === 0) { - $(this.element) - .find(".d-editor-preview *") - .contents() - .each(function() { - if (this.nodeType !== 3) return; // TEXT_NODE - const $this = $(this); - - if ($this.text().match(imageScaleRegex)) { - $this.wrap(""); - } - }); - } - - const $images = $preview.find("img.resizable, span.codeblock-image"); - - this._appendImageScaleButtons($images, imageScaleRegex); - this._registerImageScaleButtonClick($preview, imageScaleRegex); - }, - @on("willDestroyElement") _unbindUploadTarget() { this._validUploads = 0; @@ -1079,7 +1006,7 @@ export default Component.extend({ ); } - this._placeImageScaleButtons($preview); + this._registerImageScaleButtonClick($preview); this.trigger("previewRefreshed", $preview); this.afterRefresh($preview); diff --git a/app/assets/javascripts/markdown-it-bundle.js b/app/assets/javascripts/markdown-it-bundle.js index 2d0ec14ee52..052891aaf5e 100644 --- a/app/assets/javascripts/markdown-it-bundle.js +++ b/app/assets/javascripts/markdown-it-bundle.js @@ -16,4 +16,5 @@ //= require ./pretty-text/engines/discourse-markdown/text-post-process //= require ./pretty-text/engines/discourse-markdown/upload-protocol //= require ./pretty-text/engines/discourse-markdown/inject-line-number +//= require ./pretty-text/engines/discourse-markdown/resize-controls //= require ./pretty-text/engines/discourse-markdown/d-wrap diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 new file mode 100644 index 00000000000..6231a265446 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 @@ -0,0 +1,157 @@ +function isUpload(token) { + return token.content.includes("upload://"); +} + +function hasMetadata(token) { + return token.content.match(/(\d{1,4}x\d{1,4})/); +} + +function buildToken(state, type, tag, klass, nesting) { + const token = new state.Token(type, tag, nesting); + token.block = true; + token.attrs = [["class", klass]]; + return token; +} + +function wrapImage(tokens, index, state, imgNumber) { + const imgToken = tokens[index]; + const selectedScale = imgToken.content + .split(",") + .pop() + .trim(); + tokens.splice( + index, + 0, + buildToken(state, "wrap_image_open", "div", "image-wrapper", 1) + ); + + const newElements = []; + const btnWrapper = buildToken( + state, + "wrap_button_open", + "div", + "button-wrapper", + 1 + ); + btnWrapper.attrs.push(["data-image-index", imgNumber]); + newElements.push(btnWrapper); + + const minimumScale = 50; + const scales = [100, 75, minimumScale]; + scales.forEach(scale => { + const scaleText = `${scale}%`; + + const btnClass = + scaleText === selectedScale ? "scale-btn active" : "scale-btn"; + const scaleBtn = buildToken( + state, + "scale_button_open", + "span", + btnClass, + 1 + ); + scaleBtn.attrs.push(["data-scale", scale]); + newElements.push(scaleBtn); + + let textToken = buildToken(state, "text", "", "", 0); + textToken.content = scaleText; + newElements.push(textToken); + + newElements.push(buildToken(state, "scale_button_close", "span", "", -1)); + + if (scale !== minimumScale) { + newElements.push(buildToken(state, "separator", "span", "separator", 1)); + let separatorToken = buildToken(state, "text", "", "", 0); + separatorToken.content = " • "; + newElements.push(separatorToken); + newElements.push(buildToken(state, "separator_close", "span", "", -1)); + } + }); + newElements.push(buildToken(state, "wrap_button_close", "div", "", -1)); + + newElements.push(buildToken(state, "wrap_image_close", "div", "", -1)); + + const afterImageIndex = index + 2; + tokens.splice(afterImageIndex, 0, ...newElements); +} + +function removeParagraph(tokens, imageIndex) { + if ( + tokens[imageIndex - 1] && + tokens[imageIndex - 1].type === "paragraph_open" + ) + tokens.splice(imageIndex - 1, 1); + if (tokens[imageIndex] && tokens[imageIndex].type === "paragraph_close") + tokens.splice(imageIndex, 1); +} + +function updateIndexes(indexes, name) { + indexes[name].push(indexes.current); + indexes.current++; +} + +function wrapImages(tokens, tokenIndexes, state, imgNumberIndexes) { + //We do this in reverse order because it's easier for #wrapImage to manipulate the tokens array. + for (let j = tokenIndexes.length - 1; j >= 0; j--) { + let index = tokenIndexes[j]; + removeParagraph(tokens, index); + wrapImage(tokens, index, state, imgNumberIndexes.pop()); + } +} + +function rule(state) { + let blockIndexes = []; + const indexNumbers = { current: 0, blocks: [], childrens: [] }; + + for (let i = 0; i < state.tokens.length; i++) { + let blockToken = state.tokens[i]; + const blockTokenImage = blockToken.tag === "img"; + + if (blockTokenImage && isUpload(blockToken) && hasMetadata(blockToken)) { + blockIndexes.push(i); + updateIndexes(indexNumbers, "blocks"); + } + + if (!blockToken.children) continue; + + const childrenIndexes = []; + for (let j = 0; j < blockToken.children.length; j++) { + let token = blockToken.children[j]; + const childrenImage = token.tag === "img"; + + if (childrenImage && isUpload(blockToken) && hasMetadata(token)) { + removeParagraph(state.tokens, i); + childrenIndexes.push(j); + updateIndexes(indexNumbers, "childrens"); + } + } + + wrapImages( + blockToken.children, + childrenIndexes, + state, + indexNumbers.childrens + ); + } + + wrapImages(state.tokens, blockIndexes, state, indexNumbers.blocks); +} + +export function setup(helper) { + const opts = helper.getOptions(); + if (opts.previewing) { + helper.whiteList([ + "div.image-wrapper", + "div.button-wrapper", + "span[class=scale-btn]", + "span[class=scale-btn active]", + "span.separator", + "span.scale-btn[data-scale]", + "span.button-wrapper[data-image-index]" + ]); + + helper.registerPlugin(md => { + md.core.ruler.after("upload-protocol", "resize-controls", rule); + }); + } +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 index 196f52066f4..7790998e026 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 @@ -20,15 +20,12 @@ function rule(state) { addImage(uploads, blockToken); } - if (!blockToken.children) { - continue; - } + if (!blockToken.children) continue; for (let j = 0; j < blockToken.children.length; j++) { let token = blockToken.children[j]; - if (token.tag === "img" || token.tag === "a") { - addImage(uploads, token); - } + + if (token.tag === "img" || token.tag === "a") addImage(uploads, token); } } diff --git a/test/javascripts/acceptance/composer-test.js.es6 b/test/javascripts/acceptance/composer-test.js.es6 index 655df953570..273275cde55 100644 --- a/test/javascripts/acceptance/composer-test.js.es6 +++ b/test/javascripts/acceptance/composer-test.js.es6 @@ -755,34 +755,46 @@ QUnit.test("Image resizing buttons", async assert => { // Default uploads[0] = "![test|690x313, 50%](upload://test.png)"; - await click(find(".button-wrapper .scale-btn[data-scale='50']")[0]); + await click( + find(".button-wrapper[data-image-index='0'] .scale-btn[data-scale='50']") + ); assertImageResized(assert, uploads); // Targets the correct image if two on the same line uploads[6] = "![onTheSameLine1|200x200, 50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)"; - await click(find(".button-wrapper .scale-btn[data-scale='50']")[3]); + await click( + find(".button-wrapper[data-image-index='3'] .scale-btn[data-scale='50']") + ); assertImageResized(assert, uploads); // Try the other image on the same line uploads[6] = "![onTheSameLine1|200x200, 50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250, 75%](upload://onTheSameLine2.jpeg)"; - await click(find(".button-wrapper .scale-btn[data-scale='75']")[4]); + await click( + find(".button-wrapper[data-image-index='4'] .scale-btn[data-scale='75']") + ); assertImageResized(assert, uploads); // Make sure we target the correct image if there are duplicates uploads[7] = "![identicalImage|300x300, 50%](upload://identicalImage.png)"; - await click(find(".button-wrapper .scale-btn[data-scale='50']")[5]); + await click( + find(".button-wrapper[data-image-index='5'] .scale-btn[data-scale='50']") + ); assertImageResized(assert, uploads); // Try the other dupe uploads[8] = "![identicalImage|300x300, 75%](upload://identicalImage.png)"; - await click(find(".button-wrapper .scale-btn[data-scale='75']")[6]); + await click( + find(".button-wrapper[data-image-index='6'] .scale-btn[data-scale='75']") + ); assertImageResized(assert, uploads); // Don't mess with image titles uploads[10] = `![image|690x220, 75%](upload://test.png "image title")`; - await click(find(".button-wrapper .scale-btn[data-scale='75']")[8]); + await click( + find(".button-wrapper[data-image-index='8'] .scale-btn[data-scale='75']") + ); assertImageResized(assert, uploads); await fillIn(