Browse Source

Updated electron paths

Former-commit-id: 022cc17f509bd3787cadb1558e059e4a0616bd70
David Benson 8 years ago
parent
commit
3f96045ead
3 changed files with 960 additions and 6 deletions
  1. 186 0
      war/electron.js
  2. 39 6
      war/index.html
  3. 735 0
      war/js/diagramly/ElectronApp.js

+ 186 - 0
war/electron.js

@@ -0,0 +1,186 @@
+const fs = require('fs')
+const path = require('path')
+const url = require('url')
+const electron = require('electron')
+const ipcMain = electron.ipcMain
+const dialog = electron.dialog
+const app = electron.app
+const BrowserWindow = electron.BrowserWindow
+
+const autoUpdater = require('electron-updater').autoUpdater
+const log = require('electron-log')
+autoUpdater.logger = log
+autoUpdater.logger.transports.file.level = 'info'
+// autoUpdater.autoDownload = false
+autoUpdater.autoDownload = true
+
+const __DEV__ = process.env.NODE_ENV === 'development'
+
+let windowsRegistry = []
+
+function createWindow (opt = {}) {
+	let options = Object.assign({
+		width: 1600,
+		height: 1200,
+		'web-security': false,
+		webPreferences: {
+			// preload: path.resolve('./preload.js'),
+		},
+	}, opt)
+
+	let mainWindow = new BrowserWindow(options)
+	windowsRegistry.push(mainWindow)
+
+	console.log('createWindow', opt)
+
+	let wurl = url.format({
+		pathname: `${__dirname}/index.html`,
+		protocol: 'file:',
+		query: {
+			'dev': __DEV__ ? 1 : 0,
+			'test': 1,
+			'db': 0,
+			'gapi': 0,
+			'od': 0,
+			'gh': 0,
+			'analytics': 0,
+			'picker': 0,
+			'mode': 'device',
+			'browser': 0,
+			'p': 'electron',
+		},
+		slashes: true,
+	})
+
+//`file://${__dirname}/index.html?dev=1&test=1&db=0&gapi=0&od=0&analytics=0&picker=0&mode=device&browser=0&p=electron`
+	// and load the index.html of the app.
+	mainWindow.loadURL(wurl)
+
+	// Open the DevTools.
+	mainWindow.webContents.openDevTools()
+
+	mainWindow.on('close', (event/*:WindowEvent*/) => {
+		const win = event.sender
+		const index = windowsRegistry.indexOf(win)
+		console.log('Window on close idx:%d', index)
+		const contents = win.webContents
+		if (contents != null) {
+			contents.executeJavaScript(`global.__emt_isModified()`, true,
+				isModified => {
+					console.log('__emt_isModified', isModified)
+					if (isModified) {
+						var choice = dialog.showMessageBox(
+							win,
+							{
+								type: 'question',
+								buttons: ['Cancel', 'Discard Changes'],
+								title: 'Confirm',
+								message: 'The document has unsaved changes. Do you really want to quit without saving?' //mxResources.get('allChangesLost')
+							})
+						if (choice === 1) {
+							win.destroy()
+						}
+					} else {
+						win.destroy()
+					}
+				})
+			event.preventDefault()
+		}
+	})
+
+	// Emitted when the window is closed.
+	mainWindow.on('closed', (event/*:WindowEvent*/) => {
+		const index = windowsRegistry.indexOf(event.sender)
+		console.log('Window closed idx:%d', index)
+		windowsRegistry.splice(index, 1)
+	})
+
+	return mainWindow.id
+}
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.on('ready', e => {
+	//asynchronous
+	ipcMain.on('asynchronous-message', (event, arg) => {
+		console.log(arg)  // prints "ping"
+		event.sender.send('asynchronous-reply', 'pong')
+	})
+	//synchronous
+	ipcMain.on('winman', (event, arg) => {
+		console.log('ipcMain.on winman', arg)
+		if (arg.action === 'newfile') {
+			event.returnValue = createWindow(arg.opt)
+			return
+		}
+		event.returnValue = 'pong'
+	})
+	createWindow()
+	// checkUpdate()
+	if (!__DEV__)
+	{
+		autoUpdater.checkForUpdates()
+	}
+})
+
+// Quit when all windows are closed.
+app.on('window-all-closed', function () {
+	console.log('window-all-closed', windowsRegistry.length)
+	// On OS X it is common for applications and their menu bar
+	// to stay active until the user quits explicitly with Cmd + Q
+	if (process.platform !== 'darwin') {
+		app.quit()
+	}
+})
+
+app.on('activate', function () {
+	console.log('app on activate', windowsRegistry.length)
+	// On OS X it's common to re-create a window in the app when the
+	// dock icon is clicked and there are no other windows open.
+	if (windowsRegistry.length === 0) {
+		createWindow()
+	}
+})
+
+function checkUpdate () {
+	autoUpdater.checkForUpdates().then(UpdateCheckResult => {
+		if (UpdateCheckResult) {
+			let idx = dialog.showMessageBox({
+				type: 'question',
+				buttons: ['Ok', 'Cancel'],
+				title: 'Confirm Update',
+				message: 'Update available.\n\nWould you like to download and install new version?',
+				detail: 'Application will automatically restart to apply update after download',
+			})
+			if (idx === 0) return autoUpdater.downloadUpdate()
+		}
+	}).then((a, b) => {
+		log.info('@cfu update-downloaded@\n', a, b)
+	}).catch(e => {
+		log.error('@cfu then error@\n', e)
+	})
+}
+
+autoUpdater.on('error', e => log.error('@error@\n', e))
+
+autoUpdater.on('update-available',
+	(a, b) => log.info('@update-available@\n', a, b))
+
+/**/
+autoUpdater.on('update-downloaded', (event, info) => {
+	log.info('@update-downloaded@\n', info, event)
+	// Ask user to update the app
+	dialog.showMessageBox({
+		type: 'question',
+		buttons: ['Install and Relaunch', 'Later'],
+		defaultId: 0,
+		message: 'A new version of ' + app.getName() + ' has been downloaded',
+		detail: 'It will be installed the next time you restart the application',
+	}, response => {
+		if (response === 0) {
+			setTimeout(() => autoUpdater.quitAndInstall(), 1)
+		}
+	})
+})
+/**/

