Jspreadsheet CEの日本語入力を改善する

# 概要
Jspreadsheet CEは日本語入力に課題がある。
それはセルが編集モードになっていない状態で「あ」や「か」などの日本語を入力すると、「a」や「kあ」などと入力されてしまうことだ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132879/50a1984f-afa0-201c-afb5-6491e914d20e.png)


最初のキーが押された時点でinput要素を生成しそれにfocusを移すのだが、最初のキーが押された時点ではinputにIMEの状態が反映されないのだ。Jspreadsheet Proではこの問題は発生しない。
この記事ではJspreadsheet CEでカスタムエディターを使ってこの問題を解決する。

# 動作環境
* Windows 11 Pro 23H2
* Jspreadsheet CE v.4.3.0／v.5.0.0
* Google Chrome バージョン: 132.0.6834.196（Official Build） （64 ビット）
* Microsoft Edge 132.0.2957.140（64 ビット）
* Firefox 128.5.1esr（64 ビット）
* Mobile Safari 18.2.1

# 実装
標準のtext型のセルでは概要に記載した問題があるため、カスタムエディターを使ってdiv要素にcontenteditable属性を設定して入力を受け付けるようにする
```javascript
// エディタとしてdivを作成
var editor = document.createElement('div');
editor.setAttribute('id', 'editor');
editor.setAttribute('contenteditable', true);
editor.style.position = 'absolute';
editor.style.outline = 'none';
editor.style.display = 'none';
editor.style.alignContent = 'center';

// カスタムエディタを定義
var customColumn = {
	closeEditor : function(cell, save) {
		let value = save ? editor.innerText : cell.innerText;
		cell.style.color = '';
		cell.style.caretColor = 'transparent';
		editor.innerText = '';
		editor.style.caretColor = 'transparent';
		editing = false;
		return value;
	},
	openEditor : function(cell, el, empty, e) {
		if (cell == editor) {
			// touchイベントではeditorが渡されるのでcellに変換する
			let x = editor.getAttribute('data-x');
			let y = editor.getAttribute('data-y');
			cell = jexcel.current.getCellFromCoords(x, y);
		}
        cell.classList.add('editor');
		// カスタムエディタではcellに何らかの子要素を追加する必要があるのでとりあえずdivを追加
		let div = document.createElement('div');
		cell.appendChild(div);
		cell.style.color = 'transparent';
		editor.innerText = empty == true ? '' : cell.innerText;
		if (editor.innerText.length > 0) {
			if (event !== undefined) {
				// タッチ操作以外で何か入力されている場合はその末尾にキャレットを表示する
				event.preventDefault();
				let selection = document.getSelection();
				selection?.setPosition(editor.childNodes[0], editor.innerText.length);
			}
		} else {
			// 空のセルの場合、vertical-align:centerを実現するためにdivを追加する
			editor.style.display = 'flex';
			const style = "width:100%;outline:none;caret-color:black;margin:auto;";
			editor.innerHTML = `<div style="${style}">_</div>`;
			let height = editor.children[0].clientHeight;
			editor.innerHTML = `<div contenteditable="true" style="${style}height:${height}px;"></div>`;
			editor.children[0].focus();
		}
        if (event == undefined) {
			// タッチ操作の場合、選択を解除
			document.getSelection().collapse(editor);
		}
		editor.style.caretColor = 'black';
		editor.focus();
	},
	getValue : function(cell) {
		return cell.innerText;
	},
	updateCell : function(div, value, force) {
		div.innerText = value;
		return value;
	}
};
```

