Spaces:
Running
Running
<html lang="ja" data-bs-theme="dark"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>テキスト編集ユーティリティ</title> | |
<!-- Bootstrap 5 CSS --> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> | |
<!-- Font Awesome --> | |
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
<style> | |
.text-area-container { | |
margin: 20px 0; | |
position: relative; | |
} | |
.text-area-controls { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
z-index: 10; | |
display: flex; | |
gap: 8px; | |
} | |
.text-area-controls .btn { | |
padding: 6px 12px; | |
font-size: 14px; | |
border-radius: 6px; | |
} | |
.text-area-container textarea { | |
padding-right: 80px; | |
} | |
.accordion-button:not(.collapsed) { | |
background-color: var(--bs-primary-bg-subtle); | |
} | |
.layout-wrapper { | |
display: flex; | |
flex-direction: row; | |
height: 100vh; | |
} | |
.sidebar { | |
width: 250px; | |
min-width: 250px; | |
transition: all 0.3s; | |
z-index: 1000; | |
background-color: var(--bs-body-bg); | |
border-right: 1px solid var(--bs-border-color, #444); | |
margin-top: 32px; | |
} | |
.main-content { | |
flex: 1 1 0%; | |
transition: all 0.3s; | |
margin-top: 32px; | |
display: flex; | |
flex-direction: column; | |
align-items: stretch; | |
} | |
.main-inner { | |
width: 100%; | |
max-width: 100%; | |
padding: 0 8px; | |
margin: 0; | |
} | |
@media (min-width: 768px) { | |
.layout-wrapper { | |
padding: 0 25vh; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="layout-wrapper"> | |
<!-- メインコンテンツ --> | |
<div class="main-content" id="mainContent"> | |
<div class="main-inner"> | |
<h2 class="mb-3">テキスト編集ユーティリティ</h2> | |
<div class="d-grid gap-2"> | |
<button class="btn btn-primary" id="processBtn"> | |
<i class="fas fa-cog me-2"></i>Process | |
</button> | |
<button class="btn btn-secondary" id="deprocessBtn"> | |
<i class="fas fa-undo me-2"></i>Deprocess | |
</button> | |
</div> | |
<div class="accordion" id="textEditorAccordion"> | |
<!-- 上部テキストエリア --> | |
<div class="accordion-item"> | |
<h2 class="accordion-header"> | |
<button class="accordion-button" type="button" data-bs-toggle="collapse" | |
data-bs-target="#collapseOne"> | |
<i class="fas fa-chevron-down me-2"></i>上部テキストエリア | |
</button> | |
</h2> | |
<div id="collapseOne" class="accordion-collapse collapse show" | |
data-bs-parent="#textEditorAccordion"> | |
<div class="accordion-body"> | |
<div class="text-area-container"> | |
<div class="text-area-controls"> | |
<button class="btn btn-outline-success btn-sm" onclick="copyToClipboard('topText', event)" title="コピー"> | |
<i class="fas fa-copy"></i> | |
</button> | |
<button class="btn btn-outline-primary btn-sm" onclick="pasteFromClipboard('topText')" title="ペースト"> | |
<i class="fas fa-paste"></i> | |
</button> | |
<button class="btn btn-outline-danger btn-sm" onclick="clearTextarea('topText')" title="クリア"> | |
<i class="fas fa-trash"></i> | |
</button> | |
</div> | |
<textarea id="topText" class="form-control" style="width:100%; min-height:50vh;" | |
rows="10" placeholder="ここにテキストを入力してください"></textarea> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- 下部テキストエリア --> | |
<div class="accordion-item"> | |
<h2 class="accordion-header"> | |
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | |
data-bs-target="#collapseTwo"> | |
<i class="fas fa-chevron-down me-2"></i>下部テキストエリア | |
</button> | |
</h2> | |
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#textEditorAccordion"> | |
<div class="accordion-body"> | |
<div class="text-area-container"> | |
<div class="text-area-controls"> | |
<button class="btn btn-outline-success btn-sm" onclick="copyToClipboard('bottomText', event)" title="コピー"> | |
<i class="fas fa-copy"></i> | |
</button> | |
<button class="btn btn-outline-primary btn-sm" onclick="pasteFromClipboard('bottomText')" title="ペースト"> | |
<i class="fas fa-paste"></i> | |
</button> | |
<button class="btn btn-outline-danger btn-sm" onclick="clearTextarea('bottomText')" title="クリア"> | |
<i class="fas fa-trash"></i> | |
</button> | |
</div> | |
<textarea id="bottomText" class="form-control" style="width:100%; min-height:50vh;" | |
rows="10" placeholder="ここにテキストを入力してください"></textarea> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="accordion-item"> | |
<h2 class="accordion-header"> | |
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" | |
data-bs-target="#collapseThree"> | |
<i class="fas fa-chevron-down me-2"></i>メモ | |
</button> | |
</h2> | |
<div id="collapseThree" class="accordion-collapse collapse" | |
data-bs-parent="#textEditorAccordion"> | |
<div class="accordion-body"> | |
<div class="text-area-container"> | |
<div class="text-area-controls"> | |
<button class="btn btn-outline-success btn-sm" onclick="copyToClipboard('memoArea', event)" title="コピー"> | |
<i class="fas fa-copy"></i> | |
</button> | |
<button class="btn btn-outline-primary btn-sm" onclick="pasteFromClipboard('memoArea')" title="ペースト"> | |
<i class="fas fa-paste"></i> | |
</button> | |
<button class="btn btn-outline-danger btn-sm" onclick="clearTextarea('memoArea')" title="クリア"> | |
<i class="fas fa-trash"></i> | |
</button> | |
</div> | |
<textarea id="memoArea" class="form-control" style="width:100%; min-height:50vh;" | |
rows="10" placeholder="ここにテキストを入力してください"></textarea> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Bootstrap 5 JS Bundle with Popper --> | |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> | |
<script> | |
let lastSaveTimestamp = 0; | |
// 0pxスペース(ゼロ幅スペース)の定数 | |
const ZERO_WIDTH_SPACE = '‌'; | |
// 既知のダミー文字の候補 | |
const KNOWN_DUMMY_CHARS = [ | |
'\u200c', '\u200b', '\u200d', // ゼロ幅文字 | |
'‌', '​', '‌', // HTMLエンティティ | |
'\u200e', '\u200f', // 方向制御文字 | |
'\u2060', '\u2061', '\u2062', '\u2063', '\u2064' // その他の制御文字 | |
]; | |
// 文字の出現頻度を分析する関数 | |
function analyzeCharFrequency(text) { | |
const frequency = new Map(); | |
for (let i = 0; i < text.length; i++) { | |
const char = text[i]; | |
frequency.set(char, (frequency.get(char) || 0) + 1); | |
} | |
return frequency; | |
} | |
// 文字列のパターンを分析してダミー文字の候補を探す関数 | |
function findPatternCandidate(text) { | |
if (!text || text.length < 3) return null; | |
// 文字の出現頻度を分析 | |
const frequency = analyzeCharFrequency(text); | |
// 文字列を2文字ずつに分割して、各文字の間の文字を確認 | |
const patternMap = new Map(); | |
for (let i = 1; i < text.length - 1; i += 2) { | |
const char = text[i]; | |
const prevChar = text[i - 1]; | |
const nextChar = text[i + 1]; | |
// 前後の文字が同じで、かつ現在の文字が一定の頻度で出現している場合 | |
if (prevChar === nextChar && | |
frequency.get(char) > text.length * 0.3) { // 30%以上の出現率 | |
patternMap.set(char, (patternMap.get(char) || 0) + 1); | |
} | |
} | |
// 最も頻出するパターンを返す | |
let maxCount = 0; | |
let candidate = null; | |
for (const [char, count] of patternMap) { | |
if (count > maxCount) { | |
maxCount = count; | |
candidate = char; | |
} | |
} | |
return candidate; | |
} | |
// 文字間のダミー文字を検出する関数 | |
function detectDummyChar(text) { | |
if (!text || text.length < 3) return null; | |
// まず既知のダミー文字をチェック | |
for (let i = 1; i < text.length - 1; i += 2) { | |
const char = text[i]; | |
if (KNOWN_DUMMY_CHARS.includes(char) && | |
text[i - 1] !== char && text[i + 1] !== char) { | |
return char; | |
} | |
} | |
// 既知のダミー文字が見つからない場合はパターン分析を実行 | |
return findPatternCandidate(text); | |
} | |
// 文字列の各文字の間に指定の文字列を挟む | |
function insertBetweenChars(text, insertStr) { | |
if (!text) return ''; | |
return text.split('').join(insertStr); | |
} | |
// 文字列から指定の文字列を除去 | |
function removeBetweenChars(text, removeStr) { | |
if (!text) return ''; | |
return text.split(removeStr).join(''); | |
} | |
// HTMLエンティティを実体参照に変換する関数 | |
function decodeHtmlEntities(str) { | |
const textarea = document.createElement('textarea'); | |
textarea.innerHTML = str; | |
return textarea.value; | |
} | |
// テキストエリアの値を取得・設定する関数 | |
function getUpperText() { | |
return document.querySelectorAll('.text-area-container textarea')[0].value; | |
} | |
function setUpperText(val) { | |
document.querySelectorAll('.text-area-container textarea')[0].value = val; | |
} | |
function getLowerText() { | |
return document.querySelectorAll('.text-area-container textarea')[1].value; | |
} | |
function setLowerText(val) { | |
document.querySelectorAll('.text-area-container textarea')[1].value = val; | |
} | |
// processボタンの挙動 | |
document.getElementById('processBtn').addEventListener('click', function () { | |
const upperText = getUpperText(); | |
const processed = insertBetweenChars(upperText, ZERO_WIDTH_SPACE); | |
setLowerText(processed); | |
// 下部テキストエリアを表示 | |
const lowerAccordion = new bootstrap.Collapse(document.getElementById('collapseTwo'), { | |
toggle: false | |
}); | |
lowerAccordion.show(); | |
}); | |
// deprocessボタンの挙動 | |
document.getElementById('deprocessBtn').addEventListener('click', function () { | |
let lowerText = getLowerText(); | |
// まずHTMLエンティティを実体参照に変換 | |
lowerText = decodeHtmlEntities(lowerText); | |
const dummyChar = detectDummyChar(lowerText); | |
if (!dummyChar) { | |
alert('文字間のダミー文字を検出できませんでした。'); | |
return; | |
} | |
const deprocessed = removeBetweenChars(lowerText, dummyChar); | |
setUpperText(deprocessed); | |
// 上部テキストエリアを表示 | |
const upperAccordion = new bootstrap.Collapse(document.getElementById('collapseOne'), { | |
toggle: false | |
}); | |
upperAccordion.show(); | |
}); | |
function saveToUserStorage(force = false) { | |
const currentTime = Date.now(); | |
if (currentTime - lastSaveTimestamp < 5000 && !force) { | |
console.debug('セーブをスキップします'); | |
return; | |
} | |
console.debug('セーブを実行します'); | |
// 既存のデータを取得 | |
const textUtilData = JSON.parse(localStorage.getItem('textUtil') || '{}'); | |
const newData = {}; | |
Array.from(document.querySelectorAll("input[id], textarea[id], select[id]")).forEach(el => { | |
if (el.id) { | |
newData[el.id] = el.type === 'checkbox' ? el.checked : el.value; | |
} | |
}); | |
Object.assign(textUtilData, newData); | |
console.log(textUtilData); | |
localStorage.setItem('textUtil', JSON.stringify(textUtilData)); | |
lastSaveTimestamp = currentTime; | |
} | |
function loadFromUserStorage() { | |
const textUtilData = JSON.parse(localStorage.getItem('textUtil') || '{}'); | |
document.getElementById('bottomText').value = textUtilData['bottomText'] || ''; | |
document.getElementById('topText').value = textUtilData['topText'] || ''; | |
document.getElementById('memoArea').value = textUtilData['memoArea'] || ''; | |
} | |
document.querySelectorAll("#bottomText, #topText").forEach(el => { | |
el.addEventListener('input', () => { | |
saveToUserStorage(false); | |
}); | |
}); | |
document.querySelectorAll("#memoArea").forEach(el => { | |
el.addEventListener('input', () => { | |
saveToUserStorage(true); | |
}); | |
}); | |
document.addEventListener('DOMContentLoaded', function () { | |
// ページ読み込み時にデータを復元 | |
loadFromUserStorage(); | |
}); | |
// クリップボードにコピーする関数 | |
async function copyToClipboard(textareaId, event) { | |
const textarea = document.getElementById(textareaId); | |
const text = textarea.value; | |
try { | |
await navigator.clipboard.writeText(text); | |
// 成功時のフィードバック(オプション) | |
const button = event.target.closest('button'); | |
const originalText = button.innerHTML; | |
button.innerHTML = '<i class="fas fa-check"></i>'; | |
setTimeout(() => { | |
button.innerHTML = originalText; | |
}, 1000); | |
} catch (err) { | |
console.error('クリップボードへのコピーに失敗しました:', err); | |
alert('クリップボードへのコピーに失敗しました'); | |
} | |
} | |
// クリップボードからペーストする関数 | |
async function pasteFromClipboard(textareaId) { | |
const textarea = document.getElementById(textareaId); | |
try { | |
const text = await navigator.clipboard.readText(); | |
textarea.value = text; | |
// ペースト後に自動保存 | |
saveToUserStorage(true); | |
} catch (err) { | |
console.error('クリップボードからのペーストに失敗しました:', err); | |
alert('クリップボードからのペーストに失敗しました'); | |
} | |
} | |
// テキストエリアをクリアする関数 | |
function clearTextarea(textareaId) { | |
const textarea = document.getElementById(textareaId); | |
textarea.value = ''; | |
saveToUserStorage(true); // クリアしたら自動保存 | |
} | |
</script> | |
</body> | |
</html> |