見出し画像

エディタつくった(ブラウザ版)

Markdownエディタ(chrome版)


ソースは記事の最下部にあります。
chrome版とか言ってますけどだいたいのエディタで動きます。
web-kit系に対応していないので、一部機能を除けば全エディタ対応。

とりあえずメモしたい時などに使う。
ローカルhtmlで使えますが、hta版 or PWAとしてならデスクトップアプリのように扱えます。
chrome(その他ブラウザ)さえあればhta版と異なりどこでも使えるほか、ブラウザの検索機能を踏襲できており高性能。

2022/06 アップデートしたので追記
・外観をAtom One Dark風に変更しました(GitHub公開のカラーコードぱくっただけ)
・コード表記にシンタックスハイライトを適用、コピー時のアニメを使いやすくしました
・画像ペーストをより高機能に修正、機能紹介のGifアニメも更新しました
・Alt+上下矢印でファイル切り替えできるようにしました
・Ctrl+Alt+上下矢印で、アンカータグの切り替えに対応しました
・コナミコマンドで、本エディタで使用しているlocalStorageのデータを出力するようにしました
・エクスポート結果にCSSを適用しました
・ファイルのリネーム機能を追加しました

★閲覧時(最新版はシンタックスハイライトはもう少し見やすくなってます)
★編集時
クリックでコピー
コピー完了


特徴


1枚のhtmlで実装したので、ローカルで開いてオフラインでも使えます。
基本的に編集や保存はlocalStorageやV8エンジン上のメモリで行います。
ここからPWAとしてインストールしても使えます。

イメージ
PWAにする場合
ローカル起動もOK



機能一覧


開発中に取得したキャプチャなので見た目が古いですがゆるして…

起動

ローカル起動
※シークレットウィンドウの場合はウィンドウを閉じるとlocalStorageがクリアされます。
※通常ウィンドウでならファイルは永続なので安心。

PWA起動

追加

ファイル追加

ファイルアップロード
1ファイルをlocalStorageに複製します

フォルダアップロード
1階層の全ファイルを、localStorageに複製します


表示

ファイル一覧
localStorage上のファイル一覧

表示切り替え
Alt+1~9は対応番号へ。Alt+0は最終ファイルに行きます。
Alt+上下矢印で移動できます。

マークアップ
マークダウンのみ。テキストファイルなどのmd以外の拡張子では無効。

タグジャンプ
マークダウンのみ。テキストファイルなどのmd以外の拡張子では無効。
サクラエディタのブックマーク機能のように使える、Qiitaにもあるがやはり便利。
2022/06/11追加でショートカットに対応。

画像ファイル表示

編集

プレビュー編集

スクロール同期

コミット/キャンセル
タブ移動でキャンセルボタン、コミットボタン

画像ペースト
クリップボードが画像の場合に、base64変換文字として画像ファイルをpngで作成、ファイル名をペーストするようにしました。
別途作成された画像ファイルはlocalStorage上に「imagefile_on_{編集中ファイル名}_{番号}.png」として保持。
一括ダウンロードでダウンロードできますが、ファイル一覧には表示されません。


ダウンロードできる

プロエディタ(ライブラリ)

simplemdeを使用.html
    <!-- MarkDown -->
    <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">

埋め込みjexcel

jexcelを埋め込んでます.html
    <!-- jexcel (with jsuites) -->
    <script src="https://bossanova.uk/jexcel/v4/jexcel.js"></script>
    <script src="https://bossanova.uk/jsuites/v2/jsuites.js"></script>
    <script src="https://jsuites.net/v4/jsuites.js"></script>
    <link rel="stylesheet" href="https://bossanova.uk/jspreadsheet/v4/jexcel.css" type="text/css" />
    <link rel="stylesheet" href="https://bossanova.uk/jspreadsheet/v4/jexcel.themes.css" type="text/css" />
    <link rel="stylesheet" href="https://jsuites.net/v4/jsuites.css" type="text/css" />

jexcel:idとなる文字を記載すると、そこがExcelになる。
編集モードでなく書き込みができる。
詳しくはjexcel紹介Qiita公式を参照ください。

ファイル削除

全ファイル削除

ダウンロード

zipダウンロード

zip化はこちら使ってます.html
    <!-- zip -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.0/jszip.min.js"></script>

htmlエクスポート
せっかくのマークダウン表示などを自分でしか見れない。
簡易的に他者にhtml表示で渡せるための機能。


エクスポート結果にCSSを適用しました。

ソースはこちら


PWAにするならこちらから。
以下をコピペしてhtmlファイルを作成し、ローカルでchromeで開く。
ローカルホストを立ててPWAにしたい場合、さらに後続をご覧ください。

