Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 1 year has passed since last update.

Markdownなどのエディタ(chrome版)

Last updated at Posted at 2022-05-31

Markdownエディタ(chrome版)

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

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

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

クリックでコピー
image.png
image.png

特徴

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

  • イメージ
    image.png

  • PWAにする場合
    image.png
    image.png

  • ローカル起動もOK
    image.png

機能一覧

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

起動

ローカル起動

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

PWA起動

image.png

追加

ファイル追加

Animation.gif

ファイルアップロード

1ファイルをlocalStorageに複製します
Animation.gif

フォルダアップロード

1階層の全ファイルを、localStorageに複製します
Animation.gif

表示

ファイル一覧

localStorage上のファイル一覧
image.png

表示切り替え

Alt+1~9は対応番号へ。Alt+0は最終ファイルに行きます
Alt+上下矢印で移動できます
Animation.gif

マークアップ

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

タグジャンプ

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

画像ファイル表示

image.png

編集

プレビュー編集

Animation.gif

スクロール同期

Animation.gif

コミット/キャンセル

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

画像ペースト

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

ダウンロードできる
Animation.gif

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

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

Animation.gif

埋め込み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公式を参照ください
Animation.gif

ファイル削除

Animation.gif

全ファイル削除

Animation.gif

ダウンロード

zipダウンロード

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

Animation.gif

htmlエクスポート

せっかくのマークダウン表示などを自分でしか見れない。
簡易的に他者にhtml表示で渡せるための機能
Animation.gif
エクスポート結果にCSSを適用しました
image.png

ソースはこちら

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

web-editor.html
<!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 like 「test.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 like 「newone.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.html
├manifest.json
└serviceworker.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;
          });
      })
  );
});
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?