Menambah Fitur Manual Preview pada HTML Editor

8 min read

Rilis HTML Editor Manual Preview
HTML Editor (Ilustrasi: Rio Astamal)

TeknoCerdas.com – Salam cerdas untuk kita semua. Pada tulisan ini saya akan mengajak anda untuk membahas bagaimana saya menambah fitur manual preview pada HTML Editor v1.3 yang saya buat. Mulai dari kode yang diubah dan bagaimana proses rilis saya lakukan.

HTML Editor adalah sebuah teks editor HTML yang dijalankan pada web browser. HTML Editor ini memiliki syntax highlighting dan instan preview window. Jadi kode HTML yang diketik akan langsung otomatis terlihat tampilannya. Aplikasi ini juga tidak memerlukan koneksi internet dan hanya terdiri dari sebuah file HMTL saja, sangat ringan dan portable.

Baca Juga
Membuat HTML Editor dengan Javascript Dilengkapi Instant Preview

HTML Editor adalah project open source dan kode sumbernya dapat dilihat pada GitHub akun saya yaitu github.com/rioastamal/html-editor.

Daftar Isi

0. Pendahuluan

Saat ini setiap mengetik pada window HTML Editor maka preview window otomatis akan diupdate karena fitur instan preview. Namun terkadang hal ini juga boros network request jika kode HTML yang diketik terdapat kode yang melakukan request assets eksternal. Tiap ketikan huruf akan mengupdate preview yang otomatis melakukan request assets.

Untuk itulah fitur Manual Preview akan dihadirkan. Sehingga pengguna punya pilihan instant preview diaktifkan atau tidak. Jika kode HTML banyak melakukan ekternal request maka pengembang punya opsi untuk mematikannya dan melakukan update preview secara manual.

1. Menambahkan Menu Baru

Langkah pertama yang paling mudah adalah menambahkan menu baru untuk mengaktifkan mode Manual Preview. Diatas item menu Contribute kita akan menambahkan menu baru tersebut.

<li>
    <a id="menu-manual-preview" href="#" data-manual-preview="no">Manual Preview</a>
    <span>Ctrl+Shift+M</span>
    <span>(CTRL+Shift+U to Update Preview)</span>
</li>

Tampilan tambahan menu baru tersebut akan seperti berikut:

HTML Editor Menu Preview
Menambahkan Menu Manual Preview

2. Mengubah CodeMirror Event Handler

Saat ini setiap ketukan keyboard yang menyebabkan perubahan kode pada Window HTML Editor akan direfleksikan pada window Preview. Hal ini karena setiap ada perubahan pada object CodeMirror cm preview akan diupdate.

cm.on('change', function() {
    updatePreview(cm.getValue());
});

Ketika menu Manual Preview diklik maka kode yang melakukan instan preview harus dihapus dari event handler change pada object CodeMirror. Untuk penghapusan event handler tidak dapat menggunakan fungsi anonymous tetapi harus diletakkan pada variabel. Untuk itu dibuat global variabel baru yaitu onCmUpdate.

...
var editorWrapper = document.getElementsByClassName('editor')[0];
var onCmUpdate = null;
var cm;
...

Pada saat pertama kali halaman di-load maka nilai onCmUpdate kita isi dengan updatePreview(cm.getValue()) agar digunakan untuk penghapusan event handler.

Kode pada saat halaman di-load berubah menjadi berikut.

window.addEventListener('DOMContentLoaded', function(e)
{
  cm = CodeMirror.fromTextArea(editor, {
      lineNumbers: true,
      styleActiveLine: true,
      mode: 'text/html',
      theme: 'monokai',
  });

  onCmUpdate = function() {
    updatePreview(cm.getValue());
  };

  cm.on('change', onCmUpdate);

  updatePreview(cm.getValue());
});

3. Event Click Menu dan Keyboard Shortcut

Kita akan menambahkan handler ketika menu Manual Preview di-klik. Ketika diklik maka flag untuk aktivasi Manual Preview juga berubah. Flag ini disimpan pada elemen anchor dengan atribut data-manual-preview.

