From 7d2ea2d4dd80165826328a9407a210715f47ac21 Mon Sep 17 00:00:00 2001 From: Maja Komel Date: Wed, 27 Feb 2019 11:46:16 +0100 Subject: [PATCH] FEATURE: image resizing discoverability (#6804) --- .../components/composer-editor.js.es6 | 130 +++++++++++++++++- .../discourse/components/d-editor.js.es6 | 19 ++- .../engines/discourse-markdown-it.js.es6 | 7 + .../discourse-markdown/image-protocol.js.es6 | 2 + app/assets/stylesheets/common/d-editor.scss | 48 +++++++ .../acceptance/composer-test.js.es6 | 79 +++++++++++ 6 files changed, 279 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index d5e0c8fe3ae..c15bd51d3c2 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -192,6 +192,14 @@ export default Ember.Component.extend({ ); } + if (!this.site.mobileView) { + $preview + .off("touchstart mouseenter", "img") + .on("touchstart mouseenter", "img", () => { + this._placeImageScaleButtons($preview); + }); + } + // Focus on the body unless we have a title if (!this.get("composer.canEditTitle") && !this.capabilities.isIOS) { this.$(".d-editor-input").putCursorAtEnd(); @@ -774,6 +782,116 @@ export default Ember.Component.extend({ } }, + _appendImageScaleButtons($images, imageScaleRegex) { + const buttonScales = [100, 75, 50]; + const imageWrapperTemplate = `
`; + const buttonWrapperTemplate = `
`; + const scaleButtonTemplate = ``; + + $images.each((i, e) => { + const $e = $(e); + + 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) + .parent() + .attr("data-image-index") + ); + + const scale = e.target.attributes["data-scale"].value; + const matchingPlaceholder = this.get("composer.reply").match( + imageScaleRegex + ); + + if (matchingPlaceholder) { + const match = matchingPlaceholder[index]; + if (!match) { + return; + } + + const replacement = match.replace(imageScaleRegex, `$1,${scale}%$3`); + this.appEvents.trigger( + "composer:replace-text", + matchingPlaceholder[index], + replacement, + { regex: imageScaleRegex, index } + ); + } + }); + }, + + _placeImageScaleButtons($preview) { + // regex matches only upload placeholders with size defined, + // which is required for resizing + + // original string `![28|690x226,5%](upload://ceEfx3vO7bx7Cecv2co1SrnoTpW.png)` + // match 1 `![28|690x226` + // match 2 `5` + // match 3 `](upload://ceEfx3vO7bx7Cecv2co1SrnoTpW.png)` + const imageScaleRegex = /(!\[(?:\S*?(?=\|)\|)*?(?:\d{1,6}x\d{1,6})+?)(?:,?(\d{1,3})?%?)?(\]\(upload:\/\/\S*?\))/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.$(".d-editor-preview *") + .contents() + .filter(function() { + return this.nodeType === 3; // TEXT_NODE + }) + .each(function() { + $(this).replaceWith( + $(this) + .text() + .replace(imageScaleRegex, "$&") + ); + }); + } + + const $images = $preview.find("img.resizable, span.codeblock-image"); + + this._appendImageScaleButtons($images, imageScaleRegex); + this._registerImageScaleButtonClick($preview, imageScaleRegex); + }, + @on("willDestroyElement") _unbindUploadTarget() { this._validUploads = 0; @@ -811,6 +929,12 @@ export default Ember.Component.extend({ this.storeToolbarState(toolbarEvent); }, + showPreview() { + const $preview = this.$(".d-editor-preview-wrapper"); + this._placeImageScaleButtons($preview); + this.send("togglePreview"); + }, + actions: { importQuote(toolbarEvent) { this.importQuote(toolbarEvent); @@ -859,7 +983,7 @@ export default Ember.Component.extend({ group: "mobileExtras", icon: "television", title: "composer.show_preview", - sendAction: this.get("togglePreview") + sendAction: this.showPreview.bind(this) }); } }, @@ -967,6 +1091,10 @@ export default Ember.Component.extend({ ); } + if (this.site.mobileView && $preview.is(":visible")) { + this._placeImageScaleButtons($preview); + } + this.trigger("previewRefreshed", $preview); this.afterRefresh($preview); } diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index e1006887826..4b67908d999 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -295,8 +295,8 @@ export default Ember.Component.extend({ this.appEvents.on("composer:insert-text", (text, options) => this._addText(this._getSelected(), text, options) ); - this.appEvents.on("composer:replace-text", (oldVal, newVal) => - this._replaceText(oldVal, newVal) + this.appEvents.on("composer:replace-text", (oldVal, newVal, opts) => + this._replaceText(oldVal, newVal, opts) ); } this._mouseTrap = mouseTrap; @@ -659,7 +659,7 @@ export default Ember.Component.extend({ } }, - _replaceText(oldVal, newVal) { + _replaceText(oldVal, newVal, opts) { const val = this.get("value"); const needleStart = val.indexOf(oldVal); @@ -677,8 +677,17 @@ export default Ember.Component.extend({ replacement: { start: needleStart, end: needleStart + newVal.length } }); - // Replace value (side effect: cursor at the end). - this.set("value", val.replace(oldVal, newVal)); + if (opts && opts.index && opts.regex) { + let i = -1; + const newValue = val.replace(opts.regex, match => { + i++; + return i === opts.index ? newVal : match; + }); + this.set("value", newValue); + } else { + // Replace value (side effect: cursor at the end). + this.set("value", val.replace(oldVal, newVal)); + } if ($("textarea.d-editor-input").is(":focus")) { // Restore cursor. diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 index 9708f5a94b3..4e503d09035 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 @@ -168,6 +168,13 @@ function renderImage(tokens, idx, options, env, slf) { if (token.attrIndex("height") === -1) { token.attrs.push(["height", height]); } + + if ( + options.discourse.previewing && + match[6] !== "x" && + match[4] !== "x" + ) + token.attrs.push(["class", "resizable"]); } } } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-protocol.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-protocol.js.es6 index 06dcc394337..bdcfba414e9 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-protocol.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-protocol.js.es6 @@ -53,6 +53,8 @@ function rule(state) { } export function setup(helper) { + const opts = helper.getOptions(); + if (opts.previewing) helper.whiteList(["img.resizable"]); helper.whiteList(["img[data-orig-src]"]); helper.registerPlugin(md => { md.core.ruler.push("image-protocol", rule); diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss index 5b402bfa80f..8ceb91f21fd 100644 --- a/app/assets/stylesheets/common/d-editor.scss +++ b/app/assets/stylesheets/common/d-editor.scss @@ -202,3 +202,51 @@ padding: 10px; border: 1px solid $primary-low; } + +.d-editor-preview img { + padding-bottom: 20px; +} + +.d-editor-preview .image-wrapper { + position: relative; + padding-bottom: 20px; + + img { + padding-bottom: 0px; + } + + &:hover { + .button-wrapper { + opacity: 1; + } + } + .button-wrapper { + transition: opacity 0.2s ease-in; + opacity: 0; + position: absolute; + left: 0; + width: 30px; + bottom: 0px; + display: flex; + + .separator { + margin: 0 5px; + } + + .scale-btn { + color: $tertiary; + + &.active { + font-weight: 700; + } + + &:hover { + text-decoration: underline; + } + } + } +} + +.mobile-view .d-editor-preview .image-wrapper .button-wrapper { + opacity: 1; +} diff --git a/test/javascripts/acceptance/composer-test.js.es6 b/test/javascripts/acceptance/composer-test.js.es6 index a84f85ff85f..9ad9d448ccc 100644 --- a/test/javascripts/acceptance/composer-test.js.es6 +++ b/test/javascripts/acceptance/composer-test.js.es6 @@ -10,6 +10,9 @@ acceptance("Composer", { draft_sequence: 42 }); }); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); }, settings: { enable_whispers: true @@ -596,3 +599,79 @@ QUnit.test("Checks for existing draft", async assert => { toggleCheckDraftPopup(false); }); + +const assertImageResized = (assert, uploads) => { + assert.equal( + find(".d-editor-input").val(), + uploads.join("\n"), + "it resizes uploaded image" + ); +}; + +QUnit.test("Image resizing buttons", async assert => { + await visit("/"); + await click("#create-topic"); + + let uploads = [ + "![test|690x313](upload://test.png)", + "[img]http://example.com/image.jpg[/img]", + "![anotherOne|690x463](upload://anotherOne.jpeg)", + "![](upload://withoutAltAndSize.jpeg)", + "`![test|690x313](upload://test.png)`", + "![withoutSize](upload://withoutSize.png)", + "", + "![onTheSameLine1|200x200](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)", + "![identicalImage|300x300](upload://identicalImage.png)", + "![identicalImage|300x300](upload://identicalImage.png)" + ]; + + await fillIn(".d-editor-input", uploads.join("\n")); + + assert.ok( + find(".button-wrapper").length === 0, + "it does not append scaling buttons before hovering images" + ); + + await triggerEvent($(".d-editor-preview img"), "mouseover"); + + assert.ok( + find(".button-wrapper").length === 6, + "it adds correct amount of scaling button groups" + ); + + uploads[0] = "![test|690x313,50%](upload://test.png)"; + await click(find(".button-wrapper .scale-btn[data-scale='50']")[0]); + assertImageResized(assert, uploads); + + await triggerEvent($(".d-editor-preview img"), "mouseover"); + + uploads[2] = "![anotherOne|690x463,75%](upload://anotherOne.jpeg)"; + await click(find(".button-wrapper .scale-btn[data-scale='75']")[1]); + assertImageResized(assert, uploads); + + await triggerEvent($(".d-editor-preview img"), "mouseover"); + + uploads[7] = + "![onTheSameLine1|200x200,50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)"; + await click(find(".button-wrapper .scale-btn[data-scale='50']")[2]); + assertImageResized(assert, uploads); + + await triggerEvent($(".d-editor-preview img"), "mouseover"); + + uploads[7] = + "![onTheSameLine1|200x200,50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250,75%](upload://onTheSameLine2.jpeg)"; + await click(find(".button-wrapper .scale-btn[data-scale='75']")[3]); + assertImageResized(assert, uploads); + + await triggerEvent($(".d-editor-preview img"), "mouseover"); + + uploads[8] = "![identicalImage|300x300,50%](upload://identicalImage.png)"; + await click(find(".button-wrapper .scale-btn[data-scale='50']")[4]); + assertImageResized(assert, uploads); + + await triggerEvent($(".d-editor-preview img"), "mouseover"); + + uploads[9] = "![identicalImage|300x300,75%](upload://identicalImage.png)"; + await click(find(".button-wrapper .scale-btn[data-scale='75']")[5]); + assertImageResized(assert, uploads); +});