+ 39 - 6
war/index.html

@@ -13,8 +13,9 @@
 		circuit and social network diagrams.">
 	<meta itemprop="image" content="https://lh4.googleusercontent.com/-cLKEldMbT_E/Tx8qXDuw6eI/AAAAAAAAAAs/Ke0pnlk8Gpg/w500-h344-k/BPMN%2Bdiagram%2Brc2f.png">
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+	<meta name="msapplication-config" content="images/browserconfig.xml">
     <meta name="mobile-web-app-capable" content="yes">
-    <link rel="canonical" href="https://www.draw.io">
+	<meta name="theme-color" content="#d89000">
     <script type="text/javascript">
 		/**
 		 * URL Parameters and protocol description are here:
@@ -101,18 +102,50 @@
 				}
 			})();
 		}
+		
+		/**
+		 * Adds meta tags with application name (depends on offline URL parameter)
+		 */
+		(function()
+		{
+			function addMeta(name, content)
+			{
+				try
+				{
+					var s = document.createElement('meta');
+					s.setAttribute('name', name);
+					s.setAttribute('content', content);
+					
+				  	var t = document.getElementsByTagName('meta')[0];
+				  	t.parentNode.insertBefore(s, t);
+				}
+				catch (e)
+				{
+					// ignore
+				}
+			};
+			
+			var name = 'draw.io';
+
+			if (urlParams['offline'] === '1')
+			{
+				name += ' app';
+			}
+			
+			addMeta('apple-mobile-web-app-title', name);
+			addMeta('application-name', name);
+		})();
+
 	</script>
     <link rel="chrome-webstore-item" href="https://chrome.google.com/webstore/detail/plgmlhohecdddhbmmkncjdmlhcmaachm">
 	<link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
 	<link rel="icon" type="image/png" sizes="32x32" href="images/favicon-32x32.png">
 	<link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
 	<link rel="mask-icon" href="images/safari-pinned-tab.svg" color="#d89000">