document.getElementById('menu-manual-preview').onclick = function(e)
{
    var currentManualPreviewStatus = this.getAttribute('data-manual-preview');

    if (currentManualPreviewStatus == 'no') {
        this.setAttribute('data-manual-preview', 'yes');
        this.innerHTML = 'Manual Preview ✓';

        cm.off('change', onCmUpdate);

        return false;
    }

    this.setAttribute('data-manual-preview', 'no');
    this.innerHTML = 'Manual Preview';

    cm.on('change', onCmUpdate);

    return false;
}

Ketika di-klik, jika sekarang flag bernilai no berarti Manual Preview belum aktif, maka aktifkan Manual Preview dengan menghapus event handler change pada object CodeMirror cm. Begitu sebaliknya.

Selain dari menu kita ingin menambahkan shortcut untuk mengaktifkan mode Manual Preview yaitu dengan kombinasi tombol CTRL+Shift+M. Untuk mengupdate window preview dapat digunakan kombinasi tombol CTRL+Shift+U.

...
if (e.ctrlKey && e.shiftKey && theChar == 'M') {
    document.getElementById('menu-manual-preview').click();
}

if (e.ctrlKey && e.shiftKey && theChar == 'U') {
    updatePreview(cm.getValue());
}
...

4. Mencoba Hasil dengan Build

Untuk mencoba kode-kode yang baru dapat dilakukan build file index.html. Cara melakukannya adalah dengan menjalankan file build.sh.

$ bash build.sh
Build file build/index.html complete.

Sekarang coba buka file tersebut dengan browser dan mencoba fitur Manual Preview yang baru saja diimplementasikan. Setelah semuanya berjalan sesuai ekspektasi maka saatnya melakukan commit.

$ git add src/index.html
$ git commit -m "New feature: Add option to manually trigger the preview"

5. Update README

Kita lanjutkan dengan melakukan update README file. Karena pada project ini Change Log berada pada file tersebut dan tidak berdiri sendiri. Update Change Log yang dilakukan adalah keterangan fitur baru yang ditambahkan yaitu Manual Preview. Setelah itu commit file tersebut.

$ git add README.md
$ git commit -m "Update README for v1.3 feature: Manually trigger preview"

6. Peningkatan Versi dari 1.2 ke 1.3

Kemudian lanjut dengan update version number pada file build.sh. Versi diubah dari 1.2 ke 1.3 karena ini adalah penambahan fitur minor yang tidak secara signifikan mengubah tampilan atau code base.

...
BUILD_FILE=build/index.html
APP_VERSION=1.3

cp src/index.html $BUILD_FILE
...

Kemudian commit file tersebut.

$ git add build.sh
$ git commit -m "Build: Bump version to 1.3"

7. Rilis Versi 1.3

Rilis HTML Editor terbaru ditandai dengan melakukan git tag untuk versi 1.3 dan melakukan push kode ke repository di GitHub.

$ git tag -a v1.3
Release v1.3

- Add option to manually trigger preview
$ git push origin HEAD
$ git push origin --tags

Penambahan fitur pada HTML Editor v1.3 telah diselesaikan. Selain di GitHub HTML Editor juga saya deploy pada website saya pribadi di alamat URL https://rioastamal.net/html-editor/.

Berikut ini adalah kode lengkap perubahan pada file index.html yang dilakukan pada versi 1.3.