columnsのeditorに定義したカスタムエディタを指定してjspreadsheetを作成する。
それ以外にもonselectionイベントを定義し、選択したセルにeditorを表示したり、セルに値を入力中に別のセルを選択した際に入力中の内容をセルに反映する。
```javascript
var data = [
    ['Jazz', 'Honda', '2019-02-12', '', true, '$ 2.000,00'],
    ['Civic', 'Honda', '2018-07-11', '', true, '$ 4.000,01']
];
jspreadsheet(document.getElementById('spreadsheet'), {
	data: data,
	columns: [
		{ type: 'text', title:'A', width:120, editor: customColumn },
		{ type: 'text', title:'B', width:120, editor: customColumn },
		{ type: 'text', title:'C', width:120, editor: customColumn },
		{ type: 'text', title:'D', width:120, editor: customColumn },
		{ type: 'text', title:'標準', width:120 },
		{ type: 'text', title:'F', width:120, editor: customColumn },
	],
	tableOverflow:true,
	tableHeight:'300px',
	tableWidth:'700px',
	freezeColumns: 1,
	fullscreen:false,
	onselection: (e,x,y,x2,y2) => {
        // 以前に選択していたセルの後処理
		if (e.jexcel.edition) {
			let currentx = editor.getAttribute('data-x');
			let currenty = editor.getAttribute('data-y');
			let cell = e.jexcel.getCellFromCoords(currentx, currenty);
			e.jexcel.closeEditor(cell, true);
		}
		let cell = e.jexcel.getCellFromCoords(x, y);
		if (!e.jexcel.options.columns[x].editor || cell.classList.contains('readonly') == true) {
			// カスタムエディタのセルではなければeditorは表示しない
			editor.style.display = 'none';
			return;
		}
		updateEditorSize(x, y);
		editor.style.display = 'block';
		editor.innerHTML = '';
		editor.style.background = '#CCC6';
		editor.style.color = '#444'
		editor.style.textAlign = 'center';
		editor.setAttribute('data-x', x);
		editor.setAttribute('data-y', y);
		editor.style.caretColor = 'transparent';
		document.getSelection().collapse(editor);
		setTimeout(() => {
			editor.focus();
		}, 1);
	},
});
// ミニファイされたjexcel.jsはjexcelが定義されていない場合があるので定義する
if (typeof jexcel === 'undefined') jexcel = jspreadsheet;
// 既定のイベントをカスタマイズ
// セルを編集時は既存のcontextmenuイベントを発生させない
document.removeEventListener("contextmenu", jexcel.contextMenuControls);
document.addEventListener("contextmenu", (e) => {
	if (jexcel.current) {
		if (!jexcel.current.edition) {
			// 編集中でない場合のみ実行
			jexcel.contextMenuControls(e);
		}
	}
});
// 空白セルを含むコピー＆ペーストにバグがあるので対策する
var defaultParseCSV = jexcel.current.parseCSV;
jexcel.current.parseCSV = function(str, delimiter) {
	if (str.length === 0 || str.charCodeAt(str.length-1) == 10) {
		str += "\n\n";
	} else {
		// Remove last line break
		str = str.replace(/\r?\n$|\r$|\n$/g, "");
		// Last caracter is the delimiter
		if (str.charCodeAt(str.length-1) == 9) {
			str += "\n\n";
		}
	}
	return defaultParseCSV(str, delimiter);
}

var defaultSelect = jexcel.current.textarea.select;
jexcel.current.textarea.select = function() {
	if (this.value.length == 0) {
		// コピーしたセルが空の場合、空文字をクリップボードにコピーする
		let copyText = document.createElement('input');
		copyText.value = '';
		copyText.select();
		copyText.setSelectionRange(0, 99999); // For mobile devices
		navigator.clipboard.writeText(copyText.value);
	}
	defaultSelect.call(this);
}

// スクロール時などにeditorの位置とサイズを補正する
var defaultUpdateCornerPosition = jexcel.current.updateCornerPosition;
jexcel.current.updateCornerPosition = function() {
	defaultUpdateCornerPosition();
	// Resizing is ongoing
	if (jexcel.current.resizing) {
		let x = editor.getAttribute('data-x');
		let y = editor.getAttribute('data-y');
		if (x && y) {
			updateEditorSize(x, y);
		}
	} else {
		updateEditorPosition();
	}
}
// セルの選択をクリアする際、editorを非表示にする
var defaultResetSelection = jexcel.current.resetSelection;
jexcel.current.resetSelection = function(blur) {
	defaultResetSelection(blur);
	editor.style.display = 'none';
}

// エディタをspreadsheetの一部として追加
jexcel.current.content.appendChild(editor);

function updateEditorPosition() {
	if (!jexcel.current) return;
	let x = editor.getAttribute('data-x');
	let y = editor.getAttribute('data-y');
	if (x == undefined || y == undefined) return;
	x = parseInt(x);
	y = parseInt(y);
	if (jexcel.current.colgroup.length - 1 < x || jexcel.current.rows.length - 1 < y) {
		// redoにより選択していたセルがなくなった場合、有効なセルを再選択する
		if (jexcel.current.colgroup.length - 1 < x) {
			x = jexcel.current.colgroup.length - 1;
			editor.setAttribute('data-x', x);
		}
		if (jexcel.current.rows.length - 1 < y) {
			y = jexcel.current.rows.length - 1;
			editor.setAttribute('data-y', y);
		}
		jexcel.current.updateSelectionFromCoords(x, y, x, y);
	}
	updateEditorSize(x, y);
}

function updateEditorSize(x, y) {
	let cornerCell = jexcel.current.headerContainer.children[0].getBoundingClientRect();
	let contentRect = jexcel.current.content.getBoundingClientRect();
	let cell = jexcel.current.getCellFromCoords(x, y)
	let info = cell.getBoundingClientRect();
	editor.style.minWidth = (info.width) + 'px';
	editor.style.minHeight = (info.height) + 'px';
	const scrollTop = jexcel.current.content.scrollTop;
	const scrollLeft = jexcel.current.content.scrollLeft;
	editor.style.top = (info.top - cornerCell.top + scrollTop) + 'px';
	if (cornerCell.left >= 0) {
		editor.style.left = (info.left - cornerCell.left) + 'px';
	} else {
		editor.style.left = (info.left + scrollLeft - contentRect.left) + 'px';
	}
	editor.focus();
}
```

