必要になったので。案外それっぽいツールないんですよねえ……
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高機能サイトマップジェネレーター</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
<!-- Font Awesome (for icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" />
<style id="sitemap-styles">
/* (基本CSSは変更なし) */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--danger-color: #dc3545;
--info-color: #17a2b8;
--bg-color: #f8f9fa;
--border-color: #dee2e6;
--text-color: #333;
--level1-bg: #e0f7fa;
--level2-bg: #b2ebf2;
--level3-bg: #80deea;
--level4-bg: #4dd0e1;
}
body {
font-family: 'Noto Sans JP', sans-serif;
margin: 0;
padding: 20px;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
gap: 20px;
}
.container { display: flex; width: 100%; gap: 20px; }
.editor-pane, .preview-pane {
background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); overflow: auto;
}
.editor-pane { width: 50%; }
.preview-pane { width: 50%; }
h1, h2 { margin-top: 0; border-bottom: 2px solid var(--primary-color); padding-bottom: 10px; }
.control-panel, .output-controls { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid var(--border-color); }
.btn { padding: 8px 15px; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; display: inline-flex; align-items: center; gap: 8px; transition: background-color 0.2s; }
.btn:disabled { background-color: #ccc; cursor: not-allowed; }
.btn i { font-size: 1.1em; }
.btn-primary { background-color: var(--primary-color); color: white; }
.btn-primary:hover:not(:disabled) { background-color: #0056b3; }
.btn-secondary { background-color: var(--secondary-color); color: white; }
.btn-secondary:hover:not(:disabled) { background-color: #5a6268; }
.btn-success { background-color: #28a745; color: white; }
.btn-success:hover:not(:disabled) { background-color: #218838; }
.btn-danger { background-color: var(--danger-color); color: white; }
.btn-danger:hover:not(:disabled) { background-color: #c82333; }
.btn-info { background-color: var(--info-color); color: white; }
.btn-info:hover:not(:disabled) { background-color: #138496; }
#site-name-input { margin-bottom: 15px; }
#site-name-input input { width: calc(100% - 10px); padding: 5px; border: 1px solid var(--border-color); border-radius: 4px; }
.page-input { margin-bottom: 5px; padding-left: 20px; position: relative; }
.page-item { padding: 8px; background-color: #f9f9f9; border: 1px solid var(--border-color); border-radius: 4px; display: flex; align-items: center; gap: 5px; }
.page-input[data-level="1"] > .page-item { background-color: var(--level1-bg); }
.page-input[data-level="2"] > .page-item { background-color: var(--level2-bg); }
.page-input[data-level="3"] > .page-item { background-color: var(--level3-bg); }
.page-input[data-level="4"] > .page-item { background-color: var(--level4-bg); }
.page-item input { border: 1px solid #ccc; padding: 4px; border-radius: 3px; }
.page-item input.page-name { width: 120px; font-weight: bold; }
.page-item input.page-url, .page-item input.description { flex-grow: 1; }
.page-item .handle { cursor: grab; color: #777; }
.page-item .page-actions button { background: none; border: none; cursor: pointer; font-size: 1.2em; padding: 0 5px; }
.page-item .page-actions .add-child { color: green; }
.page-item .page-actions .remove-page { color: red; }
.children-container { padding-top: 5px; }
#sitemap-preview { aspect-ratio: 1 / 1.414; padding: 20px; border: 1px solid #ddd; background-color: #ffffff; }
#sitemap-preview .site-title, .sitemap-body .site-title {
font-size: 1.8em; font-weight: bold; text-decoration: underline; margin-bottom: 1em; display: block; text-align: center;
}
.page-details { padding: 5px; border-radius: 4px; }
.page-line1 { display: flex; align-items: baseline; gap: 10px; }
.page-url { font-size: 0.9em; color: #555; word-break: break-all; }
.page-name { font-weight: bold; }
.page-line2 { margin-top: 4px; padding-left: 15px; }
.description { font-size: 0.9em; color: #333; }
.sitemap-list .page-node { margin-bottom: 10px; padding-left: 30px; }
.sitemap-list .page-node[data-level="1"] .page-details { background-color: var(--level1-bg); }
.sitemap-list .page-node[data-level="2"] .page-details { background-color: var(--level2-bg); }
.sitemap-list .page-node[data-level="3"] .page-details { background-color: var(--level3-bg); }
.sitemap-list .page-node[data-level="4"] .page-details { background-color: var(--level4-bg); }
.sitemap-tree { padding-left: 20px; }
.sitemap-tree ul { padding-left: 20px; list-style: none; position: relative; }
.sitemap-tree ul::before { content: ''; position: absolute; top: 0; left: 0; border-left: 1px solid #ccc; width: 1px; height: 100%; }
.sitemap-tree li { position: relative; padding: 5px 0; }
.sitemap-tree li::before { content: ''; position: absolute; top: 18px; left: -20px; border-bottom: 1px solid #ccc; width: 20px; height: 0; }
.sitemap-tree li:last-child > ul::before { height: 18px; }
.sitemap-tree .page-details { border: 1px solid var(--border-color); display: inline-block; }
.sitemap-tree li[data-level="1"] .page-details { background-color: var(--level1-bg); }
.sitemap-tree li[data-level="2"] .page-details { background-color: var(--level2-bg); }
.sitemap-tree li[data-level="3"] .page-details { background-color: var(--level3-bg); }
.sitemap-tree li[data-level="4"] .page-details { background-color: var(--level4-bg); }
/* ★★★ 修正点:画像キャプチャ時専用のスタイルを追加 ★★★ */
#sitemap-preview.capture-mode {
width: 1200px !important;
height: auto !important;
aspect-ratio: auto !important;
}
#sitemap-preview.capture-mode .page-line1 {
display: block; /* flexを解除 */
}
#sitemap-preview.capture-mode .page-line1 > span {
display: inline-block; /* inline-blockで横並びを再現 */
vertical-align: baseline;
margin-right: 10px; /* gapの代わり */
}
/* ★★★ 修正ここまで ★★★ */
@media print {
body { display: block; }
.editor-pane, .preview-pane > h1, .preview-pane .output-controls { display: none; }
.preview-pane { width: 100%; box-shadow: none; border: none; padding: 0; }
#sitemap-preview { border: none; aspect-ratio: auto; }
}
@media (max-width: 900px) {
.container { flex-direction: column; }
.editor-pane, .preview-pane { width: auto; }
}
</style>
</head>
<body>
<div class="container">
<!-- (HTML部分は変更なし) -->
<div class="editor-pane">
<h1><i class="fas fa-edit"></i> サイトマップエディタ</h1>
<div class="control-panel">
<button id="save-btn" class="btn btn-primary"><i class="fas fa-save"></i> JSON保存</button>
<button id="load-btn" class="btn btn-secondary"><i class="fas fa-folder-open"></i> JSON読込</button>
<input type="file" id="file-input" accept=".json" style="display: none;">
</div>
<div id="site-name-input">
<label for="site-name"><strong>サイト名:</strong></label>
<input type="text" id="site-name" placeholder="サイト名を入力">
</div>
<h2><i class="fas fa-sitemap"></i> サイト構成</h2>
<div id="sitemap-editor-root" class="children-container">
<div class="page-input" data-level="1" data-id="root">
<div class="page-item">
<span class="handle">☰</span>
<input type="text" class="page-name" value="トップページ" placeholder="ページ名">
<input type="text" class="page-url" placeholder="URL">
<input type="text" class="description" placeholder="説明文">
<div class="page-actions"><button class="add-child" title="子ページを追加">+</button></div>
</div>
<div class="children-container"></div>
</div>
</div>
</div>
<div class="preview-pane">
<h1><i class="fas fa-eye"></i> プレビュー&出力</h1>
<div class="output-controls">
<div class="view-options">
<strong>表示形式:</strong>
<input type="radio" id="view-list" name="view-type" value="list" checked> <label for="view-list">リスト</label>
<input type="radio" id="view-tree" name="view-type" value="tree"> <label for="view-tree">ツリー</label>
</div>
<button id="generate-btn" class="btn btn-success"><i class="fas fa-cogs"></i> 生成</button>
<button id="save-png-btn" class="btn btn-primary"><i class="fas fa-file-image"></i> PNG保存</button>
<button id="save-image-pdf-btn" class="btn btn-primary"><i class="fas fa-image"></i> 画像PDF</button>
<button id="save-text-pdf-btn" class="btn btn-danger"><i class="fas fa-table"></i> テキストPDF</button>
<button id="save-html-btn" class="btn btn-info"><i class="fas fa-code"></i> HTML出力</button>
<button id="print-btn" class="btn btn-secondary"><i class="fas fa-print"></i> 印刷</button>
</div>
<div id="sitemap-preview"></div>
</div>
</div>
<!-- Libraries -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.2/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script>
$(document).ready(function() {
// (ライブラリチェックは変更なし)
const coreLibsLoaded = typeof jQuery !== 'undefined' && typeof Sortable !== 'undefined' && typeof Swal !== 'undefined';
const canvasLibLoaded = typeof html2canvas !== 'undefined';
const pdfLibsLoaded = typeof window.jspdf !== 'undefined' && typeof window.jspdf.jsPDF.autoTable === 'function';
if (!coreLibsLoaded) {
alert('基本ライブラリの読み込みに失敗しました。ページを再読み込みしてください。');
return;
}
if (!canvasLibLoaded) {
$('#save-png-btn, #save-image-pdf-btn').prop('disabled', true).attr('title', '画像出力ライブラリの読み込みに失敗しました');
}
if (!pdfLibsLoaded) {
$('#save-image-pdf-btn, #save-text-pdf-btn').prop('disabled', true).attr('title', 'PDF出力ライブラリの読み込みに失敗しました');
}
function generateFilenamePrefix() { /* ... */ }
function initializeSortable(element) { /* ... */ }
function updateLevels() { /* ... */ }
initializeSortable(document.getElementById('sitemap-editor-root'));
$(document).on('click', '.add-child', function() { /* ... */ });
$(document).on('click', '.remove-page', function() { /* ... */ });
function getInputDataAsObject(element) { /* ... */ }
$('#save-btn').click(function() { /* ... */ });
function buildInputFromData(dataArray, container) { /* ... */ }
$('#load-btn').click(() => $('#file-input').click());
$('#file-input').change(function(event) { /* ... */ });
function generateSitemapHTML(data, type) { /* ... */ }
$('#generate-btn').click(function() { /* ... */ });
// ★★★ ここからが修正箇所です ★★★
$('#save-png-btn').click(function() {
const previewArea = $('#sitemap-preview');
if (previewArea.html().trim() === '') {
Swal.fire('エラー', '先にサイトマップを生成してください。', 'error');
return;
}
Swal.fire({ title: 'PNG画像を生成中...', text: 'しばらくお待ちください', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); } });
// キャプチャ用に一時的にスタイルを適用
previewArea.addClass('capture-mode');
html2canvas(previewArea.get(0), { // jQueryオブジェクトからDOM要素を取得
scale: 2,
windowWidth: previewArea.get(0).scrollWidth,
windowHeight: previewArea.get(0).scrollHeight
}).then(canvas => {
const link = document.createElement('a');
link.download = generateFilenamePrefix() + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
}).catch(err => {
Swal.fire('エラー', '画像生成中にエラーが発生しました。', 'error');
console.error(err);
}).finally(() => {
// 成功しても失敗しても、必ず元のスタイルに戻す
previewArea.removeClass('capture-mode');
Swal.close();
});
});
$('#save-image-pdf-btn').click(function() {
const previewArea = $('#sitemap-preview');
if (previewArea.html().trim() === '') {
Swal.fire('エラー', '先にサイトマップを生成してください。', 'error');
return;
}
Swal.fire({ title: '画像PDFを生成中...', text: 'しばらくお待ちください', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); } });
// キャプチャ用に一時的にスタイルを適用
previewArea.addClass('capture-mode');
html2canvas(previewArea.get(0), {
scale: 2,
windowWidth: previewArea.get(0).scrollWidth,
windowHeight: previewArea.get(0).scrollHeight
}).then(canvas => {
const imgData = canvas.toDataURL('image/png');
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
const ratio = canvas.width / canvas.height;
let pdfImgWidth = pdfWidth - margin * 2;
let pdfImgHeight = pdfImgWidth / ratio;
if (pdfImgHeight > pdfHeight - margin * 2) {
pdfImgHeight = pdfHeight - margin * 2;
pdfImgWidth = pdfImgHeight * ratio;
}
const x = (pdfWidth - pdfImgWidth) / 2;
const y = (pdfHeight - pdfImgHeight) / 2;
pdf.addImage(imgData, 'PNG', x, y, pdfImgWidth, pdfImgHeight);
pdf.save(generateFilenamePrefix() + '_image.pdf');
}).catch(err => {
Swal.fire('エラー', 'PDF生成中にエラーが発生しました。', 'error');
console.error(err);
}).finally(() => {
// 必ず元のスタイルに戻す
previewArea.removeClass('capture-mode');
Swal.close();
});
});
// --- 他の保存ボタン (変更なし) ---
$('#save-text-pdf-btn').click(function() { /* ... */ });
$('#save-html-btn').click(function() { /* ... */ });
$('#print-btn').click(function() { /* ... */ });
// (省略した関数の中身を再掲)
function generateFilenamePrefix() { const siteName = ($('#site-name').val().trim() || 'サイトマップ').replace(/[\\/:*?"<>|]/g, ''); const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); const dateString = `${year}${month}${day}`; return `サイトマップ_${siteName}_${dateString}`; }
function initializeSortable(element) { $(element).find('.children-container').each(function() { new Sortable(this, { animation: 150, group: 'sitemap', handle: '.handle', onEnd: function (evt) { updateLevels(); } }); }); }
function updateLevels() { $('#sitemap-editor-root').find('.page-input').each(function() { const level = $(this).parents('.page-input').length + 1; $(this).attr('data-level', level); }); }
$(document).on('click', '.add-child', function() { const parent = $(this).closest('.page-input'); const newLevel = parseInt(parent.data('level')) + 1; if (newLevel > 4) { Swal.fire('エラー', '階層は4階層までです。', 'error'); return; } const newPageId = 'page-' + Date.now(); const newPageInput = ` <div class="page-input" data-level="${newLevel}" data-id="${newPageId}"> <div class="page-item"> <span class="handle">☰</span> <input type="text" class="page-name" placeholder="ページ名"> <input type="text" class="page-url" placeholder="URL"> <input type="text" class="description" placeholder="説明文"> <div class="page-actions"> <button class="add-child" title="子ページを追加">+</button> <button class="remove-page" title="このページを削除">-</button> </div> </div> <div class="children-container"></div> </div> `; const childrenContainer = parent.find('> .children-container'); childrenContainer.append(newPageInput); initializeSortable(childrenContainer[0]); });
$(document).on('click', '.remove-page', function() { const pageToRemove = $(this).closest('.page-input'); Swal.fire({ title: 'ページを削除しますか?', text: "この操作は元に戻せません。", icon: 'warning', showCancelButton: true, confirmButtonColor: '#d33', cancelButtonColor: '#3085d6', confirmButtonText: 'はい、削除します', cancelButtonText: 'キャンセル' }).then((result) => { if (result.isConfirmed) { pageToRemove.remove(); Swal.fire('削除しました', '', 'success'); } }); });
function getInputDataAsObject(element) { const data = []; $(element).find('> .page-input').each(function() { const page = $(this); const pageData = { id: page.data('id'), name: page.find('> .page-item > .page-name').val(), url: page.find('> .page-item > .page-url').val(), description: page.find('> .page-item > .description').val(), children: getInputDataAsObject(page.find('> .children-container')) }; data.push(pageData); }); return data; }
$('#save-btn').click(function() { const siteData = { siteName: $('#site-name').val(), structure: getInputDataAsObject($('#sitemap-editor-root')) }; const jsonString = JSON.stringify(siteData, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = generateFilenamePrefix() + '.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); Swal.fire('成功', 'JSONファイルとして保存しました。', 'success'); });
function buildInputFromData(dataArray, container) { container.empty(); dataArray.forEach(item => { const newLevel = $(container).closest('.page-input').length + 1; const removeButton = item.id !== 'root' ? `<button class="remove-page" title="このページを削除">-</button>` : ''; const newPageInput = $(` <div class="page-input" data-level="${newLevel}" data-id="${item.id || 'page-' + Date.now()}"> <div class="page-item"> <span class="handle">☰</span> <input type="text" class="page-name" value="${item.name || ''}" placeholder="ページ名"> <input type="text" class="page-url" value="${item.url || ''}" placeholder="URL"> <input type="text" class="description" value="${item.description || ''}" placeholder="説明文"> <div class="page-actions"> <button class="add-child" title="子ページを追加">+</button> ${removeButton} </div> </div> <div class="children-container"></div> </div> `); container.append(newPageInput); if (item.children && item.children.length > 0) { buildInputFromData(item.children, newPageInput.find('.children-container')); } }); }
$('#file-input').change(function(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { try { const data = JSON.parse(e.target.result); $('#site-name').val(data.siteName || ''); buildInputFromData(data.structure, $('#sitemap-editor-root')); initializeSortable($('#sitemap-editor-root')[0]); updateLevels(); Swal.fire('成功', 'データを読み込みました。', 'success'); } catch (err) { Swal.fire('エラー', 'JSONファイルの形式が正しくありません。', 'error'); } }; reader.readAsText(file); $(this).val(''); });
function generateSitemapHTML(data, type) { function buildPageBlock(item) { const urlHtml = item.url ? `<span class="page-url">${item.url}</span>` : ''; const descriptionHtml = item.description ? `<div class="page-line2"><span class="description">${item.description}</span></div>` : ''; return ` <div class="page-details"> <div class="page-line1"> ${urlHtml} <span class="page-name">${item.name}</span> </div> ${descriptionHtml} </div> `; } if (type === 'list') { let html = ''; function buildList(items, level) { items.forEach(item => { html += `<div class="page-node" data-level="${level}">${buildPageBlock(item)}</div>`; if (item.children && item.children.length > 0) { buildList(item.children, level + 1); } }); } buildList(data, 1); return `<div class="sitemap-list">${html}</div>`; } if (type === 'tree') { let html = ''; function buildTree(items, level) { html += '<ul>'; items.forEach(item => { html += `<li data-level="${level}">${buildPageBlock(item)}`; if (item.children && item.children.length > 0) { buildTree(item.children, level + 1); } html += '</li>'; }); html += '</ul>'; } buildTree(data, 1); return `<div class="sitemap-tree">${html}</div>`; } }
$('#generate-btn').click(function() { const previewArea = $('#sitemap-preview'); previewArea.empty(); const siteName = $('#site-name').val(); const siteData = getInputDataAsObject($('#sitemap-editor-root')); const viewType = $('input[name="view-type"]:checked').val(); if(!siteData.length) { Swal.fire('情報がありません', 'サイト構成を入力してください。', 'info'); return; } previewArea.append(`<div class="site-title">${siteName}</div>`); previewArea.append(generateSitemapHTML(siteData, viewType)); });
$('#save-text-pdf-btn').click(function() { const siteData = getInputDataAsObject($('#sitemap-editor-root')); if (!siteData.length) { Swal.fire('エラー', 'サイト構成が入力されていません。', 'error'); return; } Swal.fire({ title: 'テキストPDFを生成中...', allowOutsideClick: false, didOpen: () => { Swal.showLoading(); } }); const { jsPDF } = window.jspdf; const doc = new jsPDF(); doc.setFont('helvetica', 'normal'); const siteName = $('#site-name').val() || 'サイトマップ'; doc.text(siteName, 14, 15); const tableData = []; function flattenData(items, level) { items.forEach(item => { const indent = " ".repeat((level - 1) * 4); tableData.push([ indent + item.name, item.url, item.description ]); if (item.children && item.children.length > 0) { flattenData(item.children, level + 1); } }); } flattenData(siteData, 1); doc.autoTable({ head: [['ページ名', 'URL', '説明文']], body: tableData, startY: 20, styles: { font: 'helvetica', fontSize: 10 }, headStyles: { fillColor: [41, 128, 185] }, }); doc.save(generateFilenamePrefix() + '_text.pdf'); Swal.close(); });
$('#save-html-btn').click(function() { const previewArea = $('#sitemap-preview'); if (previewArea.html().trim() === '') { Swal.fire('エラー', '先にサイトマップを生成してください。', 'error'); return; } const sitemapBodyHtml = previewArea.html(); const styles = $('#sitemap-styles').html(); const fullHtml = ` <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>${$('#site-name').val() || 'サイトマップ'}</title> <style> ${styles} body { display: block; padding: 20px; } .sitemap-body { border: 1px solid #ccc; padding: 20px; max-width: 1000px; margin: 0 auto; } </style> </head> <body> <div class="sitemap-body"> ${sitemapBodyHtml} </div> </body> </html> `; const blob = new Blob([fullHtml], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = generateFilenamePrefix() + '.html'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); });
$('#print-btn').click(function() { const previewArea = document.getElementById('sitemap-preview'); if (previewArea.innerHTML.trim() === '') { Swal.fire('エラー', '先にサイトマップを生成してください。', 'error'); return; } window.print(); });
});
</script>
</body>
</html>