<!DOCTYPE html>
<html lang="en" class="full">
<head>
    <meta name="robots" content="noindex">
    <meta charset="utf-8">
    <!-- PWA -->
    <script>
        if ('serviceWorker' in navigator && window.location.host !== '') {
            let manifest = document.createElement('link')
            manifest.rel = 'manidest'
            manifest.href = './manifest.json'
            document.head.appendChild(manifest)
            window.onload = () => navigator.serviceWorker
                .register('serviceworker.js')
                .then(swr => swr.onupdatefound = () => swr.update())
        }
    </script>
    <!-- jquery -->
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"
        integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <!-- MarkDown -->
    <script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
    <!-- syntax highlit -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/styles/default.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
    <!-- zip -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.0/jszip.min.js"></script>
    <!-- jexcel (with jsuites) -->
    <script src="https://bossanova.uk/jexcel/v4/jexcel.js"></script>
    <script src="https://bossanova.uk/jsuites/v2/jsuites.js"></script>
    <script src="https://jsuites.net/v4/jsuites.js"></script>
    <link rel="stylesheet" href="https://bossanova.uk/jspreadsheet/v4/jexcel.css" type="text/css" />
    <link rel="stylesheet" href="https://bossanova.uk/jspreadsheet/v4/jexcel.themes.css" type="text/css" />
    <link rel="stylesheet" href="https://jsuites.net/v4/jsuites.css" type="text/css" />
    <!-- jexcel darkmode -->
    <style>
        :root {
            --jexcel_header_color: #888;
            --jexcel_header_color_highlighted: #444;
            --jexcel_header_background: #313131;
            --jexcel_header_background_highlighted: #777;
            --jexcel_content_color: #ddd;
            --jexcel_content_color_highlighted: #7e7e7e;
            --jexcel_content_background: #3e3e3e;
            --jexcel_content_background_highlighted: #555151;
            --jexcel_menu_background: #7e7e7e;
            --jexcel_menu_background_highlighted: #020202;
            --jexcel_menu_color: #ddd;
            --jexcel_menu_color_highlighted: #222;
            --jexcel_menu_box_shadow: unset;
            --jexcel_border_color: #5f5f5f;
            --jexcel_border_color_highlighted: #999;
            --active_color: #eee;
        }
    </style>
    <!-- style -->
    <style>
        /* base */
        :root {
            --atom-one-dark-white: rgb(171, 178, 191);
            --atom-one-dark-gray: rgb(40, 44, 52);
            --atom-one-dark-green: rgb(152, 195, 121);
            --atom-one-dark-blue: rgb(97, 175, 239);
            --atom-one-dark-cyan: rgb(86, 182, 194);
            --atom-one-dark-yellow: rgb(209, 154, 102);
            --atom-one-dark-red: rgb(190, 80, 70);
        }
        html {
            background-color: black;
            color: var(--atom-one-dark-white);
            font-family: "YakuHanJPs", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
        }
        .full {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            overflow: hidden;
        }
        .base {
            width: 98%;
            height: 97%;
            padding: 1%;
            display: grid;
            grid-template-columns: 16% 66% 16%;
            gap: 1%;
        }
        .panel {
            background-color: var(--atom-one-dark-gray);
            overflow-x: hidden;
            overflow-y: auto;
        }
        .focus {
            color: var(--atom-one-dark-red) !important;
        }
        /* explorer & actoins */
        .explorer {
            padding-left: 3%;
        }
        #actions>div::before,
        #file-action>div::before {
            content: '■';
        }
        #actions>div,
        #file-action>div {
            cursor: pointer;
            color: var(--atom-one-dark-green);
            font-size: small;
            text-decoration: underline;
        }
        #files {
            counter-reset: section;
        }
        #files>div::before {
            counter-increment: section;
            content: counter(section) ": ";
        }
        #files>div {
            cursor: pointer;
            color: var(--atom-one-dark-blue);
            text-decoration: underline;
        }
        /* content */
        .content {
            overflow-x: auto;
        }
        .content>div {
            margin-left: 3%;
            margin-right: 3%;
        }
        /* edit */
        .edit-layer {
            position: fixed;
            top: 0;
            width: 98%;
            height: 97%;
            padding: 1%;
            background-color: rgb(0, 0, 0, 0.8);
            z-index: 3;
        }
        .edit-base {
            width: 100%;
            height: 100%;
            display: grid;
            grid-template-columns: 48% 48%;
            gap: 2%;
        }
        .edit-base h2 {
            border-bottom: solid;
            border-width: 3px;
        }
        .edit-base>div {
            width: 100%;
            height: 100%;
            background-color: var(--atom-one-dark-gray);
        }
        #editor-form,
        #preview-area,
        #preview {
            width: 100%;
            height: 85vh;
        }
        #preview-area {
            background-color: var(--atom-one-dark-gray);
        }
        #editor {
            width: 98%;
            height: 98%;
            font-size: medium;
            font-family: "YakuHanJPs", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
            color: var(--atom-one-dark-white);
            background-color: var(--atom-one-dark-gray);
        }
        #editor-form,
        #preview-area {
            overflow-y: auto;
        }
        .btns {
            display: flex;
            justify-content: space-around;
            align-items: center;
        }
        /* classic editor */
        #editor-form>div.editor-toolbar.fullscreen {
            background-color: beige;
        }
        #editor-form>div.CodeMirror.cm-s-paper.CodeMirror-wrap.CodeMirror-sided.CodeMirror-fullscreen {
            background-color: rgb(220, 200, 255);
        }
        #editor-form>div.editor-preview-side.editor-preview-active-side {
            background-color: aquamarine;
        }
        /* markdown */
        #taglist a {
            color: var(--atom-one-dark-yellow);
        }
        .content a,
        #preview-area a {
            color: var(--atom-one-dark-cyan);
        }
        .content h1,
        .content h2,
        #preview-area h1,
        #preview-area h2 {
            border-bottom: solid;
            border-width: thin;
        }
        .content pre,
        #preview-area pre {
            padding: 13px;
            background-color: rgb(92, 99, 112, 0.5);
            position: relative;
        }
        .click::before {
            position: absolute;
            top: 0;
            right: 0;
            content: 'click to copy';
            z-index: 1;
        }
        .clicked::before {
            position: absolute;
            top: 0;
            right: 0;
            content: 'copied!';
            z-index: 1;
        }
        .hljs {
            background-color: rgb(150, 150, 150);
            font-family: "YakuHanJPs", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
        }
    </style>
