UNPKG

svgedit

Version:

Powerful SVG-Editor for your browser

799 lines (731 loc) 28 kB
/* globals seConfirm seAlert */ import './touch.js'; import { convertUnit } from '../common/units.js'; import { putLocale } from './locale.js'; import { hasCustomHandler, getCustomHandler, injectExtendedContextMenuItemsIntoDom } from './contextmenu.js'; import editorTemplate from './templates/editorTemplate.js'; import SvgCanvas from '../svgcanvas/svgcanvas.js'; import Rulers from './Rulers.js'; /** * @fires module:svgcanvas.SvgCanvas#event:svgEditorReady * @returns {void} */ const readySignal = () => { // let the opener know SVG Edit is ready (now that config is set up) const w = window.opener || window.parent; if (w) { try { /** * Triggered on a containing `document` (of `window.opener` * or `window.parent`) when the editor is loaded. * @event module:SVGEditor#event:svgEditorReadyEvent * @type {Event} * @property {true} bubbles * @property {true} cancelable */ /** * @name module:SVGthis.svgEditorReadyEvent * @type {module:SVGEditor#event:svgEditorReadyEvent} */ const svgEditorReadyEvent = new w.CustomEvent('svgEditorReady', { bubbles: true, cancelable: true }); w.document.documentElement.dispatchEvent(svgEditorReadyEvent); } catch (e) {/* empty fn */} } }; const { $id, $qq } = SvgCanvas; /** * */ class EditorStartup { /** * */ constructor (div) { this.extensionsAdded = false; this.messageQueue = []; this.$container = div??$id('svg_editor'); } /** * Auto-run after a Promise microtask. * @function module:SVGthis.init * @returns {void} */ async init () { if ('localStorage' in window) { this.storage = window.localStorage; } this.configObj.load(); const { i18next } = await putLocale(this.configObj.pref('lang'), this.goodLangs); this.i18next = i18next; await import(`./components/index.js`); await import(`./dialogs/index.js`); try { // add editor components to the DOM this.$container.append(editorTemplate.content.cloneNode(true)); this.$svgEditor = $qq('.svg_editor'); // allow to prepare the dom without display this.$svgEditor.style.visibility = 'hidden'; this.workarea = $id('workarea'); // Image props dialog added to DOM const newSeImgPropDialog = document.createElement('se-img-prop-dialog'); newSeImgPropDialog.setAttribute('id', 'se-img-prop'); this.$container.append(newSeImgPropDialog); newSeImgPropDialog.init(this.i18next); // editor prefences dialoag added to DOM const newSeEditPrefsDialog = document.createElement('se-edit-prefs-dialog'); newSeEditPrefsDialog.setAttribute('id', 'se-edit-prefs'); this.$container.append(newSeEditPrefsDialog); newSeEditPrefsDialog.init(this.i18next); // canvas menu added to DOM const dialogBox = document.createElement('se-cmenu_canvas-dialog'); dialogBox.setAttribute('id', 'se-cmenu_canvas'); this.$container.append(dialogBox); dialogBox.init(this.i18next); // alertDialog added to DOM const alertBox = document.createElement('se-alert-dialog'); alertBox.setAttribute('id', 'se-alert-dialog'); this.$container.append(alertBox); // promptDialog added to DOM const promptBox = document.createElement('se-prompt-dialog'); promptBox.setAttribute('id', 'se-prompt-dialog'); this.$container.append(promptBox); // Export dialog added to DOM const exportDialog = document.createElement('se-export-dialog'); exportDialog.setAttribute('id', 'se-export-dialog'); this.$container.append(exportDialog); exportDialog.init(this.i18next); } catch (err) { console.error(err); } /** * @name module:SVGthis.canvas * @type {module:svgcanvas.SvgCanvas} */ this.svgCanvas = new SvgCanvas( $id('svgcanvas'), this.configObj.curConfig ); this.leftPanel.init(); this.bottomPanel.init(); this.topPanel.init(); this.layersPanel.init(); this.mainMenu.init(); const { undoMgr } = this.svgCanvas; this.canvMenu = $id('se-cmenu_canvas'); this.exportWindow = null; this.defaultImageURL = `${this.configObj.curConfig.imgPath}/logo.svg`; const zoomInIcon = 'crosshair'; const zoomOutIcon = 'crosshair'; this.uiContext = 'toolbars'; // For external openers readySignal(); this.rulers = new Rulers(this); this.layersPanel.populateLayers(); this.selectedElement = null; this.multiselected = false; const aLink = $id('cur_context_panel'); aLink.addEventListener('click', (evt) => { const link = evt.target; if (link.hasAttribute('data-root')) { this.svgCanvas.leaveContext(); } else { this.svgCanvas.setContext(link.textContent); } this.svgCanvas.clearSelection(); return false; }); // bind the selected event to our function that handles updates to the UI this.svgCanvas.bind('selected', this.selectedChanged.bind(this)); this.svgCanvas.bind('transition', this.elementTransition.bind(this)); this.svgCanvas.bind('changed', this.elementChanged.bind(this)); this.svgCanvas.bind('exported', this.exportHandler.bind(this)); this.svgCanvas.bind('exportedPDF', function (win, data) { if (!data.output) { // Ignore Chrome return; } const { exportWindowName } = data; if (exportWindowName) { this.exportWindow = window.open('', this.exportWindowName); // A hack to get the window via JSON-able name without opening a new one } if (!this.exportWindow || this.exportWindow.closed) { seAlert(this.i18next.t('notification.popupWindowBlocked')); return; } this.exportWindow.location.href = data.output; }.bind(this)); this.svgCanvas.bind('zoomed', this.zoomChanged.bind(this)); this.svgCanvas.bind('zoomDone', this.zoomDone.bind(this)); this.svgCanvas.bind( 'updateCanvas', /** * @param {external:Window} win * @param {PlainObject} centerInfo * @param {false} centerInfo.center * @param {module:math.XYObject} centerInfo.newCtr * @listens module:svgcanvas.SvgCanvas#event:updateCanvas * @returns {void} */ function (win, { center, newCtr }) { this.updateCanvas(center, newCtr); }.bind(this) ); this.svgCanvas.bind('contextset', this.contextChanged.bind(this)); this.svgCanvas.bind('extension_added', this.extAdded.bind(this)); this.svgCanvas.textActions.setInputElem($id('text')); this.setBackground(this.configObj.pref('bkgd_color'), this.configObj.pref('bkgd_url')); // update resolution option with actual resolution const res = this.svgCanvas.getResolution(); if (this.configObj.curConfig.baseUnit !== 'px') { res.w = convertUnit(res.w) + this.configObj.curConfig.baseUnit; res.h = convertUnit(res.h) + this.configObj.curConfig.baseUnit; } $id('se-img-prop').setAttribute('dialog', 'close'); $id('se-img-prop').setAttribute('title', this.svgCanvas.getDocumentTitle()); $id('se-img-prop').setAttribute('width', res.w); $id('se-img-prop').setAttribute('height', res.h); $id('se-img-prop').setAttribute('save', this.configObj.pref('img_save')); // Lose focus for select elements when changed (Allows keyboard shortcuts to work better) const selElements = document.querySelectorAll("select"); Array.from(selElements).forEach(function(element) { element.addEventListener('change', function(evt) { evt.currentTarget.blur(); }); }); // fired when user wants to move elements to another layer let promptMoveLayerOnce = false; $id('selLayerNames').addEventListener('change', (evt) => { const destLayer = evt.detail.value; const confirmStr = this.i18next.t('notification.QmoveElemsToLayer').replace('%s', destLayer); /** * @param {boolean} ok * @returns {void} */ const moveToLayer = (ok) => { if (!ok) { return; } promptMoveLayerOnce = true; this.svgCanvas.moveSelectedToLayer(destLayer); this.svgCanvas.clearSelection(); this.layersPanel.populateLayers(); }; if (destLayer) { if (promptMoveLayerOnce) { moveToLayer(true); } else { const ok = seConfirm(confirmStr); if (!ok) { return; } moveToLayer(true); } } }); $id('tool_font_family').addEventListener('change', (evt) => { this.svgCanvas.setFontFamily(evt.detail.value); }); $id('seg_type').addEventListener('change', (evt) => { this.svgCanvas.setSegType(evt.detail.value); }); const addListenerMulti = (element, eventNames, listener)=> { eventNames.split(' ').forEach((eventName)=> element.addEventListener(eventName, listener, false)); }; addListenerMulti($id('text'), 'keyup input', (evt) => { this.svgCanvas.setTextContent(evt.currentTarget.value); }); $id('link_url').addEventListener('change', (evt) => { if (evt.currentTarget.value.length) { this.svgCanvas.setLinkURL(evt.currentTarget.value); } else { this.svgCanvas.removeHyperlink(); } }); $id('g_title').addEventListener('change', (evt) => { this.svgCanvas.setGroupTitle(evt.currentTarget.value); }); let lastX = null; let lastY = null; let panning = false; let keypan = false; $id('svgcanvas').addEventListener('mouseup', (evt) => { if (panning === false) { return true; } this.workarea.scrollLeft -= (evt.clientX - lastX); this.workarea.scrollTop -= (evt.clientY - lastY); lastX = evt.clientX; lastY = evt.clientY; if (evt.type === 'mouseup') { panning = false; } return false; }); $id('svgcanvas').addEventListener('mousemove', (evt) => { if (panning === false) { return true; } this.workarea.scrollLeft -= (evt.clientX - lastX); this.workarea.scrollTop -= (evt.clientY - lastY); lastX = evt.clientX; lastY = evt.clientY; if (evt.type === 'mouseup') { panning = false; } return false; }); $id('svgcanvas').addEventListener('mousedown', (evt) => { if (evt.button === 1 || keypan === true) { panning = true; lastX = evt.clientX; lastY = evt.clientY; return false; } return true; }); window.addEventListener('mouseup', () => { panning = false; }); document.addEventListener('keydown', (e) => { if (e.target.nodeName !== 'BODY') return; if(e.code.toLowerCase() === 'space'){ this.svgCanvas.spaceKey = keypan = true; e.preventDefault(); } else if((e.key.toLowerCase() === 'shift') && (this.svgCanvas.getMode() === 'zoom')){ this.workarea.style.cursor = zoomOutIcon; e.preventDefault(); } else { return; } }); document.addEventListener('keyup', (e) => { if (e.target.nodeName !== 'BODY') return; if(e.code.toLowerCase() === 'space'){ this.svgCanvas.spaceKey = keypan = false; e.preventDefault(); } else if((e.key.toLowerCase() === 'shift') && (this.svgCanvas.getMode() === 'zoom')){ this.workarea.style.cursor = zoomInIcon; e.preventDefault(); } else { return; } }); /** * @function module:SVGthis.setPanning * @param {boolean} active * @returns {void} */ this.setPanning = (active) => { this.svgCanvas.spaceKey = keypan = active; }; let inp; /** * * @returns {void} */ const unfocus = () => { inp.blur(); }; const liElems = this.$svgEditor.querySelectorAll('button, select, input:not(#text)'); const self = this; Array.prototype.forEach.call(liElems, function(el){ el.addEventListener("focus", (e) => { inp = e.currentTarget; self.uiContext = 'toolbars'; self.workarea.addEventListener('mousedown', unfocus); }); el.addEventListener("blur", () => { self.uiContext = 'canvas'; self.workarea.removeEventListener('mousedown', unfocus); // Go back to selecting text if in textedit mode if (self.svgCanvas.getMode() === 'textedit') { $id('text').focus(); } }); }); // ref: https://stackoverflow.com/a/1038781 function getWidth() { return Math.max( document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.documentElement.clientWidth ); } function getHeight() { return Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.documentElement.clientHeight ); } const winWh = { width: getWidth(), height: getHeight() }; window.addEventListener('resize', () => { Object.entries(winWh).forEach(([ type, val ]) => { const curval = (type === 'width') ? window.innerWidth - 15 : window.innerHeight; this.workarea['scroll' + (type === 'width' ? 'Left' : 'Top')] -= (curval - val) / 2; winWh[type] = curval; }); }); this.workarea.addEventListener('scroll', () => { this.rulers.manageScroll(); }); $id('stroke_width').value = this.configObj.curConfig.initStroke.width; $id('opacity').value = this.configObj.curConfig.initOpacity * 100; const elements = document.getElementsByClassName("push_button"); Array.from(elements).forEach(function(element) { element.addEventListener('mousedown', function(event) { if (!event.currentTarget.classList.contains('disabled')) { event.currentTarget.classList.add('push_button_pressed'); event.currentTarget.classList.remove('push_button'); } }); element.addEventListener('mouseout', function(event) { event.currentTarget.classList.add('push_button'); event.currentTarget.classList.remove('push_button_pressed'); }); element.addEventListener('mouseup', function(event) { event.currentTarget.classList.add('push_button'); event.currentTarget.classList.remove('push_button_pressed'); }); }); this.layersPanel.populateLayers(); const centerCanvas = () => { // this centers the canvas vertically in the this.workarea (horizontal handled in CSS) this.workarea.style.lineHeight = this.workarea.style.height; }; addListenerMulti(window, 'load resize', centerCanvas); // Prevent browser from erroneously repopulating fields const inputEles = document.querySelectorAll('input'); Array.from(inputEles).forEach(function(inputEle) { inputEle.setAttribute('autocomplete', 'off'); }); const selectEles = document.querySelectorAll('select'); Array.from(selectEles).forEach(function(inputEle) { inputEle.setAttribute('autocomplete', 'off'); }); $id('se-svg-editor-dialog').addEventListener('change', function (e) { if (e?.detail?.copy === 'click') { this.cancelOverlays(e); } else if (e?.detail?.dialog === 'closed') { this.hideSourceEditor(); } else { this.saveSourceEditor(e); } }.bind(this)); $id('se-cmenu_canvas').addEventListener('change', function (e) { const action = e?.detail?.trigger; switch (action) { case 'delete': this.svgCanvas.deleteSelectedElements(); break; case 'cut': this.cutSelected(); break; case 'copy': this.copySelected(); break; case 'paste': this.svgCanvas.pasteElements(); break; case 'paste_in_place': this.svgCanvas.pasteElements('in_place'); break; case 'group': case 'group_elements': this.svgCanvas.groupSelectedElements(); break; case 'ungroup': this.svgCanvas.ungroupSelectedElement(); break; case 'move_front': this.svgCanvas.moveToTopSelectedElement(); break; case 'move_up': this.moveUpDownSelected('Up'); break; case 'move_down': this.moveUpDownSelected('Down'); break; case 'move_back': this.svgCanvas.moveToBottomSelectedElement(); break; default: if (hasCustomHandler(action)) { getCustomHandler(action).call(); } break; } }.bind(this)); // Select given tool this.ready(function () { const preTool = $id(`tool_${this.configObj.curConfig.initTool}`); const regTool = $id(this.configObj.curConfig.initTool); const selectTool = $id('tool_select'); const $editDialog = $id('se-edit-prefs'); if (preTool) { preTool.click(); } else if (regTool) { regTool.click(); } else { selectTool.click(); } if (this.configObj.curConfig.wireframe) { $id('tool_wireframe').click(); } if (this.configObj.curConfig.showRulers) { this.rulers.display(true); } else { this.rulers.display(false); } if (this.configObj.curConfig.showRulers) { $editDialog.setAttribute('showrulers', true); } if (this.configObj.curConfig.baseUnit) { $editDialog.setAttribute('baseunit', this.configObj.curConfig.baseUnit); } if (this.configObj.curConfig.gridSnapping) { $editDialog.setAttribute('gridsnappingon', true); } if (this.configObj.curConfig.snappingStep) { $editDialog.setAttribute('gridsnappingstep', this.configObj.curConfig.snappingStep); } if (this.configObj.curConfig.gridColor) { $editDialog.setAttribute('gridcolor', this.configObj.curConfig.gridColor); } }.bind(this)); // zoom $id('zoom').value = (this.svgCanvas.getZoom() * 100).toFixed(1); this.canvMenu.setAttribute('disableallmenu', true); this.canvMenu.setAttribute('enablemenuitems', '#delete,#cut,#copy'); this.enableOrDisableClipboard(); window.addEventListener('storage', function (e) { if (e.key !== 'svgedit_clipboard') { return; } this.enableOrDisableClipboard(); }.bind(this)); window.addEventListener('beforeunload', function (e) { // Suppress warning if page is empty if (undoMgr.getUndoStackSize() === 0) { this.showSaveWarning = false; } // showSaveWarning is set to 'false' when the page is saved. if (!this.configObj.curConfig.no_save_warning && this.showSaveWarning) { // Browser already asks question about closing the page e.returnValue = this.i18next.t('notification.unsavedChanges'); // Firefox needs this when beforeunload set by addEventListener (even though message is not used) return this.i18next.t('notification.unsavedChanges'); } return true; }.bind(this)); // Use HTML5 File API: http://www.w3.org/TR/FileAPI/ // if browser has HTML5 File API support, then we will show the open menu item // and provide a file input to click. When that change event fires, it will // get the text contents of the file and send it to the canvas if (window.FileReader) { /** * @param {Event} e * @returns {void} */ const editorObj = this; const importImage = function (e) { $id('se-prompt-dialog').title = editorObj.i18next.t('notification.loadingImage'); e.stopPropagation(); e.preventDefault(); const file = (e.type === 'drop') ? e.dataTransfer.files[0] : this.files[0]; if (!file) { $id('se-prompt-dialog').setAttribute('close', true); return; } if (!file.type.includes('image')) { return; } // Detected an image // svg handling let reader; if (file.type.includes('svg')) { reader = new FileReader(); reader.onloadend = function (ev) { const newElement = editorObj.svgCanvas.importSvgString(ev.target.result, true); editorObj.svgCanvas.ungroupSelectedElement(); editorObj.svgCanvas.ungroupSelectedElement(); editorObj.svgCanvas.groupSelectedElements(); editorObj.svgCanvas.alignSelectedElements('m', 'page'); editorObj.svgCanvas.alignSelectedElements('c', 'page'); // highlight imported element, otherwise we get strange empty selectbox editorObj.svgCanvas.selectOnly([ newElement ]); $id('se-prompt-dialog').setAttribute('close', true); }; reader.readAsText(file); } else { // bitmap handling reader = new FileReader(); reader.onloadend = function ({ target: { result } }) { /** * Insert the new image until we know its dimensions. * @param {Float} imageWidth * @param {Float} imageHeight * @returns {void} */ const insertNewImage = function (imageWidth, imageHeight) { const newImage = editorObj.svgCanvas.addSVGElementFromJson({ element: 'image', attr: { x: 0, y: 0, width: imageWidth, height: imageHeight, id: editorObj.svgCanvas.getNextId(), style: 'pointer-events:inherit' } }); editorObj.svgCanvas.setHref(newImage, result); editorObj.svgCanvas.selectOnly([ newImage ]); editorObj.svgCanvas.alignSelectedElements('m', 'page'); editorObj.svgCanvas.alignSelectedElements('c', 'page'); editorObj.topPanel.updateContextPanel(); $id('se-prompt-dialog').setAttribute('close', true); }; // create dummy img so we know the default dimensions let imgWidth = 100; let imgHeight = 100; const img = new Image(); img.style.opacity = 0; img.addEventListener('load', () => { imgWidth = img.offsetWidth || img.naturalWidth || img.width; imgHeight = img.offsetHeight || img.naturalHeight || img.height; insertNewImage(imgWidth, imgHeight); }); img.src = result; }; reader.readAsDataURL(file); } }; this.workarea.addEventListener('dragenter', this.onDragEnter); this.workarea.addEventListener('dragover', this.onDragOver); this.workarea.addEventListener('dragleave', this.onDragLeave); this.workarea.addEventListener('drop', importImage); const imgImport = document.createElement('input'); imgImport.type="file"; imgImport.addEventListener('change', importImage); window.addEventListener('importImages', () => imgImport.click()); } this.updateCanvas(true); // Load extensions this.extAndLocaleFunc(); // Defer injection to wait out initial menu processing. This probably goes // away once all context menu behavior is brought to context menu. this.ready(() => { injectExtendedContextMenuItemsIntoDom(); }); // run callbacks stored by this.ready await this.runCallbacks(); window.addEventListener('message', this.messageListener.bind(this)); } /** * @fires module:svgcanvas.SvgCanvas#event:ext_addLangData * @fires module:svgcanvas.SvgCanvas#event:ext_langReady * @fires module:svgcanvas.SvgCanvas#event:ext_langChanged * @fires module:svgcanvas.SvgCanvas#event:extensions_added * @returns {Promise<module:locale.LangAndData>} Resolves to result of {@link module:locale.readLang} */ async extAndLocaleFunc () { this.$svgEditor.style.visibility = 'visible'; try { // load standard extensions await Promise.all( this.configObj.curConfig.extensions.map(async (extname) => { /** * @tutorial ExtensionDocs * @typedef {PlainObject} module:SVGthis.ExtensionObject * @property {string} [name] Name of the extension. Used internally; no need for i18n. Defaults to extension name without beginning "ext-" or ending ".js". * @property {module:svgcanvas.ExtensionInitCallback} [init] */ try { /** * @type {module:SVGthis.ExtensionObject} */ // eslint-disable-next-line no-unsanitized/method const imported = await import(`./extensions/${encodeURIComponent(extname)}/${encodeURIComponent(extname)}.js`); const { name = extname, init: initfn } = imported.default; return this.addExtension(name, (initfn && initfn.bind(this)), { langParam: 'en' }); /** @todo change to current lng */ } catch (err) { // Todo: Add config to alert any errors console.error('Extension failed to load: ' + extname + '; ', err); return undefined; } }) ); // load user extensions (given as pathNames) await Promise.all( this.configObj.curConfig.userExtensions.map(async (extPathName) => { /** * @tutorial ExtensionDocs * @typedef {PlainObject} module:SVGthis.ExtensionObject * @property {string} [name] Name of the extension. Used internally; no need for i18n. Defaults to extension name without beginning "ext-" or ending ".js". * @property {module:svgcanvas.ExtensionInitCallback} [init] */ try { /** * @type {module:SVGthis.ExtensionObject} */ // eslint-disable-next-line no-unsanitized/method const imported = await import(encodeURI(extPathName)); const { name, init: initfn } = imported.default; return this.addExtension(name, (initfn && initfn.bind(this)), {}); } catch (err) { // Todo: Add config to alert any errors console.error('Extension failed to load: ' + extPathName + '; ', err); return undefined; } }) ); this.svgCanvas.bind( 'extensions_added', /** * @param {external:Window} _win * @param {module:svgcanvas.SvgCanvas#event:extensions_added} _data * @listens module:SvgCanvas#event:extensions_added * @returns {void} */ (_win, _data) => { this.extensionsAdded = true; this.setAll(); if (this.storagePromptState === 'ignore') { this.updateCanvas(true); } this.messageQueue.forEach( /** * @param {module:svgcanvas.SvgCanvas#event:message} messageObj * @fires module:svgcanvas.SvgCanvas#event:message * @returns {void} */ (messageObj) => { this.svgCanvas.call('message', messageObj); } ); } ); this.svgCanvas.call('extensions_added'); } catch (err) { // Todo: Report errors through the UI console.error(err); } } /** * @param {PlainObject} info * @param {any} info.data * @param {string} info.origin * @fires module:svgcanvas.SvgCanvas#event:message * @returns {void} */ messageListener ({ data, origin }) { const messageObj = { data, origin }; if (!this.extensionsAdded) { this.messageQueue.push(messageObj); } else { // Extensions can handle messages at this stage with their own // canvas `message` listeners this.svgCanvas.call('message', messageObj); } } } export default EditorStartup;