エディタつくった(ブラウザ版)
Markdownエディタ(chrome版)
ソースは記事の最下部にあります。
chrome版とか言ってますけどだいたいのエディタで動きます。
web-kit系に対応していないので、一部機能を除けば全エディタ対応。
とりあえずメモしたい時などに使う。
ローカルhtmlで使えますが、hta版 or PWAとしてならデスクトップアプリのように扱えます。
chrome(その他ブラウザ)さえあればhta版と異なりどこでも使えるほか、ブラウザの検索機能を踏襲できており高性能。
特徴
1枚のhtmlで実装したので、ローカルで開いてオフラインでも使えます。
基本的に編集や保存はlocalStorageやV8エンジン上のメモリで行います。
ここからPWAとしてインストールしても使えます。
機能一覧
開発中に取得したキャプチャなので見た目が古いですがゆるして…
起動
ローカル起動
※シークレットウィンドウの場合はウィンドウを閉じると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" />
ファイル削除
全ファイル削除
ダウンロード
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 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;
});
})
);
});