./static/js/main.js annotated source

Back to index

        

Codeframe'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