diff --git a/static/js/contentCollection.js b/static/js/contentCollection.js index 36a555b..d4726e8 100644 --- a/static/js/contentCollection.js +++ b/static/js/contentCollection.js @@ -1,11 +1,13 @@ 'use strict'; +const {_isValid, uploadFile} = require('ep_image_upload/static/js/toolbar'); + // When an image is detected give it a lineAttribute // of Image with the URL to the iamge exports.collectContentImage = (hookName, {node, state: {lineAttributes}, tname}) => { if (tname === 'div' || tname === 'p') delete lineAttributes.img; if (tname !== 'img') return; - lineAttributes.img = + const imageData = // Client-side. This will also be used for server-side HTML imports once jsdom adds support // for HTMLImageElement.currentSrc. node.currentSrc || @@ -13,6 +15,33 @@ exports.collectContentImage = (hookName, {node, state: {lineAttributes}, tname}) node.src || // Server-side HTML imports using cheerio (Etherpad <= v1.8.14). (node.attribs && node.attribs.src); + + if (typeof window !== 'undefined' && clientVars.ep_image_upload.storageType === 'local') { + if (/^http/.test(imageData)) { + // an uploaded image is copied, place a copy in the desired line + lineAttributes.img = imageData; + return; + } + + const padeditor = require('ep_etherpad-lite/static/js/pad_editor').padeditor; + + const match = imageData.match(/data:([^;]+);base64,(.*)/); + if (!match || !match[1] || !match[2]) return; + + // decode from internal base64 rep + const decodedData = Uint8Array.from(window.atob(match[2]), (c) => c.charCodeAt(0)); + + // check if size is within limits and mime type is supported + const extension = _isValid({size: decodedData.length, type: match[1]}); + if (!extension) return; + + const blob = new Blob([decodedData], {type: match[1]}); + + // image.* is a temporary name not used on the server + uploadFile(padeditor, blob, `image.${extension}`); + } else { + lineAttributes.img = imageData; + } }; exports.collectContentPre = (name, context) => { diff --git a/static/js/toolbar.js b/static/js/toolbar.js index 5b95f92..35a57c9 100644 --- a/static/js/toolbar.js +++ b/static/js/toolbar.js @@ -16,6 +16,7 @@ const _handleNewLines = (ace) => { const _isValid = (file) => { const mimedb = clientVars.ep_image_upload.mimeTypes; const mimeType = mimedb[file.type]; + const extension = mimeType ? mimeType.extensions[0] : null; let validMime = null; if (clientVars.ep_image_upload && clientVars.ep_image_upload.fileTypes) { validMime = false; @@ -44,9 +45,56 @@ const _isValid = (file) => { return false; } - return true; + return extension; }; +exports._isValid = _isValid; +const uploadFile = (context, file, filename) => { + const formData = new FormData(); + + // add assoc key values, this will be posts values + formData.append('file', file, filename ? filename : file.name); + $('#imageUploadModalLoader').addClass('popup-show'); + $.ajax({ + type: 'POST', + url: `${clientVars.padId}/pluginfw/ep_image_upload/upload`, + xhr: () => { + const myXhr = $.ajaxSettings.xhr(); + + return myXhr; + }, + success: (data) => { + $('#imageUploadModalLoader').removeClass('popup-show'); + context.ace.callWithAce((ace) => { + const imageLineNr = _handleNewLines(ace); + ace.ace_addImage(imageLineNr, data); + ace.ace_doReturnKey(); + }, 'img', true); + }, + error: (error) => { + let errorResponse; + try { + errorResponse = JSON.parse(error.responseText.trim()); + if (errorResponse.type) { + errorResponse.message = window._(`ep_image_upload.error.${errorResponse.type}`); + } + } catch (err) { + errorResponse = {message: error.responseText}; + } + + $('#imageUploadModalLoader').removeClass('popup-show'); + $('#imageUploadModalError .error').html(errorResponse.message); + $('#imageUploadModalError').addClass('popup-show'); + }, + async: true, + data: formData, + cache: false, + contentType: false, + processData: false, + timeout: 60000, + }); +}; +exports.uploadFile = uploadFile; exports.postToolbarInit = (hook, context) => { const toolbar = context.toolbar; @@ -83,49 +131,7 @@ exports.postToolbarInit = (hook, context) => { }, 'img', true); }; } else { - const formData = new FormData(); - - // add assoc key values, this will be posts values - formData.append('file', file, file.name); - $('#imageUploadModalLoader').addClass('popup-show'); - $.ajax({ - type: 'POST', - url: `${clientVars.padId}/pluginfw/ep_image_upload/upload`, - xhr: () => { - const myXhr = $.ajaxSettings.xhr(); - - return myXhr; - }, - success: (data) => { - $('#imageUploadModalLoader').removeClass('popup-show'); - context.ace.callWithAce((ace) => { - const imageLineNr = _handleNewLines(ace); - ace.ace_addImage(imageLineNr, data); - ace.ace_doReturnKey(); - }, 'img', true); - }, - error: (error) => { - let errorResponse; - try { - errorResponse = JSON.parse(error.responseText.trim()); - if (errorResponse.type) { - errorResponse.message = window._(`ep_image_upload.error.${errorResponse.type}`); - } - } catch (err) { - errorResponse = {message: error.responseText}; - } - - $('#imageUploadModalLoader').removeClass('popup-show'); - $('#imageUploadModalError .error').html(errorResponse.message); - $('#imageUploadModalError').addClass('popup-show'); - }, - async: true, - data: formData, - cache: false, - contentType: false, - processData: false, - timeout: 60000, - }); + uploadFile(context, file); } }); $(document).find('body').find('#imageInput').trigger('click'); diff --git a/static/tests/frontend/specs/insert.js b/static/tests/frontend/specs/insert.js index b1eb8c6..633970f 100644 --- a/static/tests/frontend/specs/insert.js +++ b/static/tests/frontend/specs/insert.js @@ -1,20 +1,98 @@ 'use strict'; +/** + * @param {number} line the line of the image + * @returns {string} embedded image data + */ +const fetchImageFromLine = async (line) => { + let imageUrl; + const inner$ = helper.padInner$; + + // wait for img tag to appear + await helper.waitForPromise(() => { + const image = inner$(`div:eq(${line})`)[0].querySelector('img'); + if (image) { + imageUrl = image.getAttribute('src'); + if (/^http/.test(imageUrl)) { + return true; + } else { + return false; + } + } + }, 2000); + + // get image + let embeddedImage; + $.ajax({ + type: 'GET', + url: imageUrl, + success: (data) => { + embeddedImage = data; + }, + error: (error) => { + throw error; + }, + cache: false, + processData: false, + dataType: 'text', + }); + + // size of uploadSVG blob + await helper.waitForPromise(() => embeddedImage && embeddedImage.length === 214690); + + return embeddedImage; +}; + describe('Image Upload', function () { + let storageType; + // create a new pad before each test run - beforeEach(function (cb) { - helper.newPad(cb); + beforeEach(async function () { + await helper.aNewPad(); + storageType = helper.padChrome$.window.clientVars.ep_image_upload && + helper.padChrome$.window.clientVars.ep_image_upload.storageType; this.timeout(60000); }); it('Puts an image in the pad and ensure it isnt removed', async function () { this.timeout(10000); const inner$ = helper.padInner$; - inner$('div:eq(2)').html('hello world'); + + await helper.edit('hello world\n', 3); + + // clear the first line + inner$('div').first().html('
'); + + // wait for the edit to be accepted + await helper.waitForPromise(() => helper.commits.length === 2); + + // in case of copy&paste the image is placed at the cursor position + // let's put the cursor onto the first line + const firstLine = inner$('div:eq(0)'); + helper.selectLines(firstLine, firstLine, 1, 1); + inner$('div').first().html(``); - await helper.waitForPromise(() => inner$('div').first().html().indexOf(uploadSVG) !== -1, 1000); - await helper.waitForPromise( - () => inner$('div:eq(2)').text().indexOf('hello world') !== -1, 1000); + + await helper.waitForPromise(() => helper.commits.length === 3); + + if (storageType === 'base64') { + await helper.waitForPromise(() => inner$('div:eq(0)').html().indexOf(uploadSVG) !== -1, 1000); + await helper.waitForPromise( + () => inner$('div:eq(2)').text().indexOf('hello world') !== -1, 1000); + } else { + const image = await fetchImageFromLine(0); + const uploadedSVGData = uploadSVG.match(/^data:([^;]+);base64,(.*)/); + + expect(window.atob(uploadedSVGData[2])).to.be(image); + + // ensure the image is actually displayed + const height = window.getComputedStyle(inner$('div:eq(0)')[0].querySelector('img')).height; + expect(parseInt(height)).to.be.above(200); + + // uploadFile calls `ace.ace_doReturnKey()` so there is an additional newline + await helper.waitForPromise( + () => inner$('div:eq(3)').text().indexOf('hello world') !== -1, 1000); + } }); it('Puts an image in the pad and next line is not modified', async function () { @@ -22,15 +100,39 @@ describe('Image Upload', function () { const inner$ = helper.padInner$; // puts hello world on second line - inner$('div:eq(1)').html('hello world'); + await helper.edit('hello world\n', 2); + await helper.waitForPromise(() => inner$('div:eq(1)').text() === 'hello world', 1000); + // in case of copy&paste the image is placed at the cursor position + // let's put the cursor onto the first line + const firstLine = inner$('div:eq(0)'); + helper.selectLines(firstLine, firstLine, 1, 1); + // puts image on first line inner$('div').first().html(``); - await helper.waitForPromise(() => inner$('div').first().html().indexOf(uploadSVG) !== -1, 1000); - await helper.waitForPromise( - () => inner$('div:eq(1)').text().indexOf('hello world') !== -1, 1000); + // wait for the edit to be accepted + await helper.waitForPromise(() => helper.commits.length === 2); + + if (storageType === 'base64') { + await helper.waitForPromise(() => inner$('div:eq(0)').html().indexOf(uploadSVG) !== -1, 1000); + await helper.waitForPromise( + () => inner$('div:eq(1)').text().indexOf('hello world') !== -1, 1000); + } else { + const image = await fetchImageFromLine(0); + const uploadedSVGData = uploadSVG.match(/^data:([^;]+);base64,(.*)/); + + expect(window.atob(uploadedSVGData[2])).to.be(image); + + // ensure the image is actually displayed + const height = window.getComputedStyle(inner$('div:eq(0)')[0].querySelector('img')).height; + expect(parseInt(height)).to.be.above(200); + + // uploadFile calls `ace.ace_doReturnKey()` so there is an additional newline + await helper.waitForPromise( + () => inner$('div:eq(2)').text().indexOf('hello world') !== -1, 1000); + } }); });