jQueryでリッチエディタを自作
テキスト入力時に文字を装飾したり画像や表を埋め込めるリッチエディタ。ワードプレスを使用することが増え、使用する機会は減ったものの代表的なツールも有料化され、クライアントへの提案が難しくなったためこれを機会に自作したものの一部をメモ。 PHP <div class="richtexteditor"> <form id="editorForm" method="POST" action="" onsubmit="prepareForm()"> <nav id="toolbar"> <ul> <li><button id="toggleView">ソースを見る</button></li> <li><button onclick="execCmd('bold'); return false;" id="bold"><i class="fas fa-bold"></i></button></li> <li><button onclick="execCmd('createLink', prompt('Enter the URL', 'http://')); return false;" id="links"><i class="fas fa-link"></i></button></li> <li><button onclick="execCmd('unlink')"><i class="fas fa-unlink"></i></button></li> <li><button type="button" onclick="showImageModal(); return false;"><i class="far fa-image"></i></button></li> <li><div class="toolbar__li__inner"><i class="fas fa-tint"></i><input type="color" id="textColor" title="Text Color"></div></li> <li><div class="toolbar__li__inner">フォントサイズ <select onchange="execCmd('fontSize', this.value); return false;" name="fontSize" id="fontSize"> <option value="">標準</option> <option value="10px">10px</option> <option value="12px">12px</option> <option value="14px">14px</option> <option value="16px">16px</option> <option value="18px">18px</option> <option value="24px">24px</option> <option value="30px">30px</option> <option value="36px">36px</option> </select> </div></li> </ul> </nav> <div id="editor" contenteditable="true"></div> <textarea id="textarea" style="display: none;" name="html"></textarea> <ul class="submitbtn"> <li><input type="submit" name="submit" value="登録"></li> </ul> </form> <!-- 画像選択用モーダル --> <div id="imageModal" class="modal"> <div class="modal-content"> <span class="close" onclick="closeImageModal()">×</span> <h2>画像選択</h2> <div class="image-list"> <!-- サーバー内の画像ファイルリスト --> <?php $dir = 'upload/'; $files = array_diff(scandir($dir), array('.', '..')); foreach ($files as $file) { echo '<img src="' . $dir . $file . '" onclick="selectImage(\'' . $dir . $file . '\')" alt="' . $file . '">'; } ?> </div> </div> </div> </div> サンプルは「太字」「リンク付与/打消し」「画像」「文字色」「文字サイズ」を変更できるボタンと「ソース切替」ボタンを設定。リッチエディタとして動くよう編集エリアに「contentEditable="true"」を記載。画像はモーダルウィンドウ形式にして対象フォルダ(upload)の中を参照できるようにしています。 CSS @import url('https://use.fontawesome.com/releases/v6.5.1/css/all.css'); .icon-code::before{ font-family: "Font Awesome 5 Free"; content: "\f1c9"; font-weight: 400; margin: 0 5px 0 0; } .richtexteditor .submitbtn { display: flex; justify-content: center; padding: 0; } .richtexteditor .submitbtn li { margin:0 10px; list-style: none; } .richtexteditor .submitbtn li input { background: #3f6fab; width: 20vw; line-height: 44px; border: #3f6fab solid 3px; margin: 0; color: #fff; cursor: pointer; } .richtexteditor .submitbtn li input:hover { background: #fff; color: #3f6fab; } #toolbar { background: #f8f8f8; margin: 0 0 5px; padding: 5px; box-sizing: border-box; border: 1px solid #ccc; } #toolbar ul { display: flex; flex-wrap: wrap; margin: 0; padding: 0; } #toolbar ul li { list-style: none; margin-right: 5px; } #toolbar button { background: none; padding: 10px; border: none; font-size: 14px; text-align: center; line-height: 1.0; } #toolbar button i { font-size: 14px; } #toolbar button.active { background: #fff; border: #ddd solid 1px; } .toolbar__li__inner { display: flex; align-items: center; padding: 10px; font-size: 14px; line-height: 1.0; } #toolbar select { margin: 0 0 0 5px; padding: 0; border: #ddd solid 1px; } #toolbar input[type="color"] { height: 14px; margin: 0 0 0 5px; padding: 0; border: #ddd solid 1px; } #editor, textarea { width: 100%; height: 66vh; padding: 10px; box-sizing: border-box; border: 1px solid #ccc; overflow: auto; } .modal { display: none; position: fixed; z-index: 1; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgb(0,0,0); background-color: rgba(0,0,0,0.4); padding-top: 60px; } .modal-content { background-color: #fefefe; margin: 5% auto; padding: 20px; border: 1px solid #888; width: 80%; } .close { color: #aaa; float: right; font-size: 28px; font-weight: bold; } .close:hover { color: black; text-decoration: none; cursor: pointer; } .image-list img { max-width: 100px; cursor: pointer; margin: 5px; } jQuery $(document).ready(function() { let isSourceMode = false; // 選択範囲の状態取得 document.onselectionchange = function() { updateToolbar(); } // コマンドを実行する関数 window.execCmd = function(cmd, value) { // 現在のテキスト選択を取得 var selection = window.getSelection(); // 現在の選択範囲を取得 var range = selection.getRangeAt(0); switch (cmd) { case 'bold': var ancestor = findAncestor(range.commonAncestorContainer, 'STRONG'); if (ancestor) { unwrap(ancestor); } else { var strong = document.createElement('strong'); range.surroundContents(strong); } break; case 'createLink': var a = document.createElement('a'); a.href = value; a.textContent = range.toString(); range.deleteContents(); range.insertNode(a); break; case 'unlink': var parent = range.commonAncestorContainer.parentElement; if (parent && parent.tagName === 'A') { var textNode = document.createTextNode(parent.textContent); parent.parentNode.replaceChild(textNode, parent); } break; case 'insertImage': var img = document.createElement('img'); img.src = value; range.deleteContents(); range.insertNode(img); break; case 'foreColor': var existingSpan = findExistingSpanWithStyle(range, 'color'); if (existingSpan) { existingSpan.style.color = value; } else { var span = document.createElement('span'); span.style.color = value; range.surroundContents(span); } break; case 'fontSize': var existingSpan = findExistingSpanWithStyle(range, 'fontSize'); if (existingSpan) { if (value === '') { unwrap(existingSpan); } else { existingSpan.style.fontSize = value; } } else { var span = document.createElement('span'); span.style.fontSize = value; range.surroundContents(span); } break; default: document.execCommand(cmd, false, value); } updateTextarea(); updateToolbar(); }; // RGBからHEXへ変換 function rgbToHex(color) { var hex = '#'; if (color.match(/^#[a-f\d]{3}$|^#[a-f\d]{6}$/i)){ return color; } var regex = color.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); if (regex){ var rgb = [ parseInt(regex[1]).toString(16), parseInt(regex[2]).toString(16), parseInt(regex[3]).toString(16) ]; for (var i = 0; i < rgb.length; ++i){ if (rgb[i].length == 1){ rgb[i] = '0' + rgb[i]; } hex += rgb[i]; } return hex; } } // タグの削除 function unwrap(element) { var parent = element.parentNode; while (element.firstChild) { parent.insertBefore(element.firstChild, element); } parent.removeChild(element); } //該当範囲にタグが反映されているのか確認 function findAncestor(node, tagName, allowedTags) { while (node) { if (node.nodeType === 1 && node.tagName === tagName) { return node; } if (allowedTags && allowedTags.includes(node.tagName)) { return node; } node = node.parentNode; } return null; } // 該当範囲にスタイルが適応されているかの確認 function findExistingSpanWithStyle(range, style) { var container = range.commonAncestorContainer; while (container) { if (container.nodeType === 1 && container.tagName === 'SPAN' && container.style[style]) { return container; } container = container.parentNode; } return null; } // ツールバー更新関数 function updateToolbar() { // ボタンの状態をクリアする $('#toolbar button').removeClass('active'); $('#toolbar #textColor').val('#000'); $('#toolbar select#fontSize').val(0); var selection = window.getSelection(); if (selection && selection.rangeCount > 0) { var range = selection.getRangeAt(0); var parentElement = range.commonAncestorContainer.parentElement; // bold var ancestorStrong = findAncestor(range.commonAncestorContainer, 'STRONG'); if (ancestorStrong) { $('#toolbar button#bold').addClass('active'); } // font color var existingSpanColor = findExistingSpanWithStyle(range, 'color'); if (existingSpanColor) { var fontColor = existingSpanColor.style.color; $('#toolbar #textColor').val(rgbToHex(fontColor)); } // font size var existingSpan = findExistingSpanWithStyle(range, 'fontSize'); if (existingSpan) { var fontSize = existingSpan.style.fontSize; $('#toolbar select#fontSize').val(fontSize); } } } // エディタの内容をテキストエリアに反映する関数 function updateTextarea() { var content = $('#editor').html(); $('#textarea').val(content); } // テキストエリアの内容をエディタに反映する関数 function updateEditor() { var content = $('#textarea').val(); $('#editor').html(content); } // モード切替関数 function toggleView() { if (isSourceMode) { $('#textarea').hide(); $('#editor').show(); updateEditor(); $('#toggleView').text('ソースを見る'); } else { updateTextarea(); $('#editor').hide(); $('#textarea').show(); $('#toggleView').text('エディタに戻る'); } isSourceMode = !isSourceMode; } // 画像モーダル表示関数 window.showImageModal = function() { $('#imageModal').show(); }; // 画像モーダル閉じる関数 window.closeImageModal = function() { $('#imageModal').hide(); }; // 画像選択関数 window.selectImage = function(imgSrc) { execCmd('insertImage', imgSrc); closeImageModal(); }; // フォーム送信前の処理 window.prepareForm = function() { // 不要なタグや空のタグを削除する処理 var editorContent = $('#editor').html(); var cleanedContent = cleanHTML(editorContent); $('#editor').html(cleanedContent); }; // HTMLをクリーンアップする関数 function cleanHTML(html) { var cleaned = html.replace(/<div><br><\/div>/g, '<br>'); cleaned = cleaned.replace(/<div><br\s*\/?><\/div>/g, '<br>'); cleaned = cleaned.replace(/<div><\/div>/g, ''); cleaned = cleaned.replace(/<p><br><\/p>/g, '<br>'); cleaned = cleaned.replace(/<p><br\s*\/?><\/p>/g, '<br>'); cleaned = cleaned.replace(/<p><\/p>/g, ''); // cleaned = cleaned.replace(/<strong><\/strong>/g, ''); cleaned = cleaned.replace(/<em><\/em>/g, ''); cleaned = cleaned.replace(/<u><\/u>/g, ''); return cleaned; } // 初期化:テキストエリアの内容をエディタに反映 updateEditor(); // フォーカスをエディタに設定 $('#editor').focus(); // エディタの内容が変更されたときにテキストエリアを更新 $('#editor').on('input', updateTextarea); // ソースビュー切り替えボタンのイベントハンドラ $('#toggleView').on('click', toggleView); // 文字色変更イベントハンドラ $('#textColor').on('input', function() { execCmd('foreColor', this.value); }); }); 「window.getSelection()」を使用してカーソールで選択している範囲を取得。「execCmd」関数に押されたボタンの値を送ることで各タグを実装。 「findAncestor」関数や「findExistingSpanWithStyle」関数は押したボタンのタグが既に付与されているかを判定するのに使用、スタイルのみを変更することで二重にタグを付与することを防いでいます。