残りはeditorのイベント定義を行う。
keydownイベントでは各種キー入力を補足する。特に制御する必要のないキーはそのままjspreadsheetに処理させる。
dblclickイベントではマウスでセルをダブルクリックした際にセルを編集状態にする。
blurイベントでは編集状態でEnterを押下した場合に入力内容をセルに反映する。
touchend、touchcancelイベントでは通常はcellが選択されていないといけないのでeditorをcellに変更する
```javascript
editor.addEventListener('keydown', (e) => {
	let x = editor.getAttribute('data-x');
	let y = editor.getAttribute('data-y');
	if (x == undefined || y == undefined) return;
	if (e.which == 27) {
		// Escape
	} else if (e.which == 13) {
		// Enter
		if (editing) {
			editor.blur();
		}
		e.preventDefault();
	} else if (e.which == 9) {
		// Tab
		if (editing) {
			editor.blur();
		}
	}

	// Which key
	if (e.which == 13 || e.which == 9) {
		// Move cursor, Tab
		jexcel.current.updateSelectionFromCoords(x, y, x, y);
	} if (e.which == 33 || e.which == 34) {
		// PageUp, PageDown
		// Jspreadsheet Container information
		var contentRect = jexcel.current.content.getBoundingClientRect();
		var h1 = contentRect.height;

		// Direction Left or Up
		var reference = jexcel.current.records[jexcel.current.selectedCell[3]][jexcel.current.selectedCell[2]];
		var referenceRect = reference.getBoundingClientRect();
		var h2 = referenceRect.height;
		if (e.which == 33) {
			// Up
			for (; h2 < (h1 - 30) && y > 0; y--) {
				h2 += jexcel.current.records[y][jexcel.current.selectedCell[2]].getBoundingClientRect().height;
			}
		} else {
			// Down
			for (; h2 < (h1 - 30) && y < jexcel.current.rows.length; y++) {
				h2 += jexcel.current.records[y][jexcel.current.selectedCell[2]].getBoundingClientRect().height;
			}
		}
		jexcel.current.updateSelectionFromCoords(x, y, x, y);
		return;
	} else if (e.which == 8) {
		// Backspace
		if (!jexcel.current.edition) {
			jexcel.current.setValue(jexcel.current.highlighted, '');
			e.stopImmediatePropagation();
		}
	} else {
		if ((e.ctrlKey || e.metaKey) && ! e.shiftKey) {
			if (e.which == 67 || e.which == 88) {
				// Ctrl + C, Ctrl + X
			} else if (e.which == 86) {
				// Ctrl + V
			}
		} else {
			if (e.keyCode == 113) {
				// F2
				if (!jexcel.current.edition) {
					jexcel.current.openEditor(jexcel.current.records[y][x], false);
				}
			} else if (e.keyCode == 32) {
				// space
				if (!jexcel.current.edition) {
					jexcel.current.openEditor(jexcel.current.records[y][x], false);
					// 既定ではspace押下で編集モードになるだけでspaceが入力されないためプログラムで設定する
					editor.innerText = ' ';
				}
            } else if ((e.keyCode == 8) ||
					   (e.keyCode >= 48 && e.keyCode <= 57) ||
					   (e.keyCode >= 96 && e.keyCode <= 111) ||
					   (e.keyCode >= 186) ||
					   ((String.fromCharCode(e.keyCode) == e.key || String.fromCharCode(e.keyCode).toLowerCase() == e.key.toLowerCase()) && jexcel.validLetter(String.fromCharCode(e.keyCode)))) {
				if (!jexcel.current.edition) {
					jexcel.current.openEditor(jexcel.current.records[y][x], true);
				}
			}
		}
	}
});

editor.addEventListener('dblclick', (e) => {
	if (jexcel.current.edition) return;
	let x = editor.getAttribute('data-x');
	let y = editor.getAttribute('data-y');
	jexcel.current.openEditor(jexcel.current.records[y][x], false);
});
editor.addEventListener('blur', (e) => {
	if (!jexcel.current || !jexcel.current.edition) return;
	let x = editor.getAttribute('data-x');
	let y = editor.getAttribute('data-y');
	let cell = jexcel.current.getCellFromCoords(x, y);
	jexcel.current.closeEditor(cell, true);
});
function editorTouchEnd(e) {
	if (jexcel.tmpElement == editor) {
		// editorをタッチした場合はtmpElementをcellに変更する必要がある
		let x = editor.getAttribute('data-x');
		let y = editor.getAttribute('data-y');
		let cell = jexcel.current.getCellFromCoords(x, y);
		jexcel.tmpElement = cell;
	}
}
editor.addEventListener('touchend', editorTouchEnd);
editor.addEventListener('touchcancel', editorTouchEnd);
```