-	<meta name="msapplication-config" content="images/browserconfig.xml">
+    <link rel="stylesheet" type="text/css" href="styles/grapheditor.css">
+    <link rel="canonical" href="https://www.draw.io">
 	<link rel="manifest" href="images/manifest.json">
-	<meta name="application-name" content="draw.io">
 	<link rel="shortcut icon" href="favicon.ico">
-	<meta name="theme-color" content="#d89000">
-    <link rel="stylesheet" type="text/css" href="styles/grapheditor.css">
 	<style type="text/css">
 		body { overflow:hidden; }
 		.geSidebarContainer .geTitle { color:#505050; }
@@ -312,7 +345,7 @@
 		if (window && window.process && window.process.type)
 		{
 			// Electron
-			mxscript('../etc/electron/ElectronApp.js');
+			mxscript('js/diagramly/ElectronApp.js');
 		}
 		
 		// Adds basic error handling

+ 735 - 0
war/js/diagramly/ElectronApp.js

@@ -0,0 +1,735 @@
+window.OPEN_URL = 'https://www.draw.io/open';
+window.TEMPLATE_PATH = 'templates';
+FeedbackDialog.feedbackUrl = 'https://log.draw.io/email';
+
+(function()
+{
+	// Overrides default mode
+	App.mode = App.MODE_DEVICE;
+
+	// Disables new window option in edit diagram dialog
+	EditDiagramDialog.showNewWindowOption = false;
+
+	// Redirects printing to iframe to avoid document.write
+	var printDialogCreatePrintPreview = PrintDialog.createPrintPreview; 
+	
+	PrintDialog.createPrintPreview = function()
+	{
+		var iframe = document.createElement('iframe');
+		document.body.appendChild(iframe);
+
+		var result = printDialogCreatePrintPreview.apply(this, arguments);
+		result.wnd = iframe.contentWindow;
+		result.iframe = iframe;
+				
+		// Workaround for lost gradients in print output
+		result.previousGetBaseUrl = mxSvgCanvas2D.prototype.getBaseUrl;
+		
+		mxSvgCanvas2D.prototype.getBaseUrl = function()
+		{
+			return '';
+		};
+		
+		return result;
+	};
+	
+	var oldWindowOpen = window.open;
+	window.open = function(url)
+	{
+		if (url != null && url.startsWith('http'))
+		{
+			const {shell} = require('electron');
+			shell.openExternal(url);
+		}
+		else
+		{
+			return oldWindowOpen(url);
+		}
+	}
+
+	mxPrintPreview.prototype.addPageBreak = function(doc)
+	{
+		// Do nothing
+	};
+
+	mxPrintPreview.prototype.closeDocument = function()
+	{
+		var doc = this.wnd.document;
+		
+		// Removes all event handlers in the print output
+		mxEvent.release(doc.body);
+	};
+	
+	PrintDialog.printPreview = function(preview)
+	{
+		if (preview.iframe != null)
+		{
+			preview.iframe.contentWindow.print();
+			preview.iframe.parentNode.removeChild(preview.iframe);
+		
+			mxSvgCanvas2D.prototype.getBaseUrl = preview.previousGetBaseUrl;
+			preview.iframe = null;
+		}
+	};
+	
+	PrintDialog.previewEnabled = false;
+	
+	// Enables PDF export via print
+	EditorUi.prototype.printPdfExport = true;
+	
+	var menusInit = Menus.prototype.init;
+	Menus.prototype.init = function()
+	{
+		menusInit.apply(this, arguments);
+
+		var editorUi = this.editorUi;
+		
+		// Replaces file menu to replace openFrom menu with open and rename downloadAs to export
+		this.put('file', new Menu(mxUtils.bind(this, function(menu, parent)
+		{
+			this.addMenuItems(menu, ['new', 'open', '-', 'save', 'saveAs', '-', 'import'], parent);
+			this.addSubmenu('exportAs', menu, parent);
+			this.addSubmenu('embed', menu, parent);
+			this.addMenuItems(menu, ['-', 'newLibrary', 'openLibrary', '-', 'documentProperties', 'print'], parent);
+		})));
+		
+		this.put('extras', new Menu(mxUtils.bind(this, function(menu, parent)
+		{
+			this.addMenuItems(menu, ['copyConnect', 'collapseExpand', '-', 'mathematicalTypesetting', 'autosave', '-',
+			                         'createShape', 'editDiagram', '-', 'tags', '-', 'online'], parent);
+		})));
+	};
+	
+	// Initializes the user interface
+	var editorUiInit = EditorUi.prototype.init;
+	EditorUi.prototype.init = function()
+	{
+		editorUiInit.apply(this, arguments);
+
+		var editorUi = this;
+		var graph = this.editor.graph;
+		this.editor.autosave = false;
+		
+		global.__emt_isModified = e => {
+			if (this.getCurrentFile())
+				return this.getCurrentFile().isModified()
+			return false
+		}
+		// global.__emt_getCurrentFile = e => {
+		// 	return this.getCurrentFile()
+		// }
+
+		// Adds support for libraries
+		this.actions.addAction('newLibrary...', mxUtils.bind(this, function()
+		{
+			editorUi.showLibraryDialog(null, null, null, null, App.MODE_DEVICE);
+		}));
+		
+		this.actions.addAction('openLibrary...', mxUtils.bind(this, function()
+		{
+			editorUi.pickLibrary(App.MODE_DEVICE);
+		}));
+
+		// Replaces import action
+		this.actions.addAction('import...', mxUtils.bind(this, function()
+		{
+			if (editorUi.getCurrentFile() != null)
+			{
+				const electron = require('electron');
+				var remote = electron.remote;
+				var dialog = remote.dialog;
+
+		        var paths = dialog.showOpenDialog({properties: ['openFile']});
+			           
+		        if (paths !== undefined && paths[0] != null)
+		        {
+		        	var fs = require('fs');
+		        	var path = paths[0];
+		        	var index = path.lastIndexOf('.png');
+		        	var isPng = index > -1 && index == path.length - 4;
+		        	var encoding = (isPng || /\.gif$/i.test(path) || /\.jpe?g$/i.test(path) ||
+		        		/\.vsdx$/i.test(path)) ? 'base64' : 'utf-8'
+
+					if (editorUi.spinner.spin(document.body, mxResources.get('loading')))
+					{
+			        	fs.readFile(path, encoding, mxUtils.bind(this, function (e, data)
+			        	{
+			        		if (e)
+			        		{
+			        			editorUi.spinner.stop();
+			        			editorUi.handleError(e);
+			        		}
+			        		else
+			        		{
+								try
+								{
+									if (isPng)
+									{
+										var tmp = editorUi.extractGraphModelFromPng(data);
+										
+										if (tmp != null)
+										{
+											data = tmp;
+										}
+									}
+									
+									if (!editorUi.isOffline() && new XMLHttpRequest().upload && editorUi.isRemoteFileFormat(data, path))
+									{
+										// Asynchronous parsing via server
+										editorUi.parseFile(editorUi.base64ToBlob(data, 'application/octet-stream'), mxUtils.bind(this, function(xhr)
+										{
+											if (xhr.readyState == 4)
+											{
+												editorUi.spinner.stop();
+												
+												if (xhr.status >= 200 && xhr.status <= 299)
+												{
+													
+													editorUi.editor.graph.setSelectionCells(editorUi.insertTextAt(xhr.responseText, 0, 0, true));
+												}
+											}
+										}), path);
+									}
+									else if (isPng || /\.gif$/i.test(path) || /\.jpe?g$/i.test(path))
+									{
+										var img = new Image();
+										img.onload = function()
+										{
+											editorUi.resizeImage(img, img.src, function(data2, w, h)
+											{
+												editorUi.spinner.stop();
+												var pt = graph.getInsertPoint();
+												graph.setSelectionCell(graph.insertVertex(null, null, '', pt.x, pt.y, w, h,
+													'shape=image;aspect=fixed;image=' + editorUi.convertDataUri(data2) + ';'));
+											}, true);
+										};
+										
+										img.src = 'data:image/png;base64,' + data;
+									}
+									else if (data != null)
+									{
+										editorUi.spinner.stop();
+										graph.setSelectionCells(editorUi.importXml(data));
+									}
+								}
+								catch(e)
+								{
+									editorUi.spinner.stop();
+									editorUi.handleError(e);
+								}
+			        		}
+			        	}));
+					}
+		        }
+			}
+		}));
+		
+		// Replaces new action
+		var oldNew = this.actions.get('new').funct;
+		
+		this.actions.addAction('new...', mxUtils.bind(this, function()
+		{
+			mxLog.debug(this.getCurrentFile());
+
+			if (this.getCurrentFile() == null)
+			{
+				oldNew();
+			}
+			else {
+				const ipc = require('electron').ipcRenderer
+				ipc.sendSync('winman', {action: 'newfile', opt: {width: 1600}})
+
+			}
+		}), null, null, 'Ctrl+N');
+		
+		this.actions.get('open').shortcut = 'Ctrl+O';
+		
+		// Adds shortcut keys for file operations
+		editorUi.keyHandler.bindAction(78, true, 'new'); // Ctrl+N
+		editorUi.keyHandler.bindAction(79, true, 'open'); // Ctrl+O
+		
+		editorUi.actions.addAction('keyboardShortcuts...', function()
+		{
+			const electron = require('electron');
+			const remote = electron.remote;
+			const BrowserWindow = remote.BrowserWindow;
+			keyboardWindow = new BrowserWindow({width: 1200, height: 1000});
+
+			// and load the index.html of the app.
+			keyboardWindow.loadURL(`file://${__dirname}/shortcuts.svg`);
+
+			// Emitted when the window is closed.
+			keyboardWindow.on('closed', function()
+			{
+			    // Dereference the window object, usually you would store windows
+			    // in an array if your app supports multi windows, this is the time
+			    // when you should delete the corresponding element.
+				keyboardWindow = null;
+			});
+		});
+	}
+
+	// Uses local picker
+	App.prototype.pickFile = function()
+	{
+		var doPickFile = mxUtils.bind(this, function()
+		{
+			this.chooseFileEntry(mxUtils.bind(this, function(fileEntry, data)
+			{
+				var file = new LocalFile(this, data, '');
+				file.fileObject = fileEntry;
+				this.fileLoaded(file);
+			}));
+		});
+
+		var file = this.getCurrentFile();
+		
+		if (file != null && file.isModified())
+		{
+			this.confirm(mxResources.get('allChangesLost'), null, doPickFile,
+				mxResources.get('cancel'), mxResources.get('discardChanges'));
+		}
+		else
+		{
+			doPickFile();
+		}
+	};
+	
+	/**
+	 * Selects a library to load from a picker
+	 * 
+	 * @param mode the device mode, ignored in this case
+	 */
+	App.prototype.pickLibrary = function(mode)
+	{
+		this.chooseFileEntry(mxUtils.bind(this, function(fileEntry, data)
+		{
+			try
+			{
+				var library = new LocalLibrary(this, data, fileEntry.name);
+				library.fileObject = fileEntry;
+				this.loadLibrary(library);
+			}
+			catch (e)
+			{
+				this.handleError(e, mxResources.get('errorLoadingFile'));
+			}
+		}));
+	};
+	
+	// Uses local picker
+	App.prototype.chooseFileEntry = function(fn)
+	{
+		const electron = require('electron');
+		var remote = electron.remote;
+		var dialog = remote.dialog;
+
+        var paths = dialog.showOpenDialog({properties: ['openFile']});
+	           
+        if (paths !== undefined && paths[0] != null)
+        {
+        	var fs = require('fs');
+        	var path = paths[0];
+        	var index = path.lastIndexOf('.png');
+        	var isPng = index > -1 && index == path.length - 4;
+        	var encoding = isPng ? 'base64' : 'utf-8'
+
+        	fs.readFile(path, encoding, mxUtils.bind(this, function (e, data)
+        	{
+        		if (e)
+        		{
+        			this.handleError(e);
+        		}
+        		else
+        		{
+        			if (isPng)
+        			{
+        				// Detecting png by extension. Would need https://github.com/mscdex/mmmagic
+        				// to do it by inspection
+        				data = this.extractGraphModelFromPng(data, true);
+        			}
+
+        			var fileEntry = new Object();
+        			fileEntry.path = path;
+        			fileEntry.name = path.replace(/^.*[\\\/]/, '');
+        			fileEntry.type = encoding;
+        			fn(fileEntry, data);
+        		}
+        	}));
+        }
+	};
+
+	// Disables temp files in Electron
+	var LocalFileCtor = LocalFile;
+	
+	LocalFile = function(ui, data, title, temp)
+	{
+		LocalFileCtor.call(this, ui, data, title, false);
+	};
+
+	mxUtils.extend(LocalFile, LocalFileCtor);
+
+	LocalFile.prototype.isAutosave = function()
+	{
+		return this.ui.editor.autosave && this.fileObject != null;
+	};
+	
+	LocalFile.prototype.isAutosaveOptional = function()
+	{
+		return true;
+	};
+	
+	LocalLibrary.prototype.isAutosave = function()
+	{
+		return this.fileObject != null;
+	};
+	
+	LocalFile.prototype.getTitle = function()
+	{
+		return (this.fileObject != null) ? this.fileObject.name : this.title;
+	};
+
+	LocalFile.prototype.isRenamable = function()
+	{
+		return false;
+	};
+	
+	// Restores default implementation of open with autosave
+	LocalFile.prototype.open = DrawioFile.prototype.open;
+
+	LocalFile.prototype.save = function(revision, success, error)
+	{
+		DrawioFile.prototype.save.apply(this, arguments);
+		
+		this.saveFile(revision, success, error);
+	};
+	
+	LocalFile.prototype.saveFile = function(revision, success, error)
+	{
+		var fn = mxUtils.bind(this, function()
+		{
+			var doSave = mxUtils.bind(this, function(data, enc)
+			{
+				if (!this.savingFile)
+				{
+					this.savingFile = true;
+					
+					// Makes sure no changes get lost while the file is saved
+					var prevModified = this.isModified;
+					var modified = this.isModified();
+					this.setModified(false);
+					var fs = require('fs');
+					
+					fs.writeFile(this.fileObject.path, data, enc || this.fileObject.encoding, mxUtils.bind(this, function (e)
+				    {
+		        		if (e)
+		        		{
+		        			this.savingFile = false;
+							this.isModified = prevModified;
+							this.setModified(modified || this.isModified());
+							
+							if (error != null)
+							{
+		        				error();
+							}
+		        		}
+		        		else
+		        		{
+							this.savingFile = false;
+							this.isModified = prevModified;
+							this.contentChanged();
+							this.lastData = data;
+							
+							if (success != null)
+							{
+								success();
+							}
+		        		}
+		        	}));
+				}
+				else
+				{
+					// TODO, already saving. Need a better error
+					if (error != null)
+					{
+        				error();
+					}
+				}
+			});
+
+			if (!/(\.png)$/i.test(this.fileObject.name))
+			{
+				doSave(this.getData());
+			}
+			else
+			{
+				var graph = this.ui.editor.graph;
+				
+				// Exports PNG for first page while other page is visible by creating a graph
+				// LATER: Add caching for the graph or SVG while not on first page
+				if (this.ui.pages != null && this.ui.currentPage != this.ui.pages[0])
+				{
+					graph = this.ui.createTemporaryGraph(graph.getStylesheet());
+					var graphGetGlobalVariable = graph.getGlobalVariable;
+					var page = this.ui.pages[0];
+			
+					graph.getGlobalVariable = function(name)
+					{
+						if (name == 'page')
+						{
+							return page.getName();
+						}
+						else if (name == 'pagenumber')
+						{
+							return 1;
+						}
+						
+						return graphGetGlobalVariable.apply(this, arguments);
+					};
+			
+					document.body.appendChild(graph.container);
+					graph.model.setRoot(page.root);
+				}
+				
+			   	this.ui.exportToCanvas(mxUtils.bind(this, function(canvas)
+			   	{
+			   		try
+			   		{
+			   	   	    var data = canvas.toDataURL('image/png');
+		   	   	   		data = this.ui.writeGraphModelToPng(data, 'zTXt', 'mxGraphModel',
+		   	   	   			atob(this.ui.editor.graph.compress(this.ui.getFileData(true))));
+		   	   	   		doSave(atob(data.substring(data.lastIndexOf(',') + 1)), 'binary');
+
+						// Removes temporary graph from DOM
+		   	   	    	if (graph != this.ui.editor.graph)
+						{
+							graph.container.parentNode.removeChild(graph.container);
+						}
+			   		}
+			   		catch (e)
+			   		{
+			   			if (error != null)
+			   			{
+			   				error(e);
+			   			}
+			   		}
+			   	}), null, null, null, mxUtils.bind(this, function(e)
+			   	{
+			   		if (error != null)
+		   			{
+		   				error(e);
+		   			}
+			   	}), null, null, null, null, null, null, graph);
+			}
+		});
+		
+		if (this.fileObject == null)
+		{
+			const electron = require('electron');
+			var remote = electron.remote;
+			var dialog = remote.dialog;
+
+			var path = dialog.showSaveDialog({defaultPath: this.title});
+
+	        if (path != null)
+	        {
+				this.fileObject = new Object();
+				this.fileObject.path = path;
+				this.fileObject.name = path.replace(/^.*[\\\/]/, '');
+				this.fileObject.type = 'utf-8';
+				fn();
+			}
+	        else if (error != null)
+			{
+				error();
+			}
+		}
+		else
+		{
+			fn();
+		}
+	};
+
+	LocalLibrary.prototype.save = function(revision, success, error)
+	{
+		LocalFile.prototype.saveFile.apply(this, arguments);
+	};
+	
+	LocalFile.prototype.saveAs = function(title, success, error)
+	{
+		const electron = require('electron');
+		var remote = electron.remote;
+		var dialog = remote.dialog;
+		var filename = this.title;
+		
+		// Adds default extension
+		if (filename.length > 0 && (!/(\.xml)$/i.test(filename) && !/(\.html)$/i.test(filename) &&
+			!/(\.svg)$/i.test(filename) && !/(\.png)$/i.test(filename)))
+		{
+			filename += '.xml';
+		}
+		
+		var path = dialog.showSaveDialog({defaultPath: filename});
+        
+        if (path != null)
+        {
+			this.fileObject = new Object();
+			this.fileObject.path = path;
+			this.fileObject.name = path.replace(/^.*[\\\/]/, '');
+			this.fileObject.type = 'utf-8';
+			this.save(false, success, error);
+		}
+        else if (error != null)
+		{
+			error();
+		}
+	};
+
+	App.prototype.saveFile = function(forceDialog)
+	{
+		var file = this.getCurrentFile();
+		
+		if (file != null)
+		{
+			if (!forceDialog && file.getTitle() != null)
+			{
+				file.save(true, mxUtils.bind(this, function(resp)
+				{
+					this.spinner.stop();
+					this.editor.setStatus(mxUtils.htmlEntities(mxResources.get('allChangesSaved')));
+				}), mxUtils.bind(this, function(resp)
+				{
+					this.editor.setStatus('');
+					this.handleError(resp, (resp != null) ? mxResources.get('errorSavingFile') : null);
+				}));
+			}
+			else
+			{
+				file.saveAs(null, mxUtils.bind(this, function(resp)
+				{
+					this.spinner.stop();
+					this.editor.setStatus(mxUtils.htmlEntities(mxResources.get('allChangesSaved')));
+				}), mxUtils.bind(this, function(resp)
+				{
+					this.editor.setStatus('');
+					this.handleError(resp, (resp != null) ? mxResources.get('errorSavingFile') : null);
+				}));
+			}
+		}
+	};
+	
+	/**
+	 * Translates this point by the given vector.
+	 * 
+	 * @param {number} dx X-coordinate of the translation.
+	 * @param {number} dy Y-coordinate of the translation.
+	 */
+	App.prototype.saveLibrary = function(name, images, file, mode, noSpin, noReload, fn)
+	{
+		mode = (mode != null) ? mode : this.mode;
+		noSpin = (noSpin != null) ? noSpin : false;
+		noReload = (noReload != null) ? noReload : false;
+		var xml = this.createLibraryDataFromImages(images);
+		
+		var error = mxUtils.bind(this, function(resp)
+		{
+			this.spinner.stop();
+			
+			if (fn != null)
+			{
+				fn();
+			}
+			
+			// Null means cancel by user and is ignored
+			if (resp != null)
+			{
+				this.handleError(resp, mxResources.get('errorSavingFile'));
+			}
+		});
+	
+		// Handles special case for local libraries
+		if (file == null)
+		{
+			file = new LocalLibrary(this, xml, name);
+		}
+		
+		if (noSpin || this.spinner.spin(document.body, mxResources.get('saving')))
+		{
+			file.setData(xml);
+			
+			var doSave = mxUtils.bind(this, function()
+			{
+				file.save(true, mxUtils.bind(this, function(resp)
+				{
+					this.spinner.stop();
+					this.hideDialog(true);
+					
+					if (!noReload)
+					{
+						this.libraryLoaded(file, images)
+					}
+					
+					if (fn != null)
+					{
+						fn();
+					}
+				}), error);
+			});
+			
+			if (name != file.getTitle())
+			{
+				var oldHash = file.getHash();
+				
+				file.rename(name, mxUtils.bind(this, function(resp)
+				{
+					// Change hash in stored settings
+					if (file.constructor != LocalLibrary && oldHash != file.getHash())
+					{
+						mxSettings.removeCustomLibrary(oldHash);
+						mxSettings.addCustomLibrary(file.getHash());
+					}
+	
+					// Workaround for library files changing hash so
+					// the old library cannot be removed from the
+					// sidebar using the updated file in libraryLoaded
+					this.removeLibrarySidebar(oldHash);
+	
+					doSave();
+				}), error)
+			}
+			else
+			{
+				doSave();
+			}
+		}
+	};
+	
+	EditorUi.prototype.saveData = function(filename, format, data, mimeType, base64Encoded)
+	{
+		const electron = require('electron');
+		var remote = electron.remote;
+		var dialog = remote.dialog;
+
+        var path = dialog.showSaveDialog({defaultPath: filename});
+
+        if (path != null)
+        {
+			this.fileObject = new Object();
+			this.fileObject.path = path;
+			this.fileObject.name = path.replace(/^.*[\\\/]/, '');
+			var isImage = mimeType != null && mimeType.startsWith('image');
+			this.fileObject.type = base64Encoded ? 'base64' : 'utf-8';
+			var fs = require('fs');
+			
+			fs.writeFile(this.fileObject.path, data, this.fileObject.type, mxUtils.bind(this, function (e)
+		    {
+				if (e)
+				{
+					this.handleError({message: mxResources.get('errorSavingFile')});
+				}
+        	}));
+		}
+	};
+	
+	EditorUi.prototype.addBeforeUnloadListener = function() {};
+})();