「NoteDiscovery」のような感じのMarkdownエディタですが、ソース編集だけでなく、インライン編集にも対応しているのがポイントです。インライン編集だけの画面にも出来るので、そちらが好きな方にも対応出来るかと。
・ソース
・ソース+プレビュー表示
・プレビュー表示
・ソース+インライン編集
・インライン編集
欲張って多数のビューを残すようにするとさまざまな用途に対応出来るのは良いですが、思わぬところでバグが出て困るのが難点。特にインライン編集側で動作のおかしい部分がありますね……シンプルが一番です!

基本はフォルダ管理で、ドラッグ&ドロップでmdファイルをフォルダに移動出来ます。フォルダ内から、上のフォルダへのファイル移動も可能。好評だった、というかかなり使っていたSimpleNote的な使い方も出来るんじゃないかと我ながら期待しています。とりあえず細かなバグは許容しながら使っていこうかと。
LXDコンテナにインストール
3342番ポートで公開します。ノートのルートフォルダは/opt/lxd-data/noteです。code-serverの拡張機能「code-note」や「NoteDiscovery」と同じようにしているので併用可能です。
#!/bin/bash
set -e
INSTALL_DIR="/opt/easynote"
DATA_DIR="/opt/lxd-data/note"
PORT=3342
echo "🔧 EasyNote v10 インストール開始..."
if ! command -v node &>/dev/null; then
echo "📦 Node.js インストール中..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
echo "Node.js: $(node -v)"
mkdir -p "$INSTALL_DIR/public"
mkdir -p "$DATA_DIR"
if ! touch "$DATA_DIR/.write-test" 2>/dev/null; then
echo "❌ $DATA_DIR への書き込みに失敗しました (権限/UID squashing等の可能性)。インストールを中止します。"
exit 1
fi
rm -f "$DATA_DIR/.write-test"
systemctl stop easynote 2>/dev/null || true
cat > "$INSTALL_DIR/public/favicon.svg" <<'SVGEOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="4" y="2" width="24" height="28" rx="3" fill="#7aa2f7" opacity="0.15"/>
<rect x="4" y="2" width="24" height="28" rx="3" fill="none" stroke="#7aa2f7" stroke-width="1.5"/>
<line x1="9" y1="9" x2="23" y2="9" stroke="#7aa2f7" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="14" x2="20" y2="14" stroke="#7aa2f7" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="19" x2="17" y2="19" stroke="#7aa2f7" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="24" x2="22" y2="24" stroke="#7aa2f7" stroke-width="1.5" stroke-linecap="round"/>
</svg>
SVGEOF
cat > "$INSTALL_DIR/server.js" <<'SERVEREOF'
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const PORT = 3342;
const DATA = '/opt/lxd-data/note';
const PUB = path.join(__dirname, 'public');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon',
};
function json(res, code, data) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
}
function safe(p) {
const real = path.resolve(p);
return real.startsWith(DATA) || real.startsWith(PUB);
}
function readBody(req) {
return new Promise(r => {
const c = [];
req.on('data', d => c.push(d));
req.on('end', () => r(Buffer.concat(c).toString()));
});
}
function listDir(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true })
.map(d => {
try {
const st = fs.statSync(path.join(dir, d.name));
return { name: d.name, isDir: d.isDirectory(), birthtime: st.birthtimeMs || 0 };
} catch { return { name: d.name, isDir: d.isDirectory(), birthtime: 0 }; }
})
.sort((a, b) => a.isDir !== b.isDir ? (a.isDir ? -1 : 1) : a.name.localeCompare(b.name));
} catch { return []; }
}
http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pn = decodeURIComponent(url.pathname);
if (pn === '/api/files' && req.method === 'GET')
return json(res, 200, listDir(DATA));
if (pn.startsWith('/api/files/') && req.method === 'GET') {
const rel = pn.slice(11), fp = path.join(DATA, rel);
if (!safe(fp)) return json(res, 403, { error: 'denied' });
if (!fs.existsSync(fp)) return json(res, 404, { error: 'not found' });
if (fs.statSync(fp).isDirectory()) return json(res, 200, listDir(fp));
return json(res, 200, { content: fs.readFileSync(fp, 'utf-8'), path: rel });
}
if (pn === '/api/files' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
const fp = path.join(b.parent || '', b.name);
const full = path.join(DATA, fp);
if (!safe(full)) return json(res, 403, { error: 'denied' });
if (fs.existsSync(full)) return json(res, 409, { error: 'exists' });
if (b.isDir) fs.mkdirSync(full, { recursive: true });
else { fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, ''); }
return json(res, 200, { ok: true, path: fp });
}
if (pn.startsWith('/api/files/') && req.method === 'PUT') {
const fp = pn.slice(11), full = path.join(DATA, fp);
if (!safe(full)) return json(res, 403, { error: 'denied' });
fs.mkdirSync(path.dirname(full), { recursive: true });
const body = JSON.parse(await readBody(req));
fs.writeFileSync(full, body.content || '', 'utf-8');
return json(res, 200, { ok: true });
}
if (pn.startsWith('/api/files/') && req.method === 'DELETE') {
const fp = pn.slice(11), full = path.join(DATA, fp);
if (!safe(full)) return json(res, 403, { error: 'denied' });
if (!fs.existsSync(full)) return json(res, 404, { error: 'not found' });
fs.rmSync(full, { recursive: true });
return json(res, 200, { ok: true });
}
if (pn === '/api/rename' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
const o = path.join(DATA, b.old), n = path.join(path.dirname(o), b.newName);
if (!safe(o) || !safe(n)) return json(res, 403, { error: 'denied' });
if (fs.existsSync(n)) return json(res, 409, { error: 'exists' });
fs.renameSync(o, n);
return json(res, 200, { ok: true, path: path.relative(DATA, n) });
}
if (pn === '/api/move' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
const src = path.join(DATA, b.from);
if (!safe(src) || !fs.existsSync(src)) return json(res, 404, { error: 'not found' });
const dest = path.join(DATA, b.to, path.basename(b.from));
if (!safe(dest)) return json(res, 403, { error: 'denied' });
if (fs.existsSync(dest)) return json(res, 409, { error: 'exists' });
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(src, dest);
return json(res, 200, { ok: true, path: path.relative(DATA, dest) });
}
if (pn === '/api/search' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
const q = (b.query || '').toLowerCase();
if (!q) return json(res, 200, []);
const results = [];
function searchDir(dir, base) {
try {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
const fp = path.join(dir, f.name);
const rel = base ? base + '/' + f.name : f.name;
if (f.isDirectory()) { searchDir(fp, rel); continue; }
if (!f.name.endsWith('.md')) continue;
try {
const content = fs.readFileSync(fp, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(q)) {
results.push({ file: rel, line: i + 1, text: lines[i].trim() });
break;
}
}
} catch {}
}
} catch {}
}
searchDir(DATA, '');
return json(res, 200, results);
}
let fp = pn === '/' ? '/index.html' : pn;
fp = path.join(PUB, fp);
if (!fs.existsSync(fp) || fs.statSync(fp).isDirectory()) fp = path.join(PUB, 'index.html');
try {
const data = fs.readFileSync(fp);
res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
res.end(data);
} catch { res.writeHead(404); res.end('Not found'); }
}).listen(PORT, '0.0.0.0', () => console.log(`EasyNote: http://0.0.0.0:${PORT}`));
SERVEREOF
cat > "$INSTALL_DIR/public/index.html" <<'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EasyNote</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#1a1b26;--bg2:#16161e;--sf:#24283b;--tx:#c0caf5;--tx2:#565f89;--ac:#7aa2f7;--bd:#292e42;--ok:#9ece6a;--er:#f7768e}
body.light{--bg:#ffffff;--bg2:#f5f5f5;--sf:#e8e8e8;--tx:#333333;--tx2:#888888;--ac:#2563eb;--bd:#d0d0d0;--ok:#16a34a;--er:#dc2626}
html,body{height:100%;overflow:hidden}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--tx)}
#app{display:flex;height:100vh}
#sb{width:240px;background:var(--bg2);border-right:1px solid var(--bd);display:flex;flex-direction:column;flex-shrink:0}
#sb.hide{width:0;overflow:hidden;border:none}
.sb-hd{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--bd);font-size:15px;font-weight:600}
.sb-hd span{color:var(--ac)}
.sb-act{padding:6px 8px;display:flex;gap:4px;border-bottom:1px solid var(--bd)}
.sb-act button{flex:1;padding:5px;background:var(--sf);color:var(--tx);border:none;border-radius:4px;cursor:pointer;font-size:12px}
.sb-act button:first-child{border:1px solid var(--ac);color:var(--ac);font-weight:600}
.sb-act button:hover{opacity:.8}
.sb-sort{display:flex;gap:4px;padding:4px 8px;border-bottom:1px solid var(--bd)}
.sort-btn{flex:1;padding:3px 6px;background:var(--bg2);color:var(--tx2);border:1px solid var(--bd);border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap}
.sort-btn:hover{background:var(--sf)}
.sort-btn.on{color:var(--ac);border-color:var(--ac)}
#ft{flex:1;overflow-y:auto;padding:6px}
.fi{display:flex;align-items:center;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:13px;gap:6px;user-select:none;white-space:nowrap}
.fi .fi-name{flex:1;overflow:hidden;text-overflow:ellipsis}
.fi-rename{opacity:0;font-size:12px;padding:2px 4px;border-radius:3px;flex-shrink:0;cursor:pointer}
.fi:hover .fi-rename{opacity:.6}
.fi-rename:hover{opacity:1!important;background:var(--bd)}
.fi.on .fi-rename{color:var(--bg)}
.fi-fav{opacity:0;font-size:12px;padding:2px 4px;border-radius:3px;flex-shrink:0;cursor:pointer;color:var(--tx2)}
.fi:hover .fi-fav{opacity:.6}
.fi-fav:hover{opacity:1!important}
.fi-fav.on{opacity:1;color:#e0a33e}
.fi-sep{border-top:1px solid var(--bd);margin:4px 8px}
.fi-favpath{font-size:11px;color:var(--tx2);overflow:hidden;text-overflow:ellipsis}
.fi:hover{background:var(--sf)}
.fi.on{background:var(--ac);color:var(--bg)}
.fi .ico{width:18px;text-align:center;font-size:14px;flex-shrink:0}
#mn{flex:1;display:flex;flex-direction:column;overflow:hidden}
#tb{display:flex;align-items:center;padding:3px 6px;background:var(--bg2);border-bottom:1px solid var(--bd);gap:1px;flex-shrink:0}
.tg{display:flex;gap:1px}
.td{width:1px;height:24px;background:var(--bd);margin:0 3px}
.bt{background:none;border:none;color:var(--tx2);cursor:pointer;padding:5px 7px;border-radius:3px;font-size:13px;display:flex;align-items:center;justify-content:center;min-width:26px;height:26px}
.bt:hover{background:var(--sf);color:var(--tx)}
.bt.on{background:var(--ac);color:var(--bg)}
.bt:disabled{opacity:.35;cursor:default}
.bt:disabled:hover{background:none;color:var(--tx2)}
#fn{font-size:12px;color:var(--tx2);margin:0 6px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sp{flex:1}
#ec{flex:1;display:flex;overflow:hidden}
#toc{width:0;overflow:hidden;background:var(--bg2);border-right:1px solid var(--bd);flex-shrink:0;transition:width .15s;display:flex;flex-direction:column}
#toc.open{width:200px}
.toc-hd{padding:8px 10px;border-bottom:1px solid var(--bd);font-size:13px;font-weight:600;color:var(--ac);display:flex;align-items:center;justify-content:space-between}
.toc-hd span{cursor:pointer;font-size:11px;color:var(--tx2)}
.toc-hd span:hover{color:var(--tx)}
#toc-list{flex:1;overflow-y:auto;padding:4px 0}
.toc-item{padding:4px 10px;cursor:pointer;font-size:12px;color:var(--tx2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:3px;margin:1px 4px}
.toc-item:hover{background:var(--sf);color:var(--tx)}
.toc-item.l1{font-weight:600;color:var(--tx)}
.toc-item.l2{padding-left:20px}
.toc-item.l3{padding-left:30px;font-size:11px}
.ep{flex:1;display:flex;flex-direction:column;overflow:hidden}
.ep.hide{display:none}
#ed{flex:1;width:100%;padding:12px 16px;background:var(--bg);color:var(--tx);border:none;outline:none;resize:none;overflow-y:auto;font-family:'Cascadia Code','Fira Code',monospace;font-size:13px;line-height:1.65;tab-size:2}
#pv{flex:1;padding:12px 20px;overflow-y:auto;font-size:14px;line-height:1.7;border-left:1px solid var(--bd)}
#il{flex:1;padding:12px 20px;overflow-y:auto;font-size:14px;line-height:1.7;outline:none;border-left:1px solid var(--bd)}
#pv h1,#il h1{font-size:1.8em;margin:.4em 0 .2em;border-bottom:1px solid var(--bd);padding-bottom:.2em}
#pv h2,#il h2{font-size:1.4em;margin:.4em 0 .2em;border-bottom:1px solid var(--bd);padding-bottom:.15em}
#pv h3,#il h3{font-size:1.2em;margin:.4em 0 .2em}
#pv p,#il p{margin:.4em 0}
#pv a,#il a{color:var(--ac);text-decoration:underline;cursor:pointer}
#pv ul,#pv ol,#il ul,#il ol{padding-left:1.8em;margin:.4em 0}
#pv li,#il li{margin:.15em 0}
#pv blockquote,#il blockquote{border-left:3px solid var(--ac);padding-left:.8em;margin:.4em 0;color:var(--tx2)}
#pv code,#il code{background:var(--sf);padding:1px 5px;border-radius:3px;font-family:'Cascadia Code',monospace;font-size:.88em}
#pv pre,#il pre{background:var(--sf);padding:10px 14px;border-radius:5px;overflow-x:auto;margin:.4em 0;position:relative;min-height:1.5em}
#pv pre code,#il pre code{background:none;padding:0;white-space:pre;display:block}
.cp-btn{position:absolute;top:4px;right:4px;background:var(--bd);color:var(--tx2);border:none;border-radius:3px;padding:2px 8px;font-size:11px;cursor:pointer;opacity:0;transition:opacity .15s}
pre:hover .cp-btn{opacity:1}
.cp-btn:hover{background:var(--ac);color:var(--bg)}
#pv hr,#il hr{border:none;border-top:1px solid var(--bd);margin:.8em 0}
#pv img,#il img{max-width:100%;border-radius:3px}
#pv table,#il table{border-collapse:collapse;width:100%;margin:.4em 0}
#pv th,#pv td,#il th,#il td{border:1px solid var(--bd);padding:6px 10px;text-align:left}
#pv th,#il th{background:var(--sf)}
#pv input[type=checkbox],#il input[type=checkbox]{margin-right:5px}
#modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100}
#modal.hide{display:none}
#modal .box{background:var(--bg2);padding:16px;border-radius:6px;min-width:280px;border:1px solid var(--bd)}
#modal h3{margin-bottom:10px;font-size:13px}
.modal-row{display:flex;gap:4px;align-items:center;margin-bottom:12px}
.modal-row input{flex:1;padding:7px 10px;background:var(--sf);color:var(--tx);border:1px solid var(--bd);border-radius:3px;font-size:13px;outline:none}
.modal-row input:focus{border-color:var(--ac)}
.date-btn{padding:7px 10px;background:var(--sf);color:var(--ac);border:1px solid var(--bd);border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap}
.date-btn:hover{background:var(--bd)}
#modal .ma{display:flex;justify-content:flex-end;gap:6px}
#modal .ma button{padding:5px 12px;border:none;border-radius:3px;cursor:pointer;font-size:12px}
.mb{background:var(--sf);color:var(--tx)}
.mp{background:var(--ac);color:var(--bg)}
#toast{position:fixed;bottom:16px;right:16px;padding:8px 14px;background:var(--sf);color:var(--tx);border-radius:4px;font-size:12px;z-index:101;border:1px solid var(--bd)}
#toast.hide{display:none}
.sr-wrap{padding:6px;border-top:1px solid var(--bd)}
.sr-row{display:flex;gap:4px}
.sr-input{flex:1;padding:5px 8px;background:var(--sf);color:var(--tx);border:1px solid var(--bd);border-radius:3px;font-size:11px;outline:none}
.sr-input:focus{border-color:var(--ac)}
.sr-btn{padding:5px 10px;background:var(--sf);color:var(--ac);border:1px solid var(--bd);border-radius:3px;cursor:pointer;font-size:11px}
.sr-btn:hover{background:var(--bd)}
#sr{max-height:140px;overflow-y:auto;padding:2px 0}
.sr-item{padding:4px 8px;font-size:11px;cursor:pointer;border-radius:3px;display:flex;gap:4px}
.sr-item:hover{background:var(--sf)}
.sr-file{color:var(--ac);white-space:nowrap}
.sr-line{color:var(--tx2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--bd);border-radius:3px}
</style>
</head>
<body>
<div id="app">
<div id="sb">
<div class="sb-hd"><span>EasyNote</span></div>
<div class="sb-act"><button onclick="newItem(false)">📄 新規</button><button onclick="newItem(true)">📁 新規</button></div>
<div class="sb-sort">
<button class="sort-btn on" id="sort-name" onclick="toggleSort('name')">ファイル名 ▲</button>
<button class="sort-btn" id="sort-date" onclick="toggleSort('date')">作成日 ▲</button>
</div>
<div id="ft"></div>
<div class="sr-wrap">
<div class="sr-row">
<input class="sr-input" id="sq" placeholder="🔍 全文検索...">
<button class="sr-btn" onclick="doSearch()">検索</button>
</div>
<div id="sr"></div>
</div>
</div>
<div id="mn">
<div id="tb">
<div class="tg">
<button class="bt" onclick="save()" title="Ctrl+S">💾</button>
</div><div class="td"></div>
<div class="tg">
<button class="bt fmt-bt" onclick="ins('heading',1)"><b>H1</b></button>
<button class="bt fmt-bt" onclick="ins('heading',2)"><b>H2</b></button>
<button class="bt fmt-bt" onclick="ins('heading',3)"><b>H3</b></button>
</div><div class="td"></div>
<div class="tg">
<button class="bt fmt-bt" onclick="ins('bold')" title="Ctrl+B"><b>B</b></button>
<button class="bt fmt-bt" onclick="ins('italic')" title="Ctrl+I"><i>I</i></button>
<button class="bt fmt-bt" onclick="ins('strike')"><s>S</s></button>
</div><div class="td"></div>
<div class="tg">
<button class="bt fmt-bt" onclick="ins('ul')">•≡</button>
<button class="bt fmt-bt" onclick="ins('ol')">1.</button>
<button class="bt fmt-bt" onclick="ins('check')">☑</button>
</div><div class="td"></div>
<div class="tg">
<button class="bt fmt-bt" onclick="ins('link')">🔗</button>
<button class="bt fmt-bt" onclick="ins('image')">🖼</button>
<button class="bt fmt-bt" onclick="ins('code')">{ }</button>
<button class="bt fmt-bt" onclick="ins('codeblock')">≡≡</button>
<button class="bt fmt-bt" onclick="ins('quote')">❝</button>
<button class="bt fmt-bt" onclick="ins('hr')">――</button>
</div><div class="td"></div>
<div class="tg">
<button class="bt on" id="vm-edit" onclick="setView('edit')" title="ソース">✎</button>
<button class="bt" id="vm-split" onclick="setView('split')" title="分割">⫿</button>
<button class="bt" id="vm-preview" onclick="setView('preview')" title="プレビュー">👁</button>
<button class="bt" id="vm-inline" onclick="setView('inline')" title="インライン+ソース">✎👁</button>
<button class="bt" id="vm-inlineonly" onclick="setView('inlineonly')" title="インラインのみ">👁</button>
</div>
<div class="sp"></div>
<span id="fn">未選択</span>
<button class="bt" onclick="toggleToc()" title="見出し一覧" id="tocBtn">📑</button>
<div class="td"></div>
<div class="tg">
<button class="bt" onclick="changeFontSize(-1)" title="文字を小さく">-</button>
<span id="fs" style="font-size:12px;color:var(--tx2);min-width:26px;text-align:center;line-height:26px">13</span>
<button class="bt" onclick="changeFontSize(1)" title="文字を大きく">+</button>
</div><div class="td"></div>
<button class="bt" onclick="toggleSb()" title="サイドバー表示切替">☰</button>
<button class="bt" onclick="toggleTheme()" title="ライト/ダーク切替" id="themeBtn">☀</button>
<button class="bt" onclick="deleteFile()" title="削除">🗑</button>
</div>
<div id="ec">
<div id="toc"><div class="toc-hd">📑 見出し <span onclick="toggleToc()">✕</span></div><div id="toc-list"></div></div>
<div class="ep" id="ep-edit"><textarea id="ed" spellcheck="false" placeholder="Markdownを入力..."></textarea></div>
<div class="ep hide" id="ep-preview"><div id="pv"></div></div>
<div class="ep hide" id="ep-inline"><div id="il" contenteditable="true"></div></div>
</div>
</div>
</div>
<div id="modal" class="hide"><div class="box"><h3 id="mt"></h3><div class="modal-row"><button class="date-btn" onclick="insertDate()">日付</button><input id="mi" autocomplete="off"></div><div class="ma"><button class="mb" onclick="modalClose()">キャンセル</button><button class="mp" onclick="modalOk()">作成</button></div></div></div>
<div id="toast" class="hide"></div>
<script>
let curFile=null,curView='edit',modalCb=null,curDir='',lastEdited='ed',savedSel=null;
const ed=document.getElementById('ed'),pv=document.getElementById('pv'),il=document.getElementById('il'),ft=document.getElementById('ft');
document.addEventListener('mousedown',e=>{if(e.target.closest('.fmt-bt')&&document.activeElement===ed){savedSel={s:ed.selectionStart,e:ed.selectionEnd}}},{capture:true});
const api=(m,u,b)=>fetch(u,{method:m,headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined}).then(r=>r.json());
function toast(t,ok=true){const e=document.getElementById('toast');e.textContent=t;e.className=ok?'':'error';setTimeout(()=>e.className='hide',2000)}
function showModal(t,v,cb){document.getElementById('mt').textContent=t;const i=document.getElementById('mi');i.value=v||'';modalCb=cb;document.getElementById('modal').className='';setTimeout(()=>i.focus(),50)}
function modalClose(){document.getElementById('modal').className='hide';modalCb=null}
function modalOk(){if(!modalCb)return;const v=document.getElementById('mi').value.trim();if(!v)return;modalCb(v);modalClose()}
document.getElementById('mi').onkeydown=e=>{if(e.key==='Escape')modalClose()};
function insertDate(){const d=new Date();const ds=d.getFullYear()+String(d.getMonth()+1).padStart(2,'0')+String(d.getDate()).padStart(2,'0')+'-';const i=document.getElementById('mi');const s=i.selectionStart;i.value=i.value.slice(0,s)+ds+i.value.slice(i.selectionEnd);i.selectionStart=i.selectionEnd=s+ds.length;i.focus()}
function toggleSb(){document.getElementById('sb').classList.toggle('hide')}
let tocOpen=localStorage.getItem('easynote-toc')==='true';function toggleToc(){tocOpen=!tocOpen;document.getElementById('toc').classList.toggle('open',tocOpen);document.getElementById('tocBtn').classList.toggle('on',tocOpen);localStorage.setItem('easynote-toc',tocOpen);if(tocOpen)updateToc()}
function updateToc(){const list=document.getElementById('toc-list');const lines=ed.value.split('\n');list.innerHTML='';lines.forEach((line,i)=>{const m=line.match(/^(#{1,3})\s+(.+)/);if(!m)return;const level=m[1].length;const el=document.createElement('div');el.className='toc-item l'+level;el.textContent=m[2];el.onclick=()=>{const pos=ed.value.split('\n').slice(0,i).join('\n').length;ed.focus();ed.setSelectionRange(pos,pos);const lineH=parseInt(getComputedStyle(ed).lineHeight);ed.scrollTop=i*lineH-ed.clientHeight/3;const headings=[];lines.forEach((l,j)=>{if(/^(#{1,3})\s+/.test(l))headings.push(j)});const idx=headings.indexOf(i);const tgt=(curView==='split'||curView==='preview')?pv:il;if(tgt&&idx>=0){const targets=tgt.querySelectorAll('h1,h2,h3');if(idx<targets.length)targets[idx].scrollIntoView({behavior:'smooth',block:'center'})}};list.appendChild(el)})}
function toggleTheme(){document.body.classList.toggle('light');const l=document.body.classList.contains('light');document.getElementById('themeBtn').textContent=l?'☾':'☀';localStorage.setItem('easynote-theme',l?'light':'dark')}
let fontSize=parseInt(localStorage.getItem('easynote-fontsize'))||13;function changeFontSize(d){fontSize=Math.max(10,Math.min(24,fontSize+d));document.getElementById('fs').textContent=fontSize;document.getElementById('ed').style.fontSize=fontSize+'px';document.getElementById('pv').style.fontSize=(fontSize+1)+'px';document.getElementById('il').style.fontSize=(fontSize+1)+'px';localStorage.setItem('easynote-fontsize',fontSize)}
function initTheme(){const t=localStorage.getItem('easynote-theme');if(t==='light'){document.body.classList.add('light');document.getElementById('themeBtn').textContent='☾'}const sv=localStorage.getItem('easynote-view');if(sv&&sv!==curView)setView(sv);const ss=localStorage.getItem('easynote-sort');if(ss){try{const s=JSON.parse(ss);sortField=s.field;sortAsc=s.asc;document.getElementById('sort-name').className='sort-btn'+(sortField==='name'?' on':'');document.getElementById('sort-date').className='sort-btn'+(sortField==='date'?' on':'');document.getElementById('sort-name').textContent='ファイル名 '+(sortField==='name'?(sortAsc?'▲':'▼'):'▲');document.getElementById('sort-date').textContent='作成日 '+(sortField==='date'?(sortAsc?'▲':'▼'):'▲')}catch(e){}}}
function fileIcon(n,d){if(d)return'📁';if(n.endsWith('.md'))return'📝';if(n.endsWith('.txt'))return'📄';return'📄'}
let dragSrc='';
function setupDrag(el,filePath){el.draggable=true;el.addEventListener('dragstart',e=>{dragSrc=filePath;e.dataTransfer.effectAllowed='move';el.style.opacity='.4'});el.addEventListener('dragend',()=>{el.style.opacity='';dragSrc=''})}
function setupDrop(el,targetPath){el.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='move';el.style.background='var(--sf)'});el.addEventListener('dragleave',()=>{el.style.background=''});el.addEventListener('drop',async e=>{e.preventDefault();el.style.background='';if(!dragSrc||dragSrc===targetPath)return;const from=dragSrc;const r=await api('POST','/api/move',{from,to:targetPath});if(r.ok){toast('移動しました');if(curFile===from)openFile(r.path);curDir?loadDir(curDir):loadTree()}else toast(r.error||'移動失敗',false);dragSrc=''})}
let sortField='name',sortAsc=true;
let favorites=JSON.parse(localStorage.getItem('easynote-favorites')||'[]');
function isFav(path){return favorites.includes(path)}
function toggleFav(path,e){e.stopPropagation();const i=favorites.indexOf(path);if(i>=0)favorites.splice(i,1);else favorites.push(path);localStorage.setItem('easynote-favorites',JSON.stringify(favorites));curDir?loadDir(curDir):loadTree()}
function toggleSort(f){if(sortField===f)sortAsc=!sortAsc;else{sortField=f;sortAsc=true}document.getElementById('sort-name').className='sort-btn'+(sortField==='name'?' on':'');document.getElementById('sort-date').className='sort-btn'+(sortField==='date'?' on':'');document.getElementById('sort-name').textContent='ファイル名 '+(sortField==='name'?(sortAsc?'▲':'▼'):'▲');document.getElementById('sort-date').textContent='作成日 '+(sortField==='date'?(sortAsc?'▲':'▼'):'▲');localStorage.setItem('easynote-sort',JSON.stringify({field:sortField,asc:sortAsc}));loadTree()}
function sortFiles(files,pathPrefix){const d=files.filter(f=>f.isDir);const ff=files.filter(f=>!f.isDir);const c=(a,b)=>{if(sortField==='date')return sortAsc?(a.birthtime-b.birthtime):(b.birthtime-a.birthtime);return sortAsc?a.name.localeCompare(b.name):b.name.localeCompare(a.name)};d.sort(c);ff.sort(c);return[...d,...ff]}
function sortFilesAll(files,pathPrefix){const fav=files.filter(f=>!f.isDir&&f.name.endsWith('.md')&&isFav(pathPrefix?pathPrefix+'/'+f.name:f.name));const d=files.filter(f=>f.isDir);const ff=files.filter(f=>!f.isDir&&!fav.includes(f));const c=(a,b)=>{if(sortField==='date')return sortAsc?(a.birthtime-b.birthtime):(b.birthtime-a.birthtime);return sortAsc?a.name.localeCompare(b.name):b.name.localeCompare(a.name)};fav.sort(c);d.sort(c);ff.sort(c);return{fav,d,ff}}
async function scanFav(dir){const items=[];const files=await api('GET','/api/files/'+encodeURIComponent(dir));for(const f of files){if(f.isDir){const sub=await scanFav(dir+'/'+f.name);items.push(...sub)}else if(f.name.endsWith('.md')&&isFav(dir+'/'+f.name)){items.push({name:f.name,path:dir+'/'+f.name,birthtime:f.birthtime})}}return items}
function makeFavItem(fav){const d=document.createElement('div');d.className='fi'+(curFile===fav.path?' on':'');const dir=fav.path.includes('/')?fav.path.slice(0,fav.path.lastIndexOf('/')):'';d.innerHTML=`<span class="ico">📝</span><span class="fi-name">${fav.name}</span><span class="fi-favpath">${dir}</span><span class="fi-fav on" title="お気に入り解除">★</span><span class="fi-rename" title="名前変更">✏</span>`;d.onclick=()=>openFile(fav.path);d.querySelector('.fi-rename').onclick=e=>{e.stopPropagation();renameItem(fav.path,false)};d.querySelector('.fi-fav').onclick=e=>toggleFav(fav.path,e);return d}
async function loadTree(){curDir='';const files=await api('GET','/api/files');ft.innerHTML='';const allFav=[];for(const f of files){if(f.isDir){const sub=await scanFav(f.name);allFav.push(...sub)}else if(f.name.endsWith('.md')&&isFav(f.name)){allFav.push({name:f.name,path:f.name,birthtime:f.birthtime})}}
const c=(a,b)=>{if(sortField==='date')return sortAsc?(a.birthtime-b.birthtime):(b.birthtime-a.birthtime);return sortAsc?a.name.localeCompare(b.name):b.name.localeCompare(a.name)};allFav.sort(c);
if(allFav.length){allFav.forEach(fav=>ft.appendChild(makeFavItem(fav)));const sep=document.createElement('div');sep.className='fi-sep';ft.appendChild(sep)}
const dirs=files.filter(f=>f.isDir).sort(c);const nonFav=files.filter(f=>!f.isDir).sort(c);
dirs.forEach(f=>{const d=document.createElement('div');d.className='fi'+(curFile===f.name?' on':'');d.innerHTML=`<span class="ico">${fileIcon(f.name,f.isDir)}</span><span class="fi-name">${f.name}</span><span class="fi-rename" title="名前変更">✏</span>`;setupDrag(d,f.name);setupDrop(d,f.name);d.onclick=()=>loadDir(f.name);d.querySelector('.fi-rename').onclick=e=>{e.stopPropagation();renameItem(f.name,true)};ft.appendChild(d)});
nonFav.forEach(f=>{const d=document.createElement('div');d.className='fi'+(curFile===f.name?' on':'');d.innerHTML=`<span class="ico">${fileIcon(f.name,f.isDir)}</span><span class="fi-name">${f.name}</span><span class="fi-fav${isFav(f.name)?' on':''}" title="お気に入り">★</span><span class="fi-rename" title="名前変更">✏</span>`;setupDrag(d,f.name);d.onclick=()=>openFile(f.name);d.querySelector('.fi-rename').onclick=e=>{e.stopPropagation();renameItem(f.name,false)};d.querySelector('.fi-fav').onclick=e=>toggleFav(f.name,e);ft.appendChild(d)})}
async function loadDir(name){curDir=name;const files=sortFiles(await api('GET','/api/files/'+encodeURIComponent(name)),name);const p=name.includes('/')?name.slice(0,name.lastIndexOf('/')):'';ft.innerHTML=`<div class="fi" id="back-btn"><span class="ico">⬅</span><span class="fi-name">戻る</span></div>`;const backBtn=document.getElementById('back-btn');setupDrop(backBtn,p||'');backBtn.onclick=()=>p?loadDir(p):loadTree();files.forEach(f=>{const d=document.createElement('div'),fp=name+'/'+f.name;d.className='fi'+(curFile===fp?' on':'');const favBtn=!f.isDir&&f.name.endsWith('.md')?`<span class="fi-fav${isFav(fp)?' on':''}" title="お気に入り">★</span>`:'';d.innerHTML=`<span class="ico">${fileIcon(f.name,f.isDir)}</span><span class="fi-name">${f.name}</span>${favBtn}<span class="fi-rename" title="名前変更">✏</span>`;setupDrag(d,fp);if(f.isDir)setupDrop(d,fp);d.onclick=()=>f.isDir?loadDir(fp):openFile(fp);const rn=d.querySelector('.fi-rename');if(rn)rn.onclick=e=>{e.stopPropagation();renameItem(fp,f.isDir)};const fv=d.querySelector('.fi-fav');if(fv)fv.onclick=e=>toggleFav(fp,e);ft.appendChild(d)})}
async function openFile(p){const r=await api('GET','/api/files/'+encodeURIComponent(p));if(r.error)return toast(r.error,false);curFile=p;document.getElementById('fn').textContent=p.split('/').pop();ed.value=r.content;lastEdited='ed';if(curView==='split'||curView==='preview')updatePreview();if(curView==='inline'||curView==='inlineonly')updateInline();if(tocOpen)updateToc();ft.querySelectorAll('.fi').forEach(e=>e.classList.remove('on'))}
function setView(v){syncEdFromInline();curView=v;document.querySelectorAll('[id^="vm-"]').forEach(b=>b.classList.remove('on'));document.getElementById('vm-'+v).classList.add('on');document.getElementById('ep-edit').className='ep'+((v==='edit'||v==='split'||v==='inline')?'':' hide');document.getElementById('ep-preview').className='ep'+((v==='split'||v==='preview')?'':' hide');document.getElementById('ep-inline').className='ep'+((v==='inline'||v==='inlineonly')?'':' hide');if(v==='split'||v==='preview')updatePreview();if(v==='inline'||v==='inlineonly')updateInline();localStorage.setItem('easynote-view',v);updateToolbarState()}
function syncEdFromInline(){if(lastEdited==='il'){clearTimeout(previewTimer);ed.value=html2md(il.innerHTML)}}
function updateToolbarState(){const dis=curView==='preview';document.querySelectorAll('.fmt-bt').forEach(b=>b.disabled=dis)}
function updatePreview(){pv.innerHTML=md(ed.value)}let skipIlInput=false;function updateInline(){skipIlInput=true;il.innerHTML=md(ed.value);skipIlInput=false}
let previewTimer;ed.addEventListener('input',()=>{lastEdited='ed';if(curView==='split'||curView==='preview')clearTimeout(previewTimer),previewTimer=setTimeout(updatePreview,80);if(curView==='inline'||curView==='inlineonly')clearTimeout(previewTimer),previewTimer=setTimeout(updateInline,80);if(tocOpen)updateToc()});
il.addEventListener('input',()=>{if(composing||skipIlInput)return;lastEdited='il';clearTimeout(previewTimer);previewTimer=setTimeout(()=>{ed.value=html2md(il.innerHTML)},150)});
let scrollSyncing=false;function syncScroll(from){if(scrollSyncing)return;scrollSyncing=true;requestAnimationFrame(()=>{let src,tgt;if(curView==='split'){src=from==='ed'?ed:pv;tgt=from==='ed'?pv:ed}else if(curView==='inline'||curView==='inlineonly'){src=from==='ed'?ed:il;tgt=from==='ed'?il:ed}if(src&&tgt&&src.scrollHeight>src.clientHeight&&tgt.scrollHeight>tgt.clientHeight){tgt.scrollTop=src.scrollTop*(tgt.scrollHeight-tgt.clientHeight)/(src.scrollHeight-src.clientHeight)}scrollSyncing=false})}
ed.addEventListener('scroll',()=>{if(curView==='split'||curView==='inline')syncScroll('ed')});
pv.addEventListener('scroll',()=>{if(curView==='split')syncScroll('pv')});
il.addEventListener('scroll',()=>{if(curView==='inline'||curView==='inlineonly')syncScroll('il')});
let composing=false;il.addEventListener('compositionstart',()=>{composing=true});il.addEventListener('compositionend',()=>{composing=false;il.dispatchEvent(new Event('input'))});
il.addEventListener('keydown',e=>{if(composing)return;if(e.key==='Tab'){e.preventDefault();document.execCommand('insertText',false,' ')}if(e.key==='Enter'){const sel=window.getSelection();if(!sel.rangeCount)return;const anchor=sel.anchorNode;const inPre=anchor.nodeType===3?anchor.parentElement.closest('pre'):anchor.closest&&anchor.closest('pre');if(inPre){e.preventDefault();document.execCommand('insertText',false,'\n')}}});
il.addEventListener('mousedown',e=>{const a=e.target.closest('a');if(a&&a.href){e.preventDefault();window.open(a.href,'_blank');return}});
il.addEventListener('click',e=>{if(!e.target.closest('a')&&!e.target.closest('pre')&&il.lastElementChild&&il.lastElementChild.tagName==='PRE'){const p=document.createElement('p');p.innerHTML='<br>';il.appendChild(p);const r=document.createRange();r.setStart(p,0);r.collapse(true);const sel=window.getSelection();sel.removeAllRanges();sel.addRange(r)}});
function copyCode(btn){const c=btn.previousElementSibling;if(!c)return;const t=c.textContent;if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(()=>{btn.textContent='done!';setTimeout(()=>btn.textContent='copy',1500)}).catch(()=>fc(t,btn));else fc(t,btn)}
function fc(t,b){const a=document.createElement('textarea');a.value=t;a.style.position='fixed';a.style.left='-9999px';document.body.appendChild(a);a.select();try{document.execCommand('copy');b.textContent='done!';setTimeout(()=>b.textContent='copy',1500)}catch(e){}document.body.removeChild(a)}
async function save(){if(!curFile)return toast('ファイルを選択',false);syncEdFromInline();ed.value=ed.value.replace(/(```[\s\S]*?```)/g,(m)=>m.replace(/\n{3,}/g,'\n\n'));const r=await api('PUT','/api/files/'+encodeURIComponent(curFile),{content:ed.value});r.ok?toast('保存しました'):toast(r.error,false)}
function newItem(isDir){showModal(isDir?'フォルダ名':'ファイル名','',async name=>{if(!name)return;if(!isDir&&!name.endsWith('.md'))name+='.md';const r=await api('POST','/api/files',{name,isDir,parent:curDir});if(r.ok){toast('作成しました');curDir?loadDir(curDir):loadTree();if(!isDir)openFile(r.path)}else toast(r.error,false)})}
function renameFile(){if(!curFile)return toast('ファイルを選択',false);showModal('新しい名前',curFile.split('/').pop(),async name=>{if(!name)return;const r=await api('POST','/api/rename',{old:curFile,newName:name});if(r.ok){curFile=r.path;document.getElementById('fn').textContent=name;toast('名前を変更しました');loadTree()}else toast(r.error,false)})}
function renameItem(filePath,isDir){const curName=filePath.split('/').pop();showModal('新しい名前',curName,async name=>{if(!name||name===curName)return;const r=await api('POST','/api/rename',{old:filePath,newName:name});if(r.ok){if(isDir){const parent=filePath.includes('/')?filePath.slice(0,filePath.lastIndexOf('/')):'';if(curDir===filePath)curDir=parent?parent+'/'+name:name}else if(curFile===filePath){curFile=r.path;document.getElementById('fn').textContent=name}toast('名前を変更しました');curDir?loadDir(curDir):loadTree()}else toast(r.error||'変更失敗',false)})}
async function deleteFile(){if(!curFile)return;if(!confirm('削除しますか?'))return;const r=await api('DELETE','/api/files/'+encodeURIComponent(curFile));if(r.ok){curFile=null;ed.value='';pv.innerHTML='';il.innerHTML='';document.getElementById('fn').textContent='未選択';toast('削除しました');loadTree()}else toast(r.error,false)}
function ins(type,arg){
if(curView==='inlineonly'||(curView==='inline'&&document.activeElement===il)){insInline(type,arg)}
else{insSource(type,arg)}
}
function insSource(type,arg){
syncEdFromInline();
const t=ed.value;
let s,e;
if(savedSel){s=savedSel.s;e=savedSel.e;savedSel=null}else{s=ed.selectionStart;e=ed.selectionEnd}
const lineTypes={heading:1,ul:1,ol:1,check:1,quote:1};
if(lineTypes[type])s=t.lastIndexOf('\n',s-1)+1;
const sel=t.substring(s,e);let b='',a='',r=sel;
switch(type){
case'heading':b='#'.repeat(arg)+' ';break;
case'bold':b='**';a='**';break;
case'italic':b='*';a='*';break;
case'strike':b='~~';a='~~';break;
case'ul':b='- ';break;
case'ol':b='1. ';break;
case'check':b='- [ ] ';break;
case'link':r=`[${sel||'テキスト'}](${sel||'URL'})`;break;
case'image':r=``;break;
case'code':b='`';a='`';break;
case'codeblock':{const nb=s>0&&t[s-1]!=='\n',na=e<t.length&&t[e]!=='\n';b=(nb?'\n\n':'')+'```\n';a='\n```'+(na?'\n\n':'');break}
case'quote':b='> ';break;
case'hr':{const nb=s>0&&t[s-1]!=='\n',na=e<t.length&&t[e]!=='\n';r=(nb?'\n\n':'')+'---'+(na?'\n\n':'\n');break}
}
ed.value=t.slice(0,s)+b+r+a+t.slice(e);ed.focus();ed.setSelectionRange(s+b.length,s+b.length+r.length);ed.dispatchEvent(new Event('input'))
}
function getIlRange(){
const sel=window.getSelection();
if(sel.rangeCount&&il.contains(sel.anchorNode))return sel.getRangeAt(0);
const r=document.createRange();r.selectNodeContents(il);r.collapse(false);return r;
}
function setIlSelection(range){const sel=window.getSelection();sel.removeAllRanges();sel.addRange(range)}
function wrapIlSelection(tagName){
const range=getIlRange();const el=document.createElement(tagName);
if(range.collapsed){el.appendChild(document.createTextNode('\u200b'));range.insertNode(el);const r=document.createRange();r.selectNodeContents(el);setIlSelection(r)}
else{const content=range.extractContents();el.appendChild(content);range.insertNode(el);const r=document.createRange();r.selectNodeContents(el);setIlSelection(r)}
}
function insertIlHtml(html){
const range=getIlRange();range.deleteContents();
const frag=range.createContextualFragment(html);const last=frag.lastChild;
range.insertNode(frag);
if(last){const r=document.createRange();r.setStartAfter(last);r.collapse(true);setIlSelection(r)}
}
function setIlBlockFormat(tagName){
const range=getIlRange();let node=range.startContainer;
if(node.nodeType===3)node=node.parentElement;
let block=node&&node.closest?node.closest('h1,h2,h3,h4,h5,h6,p,blockquote,li'):null;
if(!block||block===il){
if(!il.firstChild){const el=document.createElement(tagName);el.innerHTML='\u200b';il.appendChild(el);const r=document.createRange();r.selectNodeContents(el);setIlSelection(r);return}
block=il.lastElementChild;if(!block||block===il)return;
}
const el=document.createElement(tagName);el.innerHTML=block.innerHTML||'\u200b';block.replaceWith(el);
const r=document.createRange();r.selectNodeContents(el);r.collapse(false);setIlSelection(r);
}
function insInline(type,arg){
if(document.activeElement!==il)il.focus();
switch(type){
case'bold':wrapIlSelection('strong');break;
case'italic':wrapIlSelection('em');break;
case'strike':wrapIlSelection('del');break;
case'code':wrapIlSelection('code');break;
case'heading':setIlBlockFormat('h'+arg);break;
case'quote':setIlBlockFormat('blockquote');break;
case'ul':insertIlHtml('<ul><li>項目</li></ul>');break;
case'ol':insertIlHtml('<ol><li>項目</li></ol>');break;
case'check':insertIlHtml('<ul><li><input type="checkbox"> </li></ul>');break;
case'link':{const range=getIlRange();const text=range&&!range.collapsed?range.toString():'テキスト';insertIlHtml(`<a href="URL" target="_blank">${text}</a>`);break}
case'image':insertIlHtml('<img src="URL" alt="alt">');break;
case'codeblock':insertIlHtml('<pre><code>ここにコード</code></pre><p><br></p>');break;
case'hr':insertIlHtml('<hr><p><br></p>');break;
}
lastEdited='il';clearTimeout(previewTimer);ed.value=html2md(il.innerHTML);
}
function md(s){
const blocks=[];
let h=s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/^```([\s\S]*?)^```/gm,(m,c)=>{
const idx=blocks.length;
blocks.push(`<pre><code>${(c.replace(/^\n/,'').replace(/\n$/,''))||'\n'}</code><button class="cp-btn" onclick="copyCode(this)">copy</button></pre>`);
return `\u0000B${idx}\u0000`;
})
.replace(/`([^`]+)`/g,'<code>$1</code>').replace(/^######\s+(.+)$/gm,'<h6>$1</h6>').replace(/^#####\s+(.+)$/gm,'<h5>$1</h5>').replace(/^####\s+(.+)$/gm,'<h4>$1</h4>').replace(/^###\s+(.+)$/gm,'<h3>$1</h3>').replace(/^##\s+(.+)$/gm,'<h2>$1</h2>').replace(/^#\s+(.+)$/gm,'<h1>$1</h1>').replace(/^---+$/gm,'<hr>').replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/\*(.+?)\*/g,'<em>$1</em>').replace(/~~(.+?)~~/g,'<del>$1</del>').replace(/!\[([^\]]*)\]\(([^)]+)\)/g,'<img src="$2" alt="$1">') .replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank">$1</a>').replace(/(?<![">\]])(https?:\/\/[^\s<)&\]]+)/g,(m)=>`<a href="${m}" target="_blank">${m}</a>`).replace(/^>\s+(.+)$/gm,'<blockquote>$1</blockquote>').replace(/^- \[x\]\s+(.+)$/gm,'<li><input type="checkbox" checked disabled> $1</li>').replace(/^- \[ \]\s+(.+)$/gm,'<li><input type="checkbox" disabled> $1</li>').replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>').replace(/^(\d+)\.\s+(.+)$/gm,'<li>$2</li>');
h=h.replace(/((?:<li>[\s\S]*?<\/li>\n*)+)/g,m=>{const items=m.split(/\n/).filter(l=>l.trim());const tag=items.every(l=>l.includes('checkbox'))?'ul':items.some(l=>/^\d+\./.test(l.trim()))?'ol':'ul';return`<${tag}>${items.join('')}</${tag}>`});
h=h.replace(/<\/blockquote>\n<blockquote>/g,'<br>');
h=h.replace(/((?:^\|.+\|$\n?)+)/gm,(m)=>{
const rows=m.trim().split('\n').filter(l=>l.trim());
if(rows.length<2)return m;
const sepIdx=rows.findIndex(r=>/^\|[\s\-:|]+\|$/.test(r.trim()));
if(sepIdx<1)return m;
let out='<table>';
for(let i=0;i<rows.length;i++){
if(i===sepIdx)continue;
const cells=rows[i].split('|').slice(1,-1).map(c=>c.trim());
const tag=i===0?'th':'td';
out+='<tr>'+cells.map(c=>`<${tag}>${c}</${tag}>`).join('')+'</tr>';
}
out+='</table>';return out;
});
h=h.replace(/([^\n])\n(?=[^\n\u0000])/g,'$1<br>');
h=h.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[i]);
h=h.replace(/\n?(<h[1-6]>[\s\S]*?<\/h[1-6]>|<hr>|<pre>[\s\S]*?<\/pre>|<ul>[\s\S]*?<\/ul>|<ol>[\s\S]*?<\/ol>|<blockquote>[\s\S]*?<\/blockquote>|<table>[\s\S]*?<\/table>)\n?/g,'\n\n$1\n\n');h=h.replace(/\n{2,}/g,'</p><p>');h='<p>'+h+'</p>';h=h.replace(/<p>\s*<(h[1-6]|hr|ul|ol|pre|blockquote|table)/g,'<$1');h=h.replace(/<\/(h[1-6]|hr|ul|ol|pre|blockquote|table)>\s*<\/p>/g,'</$1>');return h.replace(/<p>\s*<\/p>/g,'')}
function html2md(html){const d=document.createElement('div');d.innerHTML=html;function walk(n){if(n.nodeType===3)return n.textContent;if(n.nodeType!==1)return'';const t=n.tagName.toLowerCase();if(t==='button')return'';let c='';for(const ch of n.childNodes)c+=walk(ch);switch(t){case'h1':return`# ${c}\n\n`;case'h2':return`## ${c}\n\n`;case'h3':return`### ${c}\n\n`;case'h4':return`#### ${c}\n\n`;case'h5':return`##### ${c}\n\n`;case'h6':return`###### ${c}\n\n`;case'p':return`${c}\n\n`;case'br':return'\n';case'strong':case'b':return`**${c}**`;case'em':case'i':return`*${c}*`;case'del':return`~~${c}~~`;case'code':return n.parentElement&&n.parentElement.tagName.toLowerCase()==='pre'?c:`\`${c}\``;case'pre':return`\`\`\`\n${c.trim()}\n\`\`\`\n`;case'a':return`[${c}](${n.getAttribute('href')||''})`;case'img':return`||''})`;case'ul':case'ol':return c+'\n';case'li':{const p=n.parentElement;if(!p)return c+'\n';if(p.tagName.toLowerCase()==='ol')return`${Array.from(p.children).indexOf(n)+1}. ${c}\n`;const cb=n.querySelector('input[type=checkbox]');return cb?`- [${cb.checked?'x':' '}] ${c.replace(/^\s*\n?/,'')}\n`:`- ${c}\n`}case'blockquote':return`> ${c}\n\n`;case'hr':return'---\n\n';case'table':{const rows=[];n.querySelectorAll('tr').forEach(tr=>{const cells=[];tr.querySelectorAll('th,td').forEach(td=>cells.push(td.textContent.trim()));rows.push(cells)});if(!rows.length)return'';let m='| '+rows[0].join(' | ')+' |\n| '+rows[0].map(()=>'---').join(' | ')+' |\n';for(let i=1;i<rows.length;i++)m+='| '+rows[i].join(' | ')+' |\n';return m+'\n'}case'input':return n.type==='checkbox'?`- [${n.checked?'x':' '}] `:'';default:return c}}return walk(d).replace(/\n{3,}/g,'\n\n').trim()+'\n'}
let searchTimer;function doSearch(){clearTimeout(searchTimer);const q=document.getElementById('sq').value.trim(),sr=document.getElementById('sr');if(!q){sr.innerHTML='';return}searchTimer=setTimeout(async()=>{const r=await api('POST','/api/search',{query:q});sr.innerHTML=r.length?r.map(x=>`<div class="sr-item" onclick="openFile('${x.file.replace(/'/g,"\\'")}')"><span class="sr-file">${x.file}:${x.line}</span><span class="sr-line">${x.text.replace(/</g,'<')}</span></div>`).join(''):'<div class="sr-item" style="color:var(--tx2)">見つかりません</div>'},300)}
ed.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='s'){e.preventDefault();save()}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();ins('bold')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();ins('italic')}if(e.key==='Tab'){e.preventDefault();const s=ed.selectionStart;ed.value=ed.value.slice(0,s)+' '+ed.value.slice(ed.selectionEnd);ed.selectionStart=ed.selectionEnd=s+2;ed.dispatchEvent(new Event('input'))}});
document.getElementById('sq').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch()});
initTheme();changeFontSize(0);if(tocOpen){document.getElementById('toc').classList.add('open');document.getElementById('tocBtn').classList.add('on');updateToc()}loadTree();updateToolbarState();
</script>
</body>
</html>
HTMLEOF
cat > "$INSTALL_DIR/package.json" <<'PKGEOF'
{
"name": "easynote",
"version": "10.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" }
}
PKGEOF
cat > /etc/systemd/system/easynote.service <<EOF
[Unit]
Description=EasyNote Markdown Editor
After=network.target
[Service]
Type=simple
ExecStart=$(which node) $INSTALL_DIR/server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable easynote
systemctl start easynote
IP=$(hostname -I | awk '{print $1}')
echo ""
echo "✅ EasyNote v10 インストール完了!"
echo "📍 URL: http://${IP}:${PORT}"
echo "📁 データ: $DATA_DIR"
echo "📂 インストール: $INSTALL_DIR"
echo ""
echo "コマンド:"
echo " systemctl start easynote # 起動"
echo " systemctl stop easynote # 停止"
echo " systemctl restart easynote # 再起動"
echo " journalctl -u easynote -f # ログ確認"
ライトモードも可能
シンプルなぶん、動作は軽快なはず。
ライトモードにも変更可能です(右上の削除ボタンの左側のボタンで切り替え)。

今気付きましたが、フォルダ名の変更がないですね。ひとまずは別の方法で変更するとして、そのうち対応しようかなと。(対応しました)
アンインストール方法
# サービス停止・無効化
systemctl stop easynote
systemctl disable easynote
# サービスファイル削除
rm -f /etc/systemd/system/easynote.service
systemctl daemon-reload
# インストールディレクトリ削除
rm -rf /opt/easynote
データディレクトリ(/opt/lxd-data/note)について
ノートの実データはここに保存されているので、完全に削除する場合は追加で。
rm -rf /opt/lxd-data/note
データを残しておきたい場合(再インストール時に復元したい等)は、このディレクトリは消さずに上の4行だけ実行してください。
Node.jsについて
インストールスクリプトはnodeが無い場合のみNodeSourceからインストールしますが、アンインストール時にNode.js自体を消すかどうかはこのスクリプトでは判断できません(他の用途で使っている可能性があるため)。Node.jsも不要であれば別途下記も。
apt-get remove -y nodejs


