├── .gitignore ├── src ├── assets │ ├── icon-16.png │ ├── icon-48.png │ └── icon-128.png ├── sandbox │ ├── css │ │ └── preview.css │ ├── preview.html │ └── js │ │ └── preview.js ├── app-shared │ ├── fonts │ │ └── icons │ │ │ ├── icons.eot │ │ │ ├── icons.ttf │ │ │ ├── icons.woff │ │ │ └── icons.svg │ ├── css │ │ ├── icons.css │ │ ├── themes │ │ │ ├── dark-theme-vars.css │ │ │ └── light-theme-vars.css │ │ └── main.css │ └── js │ │ ├── markdown-it-plugins │ │ └── markdown-it-map-lines.js │ │ ├── preview.js │ │ ├── utilities.js │ │ └── main.js ├── js │ ├── background.js │ ├── libs │ │ └── undo.js │ ├── utilities.js │ └── main.js ├── manifest.json ├── css │ ├── utilities.css │ └── main.css └── index.html ├── package.json ├── LICENSE ├── gulpfile.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | dist/ -------------------------------------------------------------------------------- /src/assets/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioul/Minimalist-Markdown-Editor-for-Chrome/HEAD/src/assets/icon-16.png -------------------------------------------------------------------------------- /src/assets/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioul/Minimalist-Markdown-Editor-for-Chrome/HEAD/src/assets/icon-48.png -------------------------------------------------------------------------------- /src/assets/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioul/Minimalist-Markdown-Editor-for-Chrome/HEAD/src/assets/icon-128.png -------------------------------------------------------------------------------- /src/sandbox/css/preview.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: auto; 3 | -webkit-user-select: text; 4 | user-select: text; 5 | cursor: auto; 6 | } -------------------------------------------------------------------------------- /src/app-shared/fonts/icons/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioul/Minimalist-Markdown-Editor-for-Chrome/HEAD/src/app-shared/fonts/icons/icons.eot -------------------------------------------------------------------------------- /src/app-shared/fonts/icons/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioul/Minimalist-Markdown-Editor-for-Chrome/HEAD/src/app-shared/fonts/icons/icons.ttf -------------------------------------------------------------------------------- /src/app-shared/fonts/icons/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pioul/Minimalist-Markdown-Editor-for-Chrome/HEAD/src/app-shared/fonts/icons/icons.woff -------------------------------------------------------------------------------- /src/js/background.js: -------------------------------------------------------------------------------- 1 | chrome.app.runtime.onLaunched.addListener(function(){ 2 | chrome.app.window.create('index.html', { 3 | bounds: { 4 | width: Math.round(window.screen.availWidth - 100), 5 | height: Math.round(window.screen.availHeight - 100) 6 | } 7 | }); 8 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minimalist-markdown-editor-for-chrome", 3 | "scripts": { 4 | "build": "gulp" 5 | }, 6 | "devDependencies": { 7 | "beeper": "^1.1.0", 8 | "del": "^1.1.1", 9 | "gulp": "^3.8.10", 10 | "gulp-babel": "^5.1.0", 11 | "gulp-concat": "^2.6.0", 12 | "gulp-cssnext": "^1.0.1", 13 | "merge-stream": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/sandbox/preview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Minimalist Markdown Editor", 3 | "description": "This is the simplest and slickest Markdown editor. Just write Markdown and see what it looks like as you type.", 4 | "version": "3.4.0", 5 | "app": { 6 | "background": { 7 | "scripts": [ 8 | "js/background.js" 9 | ] 10 | } 11 | }, 12 | "manifest_version": 2, 13 | "icons": { 14 | "128": "assets/icon-128.png", 15 | "48": "assets/icon-48.png", 16 | "16": "assets/icon-16.png" 17 | }, 18 | "permissions": [ 19 | "storage", 20 | "unlimitedStorage", 21 | { "fileSystem": ["write", "retainEntries"] } 22 | ], 23 | "offline_enabled": true, 24 | "sandbox": { 25 | "pages": [ 26 | "sandbox/preview.html" 27 | ] 28 | } 29 | } -------------------------------------------------------------------------------- /src/app-shared/css/icons.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "icons"; 3 | src:url("../app-shared/fonts/icons/icons.eot?-mfxpn4"); 4 | src:url("../app-shared/fonts/icons/icons.eot?#iefix-mfxpn4") format("embedded-opentype"), 5 | url("../app-shared/fonts/icons/icons.woff?-mfxpn4") format("woff"), 6 | url("../app-shared/fonts/icons/icons.ttf?-mfxpn4") format("truetype"), 7 | url("../app-shared/fonts/icons/icons.svg?-mfxpn4#icons") format("svg"); 8 | font-weight: normal; 9 | font-style: normal; 10 | } 11 | 12 | .icon-sync-scroll, 13 | .icon-fullscreen, 14 | .icon-settings { 15 | display: inline-block; 16 | font-family: "icons"; 17 | speak: none; 18 | font-style: normal; 19 | font-weight: normal; 20 | font-variant: normal; 21 | text-transform: none; 22 | line-height: 1; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .icon-sync-scroll:before { 28 | content: "\e600"; 29 | } 30 | 31 | .icon-fullscreen:before { 32 | content: "\e601"; 33 | } 34 | 35 | .icon-settings:before { 36 | content: "\e602"; 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Philippe Masset 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/css/utilities.css: -------------------------------------------------------------------------------- 1 | .modal-container { 2 | position: absolute; 3 | z-index: 100; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | } 9 | 10 | .modal { 11 | position: absolute; 12 | top: 20%; 13 | left: 50%; 14 | width: 300px; 15 | margin-left: -150px; 16 | background: var(--modal-bg-color); 17 | border: 1px solid var(--modal-border-color); 18 | border-radius: 2px; 19 | } 20 | 21 | .modal .content { 22 | padding: 15px; 23 | } 24 | 25 | .modal .buttons { 26 | padding: 15px; 27 | padding-top: 0; 28 | text-align: right; 29 | } 30 | 31 | .modal .buttons .button { 32 | display: inline-block; 33 | height: 20px; 34 | margin-left: 10px; 35 | padding: 0 5px; 36 | font-size: 12px; 37 | text-decoration: none; 38 | color: var(--modal-button-color); 39 | border-radius: 2px; 40 | border: 1px solid var(--modal-button-border-color); 41 | } 42 | 43 | .modal .buttons .button.button-primary { 44 | color: var(--modal-button-primary-color); 45 | border-color: var(--modal-button-primary-border-color); 46 | font-weight: bold; 47 | } 48 | 49 | .modal .buttons .button:active, 50 | .modal .buttons .button:focus { 51 | background: var(--modal-button-active-bg-color); 52 | } 53 | 54 | .modal .buttons .decoy { 55 | display: block; 56 | width: 0; 57 | height: 0; 58 | overflow: hidden; 59 | } -------------------------------------------------------------------------------- /src/app-shared/fonts/icons/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app-shared/js/markdown-it-plugins/markdown-it-map-lines.js: -------------------------------------------------------------------------------- 1 | // Markdown-it plugin intended to be used in the browser only – it doesn't have all the Browserify cruft, 2 | // but follows the same browser conventions. 3 | 4 | // Extend renderer rules to map source lines 5 | window.markdownitMapLines = function(mdit) { 6 | "use strict"; 7 | 8 | mdit.renderer.rules.paragraph_open = function(tokens, idx) { 9 | if (tokens[idx].lines) { 10 | return "

"; 11 | } 12 | 13 | return "

"; 14 | }; 15 | 16 | mdit.renderer.rules.heading_open = function(tokens, idx) { 17 | if (tokens[idx].lines) { 18 | return ""; 19 | } 20 | 21 | return ""; 22 | }; 23 | 24 | mdit.renderer.rules.hr = function(tokens, idx) { 25 | if (tokens[idx].lines) { 26 | return "


\n"; 27 | } 28 | 29 | return "
\n"; 30 | }; 31 | 32 | mdit.renderer.rules.code_block = function(tokens, idx) { 33 | if (tokens[idx].lines) { 34 | return "
"+
35 | 						""+ escapeHtml(tokens[idx].content) +""+
36 | 					"
\n"; 37 | } 38 | 39 | return "
"+ escapeHtml(tokens[idx].content) +"
\n"; 40 | }; 41 | 42 | mdit.renderer.rules.fence = mdit.renderer.rules.code_block; 43 | 44 | mdit.renderer.rules.tr_open = function(tokens, idx) { 45 | if (tokens[idx].lines) { 46 | return ""; 47 | } 48 | 49 | return ""; 50 | }; 51 | }; -------------------------------------------------------------------------------- /src/js/libs/undo.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Undo.js - A undo/redo framework for JavaScript 3 | * 4 | * http://jzaefferer.github.com/undo 5 | * 6 | * Copyright (c) 2011 Jörn Zaefferer 7 | * MIT licensed. 8 | */ 9 | (function() { 10 | 11 | // based on Backbone.js' inherits 12 | var ctor = function(){}; 13 | var inherits = function(parent, protoProps) { 14 | var child; 15 | 16 | if (protoProps && protoProps.hasOwnProperty('constructor')) { 17 | child = protoProps.constructor; 18 | } else { 19 | child = function(){ return parent.apply(this, arguments); }; 20 | } 21 | 22 | ctor.prototype = parent.prototype; 23 | child.prototype = new ctor(); 24 | 25 | if (protoProps) extend(child.prototype, protoProps); 26 | 27 | child.prototype.constructor = child; 28 | child.__super__ = parent.prototype; 29 | return child; 30 | }; 31 | 32 | function extend(target, ref) { 33 | var name, value; 34 | for ( name in ref ) { 35 | value = ref[name]; 36 | if (value !== undefined) { 37 | target[ name ] = value; 38 | } 39 | } 40 | return target; 41 | }; 42 | 43 | var Undo; 44 | if (typeof exports !== 'undefined') { 45 | Undo = exports; 46 | } else { 47 | Undo = this.Undo = {}; 48 | } 49 | 50 | Undo.Stack = function() { 51 | this.commands = []; 52 | this.stackPosition = -1; 53 | this.savePosition = -1; 54 | }; 55 | 56 | extend(Undo.Stack.prototype, { 57 | execute: function(command) { 58 | this._clearRedo(); 59 | command.execute(); 60 | this.commands.push(command); 61 | this.stackPosition++; 62 | this.changed(); 63 | }, 64 | undo: function() { 65 | this.commands[this.stackPosition].undo(); 66 | this.stackPosition--; 67 | this.changed(); 68 | }, 69 | canUndo: function() { 70 | return this.stackPosition >= 0; 71 | }, 72 | redo: function() { 73 | this.stackPosition++; 74 | this.commands[this.stackPosition].redo(); 75 | this.changed(); 76 | }, 77 | canRedo: function() { 78 | return this.stackPosition < this.commands.length - 1; 79 | }, 80 | save: function() { 81 | this.savePosition = this.stackPosition; 82 | this.changed(); 83 | }, 84 | dirty: function() { 85 | return this.stackPosition != this.savePosition; 86 | }, 87 | _clearRedo: function() { 88 | // TODO there's probably a more efficient way for this 89 | this.commands = this.commands.slice(0, this.stackPosition + 1); 90 | }, 91 | changed: function() { 92 | // do nothing, override 93 | } 94 | }); 95 | 96 | Undo.Command = function(name) { 97 | this.name = name; 98 | } 99 | 100 | var up = new Error("override me!"); 101 | 102 | extend(Undo.Command.prototype, { 103 | execute: function() { 104 | throw up; 105 | }, 106 | undo: function() { 107 | throw up; 108 | }, 109 | redo: function() { 110 | this.execute(); 111 | } 112 | }); 113 | 114 | Undo.Command.extend = function(protoProps) { 115 | var child = inherits(this, protoProps); 116 | child.extend = Undo.Command.extend; 117 | return child; 118 | }; 119 | 120 | }).call(this); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require("gulp"), 2 | del = require("del"), 3 | babel = require("gulp-babel"), 4 | concat = require("gulp-concat"), 5 | cssnext = require("gulp-cssnext"), 6 | beeper = require("beeper"), 7 | mergeStream = require("merge-stream"); 8 | 9 | gulp.task("clean", function(c) { 10 | del("dist/**", c); 11 | }); 12 | 13 | // Transpile to ES5 14 | // Input: All JS files but those under libs/ 15 | gulp.task("to-ES5", ["clean"], function() { 16 | return gulp.src([ 17 | "src/**/*.js", 18 | "!src/**/libs/**" 19 | ], { base: "src/" }) 20 | .pipe(babel()) 21 | .pipe(gulp.dest("dist/")); 22 | }); 23 | 24 | var buildThemedCss = function(theme) { 25 | var appCssBundle; 26 | var sandboxCssBundle; 27 | 28 | // Input: All CSS files but the sandbox's, and only the current theme 29 | appCssBundle = gulp.src([ 30 | "src/**/*.css", 31 | "!src/sandbox/css/*.css", 32 | "!src/app-shared/css/themes/**/!(" + theme + "-theme-vars.css)" 33 | ], { base: "src/" }) 34 | .pipe(concat("css/bundle-" + theme + "-theme.css")) 35 | .pipe(cssnext()) 36 | .pipe(gulp.dest("dist/")); 37 | 38 | // Input: app-shared's main.css for rules that are unrelated to the Chrome 39 | // app's UI, the sandbox's CSS files, and only the current theme 40 | sandboxCssBundle = gulp.src([ 41 | "src/app-shared/css/main.css", 42 | "src/sandbox/css/*.css", 43 | "src/app-shared/css/themes/" + theme + "-theme-vars.css" 44 | ], { base: "src/" }) 45 | .pipe(concat("sandbox/css/bundle-" + theme + "-theme.css")) 46 | .pipe(cssnext()) 47 | .pipe(gulp.dest("dist/")); 48 | 49 | return mergeStream(appCssBundle, sandboxCssBundle); 50 | }; 51 | 52 | gulp.task("build-css-theme-light", ["clean"], buildThemedCss.bind(null, "light")); 53 | gulp.task("build-css-theme-dark", ["clean"], buildThemedCss.bind(null, "dark")); 54 | 55 | // Simply copy the rest of the files from src/ to dist/ 56 | // Input: All files but JS files that are not under libs/ 57 | gulp.task("copy", ["clean"], function() { 58 | return gulp.src([ 59 | "src/**", 60 | "!src/**/*.css", 61 | "!src/**/!(libs)/*.js" // With this glob, JS files must be directly under libs/ (change the glob if nesting folders) 62 | ], { base: "src/" }) 63 | .pipe(gulp.dest("dist/")); 64 | }); 65 | 66 | gulp.task("default", ["to-ES5", "build-css-theme-light", "build-css-theme-dark", "copy"], function() { 67 | console.log("Watching..."); 68 | }); 69 | 70 | gulp.task("build-from-watch", ["default"], function() { 71 | if (getArg("--beep")) beeper(2); // Audio feedback when build is complete (enable by passing --beep param) 72 | }); 73 | 74 | // Build as soon as a file changes 75 | gulp.watch("src/**", ["build-from-watch"]) 76 | .on("change", function(event) { 77 | console.log("Watch: file " + event.path + " was " + event.type + ", building..."); 78 | }); 79 | 80 | // From http://stackoverflow.com/a/26946499/408173 81 | // Eases reading command line params 82 | function getArg(key) { 83 | var index = process.argv.indexOf(key); 84 | var next = process.argv[index + 1]; 85 | return (index < 0) ? null : (!next || next[0] === "-") ? true : next; 86 | } -------------------------------------------------------------------------------- /src/app-shared/js/preview.js: -------------------------------------------------------------------------------- 1 | var preview; 2 | 3 | $(document).ready(function() { 4 | "use strict"; 5 | 6 | var markdownPreview = document.getElementById("preview"); 7 | 8 | preview = { 9 | // Return the top and bottom offsets of the previewed DOM element(s) surrounding 10 | // the given source line, using the preview's mapped start and end line numbers. 11 | getSourceLineOffset: (function() { 12 | var getElAtLine = function(line) { 13 | return document.getElementById("line-start-"+ line); 14 | }; 15 | 16 | return function(line, lineCount) { 17 | var topOffset, count, offsets, lookAtLine, keepLooking, lineEnd, 18 | el = getElAtLine(line); 19 | 20 | // Return top and bottom offsets of the element found at the given line 21 | if (el) { 22 | topOffset = getElRefOffset(el, "top"); 23 | return [topOffset, topOffset + el.offsetHeight]; 24 | } 25 | 26 | // Return top offset of first element found above line, and bottom offset of first element found below 27 | count = 0; 28 | offsets = [null, null]; 29 | keepLooking = [true, true]; 30 | 31 | do { 32 | count++; 33 | 34 | // Find element above 35 | if (keepLooking[0]) { 36 | lookAtLine = line - count; 37 | 38 | // Reached top of doc 39 | if (lookAtLine < 0) { 40 | offsets[0] = 0; 41 | keepLooking[0] = false; 42 | // Inspect el at line above 43 | } else { 44 | el = getElAtLine(lookAtLine); 45 | 46 | if (el) { 47 | offsets[0] = getElRefOffset(el, "top"); 48 | keepLooking[0] = false; 49 | 50 | // If the element we just found spans multiple lines, no need to continue looking 51 | // for an element below if this element's end line is greater than the given line 52 | lineEnd = el.getAttribute("data-line-end"); 53 | if (lineEnd && lineEnd >= line) { 54 | offsets[1] = offsets[0] + el.offsetHeight; 55 | keepLooking[1] = false; 56 | } 57 | } 58 | } 59 | } 60 | 61 | // Find element below 62 | if (keepLooking[1]) { 63 | lookAtLine = line + count; 64 | 65 | // Reached bottom of doc 66 | if (lookAtLine >= lineCount) { 67 | offsets[1] = markdownPreview.scrollHeight; 68 | keepLooking[1] = false; 69 | // Inspect el at line below 70 | } else { 71 | el = getElAtLine(lookAtLine); 72 | 73 | if (el) { 74 | offsets[1] = getElRefOffset(el, "top") + el.offsetHeight; 75 | keepLooking[1] = false; 76 | } 77 | } 78 | } 79 | } while (keepLooking[0] || keepLooking[1]); 80 | 81 | return offsets; 82 | }; 83 | })(), 84 | 85 | onImagesLoad: function(c) { 86 | var onImageLoad, 87 | images = $body.find("img"), 88 | imageCount = images.length; 89 | 90 | if (!imageCount) return; 91 | 92 | onImageLoad = function() { 93 | if (--imageCount <= 0) c(); 94 | }; 95 | 96 | images.each(function() { 97 | var image = $(this); 98 | 99 | if (image.complete) onImageLoad(); 100 | else image.on("load", onImageLoad); 101 | }); 102 | } 103 | }; 104 | 105 | }); -------------------------------------------------------------------------------- /src/app-shared/css/themes/dark-theme-vars.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* General color properties */ 3 | 4 | --main-text-color: #ced0c2; 5 | --secondary-text-color: #ababab; 6 | 7 | --main-element-color: #4b4b4b; 8 | --energetic-element-color: #4d4d4d; 9 | --structural-element-color: #424241; 10 | 11 | --main-bg-color: #222; 12 | 13 | /* Elements properties */ 14 | 15 | --column-border-color: var(--structural-element-color); 16 | 17 | --body-color: var(--main-text-color); 18 | --link-color: var(--main-text-color); 19 | --textarea-color: #dee0d2; 20 | --body-bg-color: var(--main-bg-color); 21 | 22 | --top-panel-link-color: var(--main-text-color); 23 | --top-panel-bg-color: var(--structural-element-color); 24 | --top-panel-border-color: var(--structural-element-color); 25 | 26 | --top-panel-close-color: #aaa; 27 | --top-panel-close-hover-color: #bbb; 28 | --top-panel-close-hover-bg-color: #5b5b5b; 29 | 30 | --top-panel-btn-bg-color: #525251; 31 | --top-panel-btn-active-bg-color: #626261; 32 | --top-panel-btn-disabled-bg-color: #626261; 33 | 34 | --top-bar-button-color: var(--secondary-text-color); 35 | --top-bar-button-bg-color: var(--structural-element-color); 36 | --top-bar-button-active-bg-color: var(--main-element-color); 37 | --top-bar-button-enabled-border-color: #5e5e5e; 38 | 39 | --top-bar-arrow-bg-color: #4c4c4b; 40 | --top-bar-arrow-active-bg-color: #555555; 41 | 42 | --bottom-bar-word-count-color: var(--secondary-text-color); 43 | 44 | --file-menu-item-color: var(--secondary-text-color); 45 | --file-menu-item-border-color: var(--main-element-color); 46 | --file-menu-item-enabled-border-color: var(--energetic-element-color); 47 | 48 | --file-menu-item-close-color: var(--energetic-element-color); 49 | --file-menu-item-close-hover-color: var(--secondary-text-color); 50 | --file-menu-item-close-hover-bg-color: var(--main-element-color); 51 | --file-menu-item-edited-close-bg-color: var(--energetic-element-color); 52 | --file-menu-item-edited-close-border-color: var(--main-bg-color); 53 | 54 | --modal-bg-color: #252525; 55 | --modal-border-color: var(--energetic-element-color); 56 | 57 | --modal-button-color: var(--secondary-text-color); 58 | --modal-button-border-color: var(--energetic-element-color); 59 | --modal-button-primary-color: var(--main-text-color); 60 | --modal-button-primary-border-color: var(--secondary-text-color); 61 | --modal-button-active-bg-color: #3b3b3b; 62 | 63 | --drag-mask-color: #999; 64 | --drag-mask-bg-color: rgba(255, 255, 255, .15); 65 | --drag-mask-border-color: #999; 66 | 67 | --quote-border-color: var(--main-element-color); 68 | --hr-border-color: var(--main-element-color); 69 | --table-border-color: var(--main-element-color); 70 | --table-row-even-bg-color: #333; 71 | --pre-text-bg-color: var(--main-element-color); 72 | --pre-text-highlighted-bg-color: #5b5b5b; 73 | 74 | --settings-dark-theme-control: none; 75 | --settings-light-theme-control: inline; 76 | 77 | --scrollbar-size: 16px; 78 | --scrollbar-button-size: 0; 79 | --scrollbar-button-display: none; 80 | --scrollbar-corner-bg-color: transparent; 81 | --scrollbar-track-bg-color: var(--structural-element-color); 82 | --scrollbar-bar-border: 2px solid transparent; 83 | --scrollbar-thumb-bg-clip: padding-box; 84 | --scrollbar-thumb-min-size: 28px; 85 | --scrollbar-thumb-bg-color: hsl(0, 0%, 62%); 86 | --scrollbar-thumb-hover-bg-color: hsl(0, 0%, 73%); 87 | --scrollbar-thumb-active-bg-color: hsl(0, 0%, 77%); 88 | } -------------------------------------------------------------------------------- /src/app-shared/css/themes/light-theme-vars.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* General color properties */ 3 | 4 | --main-text-color: #333; 5 | --secondary-text-color: #888; 6 | 7 | --main-element-color: #e7e7e7; 8 | --energetic-element-color: #b5b5b5; 9 | --structural-element-color: #f0f0f0; 10 | 11 | --main-bg-color: #fff; 12 | 13 | /* Other properties */ 14 | 15 | --use-default: null; /* Invalid value to prevent changing the property */ 16 | 17 | /* Elements properties */ 18 | 19 | --column-border-color: var(--structural-element-color); 20 | 21 | --body-color: var(--main-text-color); 22 | --link-color: var(--use-default); 23 | --textarea-color: #111; 24 | --body-bg-color: var(--main-bg-color); 25 | 26 | --top-panel-link-color: var(--main-text-color); 27 | --top-panel-bg-color: var(--structural-element-color); 28 | --top-panel-border-color: var(--structural-element-color); 29 | 30 | --top-panel-close-color: #999; 31 | --top-panel-close-hover-color: #777; 32 | --top-panel-close-hover-bg-color: #d7d7d7; 33 | 34 | --top-panel-btn-bg-color: #e0e0e0; 35 | --top-panel-btn-active-bg-color: #d0d0d0; 36 | --top-panel-btn-disabled-bg-color: #d0d0d0; 37 | 38 | --top-bar-button-color: var(--secondary-text-color); 39 | --top-bar-button-bg-color: var(--structural-element-color); 40 | --top-bar-button-active-bg-color: var(--main-element-color); 41 | --top-bar-button-enabled-border-color: var(--energetic-element-color); 42 | 43 | --top-bar-arrow-bg-color: #eaeaea; 44 | --top-bar-arrow-active-bg-color: #e1e1e1; 45 | 46 | --bottom-bar-word-count-color: var(--secondary-text-color); 47 | 48 | --file-menu-item-color: var(--secondary-text-color); 49 | --file-menu-item-border-color: var(--main-element-color); 50 | --file-menu-item-enabled-border-color: var(--energetic-element-color); 51 | 52 | --file-menu-item-close-color: var(--energetic-element-color); 53 | --file-menu-item-close-hover-color: var(--secondary-text-color); 54 | --file-menu-item-close-hover-bg-color: var(--main-element-color); 55 | --file-menu-item-edited-close-bg-color: var(--energetic-element-color); 56 | --file-menu-item-edited-close-border-color: var(--main-bg-color); 57 | 58 | --modal-bg-color: var(--main-bg-color); 59 | --modal-border-color: var(--energetic-element-color); 60 | 61 | --modal-button-color: var(--secondary-text-color); 62 | --modal-button-border-color: var(--energetic-element-color); 63 | --modal-button-primary-color: var(--main-text-color); 64 | --modal-button-primary-border-color: var(--secondary-text-color); 65 | --modal-button-active-bg-color: #f7f7f7; 66 | 67 | --drag-mask-color: #999; 68 | --drag-mask-bg-color: rgba(255, 255, 255, .8); 69 | --drag-mask-border-color: #ddd; 70 | 71 | --quote-border-color: var(--main-element-color); 72 | --hr-border-color: var(--main-element-color); 73 | --table-border-color: var(--main-element-color); 74 | --table-row-even-bg-color: #f8f8f8; 75 | --pre-text-bg-color: var(--main-element-color); 76 | --pre-text-highlighted-bg-color: #d7d7d7; 77 | 78 | --settings-light-theme-control: none; 79 | --settings-dark-theme-control: inline; 80 | 81 | --scrollbar-size: var(--use-default); 82 | --scrollbar-button-size: var(--use-default); 83 | --scrollbar-button-display: var(--use-default); 84 | --scrollbar-corner-bg-color: var(--use-default); 85 | --scrollbar-track-bg-color: var(--use-default); 86 | --scrollbar-bar-border: var(--use-default); 87 | --scrollbar-thumb-bg-clip: var(--use-default); 88 | --scrollbar-thumb-min-size: var(--use-default); 89 | --scrollbar-thumb-bg-color: var(--use-default); 90 | --scrollbar-thumb-hover-bg-color: var(--use-default); 91 | --scrollbar-thumb-active-bg-color: var(--use-default); 92 | } -------------------------------------------------------------------------------- /src/js/utilities.js: -------------------------------------------------------------------------------- 1 | var confirm, alert, normalizeNewlines, limitStrLen; 2 | 3 | (function() { 4 | "use strict"; 5 | 6 | // Extend the keyCode constants with values only needed in this app 7 | $.extend(keyCode, { 8 | N: 78, 9 | O: 79, 10 | S: 83, 11 | T: 84, 12 | W: 87, 13 | Y: 89, 14 | Z: 90, 15 | 1: 49, 16 | 2: 50, 17 | 3: 51, 18 | 4: 52, 19 | 5: 53, 20 | 6: 54, 21 | 7: 55, 22 | 8: 56, 23 | 9: 57, 24 | F4: 115, 25 | PGUP: 33, 26 | PGDOWN: 34, 27 | ARROWLEFT: 37, 28 | ARROWRIGHT: 39 29 | }); 30 | 31 | confirm = function(text, buttons) { 32 | return new Promise(function(resolvePromise, rejectPromise) { 33 | rejectPromise = rejectPromise.bind(null, confirm.REJECTION_MSG); 34 | 35 | if (typeof buttons == "undefined") buttons = [new confirm.Button(confirm.Button.CANCEL_BUTTON), new confirm.Button(confirm.Button.OK_BUTTON)]; 36 | 37 | var modal = new Modal({ 38 | content: text, 39 | buttons: buttons.join(""), 40 | 41 | onInit: function() { 42 | var modal = this; 43 | 44 | modal.el.on("close.modal", function() { rejectPromise() }); 45 | 46 | modal.buttonsEls.filter("[data-action=\"cancel\"]").on("click", function(e) { 47 | e.preventDefault(); 48 | rejectPromise(); 49 | modal.close(); 50 | }); 51 | 52 | modal.buttonsEls.filter("[data-action=\"confirm\"]").on("click", function(e) { 53 | e.preventDefault(); 54 | resolvePromise($(e.target).data("value")); 55 | modal.close(); 56 | }); 57 | } 58 | }); 59 | 60 | modal.show(); 61 | }); 62 | }; 63 | 64 | confirm.REJECTION_MSG = "User closed confirm modal."; 65 | 66 | confirm.Button = (function() { 67 | // Yes, I'm using a constructor to return a string (well, a String obj). "new Button()" looks cool, and it's abstracted enough that we don't care what's underneath. 68 | var Button = function(options) { 69 | return new String(""+ options.text +""); 70 | }, 71 | 72 | ButtonConfig = function(options) { 73 | $.extend(this, options); 74 | }; 75 | 76 | ButtonConfig.prototype.extend = function(options) { 77 | return $.extend({}, this, options); 78 | }; 79 | 80 | // Sets of options 81 | Button.CANCEL_BUTTON = new ButtonConfig({ 82 | class: "button", 83 | dataAction: "cancel", 84 | text: "Cancel" 85 | }); 86 | Button.OK_BUTTON = new ButtonConfig({ 87 | class: "button button-primary", 88 | dataAction: "confirm", 89 | text: "Ok" 90 | }); 91 | 92 | return Button; 93 | })(); 94 | 95 | alert = function(text) { 96 | var modal = new Modal({ 97 | content: text, 98 | buttons: new confirm.Button(confirm.Button.OK_BUTTON), 99 | 100 | onInit: function() { 101 | var modal = this; 102 | 103 | modal.buttonsEls.on("click", function(e) { 104 | e.preventDefault(); 105 | modal.close(); 106 | }); 107 | } 108 | }); 109 | 110 | modal.show(); 111 | }; 112 | 113 | // The editor regularly compares files' contents to detect changes, and different line break format will mess with the result. 114 | // Chrome (and all browsers) already normalize line breaks as LF characters inside a form element's API value (which is 115 | // W3C lingo for "an element's `value` IDL attribute") (see http://www.w3.org/TR/html5/forms.html#attr-textarea-wrap). Hence, 116 | // normalizing line breaks in text that doesn't get through that native normalization (text that doesn't get in and out 117 | // the editor's textarea) seems like the sanest way to go. 118 | normalizeNewlines = function(str) { 119 | return String(str).replace(/\r/g, ""); 120 | }; 121 | 122 | // Limit the length of a string by, if it's longer than intended, remove text from the middle and inserting an ellipsis 123 | limitStrLen = function(str, length) { 124 | if (str.length > length) length = length / 2 - 1, str = str.substr(0, length) +"…"+ str.substr(-length); 125 | 126 | return str; 127 | }; 128 | })(); -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | #preview-iframe { 2 | width: 100%; 3 | border: 0; 4 | } 5 | 6 | .topbar .file-menu { 7 | position: relative; 8 | margin-right: 10px; 9 | overflow: hidden; 10 | white-space: nowrap; 11 | } 12 | 13 | .topbar .file-menu .file-menu-item { 14 | position: relative; 15 | display: inline-block; 16 | height: 18px; 17 | margin-right: 1px; 18 | padding: 2px 20px 0 8px; 19 | color: var(--file-menu-item-color); 20 | font-size: 11px; 21 | line-height: 18px; 22 | cursor: pointer; 23 | border-bottom: 2px solid transparent; 24 | } 25 | 26 | .topbar .file-menu .file-menu-item:last-of-type { 27 | margin-right: 0; 28 | } 29 | 30 | .topbar .file-menu .file-menu-item.active { 31 | border-bottom-color: var(--file-menu-item-enabled-border-color); 32 | } 33 | 34 | .topbar .file-menu .file-menu-item:before { 35 | content: ""; 36 | position: absolute; 37 | top: 4px; 38 | right: -1px; 39 | bottom: 2px; 40 | width: 1px; 41 | background: var(--file-menu-item-border-color); 42 | } 43 | 44 | .topbar .file-menu .file-menu-item:last-of-type:before { 45 | content: none; 46 | } 47 | 48 | .topbar .file-menu .file-menu-item .filename { 49 | position: relative; 50 | z-index: -1; /* Place .filename under .file-menu-item for click events to only fire on .file-menu-item; creating other stacking contexts around here or changing these properties might break the file menu on the JS side. */ 51 | } 52 | 53 | .topbar .file-menu .file-menu-item .close { 54 | position: absolute; 55 | top: 5px; 56 | right: 5px; 57 | bottom: 3px; 58 | width: 12px; 59 | line-height: 12px; 60 | font-size: 13px; 61 | text-align: center; 62 | color: var(--file-menu-item-close-color); 63 | border-radius: 6px; 64 | } 65 | 66 | .topbar .file-menu .file-menu-item .close:hover { 67 | color: var(--file-menu-item-close-hover-color); 68 | background: var(--file-menu-item-close-hover-bg-color); 69 | } 70 | 71 | .topbar .file-menu .file-menu-item.has-changed .close::before { 72 | content: ""; 73 | position: absolute; 74 | top: 0; 75 | right: 0; 76 | width: 5px; 77 | height: 5px; 78 | background: var(--file-menu-item-edited-close-bg-color); 79 | border: 4px solid var(--file-menu-item-edited-close-border-color); 80 | border-radius: 6.5px; 81 | } 82 | 83 | .topbar .file-menu .file-menu-item.has-changed .close:hover::before { 84 | display: none; 85 | } 86 | 87 | .topbar .buttons-container:nth-child(2) { 88 | float: left; 89 | } 90 | 91 | .topbar .buttons-container .button[data-file-menu-control] { 92 | display: none; 93 | } 94 | 95 | .topbar .buttons-container.show-file-menu-controls .button[data-file-menu-control] { 96 | display: block; 97 | } 98 | 99 | .topbar .buttons-container .button.icon-chevron-left, 100 | .topbar .buttons-container .button.icon-chevron-right { 101 | font-size: 10px; 102 | border-bottom: 0; 103 | padding: 0; 104 | width: 18px; 105 | height: 18px; 106 | line-height: 18px; 107 | text-align: center; 108 | margin: 2px; 109 | border-radius: 2px; 110 | text-indent: 2px; 111 | background: var(--top-bar-arrow-bg-color); 112 | } 113 | 114 | .topbar .buttons-container .button.icon-chevron-left { 115 | text-indent: -2px; 116 | } 117 | 118 | .topbar .buttons-container .button.icon-chevron-left:active, 119 | .topbar .buttons-container .button.icon-chevron-right:active { 120 | background: var(--top-bar-arrow-active-bg-color); 121 | } 122 | 123 | .topbar .buttons-container .button.icon-chevron-left:before { 124 | content: "\25C4"; 125 | } 126 | 127 | .topbar .buttons-container .button.icon-chevron-right:before { 128 | content: "\25BA"; 129 | } 130 | 131 | #drag-mask, 132 | #drag-mask > span { 133 | position: absolute; 134 | top: 0; 135 | left: 0; 136 | right: 0; 137 | bottom: 0; 138 | } 139 | 140 | #drag-mask { 141 | display: none; 142 | z-index: 200; 143 | background-color: var(--drag-mask-bg-color); 144 | } 145 | 146 | #drag-mask.visible { 147 | display: block; 148 | } 149 | 150 | #drag-mask > span { 151 | display: flex; 152 | justify-content: center; 153 | align-items: center; 154 | margin: 20px; 155 | font-size: 30px; 156 | color: var(--drag-mask-color); 157 | border: 4px dashed var(--drag-mask-border-color); 158 | pointer-events: none; 159 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimalist Markdown Editor for Chrome 2 | 3 | This is the source for the **simplest** and **slickest** Markdown editor for Chrome (both the browser and the OS). [Download on the Chrome Web Store.](https://chrome.google.com/webstore/detail/minimalist-markdown-edito/pghodfjepegmciihfhdipmimghiakcjf) 4 | Just write Markdown and see what it looks like as you type. And convert it to HTML in one click. 5 | 6 | ## The Minimalist Markdown Editor project 7 | 8 | The Minimalist Markdown Editor project is available both online as a web app, and offline and with file support as a Chrome app: 9 | 10 | - [Web app](http://markdown.pioul.fr) ([GitHub repository](https://github.com/pioul/Minimalist-Online-Markdown-Editor)) 11 | - [Chrome app](https://chrome.google.com/webstore/detail/minimalist-markdown-edito/pghodfjepegmciihfhdipmimghiakcjf) (the source code is in this repo) 12 | 13 | ## Contributing 14 | 15 | ### Building 16 | 17 | 1. Ensure that [Node.js](http://nodejs.org/) is installed, and open a terminal in the project's root directory. 18 | 2. Run `npm install` to install the project's developement dependencies. 19 | 3. Run `npm run build` to build the Chrome app. The builds will be placed in the `dist/` directory. 20 | 21 | Note: Building should only be necessary if you think about contributing. If you want to run one of the apps, hit one of the links above. 22 | 23 | ### Git workflow 24 | 25 | The two project branches share a decent amount of code. This common source code sits in `app-shared/` in each of these repos. As you can see from the commit history, changes to files in- and outside of `app-shared/` are committed separately to help with cherry-picking the common source changes from the other repo afterward. 26 | 27 | E.g. You've made changes to `src/css/main.css` and `src/app-shared/css/main.css`. Since changes have been made to files in- and outside of `app-shared/`, instead of committing all changes at once, make two commits: 28 | 29 | 1. The first one will be called "[app-shared] commit_message", and will commit changes to `src/app-shared/css/main.css`. 30 | 2. The second one will be called "commit_message", and will commit changes to `src/css/main.css`. 31 | 32 | If changes had only been made to `src/css/main.css`, then there would've been no need for the first commit. And if changes had only been made to `src/app-shared/css/main.css`, there would've been no need for the second commit. 33 | 34 | That's really all there is to know about this project's Git workflow, so fork away! 35 | 36 | ### A word about ES6+ and target envs 37 | 38 | ES6's feature set is frozen, and the standard should be published by June 2015; ES7 is also moving forward. Lots of [ES6+ features](http://kangax.github.io/compat-table/es6/) have been implemented in major JavaScript engines (including V8, Chrome's JS engine), which means they can already be used pretty safely. 39 | 40 | Currently, only the Chrome app is authored using ES6+. That means the shared source code between the two apps (in `app-shared/`) has to be authored in ES5. 41 | 42 | The suggested dev environment for the Chrome app is [Chrome Canary](https://www.google.com/chrome/browser/canary.html). That's because V8 implements features at a good pace, and since the target engine is V8 (after all, we're talking about a Chrome app), we might as well work with what we have in V8 and avoid polyfills and their downsides. 43 | 44 | Also, since some ES6+ features are hidden behind the "Enable Experimental JavaScript" flag in Chrome and that Canary may support more features than Stable, part of the build process consists in transpiling the ES6+ source to ES5. 45 | 46 | Summary: 47 | 48 | - Dev env (running `src/`, authored in ES6+, no polyfills): Chrome Canary with "Enable Experimental JavaScript" flag enabled 49 | - Target env (running `dist/`, transpiled to ES5, no polyfills): Chrome (browser or OS) 50 | - Target env in `app-shared/` (authored in ES5): all major browsers, down to IE9 51 | 52 | ## Edge cases 53 | 54 | ### Are symlinks supported? 55 | 56 | Unfortunately, the Chrome API through which MME accesses the filesystem isn't consistent regarding symlinks. What I've observed: 57 | 58 | - On Windows: 59 | - File shortcuts: work as expected 60 | - File soft links: can't read + can't detect dupes 61 | - File hard links: can't detect dupes 62 | - Files inside linked directory: can't detect dupes 63 | - On Linux: *haven't tested, feel free to test and send a PR* 64 | - On Mac: *haven't tested, feel free to test and send a PR* 65 | - On ChromeOS: *haven't tested, feel free to test and send a PR* 66 | 67 | ### Encoding 68 | 69 | Like most software nowadays, MME uses UTF-8 to read from and write to files. It currently doesn't support other charsets. 70 | 71 | If you happen to want to open in MME a non-UTF-8 file, it probably won't be read properly. To fix that, open it using the original program or another text editor, and paste its contents in MME: it'll display properly and subsequent saves will save the file as UTF-8. 72 | 73 | If you must work with non-UTF-8 files on a regular basis though, please get in touch so that we can discuss your particular use-case. 74 | 75 | ### Undo manager 76 | 77 | A JS-based undo manager is used in place of the native one. That means you can only use keyboard shortcuts to undo/redo, not native commands such as the ones that appear in context menus. -------------------------------------------------------------------------------- /src/sandbox/js/preview.js: -------------------------------------------------------------------------------- 1 | var $window = $(window); 2 | 3 | $window.on("load", function() { 4 | "use strict"; 5 | 6 | var wasLastHtmlUpdateAfterUserInput = null, 7 | 8 | // Post messages to the parent window 9 | messageParent = function(data) { 10 | parent.postMessage(data, "*"); 11 | }, 12 | 13 | // Receive messages sent to this iframe (from the parent window) 14 | receiveMessage = function(e) { 15 | var data = e.originalEvent.data; 16 | 17 | if (data.hasOwnProperty("html")) updateHtml(data.html, data.isAfterUserInput); 18 | if (data.hasOwnProperty("scrollLineIntoView")) scrollLineIntoView(data.scrollLineIntoView, data.lineCount); 19 | if (data.hasOwnProperty("fontSizeCssIncrement")) updateFontSize(data.fontSizeCssIncrement); 20 | if (data.hasOwnProperty("themeStylesheet")) useTheme(data.themeStylesheet); 21 | }, 22 | 23 | // Send the iframe's height to the parent window 24 | postHeight = function() { 25 | messageParent({ 26 | height: $body.height(), 27 | isAfterUserInput: wasLastHtmlUpdateAfterUserInput 28 | }); 29 | }, 30 | 31 | // Send the iframe's height and text to the parent window 32 | postAll = function() { 33 | messageParent({ 34 | height: $body.height(), 35 | text: $body.text(), 36 | isAfterUserInput: wasLastHtmlUpdateAfterUserInput 37 | }); 38 | }, 39 | 40 | updateHtml = function(html, isAfterUserInput) { 41 | $body.html(html); 42 | wasLastHtmlUpdateAfterUserInput = isAfterUserInput; 43 | 44 | postAll(); 45 | 46 | // If there are images, the height of the iframe has to be manually updated to reflect the height of the images 47 | // Thus, wait for all images to load, then send the actual height to the parent window 48 | preview.onImagesLoad(postHeight); 49 | }, 50 | 51 | // When scrolling a line into view, the parent window is the one doing the job. 52 | // The iframe is only sollicited to run the numbers and post back the top and 53 | // bottom offsets of the element(s) surrounding the given source line, since 54 | // it requires access to the preview's DOM for that. 55 | scrollLineIntoView = function(line, lineCount) { 56 | var offsets = preview.getSourceLineOffset(line, lineCount); 57 | messageParent({ scrollMarkdownPreviewIntoViewAtOffset: offsets }); 58 | }, 59 | 60 | updateFontSize = function(cssIncrement) { 61 | updateElFontSize($body, cssIncrement); 62 | postHeight(); 63 | }, 64 | 65 | useTheme = function(stylesheet) { 66 | document.getElementById("theme").setAttribute("href", stylesheet); 67 | }; 68 | 69 | $window.on({ 70 | resize: postHeight, 71 | message: receiveMessage, 72 | 73 | // Post a message to the parent window with interesting properties of all keydown events 74 | // to dispatch equivalent synthetic events there, since the original ones don't naturally 75 | // reach that parent window. 76 | keydown: function(e) { 77 | messageParent({ 78 | // Only post event props we care about 79 | keydownEventObj: { 80 | type: e.type, 81 | keyCode: e.keyCode, 82 | ctrlKey: e.ctrlKey, 83 | metaKey: e.metaKey, 84 | altKey: e.altKey, 85 | shiftKey: e.shiftKey 86 | } 87 | }); 88 | 89 | // All keydown events from this sandboxed frame are posted to and triggered in the parent window. 90 | // However, the original event isn't posted, so all keydown events that are cancelled by the app 91 | // to prevent their default action must be cancelled by hand here too. That solution isn't DRY, 92 | // but it's the best one around. 93 | // Currently applies to: CTRL (mirrored by META) + W 94 | if ((e.ctrlKey || e.metaKey) && e.keyCode == 87) e.preventDefault(); 95 | }, 96 | 97 | // Post a message to the parent window with interesting properties of all wheel events 98 | // to dispatch equivalent synthetic events there, since the original ones don't naturally 99 | // reach that parent window. 100 | wheel: function(e) { 101 | messageParent({ 102 | // Only post event props we care about 103 | wheelEventObj: { 104 | type: e.type, 105 | ctrlKey: e.ctrlKey, 106 | metaKey: e.metaKey, 107 | deltaY: e.originalEvent.deltaY, 108 | isSynthetic: true 109 | } 110 | }); 111 | } 112 | }); 113 | 114 | $body.on("click", function(e) { 115 | if (e.target.nodeName != "A") return; // Not using jQuery for event delegation since it lead to an issue where middle mouse clicks didn't trigger "click" events 116 | 117 | e.preventDefault(); 118 | 119 | var href = $(e.target).attr("href"), 120 | isAnchor = href.slice(0, 1) == "#"; 121 | 122 | // If the link is an anchor, manually scroll to the anchor target's position 123 | if (isAnchor) { 124 | let target = href.slice(1), 125 | targetOffset = null; 126 | 127 | if (target == "" || target == "top") { 128 | targetOffset = 0; 129 | } else { 130 | let targetEl = document.getElementById(target); 131 | if (targetEl) targetOffset = getElRefOffset(targetEl); 132 | } 133 | 134 | if (targetOffset != null) messageParent({ scrollMarkdownPreviewToOffset: targetOffset }); 135 | // Otherwise open the link in an external window 136 | } else { 137 | // If the URL is missing a scheme, add one 138 | let validURIScheme = /^[a-z][a-z\d+.-]+:\/\//i; 139 | if (!validURIScheme.test(href)) href = "http://"+ href; 140 | 141 | open(href, "MME_external_link"); 142 | } 143 | 144 | }); 145 | 146 | }); -------------------------------------------------------------------------------- /src/app-shared/css/main.css: -------------------------------------------------------------------------------- 1 | .clearfix { 2 | *zoom: 1; 3 | } 4 | 5 | .clearfix:before, 6 | .clearfix:after { 7 | content: ""; 8 | display: table; 9 | } 10 | 11 | .clearfix:after { 12 | clear: both; 13 | } 14 | 15 | div.full-height { 16 | overflow: auto; 17 | } 18 | 19 | .visible-when-fullscreen, 20 | .fullscreen .hidden-when-fullscreen { 21 | display: none; 22 | } 23 | 24 | .fullscreen .visible-when-fullscreen, 25 | .hidden-when-fullscreen { 26 | display: block; 27 | } 28 | 29 | html { 30 | height: 100%; 31 | } 32 | 33 | body { 34 | width: 100%; 35 | height: 100%; 36 | margin: 0; 37 | padding: 0; 38 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 39 | font-size: 14px; 40 | line-height: 20px; 41 | color: var(--body-color); 42 | overflow: hidden; 43 | background: var(--body-bg-color); 44 | } 45 | 46 | a { 47 | color: var(--link-color); 48 | } 49 | 50 | h1, h2, h3, h4, h5, h6 { 51 | line-height: 1em; 52 | } 53 | 54 | pre, code, kbd { 55 | background: var(--pre-text-bg-color); 56 | } 57 | 58 | code, kbd { 59 | padding: 1px 5px; 60 | } 61 | 62 | pre { 63 | padding: 5px; 64 | overflow-x: auto; 65 | word-wrap: normal; 66 | } 67 | 68 | pre .highlight { 69 | background: var(--pre-text-highlighted-bg-color); 70 | } 71 | 72 | pre code { 73 | padding: 0; 74 | } 75 | 76 | blockquote { 77 | border-left: 5px solid var(--quote-border-color); 78 | margin: 1em 0; 79 | padding-left: 10px; 80 | } 81 | 82 | hr { 83 | border: 0; 84 | border-top: 1px solid var(--hr-border-color); 85 | } 86 | 87 | table { 88 | border-collapse: collapse; 89 | border-spacing: 0; 90 | } 91 | 92 | table thead { 93 | border-bottom: 2px solid var(--table-border-color); 94 | } 95 | 96 | table tbody tr:nth-child(2n) { 97 | background-color: var(--table-row-even-bg-color); 98 | } 99 | 100 | table th, table td { 101 | padding: 6px 13px; 102 | border: 1px solid var(--table-border-color); 103 | } 104 | 105 | table th { 106 | font-weight: bold; 107 | } 108 | 109 | li p { 110 | margin: .5em 0; 111 | } 112 | 113 | #left-column, 114 | #right-column { 115 | float: left; 116 | width: 50%; 117 | height: 100%; 118 | } 119 | 120 | .wrapper { 121 | position: relative; 122 | padding: 22px 0 20px 10px; 123 | } 124 | 125 | #left-column .wrapper { 126 | border-right: 1px solid var(--column-border-color); 127 | } 128 | 129 | #right-column .wrapper { 130 | padding-left: 9px; 131 | border-left: 1px solid var(--column-border-color); 132 | } 133 | 134 | .topbar, 135 | .bottom-bar { 136 | position: absolute; 137 | top: 0; 138 | right: 0; 139 | left: 0; 140 | } 141 | 142 | .bottom-bar { 143 | top: auto; 144 | bottom: 0; 145 | padding-right: 5px; 146 | } 147 | 148 | .topbar .buttons-container { 149 | float: right; 150 | 151 | } 152 | 153 | .topbar .buttons-container .button, 154 | .topbar .buttons-container .button-group { 155 | float: left; 156 | } 157 | 158 | .topbar .buttons-container .button { 159 | height: 18px; 160 | padding: 2px 6px 0; 161 | line-height: 18px; 162 | color: var(--top-bar-button-color); 163 | text-decoration: none; 164 | font-size: 11px; 165 | border-bottom: 2px solid transparent; 166 | background: var(--top-bar-button-bg-color); 167 | } 168 | 169 | .topbar .buttons-container .button:active { 170 | background: var(--top-bar-button-active-bg-color); 171 | } 172 | 173 | .topbar .buttons-container .button.active { 174 | border-bottom-color: var(--top-bar-button-enabled-border-color); 175 | } 176 | 177 | .topbar .buttons-container .button-group .button.switch { 178 | padding-left: 3px; 179 | padding-right: 3px; 180 | } 181 | 182 | .topbar .buttons-container .button-group .button.switch:first-child { 183 | padding-left: 6px; 184 | } 185 | 186 | .topbar .buttons-container .button-group .button.switch:last-child { 187 | padding-right: 6px; 188 | } 189 | 190 | .topbar .buttons-container .button-group .button.switch:after { 191 | content: "/"; 192 | position: relative; 193 | right: -4px; 194 | } 195 | 196 | .topbar .buttons-container .button-group .button.switch:last-child:after { 197 | content: none; 198 | } 199 | 200 | .topbar .buttons-container .button.icon-sync-scroll, 201 | .topbar .buttons-container .button.icon-fullscreen { 202 | width: 22px; 203 | padding-left: 0; 204 | padding-right: 0; 205 | text-align: center; 206 | } 207 | 208 | .topbar .buttons-container .button.icon-sync-scroll { 209 | width: 18px; 210 | } 211 | 212 | .topbar .buttons-container .button.icon-sync-scroll:before { 213 | display: inline-block; 214 | margin-top: 1px; 215 | } 216 | 217 | .topbar .buttons-container .button.icon-fullscreen { 218 | font-size: 16px; 219 | } 220 | 221 | .topbar .buttons-container .button.icon-settings { 222 | font-size: 14px; 223 | } 224 | 225 | .bottom-bar .word-count { 226 | float: right; 227 | height: 20px; 228 | line-height: 20px; 229 | font-size: 11px; 230 | color: var(--bottom-bar-word-count-color); 231 | } 232 | 233 | #preview { 234 | word-wrap: break-word; 235 | line-height: normal; 236 | } 237 | 238 | #markdown, 239 | #html { 240 | display: block; 241 | box-sizing: border-box; 242 | width: 100%; 243 | resize: none; 244 | margin: 0; 245 | padding: 5px 0 0; 246 | border: 0; 247 | -moz-tab-size: 4; 248 | -o-tab-size: 4; 249 | tab-size: 4; 250 | color: var(--textarea-color); 251 | background-color: transparent; 252 | } 253 | 254 | #markdown:focus, 255 | #html:focus { 256 | outline: none; 257 | } 258 | 259 | #top_panels_container { 260 | max-height: 100%; 261 | overflow-y: auto; 262 | overflow-x: hidden; 263 | } 264 | 265 | #top_panels_container .top_panel { 266 | display: none; 267 | position: relative; 268 | padding: 10px 20px; 269 | background: var(--top-panel-bg-color); 270 | border-bottom: 2px solid var(--top-panel-border-color); 271 | } 272 | 273 | #top_panels_container .top_panel a { 274 | color: var(--top-panel-link-color); 275 | } 276 | 277 | #top_panels_container .top_panel .close { 278 | position: absolute; 279 | top: 15px; 280 | right: 15px; 281 | width: 14px; 282 | height: 14px; 283 | line-height: 14px; 284 | font-size: 14px; 285 | text-align: center; 286 | color: var(--top-panel-close-color); 287 | cursor: pointer; 288 | border-radius: 7px; 289 | } 290 | 291 | #top_panels_container .top_panel .close:hover { 292 | color: var(--top-panel-close-hover-color); 293 | background: var(--top-panel-close-hover-bg-color); 294 | } 295 | 296 | .top_panel .button { 297 | padding: 5px 12px; 298 | background: var(--top-panel-btn-bg-color); 299 | border-radius: 3px; 300 | cursor: pointer; 301 | text-decoration: none; 302 | } 303 | 304 | .top_panel .button:active { 305 | background: var(--top-panel-btn-active-bg-color); 306 | } 307 | 308 | .top_panel .button.is-disabled { 309 | background: var(--top-panel-btn-disabled-bg-color); 310 | opacity: .5; 311 | cursor: not-allowed; 312 | } 313 | 314 | #quick-reference table, 315 | #quick-reference > p { 316 | font-size: 12px; 317 | line-height: 1.25em; 318 | } 319 | 320 | #quick-reference table tr { 321 | background: transparent; 322 | } 323 | 324 | #quick-reference table td { 325 | padding: 1px 0; 326 | border: 0; 327 | } 328 | 329 | #quick-reference table td:first-child { 330 | cursor: pointer; 331 | } 332 | 333 | #quick-reference table td:last-child { 334 | padding-left: 20px; 335 | } 336 | 337 | #quick-reference table td pre, 338 | #quick-reference table td p { 339 | margin: 0; 340 | } 341 | 342 | #quick-reference p { 343 | margin-bottom: 0; 344 | } 345 | 346 | #settings #increase-font-size, 347 | #settings #decrease-font-size { 348 | position: relative; 349 | padding: 5px 9px 5px 19px; 350 | line-height: 16px; 351 | } 352 | 353 | #settings #increase-font-size:before, 354 | #settings #decrease-font-size:before { 355 | content: "A"; 356 | position: absolute; 357 | top: 5px; 358 | left: 9px; 359 | height: 16px; 360 | line-height: 16px; 361 | font-family: monospace; 362 | } 363 | 364 | #settings #decrease-font-size:before { 365 | font-size: 13px; 366 | } 367 | 368 | #settings #increase-font-size:before { 369 | font-size: 17px; 370 | } 371 | 372 | #settings #decrease-font-size:after { 373 | content: "-"; 374 | } 375 | 376 | #settings #increase-font-size:after { 377 | content: "+"; 378 | } 379 | 380 | #settings .theme-control .button { 381 | margin-left: 10px; 382 | padding-left: 5px; 383 | padding-right: 5px; 384 | } 385 | 386 | #settings .theme-control.theme-control-light { 387 | display: var(--settings-light-theme-control); 388 | } 389 | 390 | #settings .theme-control.theme-control-dark { 391 | display: var(--settings-dark-theme-control); 392 | } 393 | 394 | #settings label { 395 | padding-right: 20px; 396 | } 397 | 398 | .fullscreen #left-column, 399 | .fullscreen #right-column { 400 | float: none; 401 | width: 100%; 402 | } 403 | 404 | .fullscreen #left-column .wrapper { 405 | border-right-color: transparent; 406 | } 407 | 408 | .fullscreen #right-column .wrapper { 409 | border-left-color: transparent; 410 | } 411 | 412 | .fullscreen #markdown, 413 | .fullscreen #html { 414 | max-width: 800px; 415 | margin: 0 auto; 416 | } 417 | 418 | ::-webkit-scrollbar { 419 | width: var(--scrollbar-size); 420 | height: var(--scrollbar-size); 421 | } 422 | 423 | 424 | ::-webkit-scrollbar-button { 425 | width: var(--scrollbar-button-size); 426 | height: var(--scrollbar-button-size); 427 | display: var(--scrollbar-button-display); 428 | } 429 | 430 | 431 | ::-webkit-scrollbar-corner { 432 | background-color: var(--scrollbar-corner-bg-color); 433 | } 434 | 435 | 436 | ::-webkit-scrollbar-track { 437 | background-color: var(--scrollbar-track-bg-color); 438 | } 439 | 440 | ::-webkit-scrollbar-track:vertical, 441 | ::-webkit-scrollbar-thumb:vertical { 442 | border-left: var(--scrollbar-bar-border); 443 | } 444 | 445 | ::-webkit-scrollbar-track:horizontal, 446 | ::-webkit-scrollbar-thumb:horizontal { 447 | border-top: var(--scrollbar-bar-border); 448 | border-bottom: var(--scrollbar-bar-border); 449 | } 450 | 451 | ::-webkit-scrollbar-thumb { 452 | background-clip: var(--scrollbar-thumb-bg-clip); 453 | } 454 | 455 | ::-webkit-scrollbar-thumb:vertical { 456 | min-height: var(--scrollbar-thumb-min-size); 457 | } 458 | 459 | ::-webkit-scrollbar-thumb:horizontal { 460 | min-width: var(--scrollbar-thumb-min-size); 461 | } 462 | 463 | ::-webkit-scrollbar-thumb { 464 | background-color: var(--scrollbar-thumb-bg-color); 465 | } 466 | 467 | 468 | ::-webkit-scrollbar-thumb:hover { 469 | background-color: var(--scrollbar-thumb-hover-bg-color); 470 | } 471 | 472 | 473 | ::-webkit-scrollbar-thumb:active { 474 | background-color: var(--scrollbar-thumb-active-bg-color); 475 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Minimalist Markdown Editor 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 |
×
27 | 28 |

Quick Reference

29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 54 | 55 | 56 | 57 | 61 | 62 | 63 | 64 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | 90 | 91 | 92 |
33 |
*This is italicized*, and **this is bold**.
34 |

Use * or _ for emphasis.

39 |
# This is a first level header
40 |

Use one or more hash marks for headers: # H1, ## H2, ### H3

45 |
This is a link to [Google](http://www.google.com)
46 |

51 |
First line.  
 52 | Second line.
53 |

End a line with two spaces for a linebreak.

58 |
- Unordered list item
 59 | - Unordered list item
60 |

Unordered (bulleted) lists use asterisks, pluses, or hyphens (*, +, or -) as list markers.

65 |
1. Ordered list item
 66 | 2. Ordered list item
67 |

Ordered (numbered) lists use regular numbers, followed by periods, as list markers.

    /* This is a code block */

Indent four spaces for a preformatted block.

Let's talk about `<html>`!

Use backticks for inline code.

80 |
![](http://w3.org/Icons/valid-xhtml10)
81 |

Images are exactly like links, with an exclamation mark in front of them.

86 |
| Header 1 | Header 2 |
 87 | |----------|----------|
 88 | |  Cell 1  |  Cell 2  |
89 |

You can also use tables.

93 | 94 |

Full Markdown documentation

95 |
96 | 97 |
98 |
×
99 | 100 |

About MME

101 | 102 |

Hi, I'm Philippe Masset.

103 |

I made the Minimalist Markdown Editor because I love Markdown and simple things.
104 | The whole source code is on GitHub, and this editor is also available online as a web app.

105 |

If you have any suggestions or remarks whatsoever, just click on my name above and you'll have plenty of ways of contacting me.

106 | 107 |

Privacy

108 | 109 |
    110 |
  • No data is sent to any server – everything you type stays inside your application
  • 111 |
  • The editor automatically saves what you write locally for future use.
    112 | If using a public computer, close all tabs before leaving the editor
  • 113 |
114 |
115 | 116 |
117 |
×
118 | 119 |

Settings

120 | 121 |

122 | 123 | 124 | 125 |

126 | 127 |

128 | 129 | 130 | Light 131 | Change to Dark Theme 132 | 133 | 134 | Dark 135 | Change to Light Theme 136 | 137 |

138 | 139 |

Shortcuts

140 | 141 |
    142 |
  • Ctrl + O to open files
  • 143 |
  • Ctrl + T or Ctrl + N to open a new tab
  • 144 |
  • Ctrl + S to save the current file or tab, Ctrl + Shift + S to save as a new file
  • 145 |
  • Ctrl + W to close the current file or tab
  • 146 |
  • Ctrl + Tab to switch to the next tab, Ctrl + Shift + Tab to the previous
  • 147 |
  • Ctrl + 1-8 to switch to tabs number one through eight, Ctrl + 9 to switch to the last tab
  • 148 |
  • Ctrl + Z to undo your last action, Ctrl + Shift + Z or Ctrl + Y to redo it
  • 149 |
  • Ctrl + + to increase the text size
  • 150 |
  • Ctrl + - to decrease the text size
  • 151 |
152 | 153 |

Shortcuts on Mac

154 | 155 |

On Mac, replace Ctrl with (the command key) in the shortcuts above.
156 | The only difference is for switching tabs; on Mac, use:

157 | 158 |
    159 |
  • + Option + right arrow to switch to the next tab
  • 160 |
  • + Option + left arrow to switch to the previous tab
  • 161 |
162 |
163 |
164 |
165 |
166 |
167 | 168 | Quick Reference 169 | About 170 | 171 | 172 |
173 |
174 | 175 |
176 |
177 |
178 | 179 |
180 |
181 | 182 |
183 |
184 |
185 |
186 |
187 | HTML 188 | Preview 189 |
190 | 191 | 192 |
193 |
194 |
195 |
196 |
197 | 198 |
199 | 200 |
201 |
202 |
203 | 204 |
205 |
206 | 207 |
208 | Markdown 209 | HTML 210 | Preview 211 |
212 | 213 |
214 |
215 | 216 |
217 |
218 |
219 | 220 |
221 |
222 |
223 | 224 |
225 | Drop files here 226 |
227 | 228 | -------------------------------------------------------------------------------- /src/app-shared/js/utilities.js: -------------------------------------------------------------------------------- 1 | var $body, keyCode, doesSupportInputEvent, scrollIntoView, getElRefOffset, escapeHtml, updateElFontSize, Modal, shortcutManager, 2 | $document = $(document); 3 | 4 | $document.ready(function() { 5 | $body = $(document.body); 6 | }); 7 | 8 | (function() { 9 | "use strict"; 10 | 11 | keyCode = { 12 | TAB: 9, 13 | ESCAPE: 27, 14 | MINUS: 189, 15 | MINUS_FF: 173, // Firefox-specific 16 | PLUS: 187, 17 | PLUS_FF: 61, // Firefox-specific 18 | NUMPADMINUS: 109, 19 | NUMPADPLUS: 107 20 | }; 21 | 22 | doesSupportInputEvent = (function() { 23 | var doesSupport = "oninput" in document.createElement("textarea"); 24 | 25 | if (doesSupport && navigator.userAgent.indexOf("MSIE 9.0") != -1) doesSupport = false; // IE 9 supports the input event, but has a buggy implementation that makes it useless in that project 26 | 27 | return doesSupport; 28 | })(); 29 | 30 | if (!String.prototype.trim) String.prototype.trim = function() { return $.trim(this) }; 31 | 32 | // Scroll elements into view, horizontally or vertically, when Element.scrollIntoView() 33 | // doesn't do exactly what we want (e.g., it doesn't always make an el entirely visible 34 | // if it already partly is). 35 | // 36 | // Takes parameters as an object: 37 | // - Mandatory: 38 | // - el: the element to scroll into view (can optionally be replaced with 39 | // param.offsets if these numbers are already known) 40 | // - ref: the reference element (i.e., the one with the scrollbar) (must be 41 | // a valid offset parent – body, table, th, td, or any positioned parent – when 42 | // param.el isn't replaced with param.offsets) 43 | // - Optional: 44 | // - axis: "horizontal" or "vertical" (default) 45 | // - padding: padding to be left around param.el after scrolling (default: 0) 46 | scrollIntoView = (function() { 47 | var scrollIntoView = function(ref, axis, elOffsets, elSize, padding) { 48 | var refScrollPos, diff, 49 | scrollPosPropName = (axis == "vertical")? "scrollTop" : "scrollLeft", 50 | refSize = (axis == "vertical")? ref.offsetHeight : ref.offsetWidth, 51 | elOuterSize = elSize + padding * 2; 52 | 53 | // Too large to fit in the ref? Position it so as to fill the ref 54 | if (elOuterSize > refSize) { 55 | ref[scrollPosPropName] = elOffsets[0] + (elOuterSize - refSize) / 2; 56 | return; 57 | } 58 | 59 | refScrollPos = ref[scrollPosPropName]; 60 | 61 | // Align to top/left? 62 | diff = refScrollPos - elOffsets[0] + padding; 63 | if (diff > 0) { 64 | ref[scrollPosPropName] -= diff; 65 | return; 66 | } 67 | 68 | // Or align to bottom/right? 69 | diff = elOffsets[1] - (refScrollPos + refSize) + padding; 70 | if (diff > 0) ref[scrollPosPropName] += diff; 71 | 72 | // Or do nothing 73 | }; 74 | 75 | return function(param) { 76 | param.padding = param.padding || 0; 77 | param.axis = (param.axis == "horizontal")? "horizontal" : "vertical"; 78 | 79 | var firstOffset; 80 | 81 | if (param.el) { 82 | param.elSize = (param.axis == "vertical")? param.el.offsetHeight : param.el.offsetWidth; 83 | 84 | firstOffset = getElRefOffset(param.el, (param.axis == "vertical")? "top" : "left", param.ref); 85 | param.elOffsets = [firstOffset, firstOffset + param.elSize]; 86 | } else { 87 | // If param.el not set, param.elOffsets shoud be set instead 88 | param.elSize = param.elOffsets[1] - param.elOffsets[0]; 89 | } 90 | 91 | scrollIntoView(param.ref, param.axis, param.elOffsets, param.elSize, param.padding); 92 | }; 93 | })(); 94 | 95 | // Get element offset relative to the reference offset parent (defaults to document.body) 96 | // 97 | // Browser compatibility: in IE and Webkit browsers, `position: fixed` elements have a null offsetParent 98 | // (source: http://www.quirksmode.org/dom/w3c_cssom.html#t33). 99 | // For this inconsistency to not matter, follow these two rules: 100 | // - Don't measure the offset of a `position: fixed` element 101 | // - And when you do, make sure the element's offset parents (document.body, and any positioned parent) 102 | // aren't offset in the measured direction 103 | getElRefOffset = function(el, dir, ref) { 104 | var offsetPosMethodName = (dir != "left")? "offsetTop" : "offsetLeft", 105 | offset = el[offsetPosMethodName]; 106 | 107 | if (!ref) ref = document.body; 108 | 109 | while ((el = el.offsetParent) != ref) { 110 | offset += el[offsetPosMethodName]; 111 | } 112 | 113 | return offset; 114 | }; 115 | 116 | escapeHtml = (function() { 117 | var matchingChars = /[&<>"']/g, 118 | 119 | charMap = { 120 | "&": "&", 121 | "<": "<", 122 | ">": ">", 123 | "\"": """, 124 | "'": "'" 125 | }, 126 | 127 | replaceCallback = function(char) { 128 | return charMap[char]; 129 | }; 130 | 131 | return function(str) { 132 | return str.replace(matchingChars, replaceCallback); 133 | }; 134 | })(); 135 | 136 | // Update an element's font size by adding cssIncrement to the current computed size 137 | updateElFontSize = function(el, cssIncrement) { 138 | var fontSize = parseFloat(el.css("font-size")); 139 | fontSize += cssIncrement; 140 | el.css("font-size", fontSize); 141 | }; 142 | 143 | // Since promises swallow uncaught errors and rejections, another way had to be found to keep an eye on them: .done() 144 | // When using promises, you should *always* either return the promise (to continue chaining), or end the chain with .done() 145 | // .done()'s sole purpose is to (re)throw errors for any uncaught error or rejection. It doesn't return anything so that you can only use it to end a chain. 146 | // It's a temporary failsafe while the spec keeps evolving, hopefully in a way that solves this issue in the first place, like Mozilla has done with Promise.jsm. 147 | // Make sure to throw errors and reject promises with Error objects to get a stack trace. 148 | // One issue with this implementation is that keeping track of chaining can be become hard when storing promises inside variables to pick up chaining somewhere 149 | // else. You'll have to make the effort to keep track of that and end all chains with .done() nonetheless. Mozilla's approach is superior in that it hooks to GC to 150 | // keep track of promises even outside of a chain, but you need access to the innards of a browser for that. 151 | // One other implementation idea would be to create a wrapper around promises in the form of a regular object with isResolved and isRejected properties, internally 152 | // updated by the wrapper's .then() and .catch() methods. That'd allow to Object.observe() these changes and keep an eye on all promises without boilerplate method 153 | // like .done() and without access to the browser's internals. 154 | Promise.prototype.done = function() { 155 | this.catch(function(e) { 156 | console.error("Uncaught error or rejection inside Promise", e); 157 | }); 158 | }; 159 | 160 | Modal = (function() { 161 | var generateModalMarkup = function(content, buttons) { // Decoys surround the modals' buttons to avoid issues when the prev/next tabbable element is out of the browsing context 162 | return [ 163 | "
", 164 | "
", 165 | "
"+ content +"
", 166 | "
"+ buttons +"
", 167 | "
", 168 | "
" 169 | ].join(""); 170 | }, 171 | 172 | openModals = [], 173 | 174 | closeLastOpenModal = function() { 175 | if (openModals.length) { 176 | openModals[openModals.length - 1].close(); 177 | return true; 178 | } 179 | 180 | return false; 181 | }, 182 | 183 | initModalsBindings = function() { 184 | $document.on("keydown.modal", function(e) { 185 | if (e.keyCode == keyCode.ESCAPE) { 186 | var didCloseAModal = closeLastOpenModal(); 187 | if (didCloseAModal) e.stopImmediatePropagation(); // If pressing ESC resulted in a modal being closed, don't propagate the event (we don't want something else to happen in addition to closing the modal). And yes, that variable is only here to make the code more legible. Yes, in addition to this comment. Yes. 188 | } 189 | }); 190 | }, 191 | 192 | keepFocusInsideModal = function(modal) { 193 | var decoys = modal.el[0].getElementsByClassName("decoy"), 194 | firstDecoy = decoys[0], 195 | lastDecoy = decoys[1], 196 | firstButton = modal.buttonsEls.first(), 197 | lastButton = modal.buttonsEls.last(); 198 | 199 | modal.el.on("focusin", function(e) { 200 | e.stopPropagation(); 201 | 202 | switch (e.target) { 203 | case firstDecoy: // The decoy placed before the first button is about to be focused 204 | lastButton.focus(); 205 | break; 206 | case lastDecoy: // The decoy placed after the last button is about to be focused 207 | firstButton.focus(); 208 | break; 209 | } 210 | }); 211 | }; 212 | 213 | var Modal = function(options) { 214 | var modal = this; 215 | 216 | modal.el = $(generateModalMarkup(options.content, options.buttons)).appendTo($body); 217 | modal.buttonsEls = options.buttons? modal.el.find(".buttons .button") : []; 218 | 219 | if (modal.buttonsEls.length) { 220 | keepFocusInsideModal(modal); 221 | setTimeout(function() { 222 | modal.buttonsEls.last().focus() 223 | }, 0); 224 | } 225 | 226 | if (typeof options.onInit == "function") setTimeout(function() { options.onInit.call(modal) }); 227 | }; 228 | 229 | Modal.prototype.show = function() { 230 | this.el.show(); 231 | openModals.push(this); 232 | }; 233 | 234 | Modal.prototype.close = function() { 235 | this.el.trigger("close.modal").remove(); 236 | openModals.splice(openModals.length - 1, 1); 237 | }; 238 | 239 | // Used to enforce the fact that modals are blocking: event handlers that aren't "blocked/disabled" by the modals' transparent overlay 240 | // should call this method before going forward to make sure they're not executed while a modal is open (e.g., keyboard shortcuts handlers). 241 | Modal.isModalOpen = () => !!openModals.length; 242 | 243 | initModalsBindings(); 244 | 245 | return Modal; 246 | })(); 247 | 248 | // Register handlers for keyboard shortcuts using a human-readable format 249 | shortcutManager = (function() { 250 | var sequenceSeparator = " + ", 251 | handlers = new Map(), 252 | 253 | init = function() { 254 | $body.on("keydown", runMatchingHandler); 255 | }, 256 | 257 | // Run the handler registered with the detected shortcut 258 | // For the purposes of this app, META (WIN on Win, CMD on Mac) mirrors CTRL 259 | runMatchingHandler = function(e) { 260 | if (!e.ctrlKey && !e.metaKey) return; // All shortcuts currently use CTRL (mirrored by META) 261 | 262 | var shortcut, handler, 263 | sequence = ["CTRL"]; 264 | 265 | if (e.shiftKey) sequence.push("SHIFT"); 266 | if (e.altKey) sequence.push("ALT"); // (Option on Mac) 267 | 268 | sequence.push(e.keyCode); 269 | shortcut = sequence.join(sequenceSeparator); 270 | 271 | handler = handlers.get(shortcut); 272 | if (!handler) return; 273 | 274 | if (!Modal.isModalOpen()) handler(e); 275 | else e.preventDefault(); 276 | }; 277 | 278 | $document.ready(init); 279 | 280 | return { 281 | register: function(shortcut, handler) { // shortcut can be an array of shortcuts to register the same handler on them 282 | var sequence, sequenceLastIndex, key, 283 | shortcuts = shortcut instanceof Array? shortcut : [shortcut]; 284 | 285 | for (shortcut of shortcuts) { 286 | // The last fragment of a shortcut should be a character representing a keyboard key: convert it to a keyCode 287 | sequence = shortcut.split(sequenceSeparator); 288 | sequenceLastIndex = sequence.length - 1; 289 | key = sequence[sequenceLastIndex]; 290 | 291 | if (keyCode.hasOwnProperty(key)) sequence[sequenceLastIndex] = keyCode[key]; 292 | shortcut = sequence.join(sequenceSeparator); 293 | 294 | handlers.set(shortcut, handler); 295 | } 296 | } 297 | }; 298 | })(); 299 | })(); -------------------------------------------------------------------------------- /src/app-shared/js/main.js: -------------------------------------------------------------------------------- 1 | var editor, 2 | $window = $(window); 3 | 4 | $document.ready(function() { 5 | "use strict"; 6 | 7 | var buttonsContainers = $(".buttons-container"); 8 | 9 | editor = { 10 | 11 | // Editor variables 12 | fitHeightElements: $(".full-height"), 13 | wrappersMargin: $("#left-column > .wrapper:first").outerHeight(true) - $("#left-column > .wrapper:first").height(), 14 | previewMarkdownConverter: window.markdownit({ html: true }).use(window.markdownitMapLines), 15 | cleanHtmlMarkdownConverter: window.markdownit({ html: true }), 16 | columns: $("#left-column, #right-column"), 17 | markdown: "", 18 | markdownSource: $("#markdown"), 19 | markdownHtml: document.getElementById("html"), 20 | markdownPreview: $("#preview"), 21 | markdownTargets: $("#html, #preview"), 22 | buttonsContainers: buttonsContainers, 23 | markdownTargetsTriggers: buttonsContainers.find(".switch"), 24 | topPanels: $("#top_panels_container .top_panel"), 25 | topPanelsTriggers: buttonsContainers.find(".toppanel"), 26 | quickReferencePreText: $("#quick-reference pre"), 27 | featuresTriggers: buttonsContainers.find(".feature"), 28 | wordCountContainers: $(".word-count"), 29 | isSyncScrollDisabled: true, 30 | isFullscreen: false, 31 | activePanel: null, 32 | themeSelector: document.getElementById("theme"), 33 | 34 | // Initiate editor 35 | init: function() { 36 | this.onloadEffect(0); 37 | this.initBindings(); 38 | this.fitHeight(); 39 | this.restoreState(function() { 40 | editor.onInput(); 41 | editor.onloadEffect(1); 42 | }); 43 | settings.initBindings(); 44 | }, 45 | 46 | // Handle events on several DOM elements 47 | initBindings: function() { 48 | $window.on("resize", function() { 49 | editor.fitHeight(); 50 | }); 51 | 52 | this.markdownSource.on("keydown", function(e) { 53 | if (!e.ctrlKey && e.keyCode == keyCode.TAB) editor.handleTabKeyPress(e); 54 | }); 55 | 56 | if (doesSupportInputEvent) { 57 | this.markdownSource.on("input", function() { 58 | editor.onInput(true); 59 | }); 60 | } else { 61 | var onInput = function() { 62 | editor.onInput(true); 63 | }; 64 | 65 | this.markdownSource.on({ 66 | "keyup change": onInput, 67 | 68 | "cut paste drop": function() { 69 | setTimeout(onInput, 0); 70 | } 71 | }); 72 | } 73 | 74 | this.markdownTargetsTriggers.on("click", function(e) { 75 | e.preventDefault(); 76 | editor.switchToPanel($(this).data("switchto")); 77 | }); 78 | 79 | this.topPanelsTriggers.on("click", function(e) { 80 | e.preventDefault(); 81 | editor.toggleTopPanel($("#"+ $(this).data("toppanel"))); 82 | }); 83 | 84 | this.topPanels.children(".close").on("click", function(e) { 85 | e.preventDefault(); 86 | editor.closeTopPanels(); 87 | }); 88 | 89 | this.quickReferencePreText.on("click", function() { 90 | editor.addToMarkdownSource($(this).text()); 91 | }); 92 | 93 | this.featuresTriggers.on("click", function(e) { 94 | e.preventDefault(); 95 | var t = $(this); 96 | editor.toggleFeature(t.data("feature"), t.data()); 97 | }); 98 | }, 99 | 100 | onInput: function(isUserInput) { 101 | var updatedMarkdown = this.markdownSource.val(); 102 | 103 | if (updatedMarkdown != this.markdown) { 104 | this.markdown = updatedMarkdown; 105 | this.onChange(isUserInput); 106 | } 107 | }, 108 | 109 | onChange: function(isAfterUserInput) { 110 | this.save("markdown", this.markdown); 111 | this.convertMarkdown(isAfterUserInput); 112 | }, 113 | 114 | // Resize some elements to make the editor fit inside the window 115 | fitHeight: function() { 116 | var newHeight = $window.height() - this.wrappersMargin; 117 | this.fitHeightElements.each(function() { 118 | var t = $(this); 119 | if (t.closest("#left-column").length) { 120 | var thisNewHeight = newHeight - $("#top_panels_container").outerHeight(); 121 | } else { 122 | var thisNewHeight = newHeight; 123 | } 124 | t.css({ height: thisNewHeight +"px" }); 125 | }); 126 | }, 127 | 128 | // Save a key/value pair in the app storage (either Markdown text or enabled features) 129 | save: function(key, value) { 130 | app.save(key, value); 131 | }, 132 | 133 | // Restore the editor's state 134 | restoreState: function(c) { 135 | app.restoreState(function(restoredItems) { 136 | if (restoredItems.markdown) editor.markdownSource.val(restoredItems.markdown); 137 | if (restoredItems.isSyncScrollDisabled != "y") editor.toggleFeature("sync-scroll"); 138 | if (restoredItems.isFullscreen == "y") editor.toggleFeature("fullscreen"); 139 | editor.switchToPanel(restoredItems.activePanel || "preview"); 140 | 141 | settings.restore({ 142 | fontSizeFactor: restoredItems.fontSizeFactor, 143 | theme: restoredItems.theme 144 | }); 145 | 146 | c(); 147 | }); 148 | }, 149 | 150 | // Convert Markdown to HTML and update active panel 151 | convertMarkdown: function(isAfterUserInput) { 152 | var html; 153 | 154 | if (this.activePanel != "preview" && this.activePanel != "html") return; 155 | 156 | if (this.activePanel == "preview") { 157 | html = this.previewMarkdownConverter.render(this.markdown); 158 | app.updateMarkdownPreview(html, isAfterUserInput); 159 | 160 | this.triggerEditorUpdatedEvent(isAfterUserInput); 161 | } else if (this.activePanel == "html") { 162 | html = this.cleanHtmlMarkdownConverter.render(this.markdown); 163 | this.markdownHtml.value = html; 164 | } 165 | }, 166 | 167 | triggerEditorUpdatedEvent: function(isAfterUserInput) { 168 | editor.markdownPreview.trigger("updated.editor", [{ 169 | syncScrollReference: isAfterUserInput? editor.syncScroll.ref.CARET : editor.syncScroll.ref.SCROLLBAR 170 | }]); 171 | }, 172 | 173 | // Programmatically add Markdown text to the textarea 174 | // pos = { start: Number, end: Number } 175 | addToMarkdownSource: function(markdown, pos, destPos) { 176 | var newMarkdownSourceVal, newMarkdownSourceLength, 177 | markdownSourceVal = this.markdown; 178 | 179 | // Add text at the end of the input 180 | if (typeof pos == "undefined") { 181 | if (markdownSourceVal.length) markdown = "\n\n"+ markdown; 182 | 183 | newMarkdownSourceVal = markdownSourceVal + markdown; 184 | newMarkdownSourceLength = newMarkdownSourceVal.length; 185 | 186 | this.updateMarkdownSource(newMarkdownSourceVal, { start: newMarkdownSourceLength, end: newMarkdownSourceLength }); 187 | // Add text at a given position 188 | } else { 189 | newMarkdownSourceVal = 190 | markdownSourceVal.substring(0, pos.start) + 191 | markdown + 192 | markdownSourceVal.substring(pos.end); 193 | 194 | if (destPos) pos = destPos; 195 | else pos.start = pos.end = pos.start + markdown.length; 196 | 197 | this.updateMarkdownSource(newMarkdownSourceVal, pos); 198 | } 199 | }, 200 | 201 | // Programmatically update the Markdown textarea with new Markdown text 202 | updateMarkdownSource: function(markdown, caretPos, isUserInput) { 203 | this.markdownSource.val(markdown); 204 | if (caretPos) this.setMarkdownSourceCaretPos(caretPos); 205 | 206 | this.onInput(isUserInput); 207 | }, 208 | 209 | // Doesn't work in IE<9 210 | getMarkdownSourceCaretPos: function() { 211 | var markdownSourceEl = this.markdownSource[0]; 212 | 213 | if (typeof markdownSourceEl.selectionStart != "number" || typeof markdownSourceEl.selectionEnd != "number") return; 214 | 215 | return { 216 | start: markdownSourceEl.selectionStart, 217 | end: markdownSourceEl.selectionEnd 218 | }; 219 | }, 220 | 221 | // Doesn't work in IE<9 222 | setMarkdownSourceCaretPos: function(pos) { 223 | var markdownSourceEl = this.markdownSource[0]; 224 | 225 | if (!("setSelectionRange" in markdownSourceEl)) return; 226 | 227 | // Force auto-scroll to the caret's position by blurring then focusing the input (doesn't work in IE) 228 | // When calling setSelectionRange, Firefox will properly scroll to the range into view. Chrome doesn't, 229 | // but we can hack our way around by blurring and focusing the input to force auto-scroll to the caret's 230 | // position. Neither the proper behavior nor the hack work in IE. Not a big issue, and it'll be solved 231 | // when implementing "perfect" sync-scrolling. 232 | markdownSourceEl.blur(); 233 | markdownSourceEl.setSelectionRange(pos.start, pos.end); 234 | markdownSourceEl.focus(); 235 | }, 236 | 237 | // Return the line where the character at position pos is situated in the source 238 | getMarkdownSourceLineFromPos: function(pos) { 239 | var sourceBeforePos = this.markdown.slice(0, pos.start); 240 | return sourceBeforePos.split("\n").length - 1; 241 | }, 242 | 243 | getMarkdownSourceLineCount: function(pos) { 244 | return this.markdown.split("\n").length; 245 | }, 246 | 247 | // Switch between editor panels 248 | switchToPanel: function(which) { 249 | var target = $("#"+ which), 250 | targetTrigger = this.markdownTargetsTriggers.filter("[data-switchto="+ which +"]"); 251 | 252 | if (!this.isFullscreen || which != "markdown") this.markdownTargets.not(target).hide(); 253 | target.show(); 254 | 255 | this.markdownTargetsTriggers.not(targetTrigger).removeClass("active"); 256 | targetTrigger.addClass("active"); 257 | 258 | if (which != "markdown") this.featuresTriggers.filter("[data-feature=fullscreen][data-tofocus]").last().data("tofocus", which); 259 | 260 | if (this.isFullscreen) { 261 | var columnToShow = (which == "markdown")? this.markdownSource.closest(this.columns) : this.markdownPreview.closest(this.columns); 262 | 263 | columnToShow.show(); 264 | this.columns.not(columnToShow).hide(); 265 | } 266 | 267 | this.activePanel = which; 268 | this.save("activePanel", this.activePanel); 269 | 270 | // If one of the two panels displaying the Markdown output becomes visible, convert Markdown for that panel 271 | if (this.activePanel == "preview" || this.activePanel == "html") this.convertMarkdown(); 272 | }, 273 | 274 | // Toggle a top panel's visibility 275 | toggleTopPanel: function(panel) { 276 | if (panel.is(":visible")) this.closeTopPanels(); 277 | else this.openTopPanel(panel); 278 | }, 279 | 280 | // Open a top panel 281 | openTopPanel: function(panel) { 282 | var panelTrigger = this.topPanelsTriggers.filter("[data-toppanel="+ panel.attr("id") +"]"); 283 | panel.show(); 284 | panelTrigger.addClass("active"); 285 | this.topPanels.not(panel).hide(); 286 | this.topPanelsTriggers.not(panelTrigger).removeClass("active"); 287 | this.fitHeight(); 288 | $document.off("keydown.toppanel").on("keydown.toppanel", function(e) { 289 | if (e.keyCode == keyCode.ESCAPE) editor.closeTopPanels(); 290 | }); 291 | }, 292 | 293 | // Close all top panels 294 | closeTopPanels: function() { 295 | this.topPanels.hide(); 296 | this.topPanelsTriggers.removeClass("active"); 297 | this.fitHeight(); 298 | $document.off("keydown.toppanel"); 299 | }, 300 | 301 | // Toggle editor feature 302 | toggleFeature: function(which, featureData) { 303 | var featureTrigger = this.featuresTriggers.filter("[data-feature="+ which +"]"); 304 | switch (which) { 305 | case "sync-scroll": 306 | this.toggleSyncScroll(); 307 | break; 308 | case "fullscreen": 309 | this.toggleFullscreen(featureData); 310 | break; 311 | } 312 | featureTrigger.toggleClass("active"); 313 | }, 314 | 315 | toggleSyncScroll: (function() { 316 | var isMdSourceKeyPressed, 317 | 318 | refSyncScroll = function(e, arg) { 319 | var reference; 320 | 321 | if (e && e.type == "updated") reference = arg.syncScrollReference; 322 | else reference = isMdSourceKeyPressed? editor.syncScroll.ref.CARET : editor.syncScroll.ref.SCROLLBAR; 323 | 324 | editor.syncScroll(reference); 325 | }; 326 | 327 | return function() { 328 | if (this.isSyncScrollDisabled) { 329 | this.markdownPreview.on("updated.editor", refSyncScroll); 330 | this.markdownSource.on({ 331 | "scroll.syncScroll": refSyncScroll, 332 | "keydown.syncScroll": function(e) { isMdSourceKeyPressed = e.which < 91 || e.which > 93 } 333 | }); 334 | $body.on("keyup.syncScroll", function() { isMdSourceKeyPressed = false }); 335 | 336 | refSyncScroll(); 337 | isMdSourceKeyPressed = false; 338 | } else { 339 | this.markdownPreview.off("updated.editor"); 340 | this.markdownSource.off(".syncScroll"); 341 | $body.off("keyup.syncScroll"); 342 | } 343 | 344 | this.isSyncScrollDisabled = !this.isSyncScrollDisabled; 345 | this.save("isSyncScrollDisabled", this.isSyncScrollDisabled? "y" : "n"); 346 | }; 347 | })(), 348 | 349 | toggleFullscreen: function(featureData) { 350 | var toFocus = featureData && featureData.tofocus; 351 | this.isFullscreen = !this.isFullscreen; 352 | $body.toggleClass("fullscreen"); 353 | if (toFocus) this.switchToPanel(toFocus); 354 | // Exit fullscreen 355 | if (!this.isFullscreen) { 356 | this.columns.show(); // Make sure all columns are visible when exiting fullscreen 357 | var activeMarkdownTargetsTriggersSwichtoValue = this.markdownTargetsTriggers.filter(".active").first().data("switchto"); 358 | // Force one of the right panel's elements to be active if not already when exiting fullscreen 359 | if (activeMarkdownTargetsTriggersSwichtoValue == "markdown") { 360 | this.switchToPanel("preview"); 361 | } 362 | // Emit update when exiting fullscreen and "preview" is already active since it changes width 363 | if (activeMarkdownTargetsTriggersSwichtoValue == "preview") { 364 | this.triggerEditorUpdatedEvent(); 365 | } 366 | $document.off("keydown.fullscreen"); 367 | // Enter fullscreen 368 | } else { 369 | this.closeTopPanels(); 370 | $document.on("keydown.fullscreen", function(e) { 371 | if (e.keyCode == keyCode.ESCAPE) editor.featuresTriggers.filter("[data-feature=fullscreen]").last().trigger("click"); 372 | }); 373 | } 374 | this.save("isFullscreen", this.isFullscreen? "y" : "n"); 375 | $body.trigger("fullscreen.editor", [this.isFullscreen]); 376 | }, 377 | 378 | // Synchronize the scroll position of the preview panel with the source 379 | syncScroll: (function() { 380 | var syncScroll = function(reference) { 381 | var markdownPreview = this.markdownPreview[0], 382 | markdownSource = this.markdownSource[0]; 383 | 384 | if (reference == editor.syncScroll.ref.SCROLLBAR) { 385 | markdownPreview.scrollTop = (markdownPreview.scrollHeight - markdownPreview.offsetHeight) * markdownSource.scrollTop / (markdownSource.scrollHeight - markdownSource.offsetHeight); 386 | } else { 387 | app.scrollMarkdownPreviewCaretIntoView(); 388 | } 389 | }; 390 | 391 | syncScroll.ref = { 392 | CARET: 0, 393 | SCROLLBAR: 1 394 | }; 395 | 396 | return syncScroll; 397 | })(), 398 | 399 | // Subtle fade-in effect 400 | onloadEffect: function(step) { 401 | switch (step) { 402 | case 0: 403 | $body.fadeTo(0, 0); 404 | break; 405 | case 1: 406 | $body.fadeTo(1000, 1); 407 | break; 408 | } 409 | }, 410 | 411 | // Insert a tab character when the tab key is pressed (instead of focusing the next form element) 412 | // If multiple lines selected, indent them instead; or unindent on SHIFT + TAB 413 | handleTabKeyPress: function(e) { 414 | var selectedText, selectedLines, precText, precTextLastNLIndex, destSelPos, 415 | shouldIndentForward = !e.shiftKey, 416 | selPos = this.getMarkdownSourceCaretPos(); 417 | 418 | if (!selPos) return; 419 | 420 | selectedText = this.markdown.slice(selPos.start, selPos.end); 421 | selectedLines = selectedText.split("\n"); 422 | 423 | // Indent/unindent lines 424 | if (selectedLines.length > 1) { 425 | destSelPos = $.extend({}, selPos); 426 | 427 | // Extend selection to first new line char preceding the current selection 428 | // (in other words, include the whole first selected line into the selection) 429 | precText = this.markdown.slice(0, selPos.start); 430 | precTextLastNLIndex = precText.lastIndexOf("\n"); 431 | selPos.start = precTextLastNLIndex + 1; // Index of char following \n if found, 0 otherwise 432 | selectedLines[0] = precText.slice(selPos.start) + selectedLines[0]; 433 | 434 | // Insert/remove tabs at the beginning of all selected lines 435 | // Also adjust text selection indices to leave the right portion of text selected 436 | selectedLines = $.map(selectedLines, function(line, i) { 437 | if (shouldIndentForward) { 438 | if (i == 0) destSelPos.start++; 439 | destSelPos.end++; 440 | 441 | return "\t"+ line; 442 | } else { 443 | if (line.charAt(0) == "\t") { 444 | if (i == 0) destSelPos.start--; 445 | destSelPos.end--; 446 | 447 | return line.slice(1); 448 | } else { 449 | return line; 450 | } 451 | } 452 | }); 453 | 454 | this.addToMarkdownSource(selectedLines.join("\n"), selPos, destSelPos); 455 | } else { 456 | // Unindent line if no text selection and previous character is a tab 457 | if (!shouldIndentForward && selPos.start == selPos.end && this.markdown.charAt(selPos.start - 1) == "\t") { 458 | selPos.start--; 459 | this.addToMarkdownSource("", selPos); 460 | // Replace selection with tab char 461 | } else { 462 | this.addToMarkdownSource("\t", selPos); 463 | } 464 | } 465 | 466 | e.preventDefault(); 467 | }, 468 | 469 | // Count the words in the Markdown output and update the word count in the corresponding 470 | // .word-count elements in the editor 471 | updateWordCount: function(text) { 472 | var wordCount = ""; 473 | 474 | if (text.length) { 475 | wordCount = text.trim().replace(/\s+/gi, " ").split(" ").length; 476 | wordCount = wordCount.toString().replace(/\B(?=(?:\d{3})+(?!\d))/g, ",") +" words"; // Format number (add commas and unit) 477 | } 478 | 479 | this.wordCountContainers.text(wordCount); 480 | } 481 | 482 | }; 483 | 484 | var settings = (function() { 485 | var settingsPanel = $("#settings"), 486 | 487 | fontSize = { 488 | buttons: { 489 | inc: document.getElementById("increase-font-size"), 490 | dec: document.getElementById("decrease-font-size"), 491 | disabledClass: "is-disabled" 492 | }, 493 | 494 | factor: 0, 495 | factorBounds: [-3, 12], 496 | cssStep: 1.2, 497 | 498 | update: function(factor) { 499 | var cssIncrement, 500 | prevFactor = this.factor; 501 | 502 | if (factor < this.factorBounds[0]) factor = this.factorBounds[0]; 503 | else if (factor > this.factorBounds[1]) factor = this.factorBounds[1]; 504 | 505 | if (factor == prevFactor) return; 506 | 507 | cssIncrement = (factor - prevFactor) * this.cssStep; 508 | this.factor = factor; 509 | editor.save("fontSizeFactor", factor); 510 | 511 | app.updateFontSize(cssIncrement); 512 | 513 | // Update buttons' visual state 514 | $(this.buttons.dec).toggleClass(this.buttons.disabledClass, factor == this.factorBounds[0]); 515 | $(this.buttons.inc).toggleClass(this.buttons.disabledClass, factor == this.factorBounds[1]); 516 | }, 517 | 518 | increase: () => { fontSize.update(fontSize.factor + 1) }, 519 | decrease: () => { fontSize.update(fontSize.factor - 1) } 520 | }, 521 | 522 | theme = { 523 | buttons: { 524 | light: document.getElementById("use-light-theme"), 525 | dark: document.getElementById("use-dark-theme") 526 | }, 527 | 528 | stylesheets: { 529 | lightThemeRef: document.getElementById("theme-light-ref"), 530 | darkThemeRef: document.getElementById("theme-dark-ref") 531 | }, 532 | 533 | use: function(theme) { 534 | editor.save("theme", theme); 535 | app.useTheme(this.stylesheets[theme + "ThemeRef"].getAttribute("data-href")); 536 | } 537 | }; 538 | 539 | return { 540 | restore: function(restoredSettings) { 541 | // restoredSettings.fontSizeFactor can be null or undefined depending on the storage used; fortunately undefined == null 542 | if (restoredSettings.fontSizeFactor != null) fontSize.update(+restoredSettings.fontSizeFactor); 543 | 544 | // Restore theme if saved, otherwise default to light theme 545 | theme.use(restoredSettings.theme || "light"); 546 | }, 547 | 548 | initBindings: function() { 549 | settingsPanel.on("click", function(e) { 550 | e.preventDefault(); 551 | 552 | if (e.target == fontSize.buttons.inc || e.target == fontSize.buttons.dec) { 553 | var factor = fontSize.factor + (e.target == fontSize.buttons.inc? 1 : -1); 554 | fontSize.update(factor); 555 | } 556 | 557 | if (e.target == theme.buttons.light) { 558 | theme.use("light"); 559 | } 560 | 561 | if (e.target == theme.buttons.dark) { 562 | theme.use("dark"); 563 | } 564 | }); 565 | 566 | shortcutManager.register([ 567 | "CTRL + PLUS", 568 | "CTRL + PLUS_FF", 569 | "CTRL + SHIFT + PLUS", 570 | "CTRL + SHIFT + PLUS_FF", 571 | "CTRL + NUMPADPLUS" 572 | ], function(e) { 573 | e.preventDefault(); 574 | fontSize.increase(); 575 | }); 576 | 577 | shortcutManager.register([ 578 | "CTRL + MINUS", 579 | "CTRL + MINUS_FF", 580 | "CTRL + SHIFT + MINUS", 581 | "CTRL + SHIFT + MINUS_FF", 582 | "CTRL + NUMPADMINUS" 583 | ], function(e) { 584 | e.preventDefault(); 585 | fontSize.decrease(); 586 | }); 587 | 588 | $document.on("wheel", function(e) { 589 | var isScrollingUp; 590 | 591 | // Clone deltaY onto the jQuery event object ourselves 592 | if (!e.hasOwnProperty("deltaY")) e.deltaY = e.originalEvent.deltaY; 593 | 594 | if ((!e.ctrlKey && !e.metaKey) || !e.deltaY) return; 595 | 596 | e.preventDefault(); 597 | 598 | if (Modal.isModalOpen()) return; 599 | 600 | isScrollingUp = e.deltaY < 0; 601 | if (isScrollingUp) fontSize.increase(); 602 | else fontSize.decrease(); 603 | }); 604 | } 605 | }; 606 | })(); 607 | 608 | }); 609 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | var app; 2 | 3 | $document.ready(function() { 4 | "use strict"; 5 | 6 | app = { 7 | 8 | // Chrome app variables 9 | markdownPreviewIframe: $("#preview-iframe"), 10 | dragMask: document.getElementById("drag-mask"), 11 | isMarkdownPreviewIframeLoaded: false, 12 | markdownPreviewIframeLoadEventCallbacks: $.Callbacks(), 13 | 14 | init: function() { 15 | editor.init(); 16 | this.initBindings(); 17 | fileSystem.initBindings(); 18 | fileMenu.init(); 19 | UndoManager.initBindings(); 20 | }, 21 | 22 | initBindings: function() { 23 | $window.on({ 24 | message: app.receiveMessage.bind(app), 25 | focus: app.checkActiveFileForChanges.bind(app), 26 | "close.modal": app.focusMarkdownSource.bind(app) 27 | }); 28 | 29 | this.markdownPreviewIframe.on({ 30 | // In the Chrome app, the preview panel requires to be in a sandboxed iframe, 31 | // hence isn't loaded immediately with the rest of the document 32 | load: function() { 33 | app.isMarkdownPreviewIframeLoaded = true; 34 | app.markdownPreviewIframeLoadEventCallbacks.fire(); 35 | 36 | app.markdownPreviewIframe.off("load"); 37 | }, 38 | 39 | wheel: app.filterWheelEvent.bind(app) 40 | }); 41 | }, 42 | 43 | // Post messages to the iframe 44 | messageSandbox: function(data) { 45 | this.markdownPreviewIframe[0].contentWindow.postMessage(data, "*"); 46 | }, 47 | 48 | // Receive messages sent to this window (from the iframe) 49 | receiveMessage: function(e) { 50 | var data = e.originalEvent.data; 51 | 52 | if (data.hasOwnProperty("height")) this.updateMarkdownPreviewIframeHeight(data.height, data.isAfterUserInput); 53 | if (data.hasOwnProperty("text")) editor.updateWordCount(data.text); 54 | if (data.keydownEventObj) this.markdownPreviewIframe.trigger(data.keydownEventObj); 55 | if (data.hasOwnProperty("scrollMarkdownPreviewIntoViewAtOffset")) this.scrollMarkdownPreviewIntoViewAtOffset(data.scrollMarkdownPreviewIntoViewAtOffset); 56 | if (data.hasOwnProperty("scrollMarkdownPreviewToOffset")) this.scrollMarkdownPreviewToOffset(data.scrollMarkdownPreviewToOffset); 57 | if (data.wheelEventObj) this.markdownPreviewIframe.trigger(data.wheelEventObj); 58 | }, 59 | 60 | focusMarkdownSource: function() { 61 | setTimeout(function() { 62 | editor.markdownSource.focus(); 63 | }, 0); 64 | }, 65 | 66 | // Save a key/value pair in chrome.storage (either Markdown text or enabled features) 67 | // This method can be called from editor.save() to save things from /editor, or directly for key/value storage 68 | // It must thus allow for two types of usage: replication of usage from editor.save, and app-specific storage 69 | // And, the Chrome app allowing multiple files and tabs to be open at once (while the basic editor doesn't), 70 | // the key/value pair has to be transformed to a more complex format when key == "markdown" 71 | save: function(key, value) { 72 | // Hijack saving to convert the key/value pair to a more complex format 73 | if (key == "markdown") { 74 | let file = fileSystem.getActiveFile(), 75 | caretPos = editor.getMarkdownSourceCaretPos(); 76 | 77 | file.undoManager.save(file.cache.tempContents, value, file.cache.caretPos, caretPos); 78 | file.cache.tempContents = value; 79 | file.cache.caretPos = caretPos; 80 | fileMenu.updateItemChangesVisualCue(file); 81 | 82 | return; 83 | } 84 | 85 | var items = {}; 86 | items[key] = value; 87 | 88 | chrome.storage.local.set(items); 89 | }, 90 | 91 | // Restore the editor's state from chrome.storage (saved Markdown and enabled features) 92 | restoreState: function(callback) { 93 | // restoreState needs the preview panel to be loaded: if it isn't loaded when restoreState is called, call restoreState again as soon as it is 94 | if (!this.isMarkdownPreviewIframeLoaded) { 95 | this.markdownPreviewIframeLoadEventCallbacks.add(function() { 96 | app.restoreState(callback); 97 | }); 98 | 99 | return; 100 | } 101 | 102 | var editorRestoredItems, 103 | 104 | tryRunningCallback = (function() { 105 | var expectedTries = 2; 106 | 107 | return function() { 108 | if (!--expectedTries) callback(editorRestoredItems); 109 | }; 110 | })(); 111 | 112 | // Retrieve locally stored data to be sent to editor 113 | // For the same reason the "markdown" key is hijacked when saving the editor's contents, it's not included here so that the app can handle the restoration of the editor's contents itself 114 | chrome.storage.local.get(["isSyncScrollDisabled", "isFullscreen", "activePanel", "fontSizeFactor", "theme"], function(restoredItems) { 115 | editorRestoredItems = restoredItems; 116 | tryRunningCallback(); 117 | }); 118 | 119 | // Retrieve locally stored data to be handled by the app. 120 | // Restore "openFilesIds", "filesCache" and "activeFileMenuItemId", in that order and while making sure the previous item has been restored before restoring the next. 121 | // Also try to restore "markdown" to update from an old version of the app that used the "markdown" key to store its contents. 122 | chrome.storage.local.get(["openFilesIds", "filesCache", "activeFileMenuItemId", "markdown"], function(restoredItems) { 123 | if (restoredItems.openFilesIds) fileSystem.restoreFiles(restoredItems.openFilesIds); 124 | if (restoredItems.filesCache) fileSystem.cache.restoreFilesCachedProps(restoredItems.filesCache); 125 | 126 | // First app launch (otherwise at least one temp file is already open) 127 | if (fileSystem.isEmpty()) { 128 | fileSystem.chooseNewTempFile(); 129 | 130 | let populateNewFile = function(text) { 131 | fileSystem.getActiveFile().undoManager.freeze(); 132 | editor.updateMarkdownSource(text); 133 | fileSystem.getActiveFile().undoManager.unfreeze(); 134 | }; 135 | 136 | // Updated from version < 3.0.0 of the editor: populate the new file with the old version's saved contents, and delete that key 137 | if (restoredItems.markdown) { 138 | populateNewFile(restoredItems.markdown); 139 | chrome.storage.local.remove("markdown"); 140 | // Fresh new install: populate the new file with welcome instructions 141 | } else { 142 | let welcomeMsg = [ 143 | "# Minimalist Markdown Editor", 144 | "", 145 | "This is the **simplest** and **slickest** Markdown editor. ", 146 | "Just write Markdown and see what it looks like as you type. And convert it to HTML in one click.", 147 | "", 148 | "## Getting started", 149 | "", 150 | "### How?", 151 | "", 152 | "Just start typing in the left panel.", 153 | "", 154 | "### Buttons you might want to use", 155 | "", 156 | "- **Quick Reference**: that's a reminder of the most basic rules of Markdown", 157 | "- **HTML | Preview**: *HTML* to see the markup generated from your Markdown text, *Preview* to see how it looks like", 158 | "", 159 | "### Most useful shortcuts", 160 | "", 161 | "- `CTRL + O` to open files", 162 | "- `CTRL + T` to open a new tab", 163 | "- `CTRL + S` to save the current file or tab", 164 | "", 165 | "### Privacy", 166 | "", 167 | "- No data is sent to any server – everything you type stays inside your application", 168 | "- The editor automatically saves what you write locally for future use. ", 169 | " If using a public computer, close all tabs before leaving the editor" 170 | ].join("\n"); 171 | 172 | populateNewFile(welcomeMsg); 173 | } 174 | } 175 | 176 | fileMenu.switchToItem(restoredItems.activeFileMenuItemId); 177 | tryRunningCallback(); 178 | }); 179 | }, 180 | 181 | // Update the preview panel with new HTML 182 | updateMarkdownPreview: function(html, isAfterUserInput) { 183 | this.messageSandbox({ 184 | html: html, 185 | isAfterUserInput: isAfterUserInput 186 | }); 187 | }, 188 | 189 | updateMarkdownPreviewIframeHeight: function(height, isAfterUserInput) { 190 | this.markdownPreviewIframe.css("height", height); 191 | editor.triggerEditorUpdatedEvent(isAfterUserInput); 192 | }, 193 | 194 | scrollMarkdownPreviewCaretIntoView: function() { 195 | // The active file's cached caret pos isn't used here since that cache is only updated when the 196 | // Markdown source is – and this use case requires the freshest pos available. 197 | var caretPos = editor.getMarkdownSourceCaretPos(); 198 | if (!caretPos) return; 199 | 200 | this.messageSandbox({ 201 | scrollLineIntoView: editor.getMarkdownSourceLineFromPos(caretPos), 202 | lineCount: editor.getMarkdownSourceLineCount() 203 | }); 204 | }, 205 | 206 | scrollMarkdownPreviewIntoViewAtOffset: (function() { 207 | var param = { 208 | ref: editor.markdownPreview[0], 209 | padding: 40 210 | }; 211 | 212 | return function(offsets) { 213 | param.elOffsets = offsets; 214 | scrollIntoView(param); 215 | }; 216 | })(), 217 | 218 | scrollMarkdownPreviewToOffset: function(offsetTop) { 219 | editor.markdownPreview[0].scrollTop = offsetTop - 20; 220 | }, 221 | 222 | // Automatically check whether the active file has changed when the app window regains focus 223 | checkActiveFileForChanges: function() { 224 | var activeFile = fileSystem.getActiveFile(); 225 | if (!activeFile.isTempFile()) activeFile.checkDiskContents(); 226 | }, 227 | 228 | // Update the font size of the source, html, and preview panels 229 | updateFontSize: function(cssIncrement) { 230 | [editor.markdownSource, $(editor.markdownHtml)].forEach(function(el) { 231 | updateElFontSize(el, cssIncrement); 232 | }); 233 | 234 | this.messageSandbox({ 235 | fontSizeCssIncrement: cssIncrement 236 | }); 237 | }, 238 | 239 | useTheme: function(stylesheet) { 240 | editor.themeSelector.setAttribute("href", stylesheet); 241 | 242 | this.messageSandbox({ 243 | themeStylesheet: stylesheet 244 | }); 245 | }, 246 | 247 | // Chrome sometimes also dispatches a wheel event into the parent window when scrolling 248 | // in the child frame. Since we're using synthetic events to listen to wheel events in 249 | // the frame and don't want duplicate events, we prevent these duplicates from bubbling. 250 | filterWheelEvent: function(e) { 251 | if (!e.isSynthetic) e.stopPropagation(); 252 | } 253 | 254 | }; 255 | 256 | // MME's file system 257 | // Handles I/O with the real FS, but also takes care of much more files-related stuff 258 | var fileSystem = (function() { 259 | 260 | var files = new Map(), 261 | entriesDisplayPathsMap = new Map(), // Maps permanent files' entries' display paths with their ids 262 | 263 | // fileSystem.cache takes care of saving things to persist them between sessions even if the user didn't explicitly ask to 264 | // fileSystem.cache saves only a subset of the files' properties, that is the enumerable props of FileCache instances 265 | // fileSystem saves to chrome.fileSystem, fileSystem.cache saves to chrome.storage.local 266 | // Files maintain a reference to their cache, and cached props must be read and set through their "public" properties, that is 267 | // the ones exposed in their prototype: set and get using e.g. file.cache.tempContents rather than file.cache._tempContents 268 | cache = (function() { 269 | var openFilesIds = [], // Stores files' ids, and their sorting order 270 | filesCache = {}; // Stores additional properties for files, mapped to the files' ids 271 | 272 | var FileCache = function() { 273 | this._entryId = null; 274 | this._origContents = ""; 275 | this._tempContents = ""; 276 | this._caretPos = { start: 0, end: 0 }; 277 | }, 278 | 279 | setFileCacheProp = function(name, val) { 280 | this[name] = val; 281 | cache.save(cache.saveThe.filesCache); 282 | }; 283 | 284 | Object.defineProperties(FileCache.prototype, { 285 | entryId: { 286 | enumerable: true, 287 | get: function() { return this._entryId }, 288 | set: function(newVal) { setFileCacheProp.call(this, "_entryId", newVal) } 289 | }, 290 | 291 | origContents: { 292 | enumerable: true, 293 | get: function() { return this._origContents }, 294 | set: function(newVal) { setFileCacheProp.call(this, "_origContents", newVal) } 295 | }, 296 | 297 | tempContents: { 298 | enumerable: true, 299 | get: function() { return this._tempContents }, 300 | set: function(newVal) { setFileCacheProp.call(this, "_tempContents", newVal) } 301 | }, 302 | 303 | caretPos: { 304 | enumerable: true, 305 | get: function() { return this._caretPos }, 306 | set: function(newVal) { setFileCacheProp.call(this, "_caretPos", newVal) } 307 | } 308 | }); 309 | 310 | return { 311 | addFile: function(id) { 312 | openFilesIds.push(id); 313 | 314 | var fileCache = new FileCache(); 315 | filesCache[id] = fileCache; 316 | 317 | this.save(); 318 | 319 | return fileCache; 320 | }, 321 | 322 | restoreFilesCachedProps: function(filesCachedProps) { 323 | var fileCachedProps; 324 | 325 | for (let id in filesCachedProps) { 326 | if (filesCachedProps.hasOwnProperty(id) && filesCache.hasOwnProperty(id)) { 327 | let file = fileSystem.getFile(id); 328 | fileCachedProps = filesCachedProps[id]; 329 | 330 | for (let propKey in fileCachedProps) { 331 | if (fileCachedProps.hasOwnProperty(propKey)) { 332 | let propVal = fileCachedProps[propKey]; 333 | 334 | // If that file has an entryId, hijack the normal flow to make the file permanent 335 | if (propKey == "_entryId" && propVal != null) { 336 | chrome.fileSystem.restoreEntry(propVal, function(entry) { 337 | if (typeof entry != "undefined") { 338 | file.makePermanent(entry).done(); 339 | fileMenu.updateItemName(file); 340 | } 341 | }); 342 | 343 | continue; 344 | } 345 | 346 | file.cache[propKey] = propVal; 347 | } 348 | } 349 | 350 | fileMenu.updateItemChangesVisualCue(file); 351 | } 352 | } 353 | }, 354 | 355 | removeFile: function(id) { 356 | var openFilesIdsIndex = openFilesIds.indexOf(id); 357 | 358 | if (openFilesIdsIndex != -1) openFilesIds.splice(openFilesIdsIndex, 1); 359 | delete filesCache[id]; 360 | 361 | this.save(); 362 | }, 363 | 364 | // Enum containing options to combine and pass to the save method 365 | saveThe: { 366 | filesCache: 1, 367 | openFilesIds: 2 368 | }, 369 | 370 | // The bit flags above can be either combined (as usual) or omitted (same result as combining them all) 371 | save: function(toSave) { 372 | if (!toSave || toSave & this.saveThe.filesCache) app.save("filesCache", filesCache); 373 | if (!toSave || toSave & this.saveThe.openFilesIds) app.save("openFilesIds", openFilesIds); 374 | }, 375 | 376 | // Shouldn't be called directly: abstracted as fileSystem.getOpenFileIdAtIndex() 377 | getOpenFileIdAtIndex: function(index) { 378 | return openFilesIds[index]; 379 | }, 380 | 381 | // Shouldn't be called directly: abstracted as fileSystem.getLastOpenFileId() 382 | getLastOpenFileId: function() { 383 | return openFilesIds[openFilesIds.length - 1]; 384 | }, 385 | 386 | // Shouldn't be called directly: abstracted as fileSystem.getClosestOpenFileId() 387 | // Returns the id of the closest open file, ideally the next one, or the prev one if none to the right, or null 388 | getClosestOpenFileId: function(id) { 389 | var openFileIdIndex = openFilesIds.indexOf(id); 390 | if (openFileIdIndex == -1 || openFilesIds.length <= 1) return null; // No open file other than this one 391 | 392 | if (openFileIdIndex >= openFilesIds.length - 1) return openFilesIds[--openFileIdIndex]; // No open file next, return previous file id instead 393 | 394 | return openFilesIds[++openFileIdIndex]; // There's an open file next, return its id 395 | }, 396 | 397 | // Shouldn't be called directly: abstracted as fileSystem.getNextOpenFileId() 398 | // Returns the id of the next open file, looping over to the first when necessary, or null 399 | getNextOpenFileId: function(id) { 400 | var openFileIdIndex = openFilesIds.indexOf(id); 401 | if (openFileIdIndex == -1 || openFilesIds.length <= 1) return null; // No open file other than this one 402 | 403 | openFileIdIndex++; 404 | if (openFileIdIndex > openFilesIds.length - 1) openFileIdIndex = 0; 405 | 406 | return openFilesIds[openFileIdIndex]; 407 | }, 408 | 409 | // Shouldn't be called directly: abstracted as fileSystem.getPrevOpenFileId() 410 | // Returns the id of the next open file, looping over to the last when necessary, or null 411 | getPrevOpenFileId: function(id) { 412 | var openFileIdIndex = openFilesIds.indexOf(id); 413 | if (openFileIdIndex == -1 || openFilesIds.length <= 1) return null; // No open file other than this one 414 | 415 | openFileIdIndex--; 416 | if (openFileIdIndex < 0) openFileIdIndex = openFilesIds.length - 1; 417 | 418 | return openFilesIds[openFileIdIndex]; 419 | } 420 | }; 421 | })(), 422 | 423 | fsMethods = { 424 | initBindings: function() { 425 | shortcutManager.register(["CTRL + N", "CTRL + T"], function(e) { 426 | e.preventDefault(); 427 | fileSystem.chooseNewTempFile(); 428 | }); 429 | 430 | shortcutManager.register("CTRL + O", function(e) { 431 | e.preventDefault(); 432 | fileSystem.chooseEntries(); 433 | }); 434 | 435 | shortcutManager.register("CTRL + S", function(e) { 436 | e.preventDefault(); 437 | 438 | var file = fileSystem.getActiveFile(); 439 | file.save() 440 | .catch(function(reason) { 441 | if ([fileSystem.File.SAVE_REJECTION_MSG, fileSystem.USER_CLOSED_DIALOG_REJECTION_MSG].indexOf(reason) == -1) throw reason; 442 | }) 443 | .done(); 444 | }); 445 | 446 | shortcutManager.register("CTRL + SHIFT + S", function(e) { 447 | e.preventDefault(); 448 | 449 | var file = fileSystem.getActiveFile(); 450 | file.saveAs() 451 | .catch(function(reason) { 452 | if (reason != fileSystem.USER_CLOSED_DIALOG_REJECTION_MSG) throw reason; 453 | }) 454 | .done(); 455 | }); 456 | 457 | shortcutManager.register(["CTRL + W", "CTRL + F4"], function(e) { 458 | e.preventDefault(); 459 | fileSystem.getActiveFile().close(); 460 | }); 461 | 462 | $body.on("dragenter dragover dragleave drop", fileSystem.dndHandler.bind(fileSystem)); 463 | }, 464 | 465 | chooseEntries: function() { 466 | chrome.fileSystem.chooseEntry( 467 | { 468 | type: "openWritableFile", 469 | accepts: [{ 470 | extensions: ["md", "txt"] 471 | }], 472 | acceptsMultiple: true 473 | }, 474 | function(entries) { 475 | if (typeof entries == "undefined") return; // undefined when user closes dialog 476 | 477 | fileSystem.importEntries(entries); 478 | } 479 | ); 480 | }, 481 | 482 | // Drag & drop events handler 483 | dndHandler: (function() { 484 | var isDragging = false, 485 | 486 | onDragEnd = function(e) { 487 | if (!isDragging || e.target != app.dragMask) return; 488 | 489 | app.dragMask.classList.remove("visible"); 490 | isDragging = false; 491 | }, 492 | 493 | isValidDtType = function(e) { 494 | return e.originalEvent.dataTransfer.types.indexOf("Files") != -1; 495 | }; 496 | 497 | return function(e) { 498 | switch(e.type) { 499 | case "dragenter": 500 | if (isDragging || !isValidDtType(e)) return; 501 | 502 | app.dragMask.classList.add("visible"); 503 | isDragging = true; 504 | break; 505 | 506 | case "dragover": 507 | if (isValidDtType(e)) e.preventDefault(); // Indicate the body is a valid drop target 508 | break; 509 | 510 | case "dragleave": 511 | onDragEnd(e); 512 | break; 513 | 514 | case "drop": 515 | onDragEnd(e); 516 | fileSystem.chooseEntriesByDrop(e); 517 | break; 518 | } 519 | }; 520 | })(), 521 | 522 | // Entries acquired through webkitGetAsEntry() don't behave properly after being restored: they're not readable using 523 | // entry.file(read) anymore (entry.file() does absolutely nothing). (This issue only appears when the app is closed, 524 | // then re-opened. Reloading it or simulating browser restart through dev tools strangely doesn't yield the same results.) 525 | // getWritableEntry() is called on these entries as a hack to retrieve "proper" entries that don't exhibit this issue. 526 | chooseEntriesByDrop: function(e) { 527 | var promisedWritableEntries, 528 | dt = e.originalEvent.dataTransfer; 529 | 530 | if (dt.types.indexOf("Files") == -1) return; 531 | 532 | e.preventDefault(); 533 | promisedWritableEntries = []; 534 | 535 | for (let i = 0, dtItem; dtItem = dt.items[i]; i++) { 536 | // Only accept files that are some type of text (most commonly "text/plain") or of unknown type (such as .md as of today) 537 | if (dtItem.kind != "file" || dtItem.type && dtItem.type.indexOf("text/") != 0) continue; 538 | 539 | promisedWritableEntries.push(new Promise(function(resolvePromise) { 540 | var entry = dtItem.webkitGetAsEntry(); 541 | 542 | chrome.fileSystem.getWritableEntry(entry, function(writableEntry) { 543 | resolvePromise(writableEntry); 544 | }); 545 | })); 546 | } 547 | 548 | Promise.all(promisedWritableEntries) 549 | .then(function(writableEntries) { 550 | fileSystem.importEntries(writableEntries); 551 | }) 552 | .done(); 553 | }, 554 | 555 | // Transform entries into perm files, hence opening them into the editor 556 | importEntries: function(entries) { 557 | var promisedPermFilesBeingCreated = [], 558 | lastChosenAlreadyOpenFile = null, 559 | wereNewFilesSuccessfullyOpen = false; 560 | 561 | for (let i = 0, entry; entry = entries[i]; i++) { 562 | if (!entry.isFile) continue; 563 | 564 | let promise = fileSystem.getEntryDisplayPath(entry).then(function(displayPath) { 565 | var fileId = fileSystem.getEntriesDisplayPathsMap(displayPath); 566 | 567 | // If the display path has already been saved, hence the file has already been opened, 568 | // save that file's id in order to switch to the file's tab later if necessary. 569 | if (fileId) { 570 | lastChosenAlreadyOpenFile = fileId; 571 | } else { 572 | return fileSystem.createPermFile(entry).then(function() { 573 | wereNewFilesSuccessfullyOpen = true; 574 | }); 575 | } 576 | }); 577 | 578 | promisedPermFilesBeingCreated.push(promise); 579 | } 580 | 581 | // Switch to the latest open file. If the selected FS entries didn't result in any new file being open, 582 | // switch to the latest of the files that were both selected and already open. 583 | Promise.all(promisedPermFilesBeingCreated).then(function() { 584 | fileMenu.switchToItem(wereNewFilesSuccessfullyOpen? null : lastChosenAlreadyOpenFile); 585 | }).done(); 586 | }, 587 | 588 | // chrome.fileSystem.getDisplayPath doesn't work reliably with symlinks (see "Edge cases" in README.md) 589 | getEntryDisplayPath: function(entry) { 590 | return new Promise(function(resolvePromise) { 591 | chrome.fileSystem.getDisplayPath(entry, function(displayPath) { 592 | resolvePromise(displayPath); 593 | }); 594 | }); 595 | }, 596 | 597 | writeToEntry: function(entry, text) { 598 | return new Promise(function(resolvePromise, rejectPromise) { 599 | chrome.fileSystem.getWritableEntry(entry, function(writableEntry) { 600 | var onError = function(error) { 601 | rejectPromise(error); 602 | }; 603 | 604 | writableEntry.createWriter(function(writer) { 605 | writer.onerror = onError; 606 | 607 | writer.onwriteend = function() { 608 | var blob = new Blob([text]); 609 | 610 | writer.onwriteend = resolvePromise; 611 | writer.write(blob, {type: "text/plain"}); 612 | }; 613 | 614 | writer.truncate(0); 615 | }, onError); 616 | }); 617 | }); 618 | }, 619 | 620 | writeToNewEntry: function(text) { 621 | return new Promise(function(resolvePromise, rejectPromise) { 622 | chrome.fileSystem.chooseEntry( 623 | { 624 | type: "saveFile", 625 | accepts: [{ 626 | extensions: ["md", "txt"] 627 | }] 628 | }, 629 | function(writableEntry) { 630 | if (typeof writableEntry == "undefined") { rejectPromise(fileSystem.USER_CLOSED_DIALOG_REJECTION_MSG); return; } 631 | 632 | var onError = function(error) { 633 | rejectPromise(error); 634 | }; 635 | 636 | writableEntry.createWriter(function(writer) { 637 | writer.onerror = onError; 638 | writer.onwriteend = resolvePromise.bind(null, writableEntry); 639 | 640 | writer.write(new Blob([text]), {type: "text/plain"}); 641 | }, onError); 642 | } 643 | ); 644 | }); 645 | }, 646 | 647 | chooseNewTempFile: function() { 648 | fileSystem.createTempFile(generateUniqueFileId()); 649 | fileMenu.switchToItem(); 650 | }, 651 | 652 | // Restore cached files from their ids, saved in chrome.storage 653 | restoreFiles: function(ids) { 654 | for (let id of ids) fileSystem.createTempFile(id); 655 | }, 656 | 657 | createTempFile: function(id) { 658 | var file = new fileSystem.File(id); 659 | fileMenu.addItem(file); 660 | 661 | return file; 662 | }, 663 | 664 | createPermFile: function(entry) { 665 | var file, promise; 666 | 667 | file = new fileSystem.File(generateUniqueFileId()); 668 | promise = file.makePermanent(entry); 669 | 670 | promise = promise.then(function() { 671 | fileMenu.addItem(file); 672 | 673 | return file.read().then(function(fileContents) { 674 | var contentsLength = fileContents.length; 675 | 676 | file.cache.tempContents = fileContents; 677 | file.cache.caretPos = { start: contentsLength, end: contentsLength }; 678 | fileMenu.updateItemChangesVisualCue(file); 679 | }); 680 | }); 681 | 682 | return promise; 683 | }, 684 | 685 | isEmpty: function(id) { return !files.size }, 686 | 687 | getActiveFile: function() { 688 | return this.getFile(fileMenu.activeItemId); 689 | }, 690 | 691 | getOpenFileIdAtIndex: cache.getOpenFileIdAtIndex.bind(cache), 692 | getLastOpenFileId: cache.getLastOpenFileId.bind(cache), 693 | getClosestOpenFileId: cache.getClosestOpenFileId.bind(cache), 694 | getNextOpenFileId: cache.getNextOpenFileId.bind(cache), 695 | getPrevOpenFileId: cache.getPrevOpenFileId.bind(cache), 696 | 697 | setFile: files.set.bind(files), 698 | getFile: files.get.bind(files), 699 | hasFile: files.has.bind(files), 700 | deleteFile: function(id) { 701 | var r = files.delete(id); 702 | if (this.isEmpty()) this.chooseNewTempFile(); 703 | 704 | return r; 705 | }, 706 | 707 | setEntriesDisplayPathsMap: entriesDisplayPathsMap.set.bind(entriesDisplayPathsMap), 708 | getEntriesDisplayPathsMap: entriesDisplayPathsMap.get.bind(entriesDisplayPathsMap), 709 | deleteEntriesDisplayPathsMap: entriesDisplayPathsMap.delete.bind(entriesDisplayPathsMap) 710 | }, 711 | 712 | fsConstants = { 713 | USER_CLOSED_DIALOG_REJECTION_MSG: "User closed dialog." 714 | }, 715 | 716 | // A temporary file (one that's only stored in the cache, w/o being linked to the file system) is identified by this.isTempFile() == true 717 | // A permanent file has this.cache.entryId set to the fs entry id, this.entry to the entry itself, and this.isTempFile() == false 718 | File = function(id) { 719 | this.id = id; 720 | this.name = fileSystem.File.DEFAULT_NAME; 721 | this.cache = cache.addFile(this.id); 722 | this.undoManager = new UndoManager(); 723 | fileSystem.setFile(this.id, this); 724 | }; 725 | 726 | File.prototype.makePermanent = function(entry) { 727 | var file = this; 728 | 729 | file.cache.entryId = chrome.fileSystem.retainEntry(entry); 730 | file.entry = entry; 731 | file.name = entry.name; 732 | 733 | return fileSystem.getEntryDisplayPath(entry) 734 | .then(function(displayPath) { 735 | fileSystem.setEntriesDisplayPathsMap(displayPath, file.id); 736 | file.entryDisplayPath = displayPath; 737 | }); 738 | }; 739 | 740 | File.prototype.makeTemporary = function() { 741 | this.name = fileSystem.File.DEFAULT_NAME; 742 | this.cache.entryId = null; 743 | this.cache.origContents = ""; 744 | fileSystem.deleteEntriesDisplayPathsMap(this.entryDisplayPath); 745 | delete this.entry; 746 | delete this.entryDisplayPath; 747 | fileMenu.updateItemName(this); 748 | fileMenu.updateItemChangesVisualCue(this); 749 | }; 750 | 751 | File.prototype.isTempFile = function() { return !this.cache.entryId }; 752 | 753 | File.prototype.read = function() { 754 | var file = this; 755 | 756 | return new Promise(function(resolvePromise, rejectPromise) { 757 | var text, 758 | reader = new FileReader(), 759 | 760 | onError = function(error) { 761 | rejectPromise(error); 762 | }; 763 | 764 | reader.onload = function() { 765 | text = normalizeNewlines(reader.result); 766 | 767 | file.cache.origContents = text; 768 | resolvePromise(text); 769 | }; 770 | 771 | reader.onerror = onError; // Currently passes one param, a FileError obj, that could change to be a DOMError obj 772 | 773 | file.entry.file(function(file) { 774 | reader.readAsText(file); 775 | }, onError); 776 | }); 777 | }; 778 | 779 | // Load the file's cached contents into the editor 780 | // Also read from the file to see if the cached contents are different from the file's 781 | // The file's undo manager is frozen because its contents won't change, while the editor's will: we don't these non-changes 782 | // to be saved when propagated from the editor 783 | File.prototype.loadInEditor = function() { 784 | this.undoManager.freeze(); 785 | editor.updateMarkdownSource(this.cache.tempContents, this.cache.caretPos); 786 | this.undoManager.unfreeze(); 787 | if (!this.isTempFile()) this.checkDiskContents(); 788 | }; 789 | 790 | // Read the file's contents from the disk, and if new content, update the cache and update the editor's contents. 791 | // (If the user has changed that file's contents in the editor in the meantime, ask him if he'd like to reload it from the fs.) 792 | // (If file file isn't found on the FS, offer to keep its contents, otherwise close it.) 793 | File.prototype.checkDiskContents = function() { 794 | var file = this, 795 | pastOrigContents = file.cache.origContents, 796 | fileHasTempChanges = file.hasTempChanges(); 797 | 798 | file.read() 799 | .then(function(fileContents) { 800 | if (fileContents != pastOrigContents) { 801 | let updateEditorContents = function() { 802 | editor.updateMarkdownSource(fileContents); 803 | }; 804 | 805 | if (fileHasTempChanges) { 806 | confirm("The file has changed on disk. Reload it?", [ 807 | new confirm.Button(confirm.Button.CANCEL_BUTTON), 808 | new confirm.Button(confirm.Button.OK_BUTTON.extend({ text: "Reload" })) 809 | ]) 810 | .then(updateEditorContents) 811 | .catch(function(reason) { 812 | if (reason != confirm.REJECTION_MSG) throw reason; 813 | }) 814 | .done(); 815 | } else { 816 | updateEditorContents(); 817 | } 818 | } 819 | }) 820 | .catch(function(error) { 821 | if (error.name != "NotFoundError") throw error; 822 | 823 | confirm("Another program deleted that file. Keep its contents in the editor?", [ 824 | new confirm.Button(confirm.Button.CANCEL_BUTTON.extend({ text: "Close the file" })), 825 | new confirm.Button(confirm.Button.OK_BUTTON.extend({ text: "Keep in editor" })) 826 | ]) 827 | .then(file.makeTemporary.bind(file)) 828 | .catch(function(reason) { 829 | if (reason != confirm.REJECTION_MSG) throw reason; 830 | 831 | file.close(); 832 | }) 833 | .done(); 834 | }) 835 | .done(); 836 | }; 837 | 838 | File.prototype.close = function() { 839 | var file = this, 840 | close = function() { 841 | fileSystem.deleteFile(file.id); 842 | fileSystem.deleteEntriesDisplayPathsMap(file.entryDisplayPath); 843 | fileMenu.removeItem(file.id); 844 | cache.removeFile(file.id); 845 | }; 846 | 847 | if (file.hasTempChanges()) { 848 | confirm("Save changes before closing?", [ 849 | new confirm.Button(confirm.Button.CANCEL_BUTTON.extend({ text: "Don't close" })), 850 | new confirm.Button(confirm.Button.OK_BUTTON.extend({ text: "Discard", dataValue: "no" })), 851 | new confirm.Button(confirm.Button.OK_BUTTON.extend({ text: "Save changes", dataValue: "yes" })) 852 | ]) 853 | .then(function(value) { 854 | if (value == "yes") return file.save(); // Can throw fileSystem.USER_CLOSED_DIALOG_REJECTION_MSG if saveAs() is called and the "save as" dialog is closed 855 | }) 856 | .then(close) 857 | .catch(function(reason) { 858 | if ([confirm.REJECTION_MSG, fileSystem.USER_CLOSED_DIALOG_REJECTION_MSG].indexOf(reason) == -1) throw reason; 859 | }) 860 | .done(); 861 | } else { 862 | close(); 863 | } 864 | }; 865 | 866 | File.prototype.hasTempChanges = function() { 867 | return this.cache.tempContents != this.cache.origContents; // This is blazingly fast, however long the strings 868 | }; 869 | 870 | File.prototype.save = function() { 871 | var file = this; 872 | 873 | if (file.isTempFile()) return file.saveAs(); 874 | if (!file.hasTempChanges()) return Promise.reject(fileSystem.File.SAVE_REJECTION_MSG); 875 | 876 | return fileSystem.writeToEntry(file.entry, file.cache.tempContents) 877 | .then(function() { 878 | file.cache.origContents = file.cache.tempContents; 879 | fileMenu.updateItemChangesVisualCue(file); 880 | }) 881 | .catch(function(reason) { // Unknown error: display "save failed" message, and rethrow error as if it was uncaught 882 | alert("Changes couldn't be saved to the file."); 883 | throw reason; 884 | }); 885 | }; 886 | 887 | File.prototype.saveAs = function() { 888 | var file = this; 889 | 890 | return fileSystem.writeToNewEntry(file.cache.tempContents) 891 | .then(function(entry) { 892 | file.cache.origContents = file.cache.tempContents; 893 | fileMenu.updateItemChangesVisualCue(file); 894 | 895 | return file.makePermanent(entry).then(function() { 896 | fileMenu.updateItemName(file); 897 | }); 898 | }) 899 | .catch(function(reason) { 900 | // Unknown error: display "save failed" message 901 | if (reason != fileSystem.USER_CLOSED_DIALOG_REJECTION_MSG) { 902 | alert("Changes couldn't be saved to the file."); 903 | } 904 | 905 | // Rethrow all errors as if theyre were uncaught 906 | throw reason; 907 | }); 908 | }; 909 | 910 | File.SAVE_REJECTION_MSG = "No changes to save."; 911 | File.GET_DISPLAY_PATH_REJECTION_MSG = "No display path: file is temporary."; 912 | File.DEFAULT_NAME = "untitled"; 913 | 914 | var generateUniqueFileId = function() { 915 | var randId; 916 | 917 | do { 918 | randId = Math.floor(Math.random() * Math.pow(10, 10)).toString(36); 919 | } while (fileSystem.hasFile(randId)); 920 | 921 | return randId; 922 | }; 923 | 924 | return $.extend(fsMethods, fsConstants, { 925 | File: File, 926 | cache: cache 927 | }); 928 | 929 | })(); 930 | 931 | // Handle the display of file menu items 932 | // Mostly called from fileSystem 933 | var fileMenu = (function() { 934 | const SCROLLBY_STEP = 160; 935 | 936 | var el = $(".file-menu"), 937 | items = new Map(), // Map files' ids with their respective menu item objects 938 | activeItemId = null, 939 | areNavControlsVisible = false, 940 | navControlsTriggers = editor.buttonsContainers.find(".file-menu-control"), 941 | 942 | // File menu DOM elements (both the file menu itself and every menu item) actually contain two DOM elements. 943 | // When scrolling, we only want to work with the visible one: that's what this method is for. 944 | getVisibleDOMEl = function($el) { 945 | return editor.isFullscreen? $el[1] : $el[0]; 946 | }, 947 | 948 | fileMenuMethods = { 949 | init: function() { 950 | this.updateNavControlsVis(); 951 | this.initBindings(); 952 | }, 953 | 954 | initBindings: function() { 955 | el.on({ 956 | "click dblclick": function(e) { 957 | e.preventDefault(); 958 | var className = e.target.className.trim().split(" ")[0]; // Get the first class 959 | 960 | switch (e.type +" on ."+ className) { 961 | case "click on .file-menu-item": 962 | fileMenu.switchToItem($(e.target).data("id")); 963 | break; 964 | case "click on .close": 965 | var id = $(e.target).closest(".file-menu-item").data("id"); 966 | fileSystem.getFile(id).close(); 967 | break; 968 | case "dblclick on .file-menu": 969 | fileSystem.chooseNewTempFile(); 970 | break; 971 | } 972 | }, 973 | 974 | wheel: fileMenu.controlNav.bind(fileMenu, "vertical-scroll") 975 | }); 976 | 977 | $window.on("resize", function() { 978 | fileMenu.updateNavControlsVis(); 979 | fileMenu.scrollActiveItemIntoView(); 980 | }); 981 | 982 | $body.on("fullscreen.editor", function() { 983 | fileMenu.updateNavControlsVis(); 984 | fileMenu.scrollActiveItemIntoView(); 985 | }); 986 | 987 | navControlsTriggers.on("click", function(e) { 988 | e.preventDefault(); 989 | fileMenu.controlNav($(this).data("fileMenuControl")); 990 | }); 991 | 992 | shortcutManager.register(["CTRL + TAB", "CTRL + PGDOWN", "CTRL + ALT + ARROWRIGHT"], function(e) { 993 | e.preventDefault(); 994 | fileMenu.controlNav("jump-right"); 995 | }); 996 | 997 | shortcutManager.register(["CTRL + SHIFT + TAB", "CTRL + PGUP", "CTRL + ALT + ARROWLEFT"], function(e) { 998 | e.preventDefault(); 999 | fileMenu.controlNav("jump-left"); 1000 | }); 1001 | 1002 | shortcutManager.register(["CTRL + 1", "CTRL + 2", "CTRL + 3", "CTRL + 4", "CTRL + 5", "CTRL + 6", "CTRL + 7", "CTRL + 8", "CTRL + 9"], function(e) { 1003 | e.preventDefault(); 1004 | fileMenu.controlNav("jump-number", e); 1005 | }); 1006 | }, 1007 | 1008 | addItem: function(file) { 1009 | var menuItemEl = $(this.generateItemMarkup()).data("id", file.id); 1010 | this.updateItemNameEl(menuItemEl, file.name); 1011 | 1012 | this.setItem(file.id, { 1013 | el: menuItemEl.appendTo(el), 1014 | visualCues: { 1015 | hasTempChanges: false 1016 | } 1017 | }); 1018 | 1019 | this.updateNavControlsVis(); 1020 | }, 1021 | 1022 | updateItemName: function(file) { 1023 | this.updateItemNameEl(this.getItem(file.id).el, file.name); 1024 | this.updateNavControlsVis(); 1025 | this.scrollActiveItemIntoView(); 1026 | }, 1027 | 1028 | updateItemNameEl: function(menuItemEl, name) { 1029 | var shortName = limitStrLen(name, 35); 1030 | 1031 | menuItemEl 1032 | .attr("title", (name != shortName)? name : "") 1033 | .children(".filename") 1034 | .text(shortName); 1035 | }, 1036 | 1037 | updateItemChangesVisualCue: function(file) { 1038 | var menuItem = this.getItem(file.id), 1039 | hadTempChanges = menuItem.visualCues.hasTempChanges; 1040 | 1041 | menuItem.visualCues.hasTempChanges = file.hasTempChanges(); 1042 | 1043 | if (hadTempChanges != menuItem.visualCues.hasTempChanges) menuItem.el.toggleClass("has-changed", menuItem.visualCues.hasTempChanges); 1044 | }, 1045 | 1046 | removeItem: function(id) { 1047 | var menuItem = this.getItem(id), 1048 | isSwitchingTabsNeeded = id == this.activeItemId; 1049 | 1050 | menuItem.el.remove(); 1051 | this.deleteItem(id); 1052 | 1053 | this.updateNavControlsVis(); 1054 | 1055 | if (isSwitchingTabsNeeded) this.switchToItem(fileSystem.getClosestOpenFileId(id)); 1056 | }, 1057 | 1058 | generateItemMarkup: function() { 1059 | return [ 1060 | "
", 1061 | "", 1062 | "×", 1063 | "
" 1064 | ].join(""); 1065 | }, 1066 | 1067 | // Switch between open files 1068 | // If id is falsy or doesn't match, defaults to the last open file 1069 | switchToItem: function(id) { 1070 | if (!id || !fileSystem.hasFile(id)) { 1071 | id = fileSystem.getLastOpenFileId(); 1072 | } 1073 | 1074 | if (id == this.activeItemId) return; 1075 | 1076 | this.forEachItem(function(menuItem, itemId) { 1077 | if (id == itemId) { 1078 | fileMenu.activeItemId = id; 1079 | app.save("activeFileMenuItemId", id); 1080 | 1081 | menuItem.el.addClass("active"); 1082 | fileSystem.getFile(id).loadInEditor(); 1083 | } else { 1084 | menuItem.el.removeClass("active"); 1085 | } 1086 | }); 1087 | 1088 | this.scrollActiveItemIntoView(); 1089 | }, 1090 | 1091 | scrollActiveItemIntoView: (function() { 1092 | var param = { 1093 | axis: "horizontal" 1094 | }; 1095 | 1096 | return function() { 1097 | if (!this.activeItemId) return; 1098 | 1099 | param.ref = getVisibleDOMEl(el); 1100 | param.el = getVisibleDOMEl(this.getItem(this.activeItemId).el); 1101 | 1102 | scrollIntoView(param); 1103 | }; 1104 | })(), 1105 | 1106 | updateNavControlsVis: function() { 1107 | var fileMenuEl = getVisibleDOMEl(el), 1108 | shouldNavControlsBeVisible = fileMenuEl.scrollWidth > fileMenuEl.offsetWidth; 1109 | 1110 | if (shouldNavControlsBeVisible == areNavControlsVisible) return; 1111 | 1112 | editor.buttonsContainers.toggleClass("show-file-menu-controls", shouldNavControlsBeVisible); 1113 | areNavControlsVisible = shouldNavControlsBeVisible; 1114 | }, 1115 | 1116 | controlNav: function(action, e) { 1117 | switch (action) { 1118 | case "vertical-scroll": 1119 | this.hScroll(e); 1120 | break; 1121 | case "scroll-left": 1122 | this.scrollBy(-SCROLLBY_STEP); 1123 | break; 1124 | case "scroll-right": 1125 | this.scrollBy(SCROLLBY_STEP); 1126 | break; 1127 | case "jump-left": 1128 | this.switchToItem(fileSystem.getPrevOpenFileId(this.activeItemId)); 1129 | break; 1130 | case "jump-right": 1131 | this.switchToItem(fileSystem.getNextOpenFileId(this.activeItemId)); 1132 | break; 1133 | case "jump-number": 1134 | var openFileId, 1135 | number = e.keyCode, 1136 | openFileIdIndexMap = new Map([ 1137 | [keyCode[1], 1], 1138 | [keyCode[2], 2], 1139 | [keyCode[3], 3], 1140 | [keyCode[4], 4], 1141 | [keyCode[5], 5], 1142 | [keyCode[6], 6], 1143 | [keyCode[7], 7], 1144 | [keyCode[8], 8] 1145 | ]); 1146 | 1147 | if (openFileIdIndexMap.has(number)) openFileId = fileSystem.getOpenFileIdAtIndex(openFileIdIndexMap.get(number) - 1); // If jump to number 1-8, jump to corresponding tab 1148 | else openFileId = fileSystem.getLastOpenFileId(); // If jump to number 9, jump to last tab 1149 | 1150 | if (openFileId) this.switchToItem(openFileId); 1151 | break; 1152 | } 1153 | }, 1154 | 1155 | // When a vertical wheel event is fired, transform it into horizontal scrolling 1156 | hScroll: function(e) { 1157 | e = e.originalEvent; 1158 | 1159 | if (e.deltaX || e.deltaZ) return; 1160 | 1161 | this.scrollBy(e.deltaY); // Units are always px since Chrome only uses e.deltaMode == WheelEvent.DOM_DELTA_PIXEL (see https://code.google.com/p/chromium/issues/detail?id=227454#c23) 1162 | 1163 | }, 1164 | 1165 | scrollBy: function(x) { 1166 | getVisibleDOMEl(el).scrollLeft += x; 1167 | }, 1168 | 1169 | setItem: items.set.bind(items), 1170 | getItem: items.get.bind(items), 1171 | deleteItem: items.delete.bind(items), 1172 | forEachItem: items.forEach.bind(items) 1173 | }; 1174 | 1175 | 1176 | 1177 | return $.extend(fileMenuMethods, { 1178 | activeItemId: activeItemId 1179 | }); 1180 | })(); 1181 | 1182 | var UndoManager = (function() { 1183 | var UndoManager = function() { 1184 | this.stack = new Undo.Stack(); 1185 | this.isFrozen = false; // Freeze UndoManager instance when undoing/redoing to avoid polluting the stack 1186 | this.saveData = { 1187 | timer: null, 1188 | firstOldVal: null, 1189 | firstOldCaretPos: null 1190 | }; 1191 | }; 1192 | 1193 | UndoManager.initBindings = function() { 1194 | shortcutManager.register("CTRL + Z", function(e) { 1195 | e.preventDefault(); 1196 | fileSystem.getActiveFile().undoManager.undo(); 1197 | }); 1198 | 1199 | shortcutManager.register(["CTRL + SHIFT + Z", "CTRL + Y"], function(e) { 1200 | e.preventDefault(); 1201 | fileSystem.getActiveFile().undoManager.redo(); 1202 | }); 1203 | }; 1204 | 1205 | UndoManager.prototype.save = function(oldVal, newVal, oldCaretPos, newCaretPos) { 1206 | if (this.isFrozen) return; 1207 | 1208 | // Save oldVal through subsequent calls to save() 1209 | if (this.saveData.firstOldVal === null) this.saveData.firstOldVal = oldVal; 1210 | else oldVal = this.saveData.firstOldVal; 1211 | 1212 | // Save oldCaretPos through subsequent calls to save() 1213 | if (this.saveData.firstOldCaretPos === null) this.saveData.firstOldCaretPos = oldCaretPos; 1214 | else oldCaretPos = this.saveData.firstOldCaretPos; 1215 | 1216 | // Only save after 250 ms of inactivity 1217 | clearTimeout(this.saveData.timer); 1218 | this.saveData.timer = setTimeout(function() { 1219 | this.stack.execute(new FileSaveCommand(oldVal, newVal, oldCaretPos, newCaretPos)); 1220 | this.saveData.firstOldVal = this.saveData.firstOldCaretPos = null; 1221 | }.bind(this), 250); 1222 | }; 1223 | 1224 | UndoManager.prototype.undo = function() { 1225 | if (this.stack.canUndo()) { 1226 | this.freeze(); 1227 | this.stack.undo(); 1228 | this.unfreeze(); 1229 | } 1230 | }; 1231 | 1232 | UndoManager.prototype.redo = function() { 1233 | if (this.stack.canRedo()) { 1234 | this.freeze(); 1235 | this.stack.redo(); 1236 | this.unfreeze(); 1237 | } 1238 | }; 1239 | 1240 | UndoManager.prototype.freeze = function() { 1241 | this.isFrozen = true; 1242 | }; 1243 | 1244 | UndoManager.prototype.unfreeze = function() { 1245 | this.isFrozen = false; 1246 | }; 1247 | 1248 | var FileSaveCommand = Undo.Command.extend({ 1249 | constructor: function(oldVal, newVal, oldCaretPos, newCaretPos) { 1250 | this.oldVal = oldVal; 1251 | this.newVal = newVal; 1252 | this.oldCaretPos = oldCaretPos; 1253 | this.newCaretPos = newCaretPos; 1254 | }, 1255 | 1256 | execute: function() {}, 1257 | 1258 | undo: function() { 1259 | editor.updateMarkdownSource(this.oldVal, this.oldCaretPos, true); 1260 | }, 1261 | 1262 | redo: function() { 1263 | editor.updateMarkdownSource(this.newVal, this.newCaretPos, true); 1264 | } 1265 | }); 1266 | 1267 | return UndoManager; 1268 | })(); 1269 | 1270 | app.init(); 1271 | 1272 | }); --------------------------------------------------------------------------------