<!DOCTYPE html>
<html>
<head>
<title>{{OG_TITLE}}</title>
<meta name="google" content="notranslate">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{META_DESCRIPTION}}">
<meta name="keywords" content="{{META_KEYWORDS}}">
<meta property="og:title" content="{{OG_TITLE}}">
<meta property="og:description" content="{{OG_DESCRIPTION}}">
<meta property="og:url" content="{{OG_URL}}">
<meta property="og:image" content="{{OG_IMAGE}}">
<meta charset="utf-8">
<style>
html {
    height: 100%;
}
* {
    margin: 0;
    padding: 0;
}
body {
    font-family: "Georgia", "Arial", "Serif";
    width: 100%;
    position: relative;
    min-height: 100%;
    box-sizing: border-box;
    height: 100%;
    color: #999;
    background-color: #272822;
}
a, a:hover {
    color: inherit;
    text-decoration: none;
}
a.button {
    padding: 0.5em 1em;
    background-color: #333;
    margin-left: 2em;
}
h2.title {
    display: block;
    width: 100%;
    padding: 0.5em 0.5em 0.5em 1em;
    font-size: 1.2em;
    font-weight: normal;
    box-sizing: border-box;
}
.text-center {
    text-align: center;
}
.hide {
    display: none;
}
.border-bottom {
    border-bottom: 1px solid #333;
}
.menu-icon {
    box-sizing: border-box;
    position: absolute;
    left: 0;
    top: 0.3em;
    z-index: 10;
}
.linemenu {
    width: 24px;
    height: 4px;
    background-color: #999;
    margin: 4px 0;
    display: block;
}
.menu {
    width: 100%;
    height: 100%;
    position: absolute;
    left: 0;
    top: 0;
    z-index: 10;
    box-sizing: border-box;
    min-height: 100%;
    padding-top: 2em;
    padding-right: 2em;
    display: none;
    background-color: inherit;
}
ul.menu-list {
    list-style: none;
    padding-left: 1.4em;
}
ul.menu-list li {
    padding: 0.2em 0.5em 0.4em 0.5em;
    border-bottom: 1px solid #333;
    margin-bottom: 0.5em;
}
ul.menu-list li a {
    display: block;
}
ul.menu-list li span {
    display: block;
    font-size: 0.6em;
    padding-top: 0.5em;
}
.menu:target {
    display: block;
}
.menu-close {
    padding: 0.5em;
    position: absolute;
    top: 0;
    right: 1em;
    background-color: black;
}
.menu-footer {
    font-size: 0.7em;
    position: absolute;
    bottom: 1.5em;
    width: 100%;
}
.menu-footer span {
    display: block;
    margin-bottom: 0.4em;
    text-align: center;
}
.menu-footer span a {
    color: #fff;
}
</style>
<!-- BEGIN CODEMIRROR -->
<script src="lib/codemirror.js"></script>
<script src="mode/xml.js"></script>
<script src="mode/javascript.js"></script>
<script src="mode/css.js"></script>
<script src="mode/htmlmixed.js"></script>
<script src="mode/active-line.js"></script>
<link rel="stylesheet" href="css/00-codemirror.css">
<link rel="stylesheet" href="css/monokai.css">
<!-- END CODEMIRROR -->
<style>
.editor, .preview, .CodeMirror {
    height: 100%;
    width: 100%;
    position: relative;
}
.editor {
    padding-bottom: 4em;
}
.preview #preview {
    height: 100%;
    width: 100%;
    border: none;
    background-color: #fff;
}
@media screen and (min-width: 720px) {
    .editor {
        width: 50%;
        float: left;
        padding-bottom: 0;
    }
    .menu {
        width: 300px;
        border-right: 1px dotted #999;
        border-bottom: 1px dotted #999;
    }
    .preview {
        width: 50%;
        float: left;
    }
    .full-width {
        width: 100%;
        float: initial;
    }
}
</style>
</style>
</head>
<body>
<!-- MENU ICON -->
<div id="menu-icon" class="menu-icon">
    <a title="Klik untuk membuka menu" href="#menu">
        <span class="linemenu"></span>
        <span class="linemenu"></span>
        <span class="linemenu"></span>
    </a>
</div>

