diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a3414..2c72fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to easymde will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - +## [Unreleased] +### Added +- Upload images functionality. (Thanks to [@roipoussiere] and [@JeroenvO], [#71], [#101] + ## [2.7.0] - 2019-07-13 ### Added - `previewClass` option for overwriting the preview screen class ([#99]). @@ -121,14 +124,17 @@ Project forked from [SimpleMDE](https://github.com/sparksuite/simplemde-markdown [#9]: https://github.com/Ionaru/easy-markdown-editor/issues/9 +[#101]: https://github.com/Ionaru/easy-markdown-editor/pull/101 [#93]: https://github.com/Ionaru/easy-markdown-editor/pull/93 [#75]: https://github.com/Ionaru/easy-markdown-editor/pull/75 +[#71]: https://github.com/Ionaru/easy-markdown-editor/pull/71 [#54]: https://github.com/Ionaru/easy-markdown-editor/pull/54 [#31]: https://github.com/Ionaru/easy-markdown-editor/pull/31 [#27]: https://github.com/Ionaru/easy-markdown-editor/pull/27 [#19]: https://github.com/Ionaru/easy-markdown-editor/pull/19 +[@JeroenvO]: https://github.com/JeroenvO [@sn3p]: https://github.com/sn3p [@roryok]: https://github.com/roryok [@ysykzheng]: https://github.com/ysykzheng diff --git a/README.md b/README.md index 2b2ab92..3a7249d 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,27 @@ easyMDE.value('New input for **EasyMDE**'); - **promptTexts**: Customize the text used to prompt for URLs. - **image**: The text to use when prompting for an image's URL. Defaults to `URL of the image:`. - **link**: The text to use when prompting for a link's URL. Defaults to `URL for the link:`. +- **uploadImage**: If set to `true`, enables the image upload functionality, which can be triggered by drag&drop, copy-paste and through the browse-file window (opened when the user click on the *upload-image* icon). Defaults to `false`. +- **imageMaxSize**: Maximum image size in bytes, checked before upload (note: never trust client, always check image size at server-side). Defaults to `1024*1024*2` (2Mb). +- **imageAccept**: A comma-separated list of mime-types used to check image type before upload (note: never trust client, always check file types at server-side). Defaults to `image/png, image/jpeg`. +- **imageUploadEndpoint**: The endpoint where the images data will be sent, via an asynchronous *POST* request. The server is supposed to save this image, and return a json response. + - if the request was successfully processed (HTTP 200-OK): `{"data": {"filePath": ""}}` where *filePath* is the relative path of the image; + - otherwise: `{"error": ""}`, where *errorCode* can be `noFileGiven` (HTTP 400), `typeNotAllowed` (HTTP 415), `fileTooLarge` (HTTP 413) or `importError` (see *errorMessages* below). If *errorCode* is not one of the *errorMessages*, it is alerted unchanged to the user. This allows for server side error messages. + No default value. +- **imageCSRFToken**: CSRF token to include with AJAX call to upload image. For instance used with Django backend. +- **imageTexts**: Texts displayed to the user (mainly on the status bar) for the import image feature, where `#image_name#`, `#image_size#` and `#image_max_size#` will replaced by their respective values, that can be used for customization or internationalization: + - **sbInit**: Status message displayed initially if `uploadImage` is set to `true`. Defaults to `Attach files by drag and dropping or pasting from clipboard.`. + - **sbOnDragEnter**: Status message displayed when the user drags a file to the text area. Defaults to `Drop image to upload it.`. + - **sbOnDrop**: Status message displayed when the user drops a file in the text area. Defaults to `Uploading images #images_names#`. + - **sbProgress**: Status message displayed to show uploading progress. Defaults to `Uploading #file_name#: #progress#%`. + - **sbOnUploaded**: Status message displayed when the image has been uploaded. Defaults to `Uploaded #image_name#`. + - **sizeUnits**: A comma-separated list of units used to display messages with human-readable file sizes. Defaults to `b,Kb,Mb`. +- **errorMessages**: Errors displayed to the user, using the `errorCallback` option, where `#image_name#`, `#image_size#` and `#image_max_size#` will replaced by their respective values, that can be used for customization or internationalization: + - **noFileGiven**: The server did not receive any file from the user. Defaults to `You must select a file.`. + - **typeNotAllowed**: The user send a file type which doesn't match the `imageAccept` list, or the server returned this error code. Defaults to `This image type is not allowed.`. + - **fileTooLarge**: The size of the image being imported is bigger than the `imageMaxSize`, or if the server returned this error code. Defaults to `Image #image_name# is too big (#image_size#).\nMaximum file size is #image_max_size#.`. + - **importError**: An unexpected error occurred when uploading the image. Defaults to `Something went wrong when uploading the image #image_name#.`. +- **errorCallback**: A callback function used to define how to display an error message. Defaults to `function(errorMessage) {alert(errorMessage);};`. - **renderingConfig**: Adjust settings for parsing the Markdown during previewing (not editing). - **codeSyntaxHighlighting**: If set to `true`, will highlight using [highlight.js](https://github.com/isagalaev/highlight.js). Defaults to `false`. To use this feature you must include highlight.js on your page or pass in using the `hljs` option. For example, include the script and the CSS files like:
``
`` - **hljs**: An injectible instance of [highlight.js](https://github.com/isagalaev/highlight.js). If you don't want to rely on the global namespace (`window.hljs`), you can provide an instance here. Defaults to `undefined`. diff --git a/package-lock.json b/package-lock.json index 8d774ec..98c7c63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,9 +95,9 @@ "dev": true }, "ajv": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.1.tgz", - "integrity": "sha512-w1YQaVGNC6t2UCPjEawK/vo/dG8OOrVtUmhBT1uJJYxbl5kU2Tj3v6LGqBcsysN1yhuCStJCCA3GqdvKY8sqXQ==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", "dev": true, "requires": { "fast-deep-equal": "^2.0.1", @@ -362,23 +362,15 @@ "dev": true }, "async-done": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.1.tgz", - "integrity": "sha512-R1BaUeJ4PMoLNJuk+0tLJgjmEqVsdN118+Z8O+alhnQDQgy0kmD5Mqi0DNEmMx2LM0Ed5yekKu+ZXYvIHceicg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", + "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==", "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.2", - "process-nextick-args": "^1.0.7", + "process-nextick-args": "^2.0.0", "stream-exhaust": "^1.0.1" - }, - "dependencies": { - "process-nextick-args": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "dev": true - } } }, "async-each": { @@ -1163,12 +1155,13 @@ } }, "d": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", - "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", "dev": true, "requires": { - "es5-ext": "^0.10.9" + "es5-ext": "^0.10.50", + "type": "^1.0.1" } }, "dash-ast": { @@ -1457,14 +1450,14 @@ } }, "es6-weak-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", - "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", "dev": true, "requires": { "d": "1", - "es5-ext": "^0.10.14", - "es6-iterator": "^2.0.1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, @@ -1561,10 +1554,13 @@ } }, "eslint-utils": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz", - "integrity": "sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==", - "dev": true + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.0.tgz", + "integrity": "sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.0.0" + } }, "eslint-visitor-keys": { "version": "1.0.0", @@ -2540,9 +2536,9 @@ "dev": true }, "glob": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", - "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", "dev": true, "requires": { "fs.realpath": "^1.0.0", @@ -2646,9 +2642,9 @@ } }, "graceful-fs": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", - "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", + "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==", "dev": true }, "gulp": { @@ -3476,12 +3472,12 @@ "dev": true }, "lodash.template": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", - "integrity": "sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz", + "integrity": "sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A=", "dev": true, "requires": { - "lodash._reinterpolate": "^3.0.0", + "lodash._reinterpolate": "~3.0.0", "lodash.templatesettings": "^4.0.0" } }, @@ -3655,9 +3651,9 @@ "dev": true }, "mixin-deep": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", - "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", "dev": true, "requires": { "for-in": "^1.0.2", @@ -4442,9 +4438,9 @@ "dev": true }, "resolve": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz", - "integrity": "sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.1.tgz", + "integrity": "sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==", "dev": true, "requires": { "path-parse": "^1.0.6" @@ -4556,9 +4552,9 @@ "dev": true }, "semver": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", - "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true }, "semver-greatest-satisfied-range": { @@ -4577,9 +4573,9 @@ "dev": true }, "set-value": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", - "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", "dev": true, "requires": { "extend-shallow": "^2.0.1", @@ -4860,9 +4856,9 @@ } }, "spdx-license-ids": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz", - "integrity": "sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", "dev": true }, "split-string": { @@ -5090,14 +5086,14 @@ } }, "terser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.0.0.tgz", - "integrity": "sha512-dOapGTU0hETFl1tCo4t56FN+2jffoKyER9qBGoUFyZ6y7WLoKT0bF+lAYi6B6YsILcGF3q1C2FBh8QcKSCgkgA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.1.2.tgz", + "integrity": "sha512-jvNoEQSPXJdssFwqPSgWjsOrb+ELoE+ILpHPKXC83tIxOlh2U75F1KuB2luLD/3a6/7K3Vw5pDn+hvu0C4AzSw==", "dev": true, "requires": { - "commander": "^2.19.0", + "commander": "^2.20.0", "source-map": "~0.6.1", - "source-map-support": "~0.5.10" + "source-map-support": "~0.5.12" }, "dependencies": { "source-map": { @@ -5243,6 +5239,12 @@ "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", "dev": true }, + "type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/type/-/type-1.0.1.tgz", + "integrity": "sha512-MAM5dBMJCJNKs9E7JXo4CXRAansRfG0nlJxW7Wf6GZzSOvH31zClSaHdIMWLehe/EGMBkqeC55rrkaOr5Oo7Nw==", + "dev": true + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -5336,38 +5338,15 @@ "dev": true }, "union-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", - "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dev": true, "requires": { "arr-union": "^3.1.0", "get-value": "^2.0.6", "is-extendable": "^0.1.1", - "set-value": "^0.4.3" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "set-value": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", - "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.1", - "to-object-path": "^0.3.0" - } - } + "set-value": "^2.0.1" } }, "unique-stream": { diff --git a/src/js/easymde.js b/src/js/easymde.js index 0d8aa5c..8beb664 100644 --- a/src/js/easymde.js +++ b/src/js/easymde.js @@ -122,8 +122,8 @@ function createToolbarButton(options, enableTooltips, shortcuts) { enableTooltips = (enableTooltips == undefined) ? true : enableTooltips; // Properly hande custom shortcuts - if( options.name && options.name in shortcuts ){ - bindings[options.name] = options.action; + if (options.name && options.name in shortcuts) { + bindings[options.name] = options.action; } if (options.title && enableTooltips) { @@ -284,7 +284,7 @@ function toggleFullScreen(editor) { if (/editor-preview-active-side/.test(sidebyside.className)) toggleSideBySide(editor); - if (editor.options.onToggleFullScreen) { + if (editor.options.onToggleFullScreen) { editor.options.onToggleFullScreen(cm.getOption('fullScreen') || false); } } @@ -707,6 +707,33 @@ function drawImage(editor) { _replaceSelection(cm, stat.image, options.insertTexts.image, url); } +/** + * Action for opening the browse-file window to upload an image to a server. + * @param editor {EasyMDE} The EasyMDE object + */ +function drawUploadedImage(editor) { + // TODO: Draw the image template with a fake url? ie: '![](importing foo.png...)' + editor.openBrowseFileWindow(); +} + +/** + * Action executed after an image have been successfully imported on the server. + * @param editor {EasyMDE} The EasyMDE object + * @param url {string} The url of the uploaded image + */ +function afterImageUploaded(editor, url) { + var cm = editor.codemirror; + var stat = getState(cm); + var options = editor.options; + var imageName = url.substr(url.lastIndexOf('/') + 1); + _replaceSelection(cm, stat.image, options.insertTexts.uploadedImage, url); + // show uploaded image filename for 1000ms + editor.updateStatusBar('upload-image', editor.options.imageTexts.sbOnUploaded.replace('#image_name#', imageName)); + setTimeout(function () { + editor.updateStatusBar('upload-image', editor.options.imageTexts.sbInit); + }, 1000); +} + /** * Action for drawing a table. */ @@ -877,6 +904,7 @@ function _replaceSelection(cm, active, startEnd, url) { Object.assign(startPoint, cm.getCursor('start')); Object.assign(endPoint, cm.getCursor('end')); if (url) { + start = start.replace('#url#', url); // url is in start for upload-image end = end.replace('#url#', url); } if (active) { @@ -1016,7 +1044,7 @@ function _toggleLine(cm, name) { char = ''; } text = arr[1] + char + arr[3] + text.replace(whitespacesRegexp, '').replace(repl[name], '$1'); - } else if (untoggleOnly == false){ + } else if (untoggleOnly == false) { text = char + ' ' + text; } return text; @@ -1141,6 +1169,24 @@ function _cleanBlock(cm) { } } +/** + * Convert a number of bytes to a human-readable file size. + * @param bytes {integer} A number of bytes, as integer. Ex: 421137 + * @param units {number[]} An array of human-readable units, ie. ['b', 'Kb', 'Mb'] + * @returns string A human-readable file size. Ex: '412Kb' + */ +function humanFileSize(bytes, units) { + if (Math.abs(bytes) < 1024) { + return '' + bytes + units[0]; + } + var u = 0; + do { + bytes /= 1024; + ++u; + } while (Math.abs(bytes) >= 1024 && u < units.length); + return '' + bytes.toFixed(1) + units[u]; +} + // Merge the properties of one object into another. function _mergeProperties(target, source) { for (var property in source) { @@ -1298,6 +1344,12 @@ var toolbarBuiltInButtons = { title: 'Insert Image', default: true, }, + 'upload-image': { + name: 'upload-image', + action: drawUploadedImage, + className: 'fa fa-image', + title: 'Import an image', + }, 'table': { name: 'table', action: drawTable, @@ -1372,6 +1424,8 @@ var toolbarBuiltInButtons = { var insertTexts = { link: ['[', '](#url#)'], image: ['![](', '#url#)'], + uploadedImage: ['![](#url#)', ''], + // uploadedImage: ['![](#url#)\n', ''], // TODO: New line insertion doesn't work here. table: ['', '\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n\n'], horizontalRule: ['', '\n\n-----\n\n'], }; @@ -1387,6 +1441,31 @@ var blockStyles = { 'italic': '*', }; +/** + * Texts displayed to the user (mainly on the status bar) for the import image + * feature. Can be used for customization or internationalization. + */ +var imageTexts = { + sbInit: 'Attach files by drag and dropping or pasting from clipboard.', + sbOnDragEnter: 'Drop image to upload it.', + sbOnDrop: 'Uploading image #images_names#...', + sbProgress: 'Uploading #file_name#: #progress#%', + sbOnUploaded: 'Uploaded #image_name#', + sizeUnits: 'b,Kb,Mb', +}; + +/** + * Errors displayed to the user, using the `errorCallback` option. Can be used for + * customization or internationalization. + */ +var errorMessages = { + noFileGiven: 'You must select a file.', + typeNotAllowed: 'This image type is not allowed.', + fileTooLarge: 'Image #image_name# is too big (#image_size#).\n' + + 'Maximum file size is #image_max_size#.', + importError: 'Something went wrong when uploading the image #image_name#.', +}; + /** * Interface of EasyMDE. */ @@ -1462,6 +1541,10 @@ function EasyMDE(options) { // Handle status bar if (!Object.prototype.hasOwnProperty.call(options, 'status')) { options.status = ['autosave', 'lines', 'words', 'cursor']; + + if (options.uploadImage) { + options.status.unshift('upload-image'); + } } @@ -1497,6 +1580,17 @@ function EasyMDE(options) { options.minHeight = options.minHeight || '300px'; + options.errorCallback = options.errorCallback || function (errorMessage) { + alert(errorMessage); + }; + + // Import-image default configuration + options.uploadImage = options.uploadImage || false; + options.imageMaxSize = options.imageMaxSize || 2097152; // 1024 * 1024 * 2 + options.imageAccept = options.imageAccept || 'image/png, image/jpeg'; + options.imageTexts = extend({}, imageTexts, options.imageTexts || {}); + options.errorMessages = extend({}, errorMessages, options.errorMessages || {}); + // Change unique_id to uniqueId for backwards compatibility if (options.autosave != undefined && options.autosave.unique_id != undefined && options.autosave.unique_id != '') @@ -1517,8 +1611,80 @@ function EasyMDE(options) { if (options.initialValue && (!this.options.autosave || this.options.autosave.foundSavedValue !== true)) { this.value(options.initialValue); } + + if (options.uploadImage) { + var self = this; + + this.codemirror.on('dragenter', function (cm, event) { + self.updateStatusBar('upload-image', self.options.imageTexts.sbOnDragEnter); + event.stopPropagation(); + event.preventDefault(); + }); + this.codemirror.on('dragend', function (cm, event) { + self.updateStatusBar('upload-image', self.options.imageTexts.sbInit); + event.stopPropagation(); + event.preventDefault(); + }); + this.codemirror.on('dragleave', function (cm, event) { + self.updateStatusBar('upload-image', self.options.imageTexts.sbInit); + event.stopPropagation(); + event.preventDefault(); + }); + + this.codemirror.on('dragover', function (cm, event) { + self.updateStatusBar('upload-image', self.options.imageTexts.sbOnDragEnter); + event.stopPropagation(); + event.preventDefault(); + }); + + this.codemirror.on('drop', function (cm, event) { + event.stopPropagation(); + event.preventDefault(); + self.uploadImages(event.dataTransfer.files); + }); + + this.codemirror.on('paste', function (cm, event) { + self.uploadImages(event.clipboardData.files); + }); + } } +/** + * Upload asynchronously a list of images to a server. + * + * Can be triggered by: + * - drag&drop; + * - copy-paste; + * - the browse-file window (opened when the user clicks on the *upload-image* icon). + * @param {FileList} files The files to upload the the server. + * @param [onSuccess] {function} see EasyMDE.prototype.uploadImage + * @param [onError] {function} see EasyMDE.prototype.uploadImage + */ +EasyMDE.prototype.uploadImages = function (files, onSuccess, onError) { + var names = []; + for (var i = 0; i < files.length; i++) { + names.push(files[i].name); + this.uploadImage(files[i], onSuccess, onError); + } + this.updateStatusBar('upload-image', this.options.imageTexts.sbOnDrop.replace('#images_names#', names.join(', '))); +}; + +/** + * Update an item in the status bar. + * @param itemName {string} The name of the item to update (ie. 'upload-image', 'autosave', etc.). + * @param content {string} the new content of the item to write in the status bar. + */ +EasyMDE.prototype.updateStatusBar = function (itemName, content) { + var matchingClasses = this.gui.statusbar.getElementsByClassName(itemName); + if (matchingClasses.length === 1) { + this.gui.statusbar.getElementsByClassName(itemName)[0].textContent = content; + } else if (matchingClasses.length === 0) { + console.log('EasyMDE: status bar item ' + itemName + ' was not found.'); + } else { + console.log('EasyMDE: Several status bar items named ' + itemName + ' was found.'); + } +}; + /** * Default markdown render. */ @@ -1713,22 +1879,22 @@ EasyMDE.prototype.autosave = function () { return; } - if(this.options.autosave.binded !== true) { - if (easyMDE.element.form != null && easyMDE.element.form != undefined) { - easyMDE.element.form.addEventListener('submit', function () { - clearTimeout(easyMDE.autosaveTimeoutId); - easyMDE.autosaveTimeoutId = undefined; + if (this.options.autosave.binded !== true) { + if (easyMDE.element.form != null && easyMDE.element.form != undefined) { + easyMDE.element.form.addEventListener('submit', function () { + clearTimeout(easyMDE.autosaveTimeoutId); + easyMDE.autosaveTimeoutId = undefined; - localStorage.removeItem('smde_' + easyMDE.options.autosave.uniqueId); + localStorage.removeItem('smde_' + easyMDE.options.autosave.uniqueId); - // Restart autosaving in case the submit will be cancelled down the line - setTimeout(function() { - easyMDE.autosave(); - }, easyMDE.options.autosave.delay || 10000); - }); - } + // Restart autosaving in case the submit will be cancelled down the line + setTimeout(function () { + easyMDE.autosave(); + }, easyMDE.options.autosave.delay || 10000); + }); + } - this.options.autosave.binded = true; + this.options.autosave.binded = true; } if (this.options.autosave.loaded !== true) { @@ -1782,6 +1948,116 @@ EasyMDE.prototype.clearAutosavedValue = function () { } }; +/** + * Open the browse-file window to upload an image to a server. + * @param [onSuccess] {function} see EasyMDE.prototype.uploadImage + * @param [onError] {function} see EasyMDE.prototype.uploadImage + */ +EasyMDE.prototype.openBrowseFileWindow = function (onSuccess, onError) { + var self = this; + var imageInput = this.gui.toolbar.getElementsByClassName('imageInput')[0]; + imageInput.click(); //dispatchEvent(new MouseEvent('click')); // replaced with click() for IE11 compatibility. + function onChange(event) { + self.uploadImages(event.target.files, onSuccess, onError); + imageInput.removeEventListener('change', onChange); + } + + imageInput.addEventListener('change', onChange); +}; + +/** + * Upload an image to the server. + * + * @param file {File} The image to upload, as a HTML5 File object (https://developer.mozilla.org/en-US/docs/Web/API/File) + * @param [onSuccess] {function} A callback function to execute after the image has been successfully uploaded, with one parameter: + * - url (string): The URL of the uploaded image. + * @param [onError] {function} A callback function to execute when the image upload fails, with one parameter: + * - error (string): the detailed error to display to the user (based on messages from options.errorMessages). + */ +EasyMDE.prototype.uploadImage = function (file, onSuccess, onError) { + var self = this; + onSuccess = onSuccess || function onSuccess(imageUrl) { + afterImageUploaded(self, imageUrl); + }; + + function onErrorSup(errorMessage) { + // show error on status bar and reset after 10000ms + self.updateStatusBar('upload-image', errorMessage); + + setTimeout(function () { + self.updateStatusBar('upload-image', self.options.imageTexts.sbInit); + }, 10000); + + // run custom error handler + if (onError && typeof onError === 'function') { + onError(errorMessage); + } + // run error handler from options, this alerts the message. + self.options.errorCallback(errorMessage); + } + + function fillErrorMessage(errorMessage) { + var units = self.options.imageTexts.sizeUnits.split(','); + return errorMessage + .replace('#image_name#', file.name) + .replace('#image_size#', humanFileSize(file.size, units)) + .replace('#image_max_size#', humanFileSize(self.options.imageMaxSize, units)); + } + + if (file.size > this.options.imageMaxSize) { + onErrorSup(fillErrorMessage(this.options.errorMessages.fileTooLarge)); + return; + } + + var formData = new FormData(); + formData.append('image', file); + + // insert CSRF token if provided in config. + if (self.options.imageCSRFToken) { + formData.append('csrfmiddlewaretoken', self.options.imageCSRFToken); + } + var request = new XMLHttpRequest(); + request.upload.onprogress = function (event) { + if (event.lengthComputable) { + var progress = '' + Math.round((event.loaded * 100) / event.total); + self.updateStatusBar('upload-image', self.options.imageTexts.sbProgress.replace('#file_name#', file.name).replace('#progress#', progress)); + } + }; + request.open('POST', this.options.imageUploadEndpoint); + + request.onload = function () { + try { + var response = JSON.parse(this.responseText); + } catch (error) { + console.error('EasyMDE: The server did not return a valid json.'); + onErrorSup(fillErrorMessage(self.options.errorMessages.importError)); + return; + } + if (this.status === 200 && response && !response.error && response.data && response.data.filePath) { + onSuccess(window.location.origin + '/' + response.data.filePath); + } else { + if (response.error && response.error in self.options.errorMessages) { // preformatted error message + onErrorSup(fillErrorMessage(self.options.errorMessages[response.error])); + } else if (response.error) { // server side generated error message + onErrorSup(fillErrorMessage(response.error)); + } else { //unknown error + console.error('EasyMDE: Received an unexpected response after uploading the image.' + + this.status + ' (' + this.statusText + ')'); + onErrorSup(fillErrorMessage(self.options.errorMessages.importError)); + } + } + }; + + request.onerror = function (event) { + console.error('EasyMDE: An unexpected error occurred when trying to upload the image.' + + event.target.status + ' (' + event.target.statusText + ')'); + onErrorSup(self.options.errorMessages.importError); + }; + + request.send(formData); + +}; + EasyMDE.prototype.createSideBySide = function () { var cm = this.codemirror; var wrapper = cm.getWrapperElement(); @@ -1912,6 +2188,20 @@ EasyMDE.prototype.createToolbar = function (items) { toolbarData[item.name || item] = el; bar.appendChild(el); + + // Create the input element (ie. ), used among + // with the 'import-image' icon to open the browse-file window. + if (item.name === 'upload-image') { + var imageInput = document.createElement('input'); + imageInput.className = 'imageInput'; + imageInput.type = 'file'; + imageInput.multiple = true; + imageInput.name = 'image'; + imageInput.accept = self.options.imageAccept; + imageInput.style.display = 'none'; + imageInput.style.opacity = 0; + bar.appendChild(imageInput); + } })(items[i]); } @@ -1944,11 +2234,10 @@ EasyMDE.prototype.createStatusbar = function (status) { var options = this.options; var cm = this.codemirror; - // Make sure the status variable is valid - if (!status || status.length === 0) + if (!status || status.length === 0) { return; - + } // Set up the built-in items var items = []; @@ -1998,6 +2287,10 @@ EasyMDE.prototype.createStatusbar = function (status) { el.setAttribute('id', 'autosaved'); } }; + } else if (name === 'upload-image') { + defaultValue = function (el) { + el.innerHTML = options.imageTexts.sbInit; + }; } items.push({ @@ -2176,7 +2469,6 @@ EasyMDE.prototype.isPreviewActive = function () { return /editor-preview-active/.test(preview.className); }; - EasyMDE.prototype.isSideBySideActive = function () { var cm = this.codemirror; var wrapper = cm.getWrapperElement(); diff --git a/types/easymde-test.ts b/types/easymde-test.ts index f240ee9..12b0555 100644 --- a/types/easymde-test.ts +++ b/types/easymde-test.ts @@ -59,3 +59,28 @@ const editor2 = new EasyMDE({ }); editor2.clearAutosavedValue(); + +const editorImages = new EasyMDE({ + uploadImage: true, + imageAccept: 'image/png, image/bmp', + imageCSRFToken: undefined, + imageMaxSize: 10485760, + imageUploadEndpoint: 'https://my.domain/image-upload/', + imageTexts: { + sbInit: 'Drag & drop images!', + sbOnDragEnter: 'Let it go, let it go', + sbOnDrop: 'Uploading...', + sbProgress: 'Uploading... (#progress#)', + sbOnUploaded: 'Upload complete!', + sizeUnits: 'b,Kb,Mb' + }, + errorMessages: { + noFileGiven: 'Please select a file', + typeNotAllowed: 'This file type is not allowed!', + fileTooLarge: 'Image too big', + importError: 'Something went oops!', + }, + errorCallback: (errorMessage) => { + console.error(errorMessage); + }, +}); diff --git a/types/easymde.d.ts b/types/easymde.d.ts index d5ce507..2a7db40 100644 --- a/types/easymde.d.ts +++ b/types/easymde.d.ts @@ -1,16 +1,16 @@ // This file is based on https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/simplemde/index.d.ts, // which is written by Scalesoft and licensed under the MIT license: -// +// // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: -// +// // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. -// +// // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -86,6 +86,22 @@ declare namespace EasyMDE { noMobile?: boolean; } + interface ImageTextsOptions { + sbInit?: string; + sbOnDragEnter?: string; + sbOnDrop?: string; + sbProgress?: string; + sbOnUploaded?: string; + sizeUnits?: string; + } + + interface ImageErrorTextsOptions { + noFileGiven?: string; + typeNotAllowed?: string; + fileTooLarge?: string; + importError?: string; + } + interface Options { autoDownloadFontAwesome?: boolean; autofocus?: boolean; @@ -114,6 +130,15 @@ declare namespace EasyMDE { toolbarTips?: boolean; onToggleFullScreen?: (goingIntoFullScreen: boolean) => void; theme?: string; + + uploadImage?: boolean; + imageMaxSize?: number; + imageAccept?: string; + imageUploadEndpoint?: string; + imageCSRFToken?: string; + imageTexts?: ImageTextsOptions; + errorMessages?: ImageErrorTextsOptions; + errorCallback?: (errorMessage: string) => void; } }