diff --git a/package-lock.json b/package-lock.json index 2180c530..6fc909af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "license": "(MIT AND Apache-2.0 AND ISC AND LGPL-3.0-or-later AND X11)", "dependencies": { "@babel/polyfill": "7.12.1", + "browser-fs-access": "^0.20.4", "canvg": "3.0.7", "core-js": "3.16.2", "elix": "15.0.0", @@ -5299,6 +5300,11 @@ "node": ">= 10.16.0" } }, + "node_modules/browser-fs-access": { + "version": "0.20.4", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.20.4.tgz", + "integrity": "sha512-rSbY1AIoDe+fvYZ1LiRDdKBnytfsd1nN/GKS/DRZAhaJkz3cfbp14IHw5lk4FFWBelD6Sw6EtdnAI990ZuBZjg==" + }, "node_modules/browser-pack": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", @@ -26396,6 +26402,11 @@ "duplexer": "0.1.1" } }, + "browser-fs-access": { + "version": "0.20.4", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.20.4.tgz", + "integrity": "sha512-rSbY1AIoDe+fvYZ1LiRDdKBnytfsd1nN/GKS/DRZAhaJkz3cfbp14IHw5lk4FFWBelD6Sw6EtdnAI990ZuBZjg==" + }, "browser-pack": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", diff --git a/package.json b/package.json index 89e91820..66ab46c1 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ ], "dependencies": { "@babel/polyfill": "7.12.1", + "browser-fs-access": "^0.20.4", "canvg": "3.0.7", "core-js": "3.16.2", "elix": "15.0.0", diff --git a/src/editor/MainMenu.js b/src/editor/MainMenu.js index 27dd08ac..840dfbd4 100644 --- a/src/editor/MainMenu.js +++ b/src/editor/MainMenu.js @@ -307,9 +307,6 @@ class MainMenu { // eslint-disable-next-line no-unsanitized/property template.innerHTML = ` - - - @@ -324,29 +321,10 @@ class MainMenu { * Associate all button actions as well as non-button keyboard shortcuts. */ - $id("tool_clear").addEventListener("click", this.clickClear.bind(this)); - $id("tool_open").addEventListener("click", (e) => { - e.preventDefault(); - this.clickOpen(); - window.dispatchEvent(new CustomEvent("openImage")); - }); $id("tool_import").addEventListener("click", () => { this.clickImport(); window.dispatchEvent(new CustomEvent("importImages")); }); - $id("tool_save").addEventListener( - "click", - function() { - const $editorDialog = $id("se-svg-editor-dialog"); - const editingsource = $editorDialog.getAttribute("dialog") === "open"; - if (editingsource) { - this.saveSourceEditor(); - } else { - this.clickSave(); - } - }.bind(this) - ); - // this.clickExport.bind(this) $id("tool_export").addEventListener("click", function() { document .getElementById("se-export-dialog") diff --git a/src/editor/extensions/ext-opensave/ext-opensave.js b/src/editor/extensions/ext-opensave/ext-opensave.js new file mode 100644 index 00000000..a068b311 --- /dev/null +++ b/src/editor/extensions/ext-opensave/ext-opensave.js @@ -0,0 +1,169 @@ +/* globals seConfirm */ +/** + * @file ext-opensave.js + * + * @license MIT + * + * @copyright 2020 OptimistikSAS + * + */ + +/** + * @type {module:svgcanvas.EventHandler} + * @param {external:Window} wind + * @param {module:svgcanvas.SvgCanvas#event:saved} svg The SVG source + * @listens module:svgcanvas.SvgCanvas#event:saved + * @returns {void} + */ +import { fileOpen, fileSave } from 'browser-fs-access'; + +const name = "opensave"; + +const loadExtensionTranslation = async function (svgEditor) { + let translationModule; + const lang = svgEditor.configObj.pref('lang'); + try { + // eslint-disable-next-line no-unsanitized/method + translationModule = await import(`./locale/${lang}.js`); + } catch (_error) { + // eslint-disable-next-line no-console + console.warn(`Missing translation (${lang}) for ${name} - using 'en'`); + // eslint-disable-next-line no-unsanitized/method + translationModule = await import(`./locale/en.js`); + } + svgEditor.i18next.addResourceBundle(lang, name, translationModule.default); +}; + +export default { + name, + async init(_S) { + const svgEditor = this; + const { imgPath } = svgEditor.configObj.curConfig; + const { svgCanvas } = svgEditor; + const { $id } = svgCanvas; + await loadExtensionTranslation(svgEditor); + + /** + * @fires module:svgcanvas.SvgCanvas#event:ext_onNewDocument + * @returns {void} + */ + const clickClear = async function () { + const [ x, y ] = svgEditor.configObj.curConfig.dimensions; + const ok = await seConfirm(svgEditor.i18next.t('notification.QwantToClear')); + if (ok === "Cancel") { + return; + } + svgEditor.leftPanel.clickSelect(); + svgEditor.svgCanvas.clear(); + svgEditor.svgCanvas.setResolution(x, y); + svgEditor.updateCanvas(true); + svgEditor.zoomImage(); + svgEditor.layersPanel.populateLayers(); + svgEditor.topPanel.updateContextPanel(); + svgEditor.svgCanvas.runExtensions("onNewDocument"); + }; + + /** + * By default, this.editor.svgCanvas.open() is a no-op. It is up to an extension + * mechanism (opera widget, etc.) to call `setCustomHandlers()` which + * will make it do something. + * @returns {void} + */ + const clickOpen = async function () { + // ask user before clearing an unsaved SVG + const response = await svgEditor.openPrep(); + if (response === 'Cancel') { return; } + svgCanvas.clear(); + try { + const blob = await fileOpen({ + mimeTypes: [ 'image/*' ] + }); + const svgContent = await blob.text(); + await svgEditor.loadSvgString(svgContent); + svgEditor.updateCanvas(); + } catch (err) { + if (err.name !== 'AbortError') { + return console.error(err); + } + } + }; + + const b64toBlob = (b64Data, contentType='', sliceSize=512) => { + const byteCharacters = atob(b64Data); + const byteArrays = []; + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + const blob = new Blob(byteArrays, { type: contentType }); + return blob; + }; + + /** + * + * @returns {void} + */ + const clickSave = async function () { + const $editorDialog = $id("se-svg-editor-dialog"); + const editingsource = $editorDialog.getAttribute("dialog") === "open"; + if (editingsource) { + svgEditor.saveSourceEditor(); + } else { + // In the future, more options can be provided here + const saveOpts = { + images: svgEditor.configObj.pref("img_save"), + round_digits: 6 + }; + // remove the selected outline before serializing + svgCanvas.clearSelection(); + // Update save options if provided + if (saveOpts) { + const saveOptions = svgCanvas.mergeDeep(svgCanvas.getSvgOption(), saveOpts); + for (const [ key, value ] of Object.entries(saveOptions)) { + svgCanvas.setSvgOption(key, value); + } + } + svgCanvas.setSvgOption('apply', true); + + // no need for doctype, see https://jwatt.org/svg/authoring/#doctype-declaration + const svg = '\n' + svgCanvas.svgCanvasToString(); + const b64Data = svgCanvas.encode64(svg); + const blob = b64toBlob(b64Data, 'image/svg+xml'); + try { + await fileSave(blob, { + fileName: 'icon.svg', + extensions: [ '.svg' ] + }); + } catch (err) { + if (err.name !== 'AbortError') { + return console.error(err); + } + } + } + }; + + return { + name: svgEditor.i18next.t(`${name}:name`), + // The callback should be used to load the DOM with the appropriate UI items + callback() { + // eslint-disable-next-line no-unsanitized/property + const buttonTemplate = ` + `; + svgCanvas.insertChildAtIndex($id('main_button'), buttonTemplate, 0); + const openButtonTemplate = ``; + svgCanvas.insertChildAtIndex($id('main_button'), openButtonTemplate, 1); + const saveButtonTemplate = ``; + svgCanvas.insertChildAtIndex($id('main_button'), saveButtonTemplate, 2); + // handler + $id("tool_clear").addEventListener("click", clickClear.bind(this)); + $id("tool_open").addEventListener("click", clickOpen.bind(this)); + $id("tool_save").addEventListener("click", clickSave.bind(this)); + } + }; + } +}; diff --git a/src/editor/extensions/ext-opensave/locale/en.js b/src/editor/extensions/ext-opensave/locale/en.js new file mode 100644 index 00000000..1c8de67d --- /dev/null +++ b/src/editor/extensions/ext-opensave/locale/en.js @@ -0,0 +1,8 @@ +export default { + name: 'opensave', + tools: { + new_doc: 'New Image', + open_doc: 'Open SVG', + save_doc: 'Save Image' + } +}; diff --git a/src/editor/extensions/ext-opensave/locale/fr.js b/src/editor/extensions/ext-opensave/locale/fr.js new file mode 100644 index 00000000..bb8d3139 --- /dev/null +++ b/src/editor/extensions/ext-opensave/locale/fr.js @@ -0,0 +1,8 @@ +export default { + name: 'ouvreenregistrer', + tools: { + new_doc: 'Nouvelle image', + open_doc: 'Ouvrir le SVG', + save_doc: 'Enregistrer l\'image' + } +}; diff --git a/src/editor/extensions/ext-opensave/locale/zh-CN.js b/src/editor/extensions/ext-opensave/locale/zh-CN.js new file mode 100755 index 00000000..b2e7f76b --- /dev/null +++ b/src/editor/extensions/ext-opensave/locale/zh-CN.js @@ -0,0 +1,8 @@ +export default { + name: '打开保存', + tools: { + new_doc: '新图片', + open_doc: '打开 SVG', + save_doc: '保存图像' + } +}; diff --git a/src/svgcanvas/svgcanvas.js b/src/svgcanvas/svgcanvas.js index f4ac97ff..5a6bc41f 100644 --- a/src/svgcanvas/svgcanvas.js +++ b/src/svgcanvas/svgcanvas.js @@ -188,6 +188,8 @@ class SvgCanvas { this.$id = $id; this.$qq = $qq; this.$qa = $qa; + this.encode64 = encode64; + this.decode64 = decode64; this.stringToHTML = stringToHTML; this.insertChildAtIndex = insertChildAtIndex; this.getClosest = getClosest; @@ -1365,6 +1367,8 @@ class SvgCanvas { /** * Group: Serialization. */ + this.getSvgOption = () => { return saveOptions; }; + this.setSvgOption = (key, value) => { saveOptions[key] = value; }; svgInit( /** @@ -1379,8 +1383,8 @@ class SvgCanvas { getCurrentGroup() { return currentGroup; }, getCurConfig() { return curConfig; }, getNsMap() { return nsMap; }, - getSvgOption() { return saveOptions; }, - setSvgOption(key, value) { saveOptions[key] = value; }, + getSvgOption: this.getSvgOption, + setSvgOption: this.setSvgOption, getSvgOptionApply() { return saveOptions.apply; }, getSvgOptionImages() { return saveOptions.images; }, getEncodableImages(key) { return encodableImages[key]; },