<!-- MENU WINDOW -->
<div id="menu" class="menu">
    <div class="menu-close"><a title="Tutup menu" href="#">X</a></div>
    <ul class="menu-list">
        <li>
            <a id="menu-open-file" href="#">Open File...</a>
            <span>Ctrl+Shift+O</span>
        </li>
        <li>
            <a id="menu-save-file" href="#">Save As...</a>
            <span>Ctrl+Shift+S</span>
        </li>
        <li>
            <a id="menu-word-wrap" href="#" data-word-wrap="no">Word Wrap</a>
            <span>Ctrl+Shift+B</span>
        </li>
        <li>
            <a id="menu-show-editor" href="#" data-show-editor="yes">Editor Window ✓</a>
            <span>Ctrl+Shift+E</span>
        </li>
        <li>
            <a id="menu-show-preview" href="#" data-show-preview="yes">Preview Window ✓</a>
            <span>Ctrl+Shift+W</span>
        </li>
        <li>
            <a id="menu-manual-preview" href="#" data-manual-preview="no">Manual Preview</a>
            <span>Ctrl+Shift+P</span>
            <span>(CTRL+Shift+U to Update Preview)</span>
        </li>
        <li><a id="menu-contribute" href="https://github.com/rioastamal/html-editor/" target="_blank">Contribute</a></li>
    </ul>
    <div class="menu-footer">
        <span>HTML Editor v{{APP_VERSION}}</span>
        <span>Made with ♥ by <a href="https://rioastamal.net/">Rio Astamal</a></span>
    </div>
</div>

<!-- EDITOR WINDOW -->
<div class="editor">
    <h2 class="title text-center border-bottom">HTML Editor</h2>
    <textarea id="editor"></textarea>
</div>

<!-- PREVIEW WINDOW -->
<div class="preview">
    <h2 class="title text-center border-bottom">Preview</h2>
    <iframe id="preview"></iframe>
</div>

<!-- HIDDEN INPUT FILE -->
<input type="file" id="file-open-input" accept=".html,.htm,.txt" class="hide">
<script>
var editor = document.getElementById('editor');
var preview = document.getElementById('preview');
var menu = document.getElementById('menu');
var fileOpenInput = document.getElementById('file-open-input');
var previewWrapper = document.getElementsByClassName('preview')[0];
var editorWrapper = document.getElementsByClassName('editor')[0];
var onCmUpdate = null;
var cm;

function updatePreview(content)
{
    preview.contentWindow.document.open();
    preview.contentWindow.document.write(content);
    preview.contentWindow.document.close();
}

document.getElementById('menu-open-file').onclick = function(e)
{
    if (fileOpenInput) {
        fileOpenInput.click();
    }
}

document.getElementById('menu-save-file').onclick = function(e)
{
    var fname = prompt('Save As: Enter a file name', 'index.html');
    if (fname) {
        saveAsFile(fname, cm.getValue());
    }
}

document.getElementById('menu-word-wrap').onclick = function(e)
{
    var currentWordWrapStatus = this.getAttribute('data-word-wrap');
    if (currentWordWrapStatus == 'no') {
        cm.setOption('lineWrapping', true);
        this.setAttribute('data-word-wrap', 'yes');
        this.innerHTML = 'Word Wrap ✓';

        return false;
    }

    cm.setOption('lineWrapping', false);
    this.setAttribute('data-word-wrap', 'no');
    this.innerHTML = 'Word Wrap';
    return false;
}

document.getElementById('menu-show-preview').onclick = function(e)
{
    var currentPreviewStatus = this.getAttribute('data-show-preview');
    var currentEditorStatus = document.getElementById('menu-show-editor').getAttribute('data-show-editor');

    if (currentPreviewStatus == 'no') {
        this.setAttribute('data-show-preview', 'yes');
        this.innerHTML = 'Preview Window ✓';

        if (currentEditorStatus == 'yes') {
            // Normal 50:50 width
            previewWrapper.className = 'preview';
            editorWrapper.className = 'editor';

            return false;
        }

        // Preview full width since editor window is hidden
        previewWrapper.className = 'preview full-width';

        return false;
    }

    previewWrapper.className = 'preview hide';
    if (currentEditorStatus == 'yes') {
        editorWrapper.className = 'editor full-width';
    }

    this.setAttribute('data-show-preview', 'no');
    this.innerHTML = 'Preview Window';
    return false;
}