# 動作イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132879/c8d95baf-1f0d-1ced-85a7-434480029a79.png)

[Jspreadsheet CEのタッチ操作を改善する](https://qiita.com/tm777/items/b90ac16b899d24cf0735https://qiita.com/tm777/items/b90ac16b899d24cf0735)を合わせて使用するとタッチ操作でセル範囲の指定などが行える。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132879/07572e76-9bd9-1d74-8861-90a4aa54fa76.png)


# 終わりに

https://github.com/tm000/JspreadsheetCE-for-Japanese

(2024/12/1)
* Backspace、Deleteの操作が元に戻せ(Ctrl+Z)なかった不具合を修正
* 元に戻す(Ctrl+Z)により選択していたセルがなくなった場合に有効なセルを再選択するように修正
* 右クリックで表示されるポップアップメニューより前にカスタムエディタが表示されてしまう問題を修正
* アットマークなどいくつか入力できなかったので入力できるように修正
* 空のセルF2を押すとエラーが発生する問題を修正

(2024/12/11)
* Page Up/Page Downが効かなかった不具合を修正
* スクロールしたり、列幅、行高さを変更した際にカスタムエディタの表示が乱れていた不具合を修正

(2024/12/22)
* ミニファイされたjexcel.jsでjexcelが定義されない場合でも動くように修正

(2024/12/26)
* スマホのタッチ操作でセルを編集できるように修正　※セルを編集するには0.5秒ロングタップします

(2024/12/28)
* タッチ操作で意図せずセルの値が異動したり消えてしまう問題を修正

(2025/1/3)
* 空白セルを含むコピー＆ペーストにバグがあったので修正
* readonly列は読み取り専用なのでeditorを表示しないように修正

(2025/1/11)
* Mobile Safariでセルを編集する際にキャレットが表示されないバグを修正
* セルを編集するためにロングタップした際、自動で全選択してしまわないように修正

(2025/1/30)
* [Jspreadsheet CEのタッチ操作を改善する](https://qiita.com/tm777/items/b90ac16b899d24cf0735)と一緒に使用できるようにコードを見直し

(2025/2/16)
* Jspreadsheet CE v5に対応した

(2025/4/5)
* 空のセルを編集して空にした場合、次回編集時にキャレットが表示されなかったバグを修正
* 空のセルを編集中にBackspaceを押下するとキャレットの垂直位置が変化してしまうバグを修正
* オプション設定を追加

(2025/6/22)
* 同一ページ内に複数のjspreadsheetが存在した場合に動作しなかったバグをしゅうｓ