./static/js/main.js annotated source
Back to indexCodeframe's editor component
2
3const {
4 StyledComponent,
5 Record,
6 Router,
7} = Torus;
8
Utility function to fetch while bypassing the cache, and with some default options.
11const cfFetch = (uri, options) => {
12 return fetch(uri, {
13 credentials: 'same-origin',
14 ...options,
15 });
16}
17
This api
object provides some utility methods we use to fetch and push stuff to and from the Codeframe internal API.
20const api = {
21 get: path => cfFetch(`/api${path}`, {
22 method: 'GET',
23 }),
24 post: (path, body) => cfFetch(`/api${path}`, {
25 method: 'POST',
26 body: body,
27 }),
28 errlog: e => console.error(`Codeframe API error:\n\t${e}`),
29}
30
Debounce coalesces multiple calls to the same function in a short period of time into one call, by cancelling subsequent calls within a given timeframe.
34const debounce = (fn, delayMillis) => {
35 let lastRun = 0;
36 let to = null;
37 return (...args) => {
38 clearTimeout(to);
39 const now = Date.now();
40 const dfn = () => {
41 lastRun = now;
42 fn(...args);
43 }
44 if (now - lastRun > delayMillis) {
45 dfn()
46 } else {
47 to = setTimeout(dfn, delayMillis);
48 }
49 }
50}
51
The PreviewPane
is the half-screen pane that shows a preview of the rendered Codeframe, alongside the URL and refresh buttons.
54class PreviewPane extends StyledComponent {
55
56 init(frameRecord) {
Percentage of the editor size that the preview pane takes up.
58 this.paneWidth = 50;
59
We create an iframe manually and insert it into the component DOM, so we can replace its URL when the preview URL changes, rather than simply having Torus set it and adding unnecessary history entries in the browser.
63 this.iframe = document.createElement('iframe');
64 this.iframe.setAttribute('frameborder', 0);
65
66 this.bind(frameRecord, data => this.render(data));
67
68 this.selectInput = this.selectInput.bind(this);
69 this.handleRefresh = this.handleRefresh.bind(this);
70 }
71
72 setWidth(width) {
73 this.paneWidth = width;
74 this.render();
75 }
76
77 styles() {
78 return css`
79 display: flex;
80 flex-direction: column;
81 justify-content: space-between;
82 align-items: flex-start;
83 height: 100%;
84 flex-grow: 1;
85 flex-shrink: 1;
86 overflow: hidden;
87 border-right: 2px solid var(--cf-black);
88 @media (max-width: 750px) {
89 /* Overriding inline style */
90 width: 100% !important;
91 height: 50%;
92 border-right: 0;
93 border-bottom: 2px solid var(--cf-black);
94 }
95 .topBar {
96 display: flex;
97 flex-direction: row;
98 align-items: center;
99 justify-content: center;
100 width: 100%;
101 padding: 0 3px;
102 border-bottom: 4px dotted var(--cf-black);
103 flex-shrink: 0;
104 height: 52px;
105 .embedded & {
106 background: var(--cf-background);
107 }
108 .inputContainer {
109 box-sizing: content-box;
110 flex-grow: 1;
111 flex-shrink: 1;
112 }
113 input {
114 display: block;
115 font-size: .75rem;
116 font-weight: bold;
117 height: 100%;
118 width: 100%;
119 box-shadow: none;
120 outline: none;
121 border: 0;
122 padding: 0;
123 font-weight: normal;
124 font-family: 'Menlo', 'Monaco', monospace;
125 }
126 a {
127 font-size: .75rem;
128 flex-grow: 0;
129 }
130 }
131 iframe {
132 height: 100%;
133 width: 100%;
134 flex-grow: 1;
135 outline: none;
136 border: 0;
137 box-shadow: none;
138 flex-shrink: 1;
139 }
140 .refreshButton {
141 font-size: 17px;
142 width: calc(.75rem + 17px);
143 height: calc(.75rem + 17px);
144 line-height: calc(.75rem + 18px);
145 flex-shrink: 0;
146 padding: 0;
147 display: flex;
148 flex-direction: column;
149 justify-content: center;
150 align-items: center;
151 &:active {
152 color: #000;
153 }
154 }
155 .unsavedWarning {
156 font-style: italic;
157 white-space: nowrap;
158 .button {
159 font-style: initial;
160 cursor: not-allowed;
161 }
162 }
163 `;
164 }
165
166 selectInput(evt) {
167 evt.preventDefault();
168 evt.target.select();
This is a weird and funky workaround for the fact that, in Safari, the selection made here from a focus event is cancelled immediately by a proceeding mouseup event, when it happens. So we .preventDefault()
only the next immediate mouseup event.
172 const cancelMouseup = e => {
173 e.preventDefault();
174 evt.target.removeEventListener('mouseup', cancelMouseup);
175 };
176 evt.target.addEventListener('mouseup', cancelMouseup);
177 gevent('preview', 'selecturl');
178 }
179
180 handleRefresh() {
Force-refreshing the preview works by clearing the "last url" fetched, so it looks like the same URL is a new URL on next render.
183 this._lastURL = '';
184 this.render();
185 gevent('preview', 'refresh');
186 }
187
188 safelySetIframeURL(url) {
If the iframe already has content, we want to replace its uri; if not, we want to give it a uri. This takes care of this logic.
191 if (this._lastURL !== url) {
If we simply assign to iframe.src
property here, it adds an entry to the parent page's back/forward history, which we don't want. We only want to add to src
if the iframe does not already have a page loaded (i.e. when contentWindow
is null).
195 if (this.iframe.contentWindow) {
196 this.iframe.contentWindow.location.replace(url);
197 } else {
198 this.iframe.src = url;
199 }
200 this._lastURL = url;
201 }
202 }
203
204 compose(data) {
If we're not rendering from a saved Codeframe from the server but instead rendering a "live" preview, generate a data URI instead and point the iframe to that. Otherwise, just use the URL to the saved preview.
208 let url = '';
209 if (data.liveRenderMarkup === null) {
210 url = `${window.location.origin}/f/${data.htmlFrameHash}/${data.jsFrameHash}.html`;
211 } else {
212 url = 'data:text/html,' + encodeURIComponent(data.liveRenderMarkup);
213 }
214 this.safelySetIframeURL(url);
215
We compare the new URL to the old URL here, to see whether we need to reset the src
attribute on the iframe. If it's unchanged, we leave the iframe alone.
218 return jdom`<div class="previewPanel" style="width:${this.paneWidth}%">
219 ${data.liveRenderMarkup === null ? (
220 jdom`<div class="topBar">
221 <div class="fixed button inputContainer">
222 <input value="${url}" onfocus="${this.selectInput}" />
223 </div>
224 <button class="button refreshButton" title="Refresh preview"
225 onclick="${this.handleRefresh}">↻</button>
226 <a class="button previewButton" title="Open preview in new tab"
227 target="_blank" href="${url}">Preview</a>
228 </div>`
229 ) : (
230 jdom`<div class="topBar">
231 <div class="unsavedWarning">
232 tap
233 <div class="fixed inline button">Save ${'&'} Reload</div>
234 to share <span class="mobile-hidden">→</span>
235 </div>
236 </div>`
237 )}
238 ${this.iframe}
239 </div>`;
240 }
241
242}
243
Shorthand function that inserts a script tag with a given source and returns a promise that resolves when the script is loaded and evaluated. We use this as a minimal module loader to bootstrap loading of editor modules.
247const loadScript = url => {
248 const tag = document.createElement('script');
249 tag.src = url;
250 tag.async = true;
251 return new Promise((res, _rej) => {
252 tag.addEventListener('load', res);
253 document.body.appendChild(tag);
254 });
255}
256
Editor implementations
258
Codeframe uses multiple different editor backends (EditorCore
implementations) to implement the actual code editor part of Codeframe. This is because... 1. It reduces reliance on one particular third-party editor. 2. It creates an interface under which new editor backends can be added / experimented with. 3. Different editor backends are best suited for different clients. We currently use two backends, Visual Studio Code's Monaco editor, which also runs CodeSandbox; and CodeMirror, which powers Glitch, Chrome DevTools, and more. You can also find a reference implementation of a bare-bones editor built on <textarea>
in static/js/editors/textarea.js
.
269
Monaco-based implementation of EditorCore
271class MonacoEditor {
272
273 constructor(callback) {
274 this.mode = 'html';
275
Frames are temporary storage of loaded / saved Codeframe source files before the editor itself has been loaded and initialized.
278 this.frames = {
279 html: '',
280 javascript: ``,
281 }
Monaco's core data structure backing each source file in the editor is the "model". Each model represents and holds edit data, history, etc. for one file. We keep track of two and swap between them in one editor instance.
285 this.models = {
286 html: null,
287 javascript: null,
288 }
289
290 this.container = document.createElement('div');
291 this.container.classList.add('editorContainer');
292 this.monacoEditor = null;
293
See setValue()
to see why this exists.
295 this._beingSetProgrammatically = false;
296
Load the core editor module loader, and begin bootstrapping the Monaco editor loading process.
299 loadScript('https://unpkg.com/monaco-editor/min/vs/loader.js').then(() => this._loadEditor(callback));
300 }
301
Loads the rest of the Monaco editor source code and initializes the editor.
303 _loadEditor(callback) {
304 require.config({
305 paths: {
306 vs: 'https://unpkg.com/monaco-editor/min/vs',
307 },
308 });
309 require(['vs/editor/editor.main'], () => {
Initialize models for both files
311 this.models.html = monaco.editor.createModel(this.frames.html, 'html');
312 this.models.javascript = monaco.editor.createModel(this.frames.javascript, 'javascript');
313
Define a custom theme
315 monaco.editor.defineTheme('cf-default', {
316 base: 'vs',
317 inherit: true,
318 rules: [
319 {token: '', foreground: '000000', background: 'ffffff'},
320 {token: 'comment', foreground: '888888', fontStyle: 'italic'},
321
Javascript-related highlights
323 {token: 'keyword', foreground: '3436bb', fontStyle: 'bold'},
324 {token: 'type', foreground: '038c6e'},
325 {token: 'string', foreground: 'b31515'},
326 {token: 'number', foreground: 'ec6a40'},
327
HTML/CSS-related highlights
329 {token: 'tag', foreground: '038c6e'},
330 {token: 'attribute.name', foreground: 'e40132'},
331 {token: 'attribute.value.html', foreground: '3436bb'},
332 ],
333 colors: {
334 'editor.background': '#ffffff',
335 'editor.lineHighlightBorder': '#f8f8f8',
336 'editor.lineHighlightBackground': '#f8f8f8',
337 'editorLineNumber.foreground': '#888888',
338 'editorLineNumber.activeForeground': '#333333',
339 'editorIndentGuide.background': '#ffffff',
340 'editorIndentGuide.activeBackground': '#ffffff',
341 },
342 });
343 monaco.editor.setTheme('cf-default');
344
Create one editor instance, in which we can swap between the two source file models being edited.
347 this.monacoEditor = monaco.editor.create(this.container, {
348 fontFamily: "'Menlo', 'Monaco', monospace",
349 });
350 this.setMode(this.mode);
351
352 callback(this);
353 });
354 }
355
356 _registerKeybindings(callbacks) {
For Monaco specifically, we capture the Ctrl/Cmd-S shortcut and call save on the frames being edited. This is specific to Monaco because CodeMirror does not currently have a way to extend the editor keybindings. We could also try to listen for different keyboard events in Workspace
and respond accordingly from there, but that would lead to inconsistencies between browsers/OS vendors and it would depend on the editor bubbling events up.
364 this.monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, callbacks.save);
365 }
366
The following interface methods are shared between all EditorCore
implementations
368
369 getValue(mode = this.mode) {
370 if (this.ready()) {
371 return this.models[mode].getValue();
372 } else {
373 return this.frames[mode];
374 }
375 }
376
377 setValue(value, mode = this.mode) {
378 if (this.ready()) {
379 if (this.models[mode].getValue() !== value) {
Monaco doesn't have the ability to distinguish between changes to editor content that happens because of user-initiated edits vs. programmatic edits using setValue()
. Since we only want to fire change event handlers for the former kind of change, we keep track of the "is this a programmatic change" state in this variable.
385 this._beingSetProgrammatically = true;
386 this.models[mode].setValue(value);
387 this._beingSetProgrammatically = false;
388 }
389 } else {
390 this.frames[mode] = value;
391 }
392 }
393
394 getMode() {
395 return this.mode;
396 }
397
398 setMode(mode) {
399 this.mode = mode;
400 if (this.ready()) {
401 this.monacoEditor.setModel(this.models[mode]);
402 }
403 }
404
405 addChangeHandler(handler) {
406 if (this.ready()) {
407 const userChangeHandler = () => {
408 if (!this._beingSetProgrammatically) {
409 handler();
410 }
411 }
In Monaco, there isn't change events in the editor, but instead change events in the models, which we listen for.
414 this.models.html.onDidChangeContent(userChangeHandler);
415 this.models.javascript.onDidChangeContent(userChangeHandler);
416 }
417 }
418
419 getContainer() {
420 return this.container;
421 }
422
423 resize() {
424 if (this.ready()) {
425 this.monacoEditor.layout();
426 }
427 }
428
429 ready() {
430 return this.monacoEditor !== null;
431 }
432
433}
434
CodeMirror-based implementation of EditorCore
, which is enabled on mobile browsers because Monaco is not mobile browser-compatible. This implementation is also slightly faster on slower networks.
438class CodeMirrorEditor {
439
440 constructor(callback) {
441 this.mode = 'html';
442
443 this.frames = {
444 html: '',
445 javascript: ``,
446 }
The Document is CodeMirror's data structure for storing file and editor contents, edit history, etc. To swap between files / modes, we swap between these two documents.
450 this.documents = {
451 html: null,
452 javascript: null,
453 }
454
455 this.container = document.createElement('div');
456 this.container.classList.add('editorContainer');
457 this.codeMirrorEditor = null;
458
This bootstraps the loading process for the CodeMirror editor.
460 this._loadEditor(callback);
461 }
462
463 async _loadEditor(callback) {
We fix the version number here because CM 6 is going to have breaking API changes, and it's unclear at time of writing if that'll be released under the same package.
467 const EDITOR_SCRIPT_ROOT = 'https://unpkg.com/codemirror@5';
468 const EDITOR_SCRIPT_SRC = EDITOR_SCRIPT_ROOT + '/lib/codemirror.js';
469 const LANG_SCRIPT_SRC = [
470 'javascript',
xml
and css
are dependencies of htmlmixed
, the standalone (not embedded) HTML language mode for CM.
473 'xml',
474 'css',
475 'htmlmixed',
476 ].map(mode => `${EDITOR_SCRIPT_ROOT}/mode/${mode}/${mode}.js`);
477 const AUX_SRC = [
478 'edit/closetag.js',
479 'edit/closebrackets.js',
480 ].map(path => `${EDITOR_SCRIPT_ROOT}/addon/${path}`);
481
CodeMirror has an accompanying stylesheet. We load this manually here.
483 const styleLink = document.createElement('link');
484 styleLink.rel = 'stylesheet';
485 styleLink.href = EDITOR_SCRIPT_ROOT + '/lib/codemirror.css';
486 document.head.appendChild(styleLink);
487
These must load in this order -- language modes must load after the core editor is loaded, but once the core editor is loaded, everything else can load in parallel.
491 await loadScript(EDITOR_SCRIPT_SRC);
492 await Promise.all([
493 ...LANG_SCRIPT_SRC,
494 ...AUX_SRC,
495 ].map(loadScript));
496
497 this.documents.html = CodeMirror.Doc(this.frames.html, 'htmlmixed');
498 this.documents.javascript = CodeMirror.Doc(this.frames.javascript, 'javascript');
499
500 this.codeMirrorEditor = CodeMirror(this.container, {
501 indentUnit: 4,
502 tabSize: 4,
503 lineWrapping: false,
504 lineNumbers: true,
505 indentWithTabs: true,
506
507 // provided by edit/closetag.js
508 autoCloseTags: true,
509
510 // provided by edit/closebrackets.js
511 autoCloseBrackets: true,
512 });
We swap the document in after instantiation rather than in creation, because the latter option strips the document of its language mode. This seems like a poorly documented behavior of CM.
516 this.setMode(this.mode);
517
518 callback(this);
519 }
520
521 getValue(mode = this.mode) {
522 if (this.ready()) {
523 return this.documents[mode].getValue();
524 } else {
525 return this.frames[mode];
526 }
527 }
528
529 setValue(value, mode = this.mode) {
530 if (this.ready()) {
531 if (this.documents[mode].getValue() !== value) {
532 this.documents[mode].setValue(value);
533 }
534 } else {
535 this.frames[mode] = value;
536 }
537 }
538
539 getMode() {
540 return this.mode;
541 }
542
543 setMode(mode) {
544 this.mode = mode;
545 if (this.ready()) {
546 this.codeMirrorEditor.swapDoc(this.documents[mode]);
547 }
548 }
549
550 addChangeHandler(handler) {
551 if (this.ready()) {
552 this.codeMirrorEditor.on('changes', (_, changeEvent) => {
We want to avoid triggering change events not generated directly from user input. i.e. we don't want to hit change handlers when we reload or reroute.
556 if (changeEvent[0].origin !== 'setValue') {
557 handler();
558 }
559 });
560 }
561 }
562
563 getContainer() {
564 return this.container;
565 }
566
567 resize() {
568 this.codeMirrorEditor.setSize();
569 }
570
571 ready() {
572 return this.codeMirrorEditor !== null;
573 }
574
575}
576
Depending on the client, pick the editor implementation that provides the best experience.
578const EditorCore = navigator.userAgent.match(/(Android|iPhone|iPad|iPod)/) ? CodeMirrorEditor : MonacoEditor;
579
The Editor
component encapsulates the editor (currently Monaco or CodeMirror), all file state and undo/redo histories, syntax highlights and other state about the files the user is editing.
583class Editor extends StyledComponent {
584
585 init(frameRecord) {
Percentage of the editor size that the editor pane takes up.
587 this.paneWidth = 50;
588
Initial values for extra settings
590 this.settings = {
Is the config bar visible?
592 visible: false,
Is the as-you-type live reload enabled?
594 asYouTypeEnabled: true,
595 }
596
Frame hashes of the last-saved editor values, to see if we need to re-fetch frame file values when the route changes.
599 this._lastHash = '';
600 this.initEditorCore();
601
602 this.bind(frameRecord, data => {
603 this.fetchFrames(data);
604 this.render(data);
605 });
606
We deep link to the editor tab using the URL hash, so restore the state here from when the page first loaded.
609 if (loadedURLState.tab !== '') {
610 this.switchMode(loadedURLState.tab);
611 }
612
Any calls to resizeEditor
should be debounced to 250ms. This is negligible to UX in fast modern laptops, but has a noticeable impact on UX for lower-end devices.
615 this.resizeEditor = debounce(this.resizeEditor.bind(this), 250);
616 window.addEventListener('resize', this.resizeEditor);
617
We create these two methods that are versions of switchMode
bound to specific editing modes, for easier use later on in compose()
.
620 this.switchHTMLMode = this.switchMode.bind(this, 'html');
621 this.switchJSMode = this.switchMode.bind(this, 'javascript');
622 this.saveFrames = this.saveFrames.bind(this);
623
Bind other methods used as callbacks
625 this.toggleSettings = this.toggleSettings.bind(this);
626 this.toggleAsYouType = this.toggleAsYouType.bind(this);
627
Live-rendering (previewing unsaved changes) should be debounced, since we don't want to re-render the iframe with every keystroke, for example. But we leave an escape hatch with *Immediate for forced re-renders, etc.
631 this.liveRenderFramesImmediate = this.liveRenderFrames.bind(this);
632 this.liveRenderFrames = debounce(this.liveRenderFramesImmediate, 750);
633 }
634
635 remove() {
636 window.removeEventListener('resize', this.resizeEditor)
637 }
638
639 setWidth(width) {
640 this.paneWidth = width;
641 this.resizeEditor();
642 this.render();
643 }
644
645 fetchFrames(data) {
fetchFrames
is in charge of (1) determining based on new data whether we need to re-fetch HTML and JS files for the current version of the Codeframe (or if we have it already) and making those requests and saving the results to the view state. We are careful here to first check if the frames we are looking to fetch aren't the ones already saved in the editor.
650 if (
651 data.htmlFrameHash + data.jsFrameHash !== this._lastHash
652 && data.htmlFrameHash
653 ) {
654 this._lastHash = data.htmlFrameHash + data.jsFrameHash;
655 api.get(`/frame/${data.htmlFrameHash}`).then(resp => {
656 return resp.text();
657 }).then(result => {
This checks against a race bug, where more recent "live" edits have been made since we began to fetch frames.
660 if (this.record.get('liveRenderMarkup') === null) {
661 this.core.setValue(result, 'html');
662 }
663 }).catch(e => api.errlog(e));
664
665 api.get(`/frame/${data.jsFrameHash}`).then(resp => {
666 return resp.text();
667 }).then(result => {
This checks against a race bug. See above.
669 if (this.record.get('liveRenderMarkup') === null) {
670 this.core.setValue(result, 'javascript');
671 }
672 }).catch(e => api.errlog(e));
673 }
674 }
675
Initialize the given editor implementation.
677 initEditorCore() {
678 this.core = new EditorCore(core => {
Here we populate potential prefilled values from the URL query strings, and force-live-render it into the preview to make those dirty changes visible without saving these examples to the server.
682 if (loadedURLState.html || loadedURLState.js) {
683 core.setValue(loadedURLState.html, 'html');
684 core.setValue(loadedURLState.js, 'javascript');
685 this.liveRenderFramesImmediate();
686 }
687 core.addChangeHandler(this.liveRenderFrames);
688
689 // Monaco-specific, so this isn't super clean, but is the best
690 // solution I could come up with without breaking encapsulation
691 // of the EditorCore interface..
692 if ('_registerKeybindings' in core) {
693 core._registerKeybindings({
694 save: this.saveFrames,
695 });
696 }
697
698 this.render();
699 this.resizeEditor();
700 });
701 }
702
703 resizeEditor() {
704 this.core.resize();
705 }
706
707 switchMode(mode) {
When we switch modes, we have to first save the value of the file being edited, then switch out the underlying file model.
710 this.core.setMode(mode);
711 this.render();
We deep link to the editor tab using the URL hash, so save the new mode state to the hash.
714 window.history.replaceState(null, document.title, '#' + mode);
715 gevent('editor', 'switchmode', mode);
716 }
717
liveRenderFrames()
is called when the editor content changes, to client-side refresh the iframe preview contents.
720 liveRenderFrames() {
721 if (!this.settings.asYouTypeEnabled) {
722 return;
723 }
724
725 const documentMarkup = `<!DOCTYPE html>
726<html>
727 <head>
728 <meta charset="utf-8"/>
729 <meta name="viewport" content="width=device-width,initial-scale=1"/>
730 <title>Live Frame | Codeframe</title>
731 </head>
732 <body>
733 ${this.core.getValue('html')}
734 <script src="https://unpkg.com/torus-dom/dist/index.min.js"></script>
735 <script>${this.core.getValue('javascript')}</script>
736 </body>
737</html>`;
738
739 this.record.update({
740 liveRenderMarkup: documentMarkup,
741 });
742 }
743
744 toggleSettings() {
745 this.settings.visible = !this.settings.visible;
746 this.render();
The settings bar changes the editor view size, so we need to re-layout the editor after render.
749 this.resizeEditor();
750 }
751
752 toggleAsYouType() {
753 this.settings.asYouTypeEnabled = !this.settings.asYouTypeEnabled;
754 this.render();
In case there are any current dirty changes when this is toggled on...
756 if (this.settings.asYouTypeEnabled) {
757 this.liveRenderFramesImmediate();
758 }
759 gevent('editor', 'settings.asyoutype', this.settings.asYouTypeEnabled.toString());
760 }
761
saveFrames
handles saving / persisting Codeframe files to the backend service, and returns a promise that resolves only once all frame files have been saved.
764 async saveFrames() {
765 const hashes = {
766 html: '',
767 js: '',
768 }
This is a nice way to make the function hang (not resolve its returned Promise) until all of its requests have resolved.
771 await Promise.all([
772 api.post('/frame/', this.core.getValue('html'))
773 .then(resp => resp.text())
774 .then(hash => hashes.html = hash)
775 .catch(e => api.errlog(e)),
776 api.post('/frame/', this.core.getValue('javascript'))
777 .then(resp => resp.text())
778 .then(hash => hashes.js = hash)
779 .catch(e => api.errlog(e)),
780 ]);
Once we've saved the current frames, open the new frames up in the preview pane by going to this route with the new frames.
783 const route = `/h/${hashes.html}/j/${hashes.js}/edit`;
784 if (window.location.pathname.startsWith(route)) {
785 this.record.update({
786 liveRenderMarkup: null,
787 });
788 } else {
789 router.go(route);
790 }
791 gevent('editor', 'save');
792 }
793
794 styles() {
795 return css`
796 height: 100%;
797 flex-grow: 1;
798 flex-shrink: 1;
799 overflow: hidden;
800 border-left: 2px solid var(--cf-black);
801 display: flex;
802 flex-direction: column;
803 @media (max-width: 750px) {
804 /* Overriding inline style */
805 width: 100% !important;
806 height: 50%;
807 border-left: 0;
808 border-top: 2px solid var(--cf-black);
809 }
810 .topBar {
811 display: flex;
812 flex-direction: row;
813 justify-content: space-between;
814 align-items: center;
815 padding: 0 3px;
816 border-bottom: 4px dotted var(--cf-black);
817 flex-shrink: 0;
818 height: 52px;
819 overflow-x: auto;
820 .embedded & {
821 background: var(--cf-background);
822 }
823 }
824 .buttonGroup {
825 display: flex;
826 flex-direction: row;
827 align-items: center;
828 }
829 .button {
830 font-size: .75rem;
831 }
832 .editorContainer .button {
833 border: 0;
834 background: transparent;
835 height: unset;
836 width: unset;
837 &::before,
838 &::after {
839 display: none;
840 }
841 }
842 .editorContainer,
843 .ready {
844 width: 100%;
845 /* dummy size, flex will resize
846 * but CodeMirror needs a set size.
847 * set to 0 to avoid flex oversize
848 * issues on Android Chrome on focus */
849 height: 0;
850 flex-shrink: 1;
851 flex-grow: 1;
852 }
853 .ready {
854 display: flex;
855 flex-direction: row;
856 justify-content: center;
857 align-items: center;
858 color: #555;
859 em {
860 display: block;
861 }
862 }
863 .CodeMirror {
864 height: 100%;
865 font-family: 'Menlo', 'Monaco', 'Courier', monospace;
866 font-size: 12px;
867 line-height: 1.5em;
868 &-gutters {
869 background: #fff;
870 border-right: 0;
871 }
872 &-lines {
873 padding-bottom: 60vh;
874 }
875 &-cursor {
876 border-width: 2px;
877 }
878 &-linenumber {
879 padding-right: 12px;
880 }
881
882 span.cm-comment {
883 color: #888;
884 }
885 span.cm-keyword {
886 color: #3436bb;
887 font-weight: bold;
888 }
889 span.cm-def {
890 color: #038c6e;
891 }
892 span.cm-string,
893 span.cm-string-2 {
894 color: #b31515;
895 }
896 span.cm-number {
897 color: #ec6140;
898 }
899 span.cm-tag {
900 color: #038c6e;
901 }
902 span.cm-attribute {
903 color: #e40132;
904 }
905 }
906 textarea.editorContainer {
907 font-family: 'Menlo', 'Monaco', 'Courier', monospace;
908 font-size: 12px;
909 line-height: 16px;
910 box-sizing: border-box;
911 padding: 4px 8px;
912 -webkit-overflow-scrolling: touch;
913 padding-bottom: 60vh;
914 &:focus {
915 outline: none;
916 }
917 }
918 `;
919 }
920
921 compose() {
922 const mode = this.core.getMode();
923
924 return jdom`<div class="editor" style="width:${this.paneWidth}%">
925 <div class="topBar">
926 <div class="buttonGroup">
927 <button
928 class="button ${mode === 'html' ? 'active' : ''} tab-html"
929 title="Switch to HTML editor"
930 onclick="${this.switchHTMLMode}">
931 HTML
932 </button>
933 <button
934 class="button ${mode === 'javascript' ? 'active' : ''} tab-js"
935 title="Switch to JavaScript editor"
936 onclick="${this.switchJSMode}">
937 JavaScript
938 </button>
939 </div>
940 <div class="buttonGroup">
941 <button class="button ${this.settings.visible ? 'active' : ''}" title="Show additional settings"
942 onclick="${this.toggleSettings}">...</button>
943 <button class="button" title="Save Codeframe and reload preview"
944 onclick="${this.saveFrames}">Save ${'&'} Reload</button>
945 </div>
946 </div>
947 ${this.settings.visible ? (
948 jdom`<div class="topBar">
949 <button class="button ${this.settings.asYouTypeEnabled ? 'active' : ''}"
950 title="Turn on or off live-reloading as you type"
951 onclick="${this.toggleAsYouType}">
952 Reload as you type
953 ${this.settings.asYouTypeEnabled ? '(on)' : '(off)'}
954 </button>
955 </div>`
956 ) : null}
957 ${this.core.ready() ? this.core.getContainer() : (
If the editor is not yet available (is still loading), show a placeholder bit of text.
960 jdom`<div class="ready"><em>Getting your editor ready...</em></div>`
961 )}
962 </div>`;
963 }
964
965}
966
The Workspace
component is the "Codeframe editor". That is to say, this is the component that wraps everything into a neat, interactive page and is the backbone of the Codeframe editing experience.
970class Workspace extends StyledComponent {
971
972 init(frameRecord) {
The left/right panes are split 50/50 between its two panes.
974 this.paneSplit = 50;
975 this.preview = new PreviewPane(frameRecord);
976 this.editor = new Editor(frameRecord);
977
978 this.bind(frameRecord, data => this.render(data));
979
this.grabDragging
represents whether there is currently a drag-to-resize-panes interaction happening.
982 this.grabDragging = false;
983 this.handleGrabMousedown = this.handleGrabMousedown.bind(this);
984 this.handleGrabMousemove = this.handleGrabMousemove.bind(this);
985 this.handleGrabMouseup = this.handleGrabMouseup.bind(this);
986
987 window.addEventListener('beforeunload', evt => {
988 if (this.record.get('liveRenderMarkup') !== null) {
989 evt.returnValue = 'You have unsaved changes to your Codeframe. Are you sure you want to leave?';
990 }
991 });
992 }
993
Shortcut method to set the sizes of children components, given one split percentage.
996 setPaneWidth(width) {
These cases implement "snapping" of panes to 0, 50, 100% increments, with 1% buffer around each snap point.
999 if (width < 1) {
1000 width = 0;
1001 } else if (width > 99) {
1002 width = 100;
1003 } else if (width > 49 && width < 51) {
1004 width = 50;
1005 }
1006
1007 this.paneSplit = width;
1008 this.preview.setWidth(width);
1009 this.editor.setWidth(100 - width);
1010 this.render();
1011 }
1012
What follows are mouse/pointer event handlers to make resizing a smooth experience.
1015
1016 handleGrabMousedown() {
1017 this.grabDragging = true;
1018 this.render();
1019 }
1020
1021 handleGrabMousemove(evt) {
1022 if (this.grabDragging) {
1023 if (!evt.defaultPrevented) {
1024 evt.preventDefault();
1025 }
If the event is a touch event, get the clientX
of the first touch point, not the whole event.
1028 if (evt.touches) {
1029 evt = evt.touches[0];
1030 }
1031 this.setPaneWidth(evt.clientX / window.innerWidth * 100);
1032 }
1033 }
1034
1035 handleGrabMouseup() {
1036 this.grabDragging = false;
1037 this.render();
1038 gevent('workspace', 'resize', 'editor', this.paneSplit);
1039 }
1040
1041 styles() {
1042 return css`
1043 width: 100%;
1044 height: 100%;
1045 display: flex;
1046 flex-direction: column;
1047 header {
1048 display: flex;
1049 flex-direction: row;
1050 justify-content: space-between;
1051 align-items: center;
1052 flex-shrink: 0;
1053 padding: 4px;
1054 background: var(--cf-background);
1055 border-bottom: 4px solid var(--cf-black);
1056 }
1057 &.embedded header {
1058 display: none;
1059 }
1060 nav {
1061 display: flex;
1062 flex-direction: row;
1063 flex-shrink: 1;
1064 overflow-x: auto;
1065 }
1066 main {
1067 display: flex;
1068 flex-direction: row;
1069 justify-content: space-between;
1070 align-items: flex-start;
1071 flex-grow: 1;
1072 flex-shrink: 1;
1073 overflow: hidden;
1074 position: relative;
1075 height: 100%;
1076 @media (max-width: 750px) {
1077 flex-direction: column;
1078 }
1079 }
1080 .button {
1081 white-space: nowrap;
1082 }
1083 .newButton {
1084 color: var(--cf-accent);
1085 }
1086 .grabHandle {
1087 position: absolute;
1088 top: 50%;
1089 left: 0;
1090 transform: translate(-50%, -50%);
1091 border-radius: 4px;
1092 width: 8px;
1093 height: 56px;
1094 background: #fff;
1095 border: 3px solid var(--cf-black);
1096 cursor: ew-resize;
1097 z-index: 2000;
1098 &:hover {
1099 background: var(--cf-background);
1100 }
1101 &:active {
1102 background: var(--cf-accent);
1103 }
1104 }
1105 .grabHandleShadow {
1106 background: rgba(0, 0, 0, .1);
1107 position: absolute;
1108 top: 0;
1109 left: 0;
1110 right: 0;
1111 bottom: 0;
1112 z-index: 1000;
1113 transition: opacity .3s;
1114 cursor: ew-resize;
1115 &.hidden {
1116 opacity: 0;
1117 pointer-events: none;
1118 }
1119 }
1120 .fullButton {
1121 display: none;
1122 }
1123 &.embedded .fullButton {
1124 display: block;
1125 position: absolute;
1126 top: unset;
1127 left: unset;
1128 bottom: 2px;
1129 right: 6px;
1130 font-size: .75em;
1131 z-index: 100;
1132 }
1133 `;
1134 }
1135
1136 compose() {
1137 return jdom`
1138 <div class="workspace ${window.frameElement === null ? '' : 'embedded'}"
1139 ontouchmove="${this.grabDragging ? this.handleGrabMousemove : ''}"
1140 ontouchend="${this.grabDragging ? this.handleGrabMouseup : ''}"
1141 onmousemove="${this.grabDragging ? this.handleGrabMousemove : ''}"
1142 onmouseup="${this.grabDragging ? this.handleGrabMouseup : ''}">
1143 <header>
1144 <div class="logo">
1145 <a class="button" href="/">Codeframe</a>
1146 </div>
1147 <nav>
1148 <a class="button newButton" href="/new?from=editor">
1149 + New <span class="mobile-hidden">Codeframe</span>
1150 </a>
1151 <a class="button tiny-hidden" href="https://twitter.com/thesephist" target="_blank">
1152 <span class="mobile-hidden">Made by</span> @thesephist
1153 </a>
1154 <a class="button" href="https://github.com/thesephist/codeframe" target="_blank">
1155 <span class="mobile-hidden">View Source on</span> GitHub
1156 </a>
1157 </nav>
1158 </header>
1159 <main>
1160 ${this.preview.node}
1161 ${this.editor.node}
1162 <a class="button fullButton"
1163 title="Open this editor in a new tab"
1164 target="_blank"
1165 href="${window.location.href}">
1166 Open in new tab
1167 </a>
1168 <div class="grabHandleShadow ${this.grabDragging ? '' : 'hidden'}"></div>
1169 <div
1170 title="Resize editor panes"
1171 class="grabHandle mobile-hidden"
1172 style="left:${this.paneSplit}%"
1173 ontouchstart="${this.handleGrabMousedown}"
1174 onmousedown="${this.handleGrabMousedown}">
1175 </div>
1176 </main>
1177 </div>`;
1178 }
1179
1180}
1181
The App
component simply wraps around the Workspace
to provide routing and a container for the workspace.
1184class App extends StyledComponent {
1185
1186 init(router) {
This frameRecord
becomes the view model for most views in the editor.
1189 this.frameRecord = new Record({
1190 htmlFrameHash: '',
1191 jsFrameHash: '',
1192 liveRenderMarkup: null,
1193 });
1194 this.workspace = new Workspace(this.frameRecord);
1195
Routing logic.
1197 this.bind(router, ([name, params]) => {
1198 switch (name) {
1199 case 'edit':
1200 this.frameRecord.update({
1201 htmlFrameHash: params.htmlFrameHash,
1202 jsFrameHash: params.jsFrameHash,
1203 liveRenderMarkup: null,
1204 });
1205 break;
1206 case 'welcome':
This is a predetermined URL that points to the welcome Codeframe.
1208 router.go('/h/34257cad6ac3/j/e3b0c44298fc/edit', {replace: true});
1209 break;
1210 default:
1211 {
If there are prefilled HTML and JavaScript values provided in the query string, parse and save it for later when the editor loads.
1215 const searchPart = window.location.search.substr(1);
1216 if (searchPart) {
1217 for (const pair of searchPart.split('&')) {
1218 const idx = pair.indexOf('=');
1219 const langPart = pair.substr(0, idx);
1220 if (langPart in loadedURLState) {
1221 loadedURLState[langPart] = decodeURIComponent(pair.substr(idx + 1));
1222 }
1223 }
1224 }
1225 }
When we redirect some URL right on page load, we want that new URL to replace the old, given URL value, not append a new history entry. So replace the history entry, not append. In this case, we redirect to the URL of an empty Codeframe
1230 router.go(`/h/e3b0c44298fc/j/e3b0c44298fc/edit`, {replace: true});
1231 break;
1232 }
1233 })
1234 }
1235
1236 styles() {
1237 return css`
1238 width: 100%;
1239 height: 100vh;
1240 display: flex;
1241 flex-direction: column;
1242 overflow: hidden;
1243 `;
1244 }
1245
1246 compose() {
1247 return jdom`<div id="root">
1248 ${this.workspace.node}
1249 </div>`;
1250 }
1251
1252}
1253
Used to save some global values for when the URL contains a pre-populated code sample or tab state saved in the hash.
1256const loadedURLState = {
1257 html: '',
1258 js: '',
1259 tab: window.location.hash.substr(1),
1260}
1261
1262const router = new Router({
1263 edit: '/h/:htmlFrameHash/j/:jsFrameHash/edit',
1264 welcome: '/welcome',
1265 default: '/new',
1266});
1267
Create a new instance of the editor app and mount it to the DOM.
1269const app = new App(router);
1270document.body.appendChild(app.node);
1271
Since the editor is a full-window app, we don't want any overflows to make the app unnecessary scrollable beyond its viewport.
1274document.body.style.overflow = 'hidden';
1275