contenteditable に字数制限をつける

概要

contenteditable に字数制限をつけるはめになりました。
inputみたいな字数制限つけようと思ったらひどいことになったので共有します。
cssで背景色変えるだけのパターンも作りました。こっちのほうが気が楽です…。

サンプル

ブログだと実体参照されてうまく動かないので別サイトにhtmlで用意しました

実行環境

jquery-3.3.1.min.js
GoogleChorome 68.0.3440.106(Official Build) (64 ビット)

キャレットの位置替えないでがんばる方(うまくいかなかった例)

とりあえずマルチバイト文字を数える関数が必要だったのでお借りしました
JavaScriptのすぐ使える7つのユーティリティ関数

そのあとキャレットの取得方法を探しました
contenteditableの文章をキャレット位置で分割する

キャレットの移動方法はここを参考にしました
contentEditable領域でキャレットを移動する

それで書いたものがこれ。
でも改行は反映されないのでTwitter形式(残り文字数を表示)にしたほうがいいと思います…。

<div contenteditable="true" class="" id="target">編集可能な箇所</div>
$(function($){
	/* contenteditableの文字列の長さを規定以上にさせない */
	var textLengthChecker = setInterval(function() {
		var targetSelector= '#target';//対象contenteditableのセレクタ
		var val = $(targetSelector).text();//切り取り対象テキスト
		var maxLength = 100;//制限したい文字数
		var fullLength = $.mb_strlen(val);//文字全長

		//制限より文字数が多ければ下記を実行
		if (fullLength > maxLength) {
			//キャレット位置取得
			var dom = $(targetSelector).get(0);
			var caretPos = getCaretPosition(dom);

			//制限からはみでた文字数
			var gap = fullLength - maxLength;

			//キャレット位置よりあとのgap字数分を切り取って対象のeditableの中へ
			var setText = $.mb_substr(val , 0,  caretPos - gap ) + $.mb_substr(val , caretPos, fullLength );
			$(targetSelector).text( setText );

			//キャレット位置を戻す
			var selection = window.getSelection();
			var range       = document.createRange();
			// キャレットの開始位置と終了位置
			range.setStart(dom.firstChild, caretPos - gap);
			range.setEnd(dom.firstChild, caretPos - gap);
			// 選択を解除して実質的にキャレットが移動される
			selection.removeAllRanges();
			selection.addRange(range);
		}
	}, 0);
	
	
	/**
	 * マルチバイト文字のサポート
	 */
	$.isSurrogatePear = function( upper, lower ) {
		return 0xD800 <= upper && upper <= 0xDBFF && 0xDC00 <= lower && lower <= 0xDFFF;
	};
	/**
	 * マルチバイト文字の長さを返す
	 */
	$.mb_strlen = function( str ) {
		var ret = 0;
		for (var i = 0; i < str.length; i++,ret++) {
			var upper = str.charCodeAt(i);
			var lower = str.length > (i + 1) ? str.charCodeAt(i + 1) : 0;
			if ( $.isSurrogatePear( upper, lower ) ) { i++; }
		}
		return ret;
	};
	/**
	 * マルチバイト文字を切り取る
	 */
	$.mb_substr = function( str, begin, end ) {
		var ret = '';
		for (var i = 0, len = 0; i < str.length; i++, len++) {
			var upper = str.charCodeAt(i);
			var lower = str.length > (i + 1) ? str.charCodeAt(i + 1) : 0;
			var s = '';
			if( $.isSurrogatePear( upper, lower ) ) {
				i++;
				s = String.fromCharCode( upper, lower );
			} else {
				s = String.fromCharCode( upper );
			}
			if ( begin <= len && len < end ) { ret += s; }
		}
		return ret;
	};
	
	/**
	 * キャレットの位置を取得する
	 * @param editableDiv htmlエレメントを渡す
	 * @returns {number}
	 */
	function getCaretPosition(editableDiv) {
		//https://stackoverflow.com/questions/3972014/
		var caretPos = 0,
			sel, range;
		if (window.getSelection) {
			sel = window.getSelection();
			if (sel.rangeCount) {
				range = sel.getRangeAt(0);
				if (range.commonAncestorContainer.parentNode == editableDiv) {
					caretPos = range.endOffset;
				}
			}
		} else if (document.selection && document.selection.createRange) {
			range = document.selection.createRange();
			if (range.parentElement() == editableDiv) {
				var tempEl = document.createElement("span");
				editableDiv.insertBefore(tempEl, editableDiv.firstChild);
				var tempRange = range.duplicate();
				tempRange.moveToElementText(tempEl);
				tempRange.setEndPoint("EndToEnd", range);
				caretPos = tempRange.text.length;
				//console.log(tempRange.text)
			}
		}
		// console.log(caretPos,sel,range)
		return caretPos;
	}
});

もし改行反映させようとするなら、contenteditableのタグをpreにして、interHTMLを取得して、改行位置を検索して\r\nとか差し込むようにしないといけないんじゃないかな…。
キャレット位置きちんと移動できるんですかね…。大変そうだ…。

字数を超えたら背景色を赤くするバージョン

再利用可能に関数化したもの
こちらは上のスクリプトの関数を下にでも書いて、cssを追加すると使えます。

<style>
 .max-length-over{
	background-color: red !important;
 }
</style>
        /* contenteditableの文字列の長さを規定以上にすると背景を赤くする */
	    var textLengthChecker2 = function( targetSelectorList , maxLength ) {
	        setInterval(function() {
	            targetSelectorList.forEach(function (data, i) {
	                var val = $(data).text();//切り取り対象テキスト取得
	                var fullLength = $.mb_strlen(val);//文字全長を取得
	 
	                //制限より文字数が多ければ背景を赤くする
	                if (fullLength > maxLength && ! $(data).hasClass('max-length-over')) {
	                    $(data).addClass('max-length-over');
	                }else if( fullLength <= maxLength && $(data).hasClass('max-length-over') ){
	                    $(data).removeClass('max-length-over');
	                }
	            });
	        }, 0);
	    };
	 
	    // 50字制限をかけるとき
	    textLengthChecker2(
	        [
	            '#max-50-color'
	        ],
	        50
	    );

// 保存ボタン押したときの挙動
$('.save-button').on('click', function(){
	// max-length-overクラスがあればアラートを出す
	if($('.max-length-over').length){
		alert('文字数が制限を超えている箇所があります');
	}else{
		// 字数制限ない場合の処理
	}
});

おわり。