Webブラウザ上にHTMLエディタを作りたい
今まで何の意識もせずに使っていたのですが、Bloggerや他のCMSを使っている際にWebブラウザ上の入力領域でコードがハイライトされます。
そのようなフォームを生成したいという案件をもらって初めて、その実装の難しさを知りました。
ここで書いているのは車輪の再発明を目指した際の技術メモです。ブラウザにコードエディタ機能を実装したい方はCodeMirror等を導入した方が早いと思います。
contenteditable
ブラウザ上で装飾をするにはCSSやstyle属性を利用するのですが、inputやtextariaではタグが有効にならないため、装飾ができません。
方法を探している際に、nymemoさんの記事にたどり着きました。
contenteditable属性にtrueをセットすると、divなどの要素を編集できるようになります。
コード.html
<div contenteditable="true">直接編集可能な箇所</div>
表示サンプル
この下のエリアはtextareaではありませんが、キーボード入力による編集ができます。
ここは赤色
ここは青色
内部にタグがありますが、入力者からは見えません
タグに囲まれた部分すべて消してしまうとスタイルも消えてしまいます。また、どこからどのスタイルが適用されるのかも入力者からみてわかりづらいです。
JavaScriptから要素を取得して、.innerHTMLで中身を取得するとデータ入力後の状態を確認することができますが、入力データはは基本的に、改行単位で<div>によって分割されます。
もう少し詳しく書くと、初期状態はブランクで、この1行目はdivタグが自動付与されることはありません。改行をすると2行目に該当する部分に<div><br></div>というタグが作られます。2行目に何か値が入ると内側のbrは消え代わりに入力された値が入ります。
innterHTML,innerText,textContentの違いの正則はリンク先のMDNのコンテンツに記載されていますが、ここでJavaScriptで入力値を取得する際は、次のように使い分けることができます。
- .innerHTML
内部に設定されているタグを含んだデータを取得できます。入力したタグは<タグ>といった形にエスケープされます。
- .innerText
内部に設定されているタグを除去した形でデータが戻りますが、改行コードが入ります。
- .textContent
タグを除去した形でデータが返ります。
この辺りの話はブラウザにより実装が違うかもしれません。筆者の環境はGoogle Chromeです。
簡易的な実装
作成してみたのが次のコードです。
要素を複製してハイライトする方を下、編集する方を上にして重ね合わせ、上の要素を少しだけ透過させました。
上側のデータの入力を変換して下側に渡します。
下側の要素で濃い色を使った背景色かアンダーラインでハイライトすることができますが、上下ふたつの要素の同期でタイムラグが発生することもあります。
下のコードでは記述していませんが、下側の文字がうっすら見えることが気になるようでしたらそれぞれのcssで背景と文字色を一緒にすることで目立ちにくくなります。
sample.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
* {
box-sizing: border-box;
}
.tag {
background-color: green;
}
.tag.error {
background-color: red;
}
.tag.warn {
background-color: yellow;
}
.search {
background-color: blue;
}
</style>
</head>
<body>
<div class="wrapper" style="position: relative; padding: 3px">
<div
id="div-script-clone"
style="
width: 100%;
height: 50vw;
background-color: #fff;
position: absolute;
overflow: scroll;
left: 0;
top: 0;
padding: 3px;
word-break: break-all;
"
></div>
<div
id="div-script"
style="
width: 100%;
height: 50vw;
background-color: #fff;
opacity: 0.9;
position: absolute;
overflow: scroll;
top: 0px;
left: 0px;
padding: 3px;
word-break: break-all;
"
contenteditable="true"
onkeyup="highlight();"
><span id="end"></span></span></div>
</div>
<script>
let codeAreaClone = document.getElementById("div-script-clone");
let codeAreaInput = document.getElementById("div-script");
//スクロールの同期
codeAreaInput.addEventListener("scroll", () => {
codeAreaClone.scrollTop = codeAreaInput.scrollTop;
codeAreaClone.scrollLeft = codeAreaInput.scrollLeft;
});
const highlight = () => {
//ハイライト処理
//コードエリアの値を取得する
let str = codeAreaInput.innerHTML;
//最短一致でコード内のタグを取得
let regExpAdd = new RegExp(/<.+?>/g);
let result = "";
//スクリプトとスタイルの中身は無視
let blnScript = false;
let blnStyle = false;
let currentIndex = 0;
let wkLastIndex = 0;
while ((arr1 = regExpAdd.exec(str)) !== null) {
wkLastIndex = regExpAdd.lastIndex;
//判定のために小文字化+空白除去
let tag = arr1[0].replaceAll(" ", "").toLowerCase();
if (tag.startsWith("</script")) {
//スクリプト終了タグの場合
blnScript = false;
result +=
str.substring(currentIndex, wkLastIndex - arr1[0].length) +
"<span class='tag'>" +
divCheck(arr1[0]) +
"</span>";
} else if (tag.startsWith("</style")) {
//スタイル終了タグの場合
blnStyle = false;
result +=
str.substring(currentIndex, wkLastIndex - arr1[0].length) +
"<span class='tag'>" +
divCheck(arr1[0]) +
"</span>";
} else if (tag.startsWith("<script")) {
//スクリプト開始タグの場合
blnScript = true;
result +=
str.substring(currentIndex, wkLastIndex - arr1[0].length) +
"<span class='tag'>" +
divCheck(arr1[0]) +
"</span>";
} else if (tag.startsWith("<style")) {
//スタイル開始タグの場合
blnStyle = true;
result +=
str.substring(currentIndex, wkLastIndex - arr1[0].length) +
"<span class='tag'>" +
divCheck(arr1[0]) +
"</span>";
} else {
if (blnStyle || blnScript) {
//スタイルかスクリプトの内側だった場合はそのまま返す
result += str.substring(currentIndex, wkLastIndex);
} else {
//タグを要素で囲む
result +=
str.substring(currentIndex, wkLastIndex - arr1[0].length) +
"<span class='tag'>" +
divCheck(arr1[0]) +
"</span>";
}
}
currentIndex = wkLastIndex;
}
if (wkLastIndex < str.length) {
result += str.substring(wkLastIndex);
}
codeAreaClone.innerHTML = result;
easyCheck(codeAreaClone);
};
const easyCheck = (elHtml) => {
//開始タグと終了が正常に対になっているかチェック
//タグはspan要素となっているのでそれを使って取得
let els = elHtml.querySelectorAll("span.tag");
let collation = [];
let blnErr = false;
//終了タグの存在を無視する要素(高速化の為自分が使用する可能性のあるタグにとどめておきます)
let ignore = ["li", "input", "br", "td","img"];
for (let i = 0; i < els.length; i++) {
//タグを取得
//textContentで取得する場合はタグがエスケープされない
let tag = els[i].textContent.match(/\<\s*\/?\s*[a-z0-9]+/i);
if (tag == null) {
continue;
}
//小文字化+余分な空白除去
tag = tag[0].replaceAll(" ", "").toLowerCase();
if (tag.startsWith("</")) {
//終了タグの場合
let compare = collation.pop();
// </tag の形で入っているので2文字目以降と比べる
if (compare != tag.substring(2)) {
if (blnErr) {
//ひとつエラーがあるとすべてずれるので残りはワーニングにする
els[i].classList.add("warn");
} else {
els[i].classList.add("error");
blnErr = true;
}
}
} else {
//開始タグの場合
// チェック対象外のタグを無視する
// <tag の形で入っているので1文字目以降と比べる
if (ignore.includes(tag.substring(1)) == false) {
collation.push(tag.substring(1));
}
}
}
};
const divCheck = (str) => {
//入力されたタグ間にdivがあることがあります
//divタグは手入力しないのでネストするのは想定外です
let regDiv = new RegExp(/(<div>|<\/div>)/g);
return str.replaceAll(regDiv, "</span>$&<span class='tag'>");
};
</script>
</body>
</html>
上側の背景色と透過性を除去して、文字の透過性を最大に(color: rgba(255, 255, 255, 0);)、キャレットの透過性を0に(caret-color: rgba(0, 0, 0, 1);)設定することで文字色を変えることができたのですが、入力レスポンスが悪いのに加えIME変換中のバーも出ないので諦めました。
contenteditableを設定した要素に直接タグをあてる方法
contenteditableを設定した要素にタグを当てたい場合も、基本的には先と同じように内部のコードを取得してinnerHTML等からタグ付きのデータに変換すればハイライト自体は可能です。ただ、今回筆者が調べたところ、この手法はというかcontenteditableを用いたコードエディタは、いろいろとトラブルがつきもののようです。
トラブルの原因はcontenteditableの実装のわかりづらさにあるという記事もみましたが、まず筆者が遭遇したのはinnerHTMLを差し替えるとカーソル位置(キャレットポジション)が変わってしまう現象です(中身を差し替えているので、あたりまえといえば当たり前なのですが……)。この現象はカーソルを元の位置に戻す処理を加えることで対応可能です。
textarea要素ではselectionStartとselectionEndプロパティでキャレットポジションを取得できますが、contenteditableで編集可能にした要素においてはこのプロパティが存在しません。
そこでdocumentのRangeオブジェクト経由で操作をします。
またこのRangeオブジェクトを使ってカーソル位置操作する際は、documentのgetSelection()メソッドからSelectionオブジェクトを取得する必要もあります。
キャレットポジションの取得
let selection = document.getSelection();
let node = selection.focusNode;
let intOffset = selection.focusOffset;
selectionオブジェクトのfocusNodeプロパティにキャレットがあるノードが入ります。これは通常はテキストノードになります。空の行を選択した際はdivエレメント、何もテキストが無い場合は一番外側のdivエレメントが設定されるようです。
focusOffsetには、focusNodeの何文字目にキャレットがあるかという数値が入っています。
キャレットポジションの設定
let selection = document.getSelection();
let range = document.createRange();
range.setStart(node, intOffsetStart);
range.setEnd(node, intOffsetEnd);
if (selection.removeAllRanges) {
selection.removeAllRanges();
}
selection.addRange(range);
キャレットをセット(移動)させるには、Rangeオブジェクトを作成しそれに対して開始ノードとそのオフセット、終了ノードとそのオフセットを設定します。その後、Rengeをselectionに加えます。
注意が必要なのは、外側のdiv要素にフォーカスが当たっていないとキャレットポジションを設定しても何も変化がおきないところです。
ちなみにこのコードをChatGPT様に相談してみたら次のような回答でした。伝え方が悪いせいもあり、機能しませんが構築のヒントにはなりそうです。
editor.html(by ChatGPT)
<!DOCTYPE html>
<html>
<head>
<title>HTML Editor</title>
<style>
.highlight {
background-color: yellow;
}
</style>
</head>
<body>
<h1>HTML Editor</h1>
<div id="editor" contenteditable="true"></div>
<button onclick="saveContent()">Save</button>
<script>
var openingTag = null;
function saveContent() {
// Get the content of the editor
var content = document.getElementById('editor').innerHTML;
// Save the content to a file or database here...
}
// Add an event listener to the editor to listen for keypress events
var editor = document.getElementById('editor');
editor.addEventListener('keypress', function(e) {
// Check if the pressed key is a "<" or ">" character
if (e.key === '<') {
// Get the position of the caret in the editor
var caretPos = editor.selectionStart;
// Insert the "<" character at the caret position
editor.innerHTML = editor.innerHTML.substring(0, caretPos) + '<' + editor.innerHTML.substring(caretPos);
// Save the inserted "<" character for later reference
openingTag = editor.childNodes[caretPos];
// Highlight the inserted "<" character
openingTag.classList.add('highlight');
} else if (e.key === '>') {
// Check if there is an open tag
if (openingTag) {
// Get the position of the caret in the editor
var caretPos = editor.selectionStart;
// Insert the ">" character at the caret position
editor.innerHTML = editor.innerHTML.substring(0, caretPos) + '>' + editor.innerHTML.substring(caretPos);
// Remove the highlighting from the opening "<" character
openingTag.classList.remove('highlight');
openingTag = null;
}
}
});
</script>
</body>
</html>
検索機能を付与したい
通常のブラウザからのControl+F機能だと正規表現による検索ができないので独自の検索機能を実装しようとしました。その際、タグの色付けと同じ階層で行おうとすると、タグの色付けと検索の色付けが部分的に重なった時にタグ付けが面倒です。
そのため検索の階層は別に持たせます。この時の透過ルールは先の通りだと3階層まで透過しにくいので、第1階層(コード入力部)の背景 は透明化、第2階層(タグ)は、透過率を設定、第3階層(検索)は透過無しとします。
前述のタグハイライト同様にスクロールの同期も取ります。
コード検索.html
<div class="wrapper" style="position: relative; padding: 3px">
<!-- 第3階層(検索ハイライト) -->
<div
id="div-script-search"
style="
width: 100%;
height: 50vw;
background-color: #fff;
color: #fff;
position: absolute;
overflow: scroll;
left: 0;
top: 0;
padding: 3px;
word-break: break-all;
"
></div>
<!-- 第2階層(タグハイライト) -->
<div
id="div-script-clone"
style="
width: 100%;
height: 50vw;
opacity: 0.9;
background-color: #fff;
color: #fff;
position: absolute;
overflow: scroll;
left: 0;
top: 0;
padding: 3px;
word-break: break-all;
"
></div>
<!-- 第1階層(コード) -->
<div
id="div-script"
style="
width: 100%;
height: 50vw;
background-color: rgba(255, 255, 255, 0);
position: absolute;
overflow: scroll;
top: 0px;
left: 0px;
padding: 3px;
word-break: break-all;
"
contenteditable="true"
onkeyup="_stop=true;highlight();"
></div>
</div>
検索用のスクリプトはシンプルにbrやdivといった行をまたぐものを考慮しない形にしました。
script.js
const search = (strRegExp, option = "g") => {
//検索処理(タグの色付けに影響するのでまた別要素で設定する)
//gオプションは必須です。
codeAreaSearch.innerHTML = "";
if (strRegExp == "") {
return;
}
Array.from(codeAreaInput.childNodes).forEach((v) => {
let regExpSearch = new RegExp(strRegExp, option);
let str = v.textContent;
let arr1;
let currentIndex = 0;
let wkLastIndex = 0;
let replaceSpan = document.createElement("span");
//ルート要素の中にはdivとbr textNodeしかない前提()
while ((arr1 = regExpSearch.exec(str)) !== null) {
//テキストの中に目的の文字があったら切り出し
wkLastIndex = regExpSearch.lastIndex;
console.log(
str.substring(currentIndex, wkLastIndex - arr1[0].length)
);
//ヒットより前の文字
replaceSpan.appendChild(
document.createTextNode(
str.substring(currentIndex, wkLastIndex - arr1[0].length)
)
);
//ヒットした文字
let span = document.createElement("span");
span.classList.add("search");
span.appendChild(document.createTextNode(arr1[0]));
replaceSpan.appendChild(span);
currentIndex = wkLastIndex;
}
if (currentIndex == 0) {
//見つからなかかった
codeAreaSearch.appendChild(v.cloneNode(true));
} else {
if (currentIndex < str.length - 1) {
//残りあり
let span = document.createElement("span");
span.appendChild(
document.createTextNode(str.substring(currentIndex))
);
replaceSpan.appendChild(span);
}
if (v.nodeType == Node.ELEMENT_NODE) {
let clone = v.cloneNode(false);
clone.appendChild(replaceSpan);
codeAreaSearch.appendChild(clone);
} else {
codeAreaSearch.appendChild(replaceSpan);
}
}
});
};
contenteditableへのコピー&ペースト
筆者の環境であるGoogleChromeでは、他のHTMLコンテンツからcontenteditable要素へコピー&ペーストをすると、タグの部分を含んでコピーされます。
そのようにしたい場合は大変便利な機能ですが、例えば意図しない背景色までコピーしてきたりと、そうでない場合も多いと思います。
それを防ぐには、pasteイベントを捕まえて、挙動を止めた後、以降の作業を記述します。
preventDefault()でデフォルトの挙動を止めた後、DataTransfer APIのgetDataにフォーマット「text/plain」を渡して、テキストデータだけを取得するようにしています。
この時改行は得られるので、それで分割してdivにセットします。innerHTMLへの値のセットは潜在的な危険性を持つのでここまで避けてきたのですが、空白を に変換するのに他の方法が思いつかなかったため、サニタイズしてセットしています。
ここでもselectionとrangeを使ってフォーカスの位置を取得し、そこへデータを挿入しています。
最後にハイライト処理をします。
paste.js
codeAreaInput.addEventListener("paste", (event) => {
event.preventDefault();
let paste = (event.clipboardData || window.clipboardData).getData(
"text/plain"
);
let rows = paste.split("\n");
const selection = window.getSelection();
if (!selection.rangeCount) return;
selection.deleteFromDocument();
for (let i = rows.length - 1; 0 <= i; i--) {
let div = document.createElement("div");
div.innerHTML = sanitizeHtml(rows[i]);
selection.getRangeAt(0).insertNode(div);
}
highlight();
});
const sanitizeHtml=str=> {
str = str.replace(/\r/g, ""); //\r\nでの改行時に残る\rを消す
str = str.replace(/&/g, "&"); //ampのエスケープは先にやらないと他に影響する
str = str.replace(/\s/g, " ");
str = str.replace(/</g, "<");
str = str.replace(/>/g, ">");
str = str.replace(/"/g, """);
str = str.replace(/'/g, "'");
return str;
}
また、エディタからカットした場合ハイライトの反映が起きないので合わせてイベント設定しておきます。
paste.js
codeAreaInput.addEventListener("cut", (event) => {
highlight();
});
ちなみに、ここで設定しているハイライトのロジックは不完全です。JavaScriptで動的にHTMLをハイライトするコードについては別ページにて構築しなおしてみましたので合わせて参照していただければと思います。
参考にさせていただきましたサイトの皆様、ありがとうございました。