<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Markdown Editor — Vanilla JS</title>
<!-- 最小依存: marked / DOMPurify / highlight.js(任意) をCDNから読み込み -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js"></script>
<style>
/* ざっくりとしたUIスタイル。Tailwind等なしで動作可 */
:root {
--bg: #0b0b0c;
--panel: #111214;
--text: #e7e7ea;
--muted: #9aa0a6;
--border: #2a2c30;
--accent: #4f46e5;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.6 system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans JP", sans-serif;
}
header {
position: sticky; top: 0; z-index: 10;
backdrop-filter: blur(6px);
background: color-mix(in oklab, var(--bg) 80%, transparent);
border-bottom: 1px solid var(--border);
}
.wrap { max-width: 1200px; margin: 0 auto; padding: 12px 16px; }
.title { font-weight: 700; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.btn {
background: var(--panel); border: 1px solid var(--border); color: var(--text);
padding: 6px 10px; border-radius: 10px; cursor: pointer;
}
.btn:hover { background: #17181b; }
.btn.secondary { color: var(--muted); }
.spacer { flex: 1; }
/* 2カラム: 左エディタ / 右プレビュー(中央ドラッグでサイズ変更) */
.grid {
display: grid; grid-template-columns: 1fr 8px 1fr; gap: 0; height: calc(100vh - 112px);
}
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
.panel-head { padding: 8px 12px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
.panel-body { flex: 1; min-height: 0; overflow: auto; }
textarea {
width: 100%; height: 100%;
background: transparent; color: var(--text); border: 0; outline: none;
font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
padding: 12px; resize: none; caret-color: var(--accent);
tab-size: 2;
}
.preview { padding: 12px 16px; }
.divider { cursor: col-resize; background: var(--border); }
/* Prose風 見た目(最低限) */
.preview :is(h1,h2,h3) { margin-top: 1.2em; }
.preview h1 { font-size: 1.8rem; }
.preview h2 { font-size: 1.4rem; }
.preview h3 { font-size: 1.2rem; }
.preview p, .preview li, .preview code, .preview pre { font-size: 0.95rem; }
.preview a { color: #93c5fd; }
.preview blockquote { border-left: 4px solid #394150; padding-left: 12px; color: #c3c7ce; }
.preview table { border-collapse: collapse; width: 100%; }
.preview th, .preview td { border: 1px solid var(--border); padding: 6px 8px; }
.preview code { background: #20222a; padding: 2px 4px; border-radius: 6px; }
.preview pre { background: #0c0d10; padding: 12px; border-radius: 10px; overflow: auto; }
.muted { color: var(--muted); }
.kicker { font-size: 12px; color: var(--muted); }
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; height: auto; gap: 12px; }
.divider { display: none; }
}
</style>
</head>
<body>
<header>
<div class="wrap" style="display:flex; gap: 12px; align-items:center;">
<div class="title">Markdown Editor(Vanilla JS)</div>
<div class="kicker" id="themeLabel">テーマ: ダーク</div>
<div class="spacer"></div>
<div class="toolbar">
<!-- ツールバー(Markdownシンタックスの挿入) -->
<button class="btn" data-action="h1">H1</button>
<button class="btn" data-action="h2">H2</button>
<button class="btn" data-action="bold"><b>B</b></button>
<button class="btn" data-action="italic"><i>I</i></button>
<button class="btn" data-action="strike">S</button>
<button class="btn" data-action="code"></></button>
<button class="btn" data-action="list">•</button>
<button class="btn" data-action="num">1.</button>
<button class="btn" data-action="quote">></button>
<button class="btn" data-action="checkbox">☑</button>
<button class="btn" data-action="hr">―</button>
<div class="spacer"></div>
<button class="btn secondary" id="btnTheme">テーマ切替</button>
<button class="btn secondary" id="btnExport">書き出し(.md)</button>
<label class="btn secondary" for="fileIn">読み込み(.md)</label>
<input id="fileIn" type="file" accept=".md,text/markdown,text/plain" style="display:none" />
<button class="btn secondary" id="btnCopyHtml">HTMLコピー</button>
<button class="btn secondary" id="btnSample">サンプル読込</button>
<button class="btn secondary" id="btnClear">クリア</button>
</div>
</div>
</header>
<main class="wrap">
<div class="grid" id="grid">
<!-- 左ペイン: エディタ -->
<section class="panel" aria-label="エディタ">
<div class="panel-head">エディタ(Ctrl/⌘+B:太字, Ctrl/⌘+I:斜体)</div>
<div class="panel-body">
<textarea id="area" spellcheck="false" placeholder="ここにMarkdownを書いてください…"></textarea>
</div>
</section>
<!-- 仕切り(ドラッグで幅変更) -->
<div class="divider" id="divider" role="separator" aria-orientation="vertical" aria-label="サイズ変更バー" tabindex="0"></div>
<!-- 右ペイン: プレビュー -->
<section class="panel" aria-label="プレビュー">
<div class="panel-head">プレビュー <span class="muted">(サニタイズ済み)</span></div>
<div id="preview" class="panel-body preview"></div>
</section>
</div>
<p class="kicker" style="text-align:center; margin: 10px 0 24px;">AutoSave: localStorage / Export: .md / Import: .md / Copy: HTML</p>
</main>
<script>
// ============================
// 設定・定数
// ============================
const LS_KEY = 'md-editor-content-v1';
const LS_THEME = 'md-editor-theme-v1';
// サンプル本文
const SAMPLE = `# ようこそ Markdown エディタへ ✨\n\n右側にプレビューが表示されます。左側で編集してみましょう。\n\n## 主な機能\n- **太字** / *斜体* / ~~取り消し~~\n- [リンク](https://example.com)\n- コード: `console.log('hello')`\n- 箇条書き\n - サブ項目\n- 引用\n> 引用はこんな感じ\n\n---\n\nチェックボックス:\n- [ ] やること1\n- [x] 完了したこと\n\n表:\n\n| 見出しA | 見出しB |\n| --- | --- |\n| セル1 | セル2 |\n\n\n\n\n\n`; // 末尾余白でスクロールの感覚をよくする
// markedの設定(GFM, 改行など)
marked.setOptions({ gfm: true, breaks: true, headerIds: true, mangle: false });
// 要素参照
const area = document.getElementById('area');
const preview = document.getElementById('preview');
const themeLabel = document.getElementById('themeLabel');
// ============================
// テーマ(ダーク/ライト)
// ============================
const applyTheme = (t) => {
if (t === 'light') {
document.documentElement.style.setProperty('--bg', '#f6f7f8');
document.documentElement.style.setProperty('--panel', '#ffffff');
document.documentElement.style.setProperty('--text', '#0f172a');
document.documentElement.style.setProperty('--muted', '#657084');
document.documentElement.style.setProperty('--border', '#e5e7eb');
document.documentElement.style.setProperty('--accent', '#4f46e5');
themeLabel.textContent = 'テーマ: ライト';
} else {
document.documentElement.style.setProperty('--bg', '#0b0b0c');
document.documentElement.style.setProperty('--panel', '#111214');
document.documentElement.style.setProperty('--text', '#e7e7ea');
document.documentElement.style.setProperty('--muted', '#9aa0a6');
document.documentElement.style.setProperty('--border', '#2a2c30');
document.documentElement.style.setProperty('--accent', '#4f46e5');
themeLabel.textContent = 'テーマ: ダーク';
}
};
const initialTheme = localStorage.getItem(LS_THEME) || 'dark';
applyTheme(initialTheme);
document.getElementById('btnTheme').addEventListener('click', () => {
const next = (localStorage.getItem(LS_THEME) || 'dark') === 'dark' ? 'light' : 'dark';
localStorage.setItem(LS_THEME, next);
applyTheme(next);
});
// ============================
// レンダリング(Markdown → HTML → サニタイズ → プレビュー)
// ============================
const render = () => {
const raw = marked.parse(area.value || '');
const clean = DOMPurify.sanitize(raw);
preview.innerHTML = clean;
// コードブロックのハイライト
if (window.hljs) {
document.querySelectorAll('pre code').forEach((el) => hljs.highlightElement(el));
}
};
// ============================
// 入出力(保存/読込/エクスポート/HTMLコピー)
// ============================
const save = () => localStorage.setItem(LS_KEY, area.value);
const load = () => localStorage.getItem(LS_KEY) || SAMPLE;
const exportMd = () => {
const blob = new Blob([area.value], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'document.md'; a.click();
URL.revokeObjectURL(url);
};
const importMd = (file) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () => { area.value = String(reader.result || ''); onInput(); };
reader.readAsText(file);
};
const copyHtml = async () => {
try {
await navigator.clipboard.writeText(preview.innerHTML);
alert('HTMLをコピーしました');
} catch (e) {
alert('コピーに失敗しました');
}
};
// ============================
// 編集操作(シンタックス挿入)
// ============================
const applySyntax = (fn) => {
const start = area.selectionStart || 0;
const end = area.selectionEnd || 0;
const before = area.value.slice(0, start);
const sel = area.value.slice(start, end);
const after = area.value.slice(end);
const replaced = fn(sel);
area.value = before + replaced + after;
const caret = (before + replaced).length;
area.focus();
area.setSelectionRange(caret, caret);
onInput();
};
const toolbarActions = {
h1: (s) => `# ${s || '見出し1'}`,
h2: (s) => `## ${s || '見出し2'}`,
bold: (s) => `**${s || '太字'}**`,
italic: (s) => `*${s || '斜体'}*`,
strike: (s) => `~~${s || '取り消し'}~~`,
code: (s) => ``${s || 'code'}``,
list: (s) => (s || '項目').split('\n').map(l => `- ${l}`).join('\n'),
num: (s) => (s || '項目').split('\n').map((l,i) => `${i+1}. ${l}`).join('\n'),
quote: (s) => (s || '引用').split('\n').map(l => `> ${l}`).join('\n'),
checkbox: (s) => (s || 'やること').split('\n').map(l => `- [ ] ${l}`).join('\n'),
hr: () => `\n\n---\n\n`,
};
document.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-action');
const fn = toolbarActions[key];
if (fn) applySyntax(fn);
});
});
// ============================
// 入力・ショートカット・ドラッグ&ドロップ
// ============================
const onInput = () => { save(); render(); };
area.addEventListener('input', onInput);
area.addEventListener('keydown', (e) => {
const accel = e.metaKey || e.ctrlKey;
if (!accel) return;
const k = e.key.toLowerCase();
if (k === 'b') { e.preventDefault(); applySyntax(toolbarActions.bold); }
if (k === 'i') { e.preventDefault(); applySyntax(toolbarActions.italic); }
});
// ファイル読み込み
document.getElementById('fileIn').addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
importMd(file);
e.target.value = '';
});
// ボタン群
document.getElementById('btnExport').addEventListener('click', exportMd);
document.getElementById('btnCopyHtml').addEventListener('click', copyHtml);
document.getElementById('btnSample').addEventListener('click', () => { area.value = SAMPLE; onInput(); });
document.getElementById('btnClear').addEventListener('click', () => { area.value = ''; onInput(); });
// ドラッグ&ドロップで.md読込
area.addEventListener('dragover', (e) => e.preventDefault());
area.addEventListener('drop', (e) => {
e.preventDefault();
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file && /\.md$/i.test(file.name)) importMd(file);
});
// 仕切りのドラッグで幅変更
const divider = document.getElementById('divider');
const grid = document.getElementById('grid');
let dragging = false;
const startDrag = () => dragging = true;
const stopDrag = () => dragging = false;
const onMove = (clientX) => {
const rect = grid.getBoundingClientRect();
const x = Math.min(Math.max(clientX - rect.left, 180), rect.width - 180); // 最小幅を確保
grid.style.gridTemplateColumns = `${x}px 8px 1fr`;
};
divider.addEventListener('mousedown', (e) => { startDrag(); onMove(e.clientX); });
window.addEventListener('mousemove', (e) => { if (dragging) onMove(e.clientX); });
window.addEventListener('mouseup', stopDrag);
// キーボードでも調整(左右キーで±20px)
divider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const rect = grid.getBoundingClientRect();
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ');
const left = parseFloat(cols[0]);
const delta = e.key === 'ArrowLeft' ? -20 : 20;
const next = Math.min(Math.max(left + delta, 180), rect.width - 180);
grid.style.gridTemplateColumns = `${next}px 8px 1fr`;
}
});
// ============================
// 初期化
// ============================
area.value = load();
render();
</script>
</body>
</html>