document.getElementById('menu-show-editor').onclick = function(e)
{
    var currentEditorStatus = this.getAttribute('data-show-editor');
    var currentPreviewStatus = document.getElementById('menu-show-preview').getAttribute('data-show-preview');

    if (currentEditorStatus == 'no') {
        this.setAttribute('data-show-editor', 'yes');
        this.innerHTML = 'Editor Window ✓';

        if (currentPreviewStatus == 'yes') {
            // Normal 50:50 width
            previewWrapper.className = 'preview';
            editorWrapper.className = 'editor';

            return false;
        }
        // Editor full width since preview window is hidden
        editorWrapper.className = 'editor full-width';

        return false;
    }

    editorWrapper.className = 'editor hide';
    if (currentPreviewStatus === 'yes') {
        previewWrapper.className = 'preview full-width';
    }
    this.setAttribute('data-show-editor', 'no');
    this.innerHTML = 'Editor Window';
    return false;
}

document.getElementById('menu-manual-preview').onclick = function(e)
{
    var currentManualPreviewStatus = this.getAttribute('data-manual-preview');

    if (currentManualPreviewStatus == 'no') {
        this.setAttribute('data-manual-preview', 'yes');
        this.innerHTML = 'Manual Preview ✓';

        cm.off('change', onCmUpdate);

        return false;
    }

    this.setAttribute('data-manual-preview', 'no');
    this.innerHTML = 'Manual Preview';

    cm.on('change', onCmUpdate);

    return false;
}

fileOpenInput.onchange = function(e)
{
    var file = fileOpenInput.files[0];
    var reader = new FileReader();
    console.log(file.type);

    reader.onload = function(e) {
        cm.setValue(reader.result);
    }
    reader.readAsText(file);
}

/**
 * @credit https://stackoverflow.com/a/33542499
 */
function saveAsFile(filename, data)
{
    var blob = new Blob([data], {type: 'text/html'});

    if (window.navigator.msSaveOrOpenBlob) {
        window.navigator.msSaveBlob(blob, filename);

        return;
    }

    var elem = window.document.createElement('a');
    elem.href = window.URL.createObjectURL(blob);
    elem.download = filename;
    document.body.appendChild(elem);

    elem.click();
    document.body.removeChild(elem);

    window.URL.revokeObjectURL(blob);
}

/**
 * Keyboard shortcut for Hide or show Editor and Preview window
 * CTRL+Shift+O = Open File
 * CTRL+Shift+S = Save File
 * CTRL+Shift+B = Word wrap
 * CTRL+Shift+E = Hide/Show Editor
 * CTRL+Shift+W = Hide/Show Preview
 */
document.onkeyup = function(e)
{
    e.preventDefault();

    // e.keyCode is deprecated from web standard and replaced with e.key
    var theChar = null;
    if (e.key !== undefined) {
        theChar = e.key.toUpperCase();
    }
    if (!theChar) {
        // Fallback to old standard
        theChar = String.fromCharCode(e.keyCode).toUpperCase();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'O') {
        document.getElementById('menu-open-file').click();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'S') {
        document.getElementById('menu-save-file').click();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'B') {
        document.getElementById('menu-word-wrap').click();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'E') {
        document.getElementById('menu-show-editor').click();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'W') {
        document.getElementById('menu-show-preview').click();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'M') {
        document.getElementById('menu-manual-preview').click();
    }

    if (e.ctrlKey && e.shiftKey && theChar == 'U') {
        updatePreview(cm.getValue());
    }
}

window.onbeforeunload = function(e)
{
  if (e) {
    // Cancel the event
    e.preventDefault();
    // Chrome requires returnValue to be set
    e.returnValue = '';
  }

  return 'Are you sure want to exit?';
};

window.addEventListener('DOMContentLoaded', function(e)
{
  cm = CodeMirror.fromTextArea(editor, {
      lineNumbers: true,
      styleActiveLine: true,
      mode: 'text/html',
      theme: 'monokai',
  });

  onCmUpdate = function() {
    updatePreview(cm.getValue());
  };

  cm.on('change', onCmUpdate);

  updatePreview(cm.getValue());
});
</script>
</body>
</html>