</head>
<body class="full"></body>
<script>
    let datas = [], currentIndex = 0, editing = false
    const binary = ['jpeg', 'png']
    const STORAGE_KEY = 'web-editor'
    // ==============================
    // rendering
    // ==============================
    function rendering() {
        // base
        let base = $('<div>', { class: 'base' })
        let explorer = $('<div>', { class: 'panel explorer' })
        let content = $('<div>', { class: 'panel content' })
        let taglist = $('<div>', { class: 'panel taglist' })
        Array.of(explorer, content, taglist).forEach(v => base.append(v))
        $('body').append(base)
        // common action
        let actions = $('<div>', { id: 'actions' })
        let menu = $('<h3>', { text: 'Common Action' })
        let add = $('<div>', { id: 'add', text: 'New File_____(Ctrl+Shift+Alt+N)' }).click(newFile)
        let dela = $('<div>', { id: 'dela', text: 'Delete All____(Ctrl+Shift+Alt+D)' }).click(delAll)
        let save = $('<div>', { id: 'save', text: 'Download____(Ctrl+Shift+Alt+S)' }).click(download)
        let focus = $('<div>', { id: 'focus', text: 'Focus________(Alt+Num,↑↓)' })
        let explain1 = $('<div>', { text: 'upload File' })
        let chose1 = $('<input type="file">').on('change', readFile)
        let explain2 = $('<div>', { text: 'upload Folder (1depth)' })
        let chose2 = $('<input type="file" webkitdirectory>').on('change', readFiles)
        Array.of(menu, add, dela, save, focus, $('<br>'), explain1, chose1, explain2, chose2).forEach(v => actions.append(v))
        // file action
        let ctrl = $('<div>', { id: 'file-action' })
        let ctrlMenu = $('<h3>', { text: 'File Action' })
        let edit = $('<div>', { id: 'edit', text: 'Edit_________(Ctrl+Alt+E)' }).click(editMode)
        let rnm = $('<div>', { id: 'rename', text: 'Rename______(Ctrl+Alt+R)' }).click(rename)
        let del = $('<div>', { id: 'del', text: 'Delete______(Ctrl+Alt+D)' }).click(delOne)
        let report = $('<div>', { id: 'report', text: 'Export html___(Ctrl+Alt+H)' }).click(exportHtml)
        let ancM = $('<div>', { id: 'ancM', text: 'Anchor focus___(Ctrl+Alt+↑↓)' }).click(anchorMove)
        Array.of(ctrlMenu, edit, rnm, del, report, ancM).forEach(v => ctrl.append(v))
        // file list
        let files = $('<div>')
        let title = $('<h3>', { text: 'Files on localStorage' })
        let list = $('<div>', { id: 'files' })
        Array.of(title, list).forEach(v => files.append(v))
        explorer.append(actions).append(files)
        taglist.append(ctrl)
        Array.of(edit, rnm, del, report, dela, save, focus).forEach(v => v.hide())
    }
    rendering()
    function activateMenus() {
        Array.of('edit', 'rename', 'del', 'dela', 'save', 'focus').forEach(v => $(`#${v}`).show())
    }
    // ==============================
    // view file
    // ==============================
    function setContent(idx) {
        if (datas.length === 0) return
        // toggle filelist color
        $(`#${currentIndex}`).removeClass('focus')
        $(`#${idx}`).addClass('focus')
        currentIndex = idx
        $('.content').empty()
        $('#report').hide()
        $('#ancM').hide()
        $('#taglist').remove()
        let target = datas[idx]
        // show picture
        if (binary.includes(target.name.split('\.')[1])) {
            $('.content').append($('<div>').append($('<img>', { src: target.text })))
            $('.content').focus()
            return
        }
        // show markdown
        if (target.name.split('\.')[1] === 'md') {
            $('#report').show()
            $('#ancM').show()
            markdown(target.text)
            jexcelEmbed()
            imageParse('.content > div')
            $('.content').focus()
            return
        }
        // show else file
        let sanitized = target.text.replaceAll(/\r\n/g, '<br>')
        $('.content').append($('<div>').html(sanitized))
        $('.content').focus()
    }
    function markdown(target) {
        // markdown parse
        marked.setOptions({ sanitize: true, breaks: true })
        $('.content').append($('<div>').html(marked.parse(target)))
        // open new tab
        $('a').each((_, elem) => elem.target = '#')
        // code viewing
        $('code').each((_, elem) => hljs.highlightElement(elem))
        $('pre').each((_, elem) => $(elem).addClass('click'))
        $('pre').each((_, elem) => elem.onclick = () => {
            console.log($(elem.childNodes[0]).text())
            navigator.clipboard.writeText($(elem.childNodes[0]).text())
            $(elem).addClass('clicked')
            setTimeout(() => $(elem).removeClass('clicked'), 1000)
        })
        // tag jump
        let taglistArea = $('<div>', { id: 'taglist' })
        let title = $('<h3>', { text: 'Anchors' })
        let ul = $('<ul>')
        taglistArea.append(title)
        taglistArea.append(ul)
        $('.taglist').append(taglistArea)
        let tags = Array.from($('body > div > div.panel.content > div').children())
            .filter(v => v.id !== '')
        for (let v of tags) {
            if (v.tagName === 'H1') {
                let a = $('<a>', { href: `#${v.id}`, text: v.textContent })
                ul.append($('<li>').append(a))
            }
            if (v.tagName === 'H2') {
                let a = $('<a>', { href: `#${v.id}`, text: v.textContent })
                ul.append($('<li>').append(a))
            }
        }
        $('#taglist a').each((i, elem) => {
            $(elem).click(() => {
                $('#taglist a').each((i, elem) => $(elem).removeClass('focus'))
                $(elem).addClass('focus')
            })
        })
    }
    function anchorMove() {
        let nowAnchor = -1
        let nextAnchor = 0
        $('#taglist a').each((i, elem) => {
            if ($(elem).hasClass('focus')) {
                nowAnchor = i + 1
            }
        })
        nextAnchor = $('#taglist a').length === nowAnchor ? 0
            : nowAnchor === -1 ? 0 : nowAnchor
        $('#taglist a')[nextAnchor].click()
    }
    function anchorReverse() {
        let nowAnchor = 0
        let nextAnchor = 0
        $('#taglist a').each((i, elem) => {
            if ($(elem).hasClass('focus')) {
                nowAnchor = i
            }
        })
        nextAnchor = 0 === nowAnchor ? $('#taglist a').length - 1 : nowAnchor - 1
        $('#taglist a')[nextAnchor].click()
    }
    // jexcel embed
    // 「jexcel:tableid」in html elemnt 'p'
    function jexcelEmbed() {
        $('.content > div p').each((idx, elem) => {
            if (!$(elem).html().startsWith('jexcel:')) return true
            if ($(elem).html().includes('<br>')) return true
            // set DOM
            let tableid = $(elem).html().split(':')[1]
            let table = $('<div>', { id: tableid })
            let commit = $('<button>', { id: `${tableid}-c`, text: 'commit' })
            $(elem).after(table).after(commit)
            commit.on('click', () => {
                let newdata = Array.from($(`#${tableid} > div.jexcel_content > table > tbody > tr`))
                    .map(v => Array.from(v.children).filter(v => v.dataset.x))
                    .reduce((stock, v) => {
                        let row = v.map(x => x.textContent)
                        stock.push(row)
                        return stock
                    }, [])
                datas[currentIndex].tables[tableid] = JSON.stringify(newdata)
                localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
            })
            // embed jexcel
            let jexcelObject = !datas[currentIndex].tables[tableid]
                ? jspreadsheet(table[0], { minDimensions: [5, 5], defaultColWidth: 100 })
                : jspreadsheet(table[0], { data: JSON.parse(datas[currentIndex].tables[tableid]), defaultColWidth: 100 })
        })
    }
    // base64 image
    function imageParse(identifer) {
        $(`${identifer} p`).each((idx, val) => {
            // embed on base64 image
            let wordList = val.innerHTML.split('<br>').map(v => {
                return v.startsWith('./') ? `<img src="${datas[currentIndex].embed.find(x => x.name === v.split('./')[1]).text}">` : v
            })
            $(val).html(wordList.join('<br>'))
        })
    }
    // ==============================
    // read
    // ==============================
    function readFile(ev) {
        let readDatas = []
        let file = ev.target.files[0]
        let filename = file.name
        let ext = filename.split('\.')[1]
        let fileReader = new FileReader()
        fileReader.onload = event => {
            // imagefile_on_
            if (filename.startsWith('imagefile_on_')) {
                let targetFname = /imagefile_on_(.*)_[0-9]*\.png/g.exec(filename)[1]
                let targetIdx = datas.findIndex(v => v.name === targetFname + '.md')
                if (targetIdx === -1) {
                    alert('embet target file not found')
                    return
                }
                datas[targetIdx].embed.push({ name: filename, text: event.target.result })
            } else {
                datas.push({ name: filename, text: event.target.result, tables: {}, embed: [] })
                readDatas.push({ name: filename, text: event.target.result })
            }
            localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
            let lastChild = $('#files > div:last-child')[0]
            let newid = lastChild ? ~~(lastChild.id) + 1 : 0
            readDatas.forEach((val, idx) => addFilelist(idx + newid, val.name))
            setContent(currentIndex)
            activateMenus()
        }
        if (binary.includes(ext)) {
            fileReader.readAsDataURL(file)
        } else {
            fileReader.readAsText(file)
        }
    }
    function readFiles(ev) {
        let readDatas = []
        // 1th depth
        let targetFiles = Array.from(ev.target.files).filter(v => {
            let section = v.webkitRelativePath.split('\/')
            return section.length === 2
        })
        let loadObjs = []
        let embedObjs = []
        for (let v of targetFiles) {
            let filename = v.webkitRelativePath.split('\/')[1]
            if (filename.startsWith('imagefile_on_')) {
                embedObjs.push(v)
            } else {
                loadObjs.push(v)
            }
        }
        let loadJobs = []
        for (let v of loadObjs) {
            const loadFunc = new Promise((resolve) => {
                let filename = v.webkitRelativePath.split('\/')[1]
                let ext = filename.split('\.')[1]
                let fileReader = new FileReader()
                fileReader.onload = event => {
                    datas.push({ name: filename, text: event.target.result, tables: {}, embed: [] })
                    readDatas.push({ name: filename, text: event.target.result })
                    resolve()
                }
                if (binary.includes(ext)) {
                    fileReader.readAsDataURL(v)
                } else {
                    fileReader.readAsText(v)
                }
            })
            loadJobs.push(loadFunc)
        }
        // last load -> view start
        Promise.all(loadJobs).then(() => {
            // embed file after loading target files
            let embedJobs = []
            for (let v of embedObjs) {
                const embedFunc = new Promise((resolve) => {
                    let filename = v.webkitRelativePath.split('\/')[1]
                    let targetFname = /imagefile_on_(.*)_[0-9]*\.png/g.exec(filename)[1]
                    let targetIdx = datas.findIndex(v => v.name === targetFname + '.md')
                    if (targetIdx === -1) {
                        resolve()
                    }
                    let fileReader = new FileReader()
                    fileReader.onload = event => {
                        datas[targetIdx].embed.push({ name: filename, text: event.target.result })
                        resolve()
                    }
                    fileReader.readAsDataURL(v)
                })
                embedJobs.push(embedFunc)
            }
            Promise.all(embedJobs).then(() => {
                localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
                let lastChild = $('#files > div:last-child')[0]
                let newid = lastChild ? ~~(lastChild.id) + 1 : 0
                readDatas.forEach((val, idx) => addFilelist(idx + newid, val.name))
                setContent(currentIndex)
                activateMenus()
            })
        })
    }
    // ==============================
    // add
    // ==============================
    function newFile() {
        let filename = window.prompt('Enter filename with extension liketest.md」')
        // name format
        if (filename.match(/^.*[(\\|/|:|\*|?|\"|<|>|\|)].*$/)
            || filename === '' || filename.split('\.').length !== 2) {
            alert('please enter like "text.md"')
            newFile()
            return
        }
        // same name
        if (datas.findIndex(v => v.name === filename) !== -1) {
            alert(`"${filename}" is already existed.`)
            newFile()
            return
        }
        // pleas dont
        if (filename.startsWith('imagefile_on_')) {
            alert("Please don't use the word 'imagefile_on_' cause of using embed image on file..")
            newFile()
            return
        }
        datas.push({ name: filename, text: '', tables: {}, embed: [] })
        localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
        let lastChild = $('#files > div:last-child')[0]
        let newid = lastChild ? ~~(lastChild.id) + 1 : 0
        addFilelist(newid, filename)
        setContent(newid)
        if (newid === 0) {
            activateMenus()
        }
    }
    function addFilelist(id, name) {
        let fname = $('<div>', { id: id, text: name })
        fname.click(() => setContent(id))
        $('#files').append(fname)
    }
    // ==============================
    // edit
    // ==============================
    function editMode() {
        editing = true
        // use embed editor
        let simplemde
        const openEmbedEditor = () => {
            if (simplemde) {
                document.querySelector('#editor-form > div.editor-toolbar > a.fa.fa-columns.no-disable.no-mobile').click()
                return
            }
            simplemde = new SimpleMDE({ element: document.getElementById('editor'), forceSync: true, spellChecker: false })
            document.querySelector('#editor-form > div.editor-toolbar > a.fa.fa-columns.no-disable.no-mobile').click()
            $('#cls').show()
        }
        // refresh preview
        const watch = () => {
            $('#editor').on('keyup', () => {
                marked.setOptions({ sanitize: true, breaks: true })
                $('#preview').empty()
                $('#preview').html(marked.parse($('#editor').val()))
                $('code').each((_, elem) => hljs.highlightElement(elem))
                imageParse('#preview')
            })
        }
        // scroll sync
        const syncScroll = () => {
            let s1 = $('#editor')[0]
            let s2 = $('#preview-area')[0]
            s1.addEventListener("scroll", () => {
                s2.scrollTop = s1.scrollTop
            })
        }
        // image paste
        const imagePaste = () => {
            let editor = $('#editor')[0]
            editor.addEventListener('paste', e => {
                if (!e.clipboardData
                    || !e.clipboardData.types
                    || (e.clipboardData.types.length !== 1)
                    || (e.clipboardData.types[0] !== 'Files')) {
                    return true
                }
                // get image (cannot use 'getAsString')
                let imageFile = e.clipboardData.items[0].getAsFile()
                let fr = new FileReader()
                fr.onload = e => {
                    let base64 = e.target.result
                    let filenamePre = `imagefile_on_${datas[currentIndex].name.split('\.')[0]}`
                    let indexs = datas[currentIndex].embed
                        .map(v => /.*_([0-9]*)\.png/g.exec(v.name)[1])
                        .map(v => ~~(v))
                    let newidx = indexs.length === 0 ? 1 : Math.max(...indexs) + 1
                    // imagefile_on_{current filename}_{idx}.png
                    let imgFName = `${filenamePre}_${newidx}.png`
                    datas[currentIndex].embed.push({ name: imgFName, text: base64 })
                    localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
                    // insert current cursor
                    editor.value = editor.value.substr(0, editor.selectionStart)
                        + `./${imgFName}`
                        + editor.value.substr(editor.selectionStart);
                }
                fr.readAsDataURL(imageFile)
            })
        }
        // create origin editor
        let layer = $('<div>', { class: 'edit-layer' })
        let base = $('<div>', { class: 'edit-base' })
        let editorArea = $('<div>')
        let previewArea = $('<div>')
        base.append(editorArea)
        base.append(previewArea)
        $('body').append(layer.append(base))
        // editor area
        let form = $('<form>', { id: 'editor-form' })
        let editor = $('<textarea>', { id: 'editor', text: datas[currentIndex].text })
        editorArea.append($('<h2>', { text: 'Editor' }))
        editorArea.append(form.append(editor))
        // btns
        let btns = $('<div>', { class: 'btns' })
        btns.append($('<button>', { text: 'cancel' }).click(() => {
            layer.remove()
            editing = false
        }))
        btns.append($('<button>', { text: 'commit' }).click(() => {
            datas[currentIndex].text = $('#editor').val().replaceAll(/\n/g, '\r\n')
            localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
            setContent(currentIndex)
            layer.remove()
            editing = false
        }))
        btns.append($('<button>', { text: 'Classic Editor' }).click(openEmbedEditor))
        btns.append($('<button>', { id: 'cls', text: 'Close Clasic Editor' }).click(() => {
            let wip = $('#editor').val()
            form.empty()
            form.append($('<textarea>', { id: 'editor', text: wip }))
            syncScroll()
            watch()
            simplemde = null
            $('#cls').hide()
        }).hide())
        editorArea.append(btns)
        // preview area
        previewArea.append($('<h2>', { text: 'PreView' }))
        previewArea.append($('<div>', { id: 'preview-area' }).append($('<div>', { id: 'preview' })))
        marked.setOptions({ sanitize: true, breaks: true })
        $('#preview').empty()
        $('#preview').html(marked.parse($('#editor').val()))
        imageParse('#preview')
        // funcs
        watch()
        syncScroll()
        imagePaste()
        editor.focus()
    }
    // ==============================
    // rename
    // ==============================
    function rename() {
        let filename = window.prompt('Rename. Enter newname with extension likenewone.md」')
        // name format
        if (filename.match(/^.*[(\\|/|:|\*|?|\"|<|>|\|)].*$/)
            || filename === '' || filename.split('\.').length !== 2) {
            alert('please enter like "text.md"')
            rename()
            return
        }
        // same name
        if (datas.findIndex(v => v.name === filename) !== -1) {
            alert(`"${filename}" is already existed.`)
            rename()
            return
        }
        // pleas dont
        if (filename.startsWith('imagefile_on_')) {
            alert("Please don't use the word 'imagefile_on_' cause of using embed image on file..")
            rename()
            return
        }
        datas[currentIndex].name = filename
        for (v of datas[currentIndex].embed) {
            let no = /imagefile_on_[^_]*_([0-9]*).png/g.exec(v.name)[1]
            let newname = `imagefile_on_${filename.split('\.')[0]}_${~~(no)}.png`
            v.name = newname
        }
        
        // apply
        localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
        $(`#${currentIndex}`).text(filename)
        setContent(currentIndex)
    }
    // ==============================
    // delete
    // ==============================
    function delOne() {
        let ok = window.confirm(`Are you sure to delete 「${datas[currentIndex].name}」 on localStorage ?`)
        if (!ok) return
        datas.splice(currentIndex, 1)
        localStorage.setItem(STORAGE_KEY, JSON.stringify(datas))
        window.location.reload()
    }
    function delAll() {
        let ok = window.confirm('Are you sure to delete ALL FILES on localStorage ?')
        if (!ok) return
        datas = []
        localStorage.setItem(STORAGE_KEY, datas)
        window.location.reload()
    }
    // ==============================
    // download
    // ==============================
    function download() {
        let zip = new JSZip()
        let folder = zip.folder(STORAGE_KEY)
        datas.forEach(v => {
            if (binary.includes(v.name.split('\.')[1])) {
                folder.file(v.name, v.text.split(`data:image/${v.name.split('\.')[1]};base64,`)[1], { base64: true })
            } else {
                folder.file(v.name, v.text)
            }
            v.embed.forEach(x => {
                folder.file(x.name, x.text.split('data:image/png;base64,')[1], { base64: true })
            })
        })
        zip.generateAsync({ type: 'blob' }).then(blob => {
            let dlLink = document.createElement("a")
            const dataUrl = URL.createObjectURL(blob)
            dlLink.href = dataUrl
            dlLink.download = `${STORAGE_KEY}.zip`
            dlLink.click()
            setTimeout(() => window.URL.revokeObjectURL(dataUrl), 1000)
        })
    }
    // ==============================
    // export html
    // ==============================
    function exportHtml() {
        let clonehtml = $('<html>')
            .append($('head').clone())
            .append($('.panel.content').clone())
        let blob = new Blob([clonehtml.html()], { 'type': 'text/plain' })
        let dlLink = document.createElement('a')
        const dataUrl = URL.createObjectURL(blob)
        dlLink.href = dataUrl
        dlLink.download = datas[currentIndex].name.split('\.')[0] + '.html'
        dlLink.click()
        setTimeout(() => window.URL.revokeObjectURL(dataUrl), 1000)
    }
    // ==============================
    // hot keys
    // ==============================
    let conamiorder = 0
    const conamicommand = ['ArrowUp', 'ArrowDown', 'ArrowUp', 'ArrowDown'
        , 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
    document.onkeydown = e => {
        if (editing) return
        // new file
        if (e.ctrlKey && e.shiftKey && e.altKey && e.key === 'N') {
            newFile()
        }
        // edit
        if (e.ctrlKey && !e.shiftKey && e.altKey && e.key === 'e') {
            if (datas.length === 0) return
            editMode()
        }
        // rename
        if (e.ctrlKey && !e.shiftKey && e.altKey && e.key === 'r') {
            if (datas.length === 0) return
            rename()
        }
        // delete
        if (e.ctrlKey && !e.shiftKey && e.altKey && e.key === 'd') {
            if (datas.length === 0) return
            delOne()
        }
        // delete all
        if (e.ctrlKey && e.shiftKey && e.altKey && e.key === 'D') {
            if (datas.length === 0) return
            delAll()
        }
        // save
        if (e.ctrlKey && e.shiftKey && e.altKey && e.key === 'S') {
            if (datas.length === 0) return
            download()
        }
        // report
        if (e.ctrlKey && !e.shiftKey && e.altKey && e.key === 'h') {
            if (datas.length === 0) return
            if (datas[currentIndex].name.split('\.')[1] !== 'md') return
            exportHtml()
        }
        // anchor move
        if (e.ctrlKey && !e.shiftKey && e.altKey && e.key === 'ArrowDown') {
            if (datas.length === 0) return
            if (datas[currentIndex].name.split('\.')[1] !== 'md') return
            anchorMove()
        }
        // anchor reverse
        if (e.ctrlKey && !e.shiftKey && e.altKey && e.key === 'ArrowUp') {
            if (datas.length === 0) return
            if (datas[currentIndex].name.split('\.')[1] !== 'md') return
            anchorReverse()
        }
        // focus
        if (e.altKey && 1 <= e.key && e.key <= 9) {
            let pressNum = ~~(e.key)
            if (datas.length < pressNum) return
            setContent(pressNum - 1)
        }
        // focus last
        if (e.altKey && e.key === '0') {
            setContent(datas.length - 1)
        }
        // focus move
        if (!e.ctrlKey && e.altKey && e.key === 'ArrowUp') {
            let nextIdx = currentIndex === 0 ? datas.length - 1 : currentIndex - 1
            setContent(nextIdx)
        }
        if (!e.ctrlKey && e.altKey && e.key === 'ArrowDown') {
            let nextIdx = currentIndex === datas.length - 1 ? 0 : currentIndex + 1
            setContent(nextIdx)
        }
        // conami cmd
        if (conamiorder == 0) {
            // accept 3s from first enter
            setTimeout(() => conamiorder = 0, 3000)
        }
        conamiorder = e.key == conamicommand[conamiorder]
            ? (conamiorder + 1) | 0
            : 0
        // success
        if (conamiorder == conamicommand.length) {
            conamiorder = 0
            let yourdata = localStorage.getItem(STORAGE_KEY)
            yourdata += '\r\n\r\nAuthor\r\nhttps://qiita.com/neras_1215/items/f5b6e29c9fb870f1b4e3'
            let blob = new Blob([yourdata], { 'type': 'text/plain' })
            let dlLink = document.createElement("a")
            const dataUrl = URL.createObjectURL(blob)
            dlLink.href = dataUrl
            dlLink.download = `${STORAGE_KEY}.txt`
            alert('CONAMI COMMAND detected. Download localStorage data.')
            dlLink.click()
            setTimeout(() => window.URL.revokeObjectURL(dataUrl), 1000)
        }
    }
    // ==============================
    // common events
    // ==============================
    // reload warning
    window.addEventListener('beforeunload', e => {
        if (editing) {
            e.preventDefault()
            e.returnValue = ''
        }
    })
    // local init
    window.addEventListener('load', () => {
        let storageData = localStorage.getItem(STORAGE_KEY)
        if (!storageData) return
        datas = JSON.parse(storageData)
        if (datas.length !== 0) {
            datas.forEach((val, idx) => addFilelist(idx, val.name))
            setContent(currentIndex)
            activateMenus()
        }
    })
</script>
</html>


どうにかローカルホストを立てて無理やりPWAにしたいとき


以下にserviceworker.jsとmanifest.jsonを置いておきます。

階層.txt
ふぉるだ
├index.htmlmanifest.jsonserviceworker.js

として、ふぉるだ をローカルホストとして起動すれば、PWAとしてインストールできます。
インストール後はローカルホスト停止後も正常に動作するのでご心配なく。


manifest.json
{
    "short_name" : "edit",
    "name" : "edit",
    "display" : "standalone",
    "start_url" : "index.html",
    "background_color": "#000000"
}
serviceworker.js
const CACHE_NAME = 'webeditor.v1';
let urlsToCache = [
  './index.html'
];
// インストール処理
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        return cache.addAll(urlsToCache)
      }));
});
// バージョン更新
self.addEventListener('activate', (event) => {
  let cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          // ホワイトリストにないキャッシュ(古いキャッシュ)は削除する
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});
// リソースフェッチ時のキャッシュロード処理
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) return response;
        // リクエストをcloneする。
        // リクエストはStreamなので一度しか処理できない。
        // ここではキャッシュ用、fetch用と2回必要なのでclone
        let fetchRequest = event.request.clone();
        return fetch(fetchRequest)
          .then((response) => {
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            let responseToCache = response.clone();
            caches.open(CACHE_NAME)
              .then((cache) => cache.put(event.request, responseToCache));
            return response;
          });
      })
  );
});


この記事のライター REIYA