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
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 | "