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 class="form-group"> | |
| <label for="numberFormatSelect" class="form-label">数字表記:</label> | |
| <select class="form-select" id="numberFormatSelect"> | |
| <option value="none">None(変更なし)</option> | |
| <option value="kanji">漢数字に統一</option> | |
| <option value="arabic">算用数字に統一</option> | |
| </select> | |
| </div> | |
| </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> | |
| <button class="btn btn-outline-secondary btn-sm" onclick="applyNovelFormat('topText')" title="小説フォーマット統一(字下げ・スペース・三点リーダー・句点削除)"> | |
| <i class="fas fa-book"></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> | |
| <button class="btn btn-outline-secondary btn-sm" onclick="applyNovelFormat('bottomText')" title="小説フォーマット統一(字下げ・スペース・三点リーダー・句点削除)"> | |
| <i class="fas fa-book"></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> | |
| <button class="btn btn-outline-secondary btn-sm" onclick="applyNovelFormat('memoArea')" title="小説フォーマット統一(字下げ・スペース・三点リーダー・句点削除)"> | |
| <i class="fas fa-book"></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.getElementById('numberFormatSelect').value = textUtilData['numberFormatSelect'] || 'none'; | |
| } | |
| document.querySelectorAll("#bottomText, #topText").forEach(el => { | |
| el.addEventListener('input', () => { | |
| saveToUserStorage(false); | |
| }); | |
| }); | |
| document.querySelectorAll("#memoArea").forEach(el => { | |
| el.addEventListener('input', () => { | |
| saveToUserStorage(true); | |
| }); | |
| }); | |
| document.querySelectorAll("#numberFormatSelect").forEach(el => { | |
| el.addEventListener('change', () => { | |
| 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); // クリアしたら自動保存 | |
| } | |
| // 日本語小説フォーマットに合わせた字下げを適用する関数 | |
| function applyJapaneseIndent(text) { | |
| if (!text) return ''; | |
| // テキストを行に分割 | |
| const lines = text.split('\n'); | |
| const processedLines = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i].trim(); | |
| // 空行はそのまま保持 | |
| if (line === '') { | |
| processedLines.push(''); | |
| continue; | |
| } | |
| // 括弧類やMarkdown見出しで始まる行は字下げしない | |
| const startsWithBracket = /^[「『(\((【〔[{]/.test(line); | |
| const startsWithMarkdownHeader = /^#+/.test(line); | |
| // 段落の開始を判定(常にtrue) | |
| const isNewParagraph = true; | |
| if (isNewParagraph && !startsWithBracket && !startsWithMarkdownHeader) { | |
| // 段落の最初の行には全角スペース1文字分の字下げを追加 | |
| processedLines.push(' ' + line); | |
| } else { | |
| // 段落の続きの行や括弧類で始まる行はそのまま | |
| processedLines.push(line); | |
| } | |
| } | |
| return processedLines.join('\n'); | |
| } | |
| // 感嘆符・ハートマーク後のスペース処理関数 | |
| function addSpacesAfterMarks(text) { | |
| if (!text) return ''; | |
| let processedText = text; | |
| // 1. まず感嘆符・ハートマークの後に全角スペースを追加 | |
| const markPattern = /([!?!?♡♥❤]+)(\s*)/g; | |
| processedText = processedText.replace(markPattern, function(match, marks, existingSpaces) { | |
| // 既存のスペースを全角スペースに統一 | |
| const normalizedSpaces = existingSpaces.replace(/[ \u00A0]/g, ' '); | |
| // 全角スペースが既に1つ以上ある場合はそのまま、ない場合は1つ追加 | |
| if (normalizedSpaces.length >= 1) { | |
| return marks + normalizedSpaces; | |
| } else { | |
| return marks + ' '; | |
| } | |
| }); | |
| // 2. 閉じ括弧の直前のスペースを削除 | |
| processedText = processedText.replace(/([!?!?♡♥❤]) +([)」』】〕]}])/g, '$1$2'); | |
| return processedText; | |
| } | |
| // 三点リーダーとダッシュの統一関数 | |
| function unifyEllipsisAndDash(text) { | |
| if (!text) return ''; | |
| let processedText = text; | |
| // 三点リーダーを2つ連続に統一(1つや3つ以上の場合も2つに) | |
| processedText = processedText.replace(/…+/g, '……'); | |
| processedText = processedText.replace(/\.{3,}/g, '……'); | |
| // ダッシュを2つ連続に統一(1つや3つ以上の場合も2つに) | |
| processedText = processedText.replace(/―+/g, '――'); | |
| processedText = processedText.replace(/-{2,}/g, '――'); | |
| return processedText; | |
| } | |
| // かぎ括弧内の句点削除関数 | |
| function removePeriodInQuotes(text) { | |
| if (!text) return ''; | |
| let processedText = text; | |
| // かぎ括弧内の文末句点を削除 | |
| processedText = processedText.replace(/「([^」]*)。」/g, '「$1」'); | |
| processedText = processedText.replace(/『([^』]*)。』/g, '『$1』'); | |
| return processedText; | |
| } | |
| // 数字表記統一関数(漢数字に統一) | |
| function unifyNumbersToKanji(text) { | |
| if (!text) return ''; | |
| let processedText = text; | |
| // 基本的な数字の変換 | |
| const numberMap = { | |
| '0': '〇', '1': '一', '2': '二', '3': '三', '4': '四', | |
| '5': '五', '6': '六', '7': '七', '8': '八', '9': '九' | |
| }; | |
| // 単独の数字を漢数字に変換(ただし、日付や時間、電話番号などは除外) | |
| processedText = processedText.replace(/(?<![0-9])([0-9])(?![0-9])/g, function(match, digit) { | |
| // 前後に数字がない単独の数字のみ変換 | |
| return numberMap[digit] || digit; | |
| }); | |
| return processedText; | |
| } | |
| // 数字表記統一関数(算用数字に統一) | |
| function unifyNumbersToArabic(text) { | |
| if (!text) return ''; | |
| let processedText = text; | |
| // 漢数字を算用数字に変換 | |
| const kanjiMap = { | |
| '〇': '0', '一': '1', '二': '2', '三': '3', '四': '4', | |
| '五': '5', '六': '6', '七': '7', '八': '8', '九': '9' | |
| }; | |
| // 単独の漢数字を算用数字に変換 | |
| processedText = processedText.replace(/[〇一二三四五六七八九]/g, function(match) { | |
| return kanjiMap[match] || match; | |
| }); | |
| return processedText; | |
| } | |
| // 小説フォーマット統一ボタンのクリックイベントハンドラー | |
| function applyNovelFormat(textareaId) { | |
| const textarea = document.getElementById(textareaId); | |
| let processedText = textarea.value; | |
| // 1. 字下げ処理 | |
| processedText = applyJapaneseIndent(processedText); | |
| // 2. 三点リーダーとダッシュの統一 | |
| processedText = unifyEllipsisAndDash(processedText); | |
| // 3. かぎ括弧内の句点削除 | |
| processedText = removePeriodInQuotes(processedText); | |
| // 4. 感嘆符・ハートマーク後のスペース処理 | |
| processedText = addSpacesAfterMarks(processedText); | |
| // 5. 数字表記の統一(セレクトメニューの選択に基づく) | |
| const numberFormatSelect = document.getElementById('numberFormatSelect'); | |
| const numberFormat = numberFormatSelect.value; | |
| if (numberFormat === 'kanji') { | |
| processedText = unifyNumbersToKanji(processedText); | |
| } else if (numberFormat === 'arabic') { | |
| processedText = unifyNumbersToArabic(processedText); | |
| } | |
| // 'none'の場合は何もしない | |
| textarea.value = processedText; | |
| saveToUserStorage(true); // フォーマット統一後に自動保存 | |
| } | |
| </script> | |
| </body> | |
| </html> |