概要
Jspreadsheet CEは日本語入力に課題がある。
それはセルが編集モードになっていない状態で「あ」や「か」などの日本語を入力すると、「a」や「kあ」などと入力されてしまうことだ。
最初のキーが押された時点でinput要素を生成しそれにfocusを移すのだが、最初のキーが押された時点ではinputにIMEの状態が反映されないのだ。Jspreadsheet Proではこの問題は発生しない。
この記事ではJspreadsheet CEでカスタムエディターを使ってこの問題を解決する。
動作環境
- Windows 11 Pro 23H2
- Jspreadsheet CE v.4.3.0
- Google Chrome バージョン: 131.0.6778.86(Official Build) (64 ビット)
- Microsoft Edge 131.0.2903.86(64 ビット)
- Firefox 128.5.1esr(64 ビット)
- Mobile Safari 18.2.1
実装
標準のtext型のセルでは概要に記載した問題があるため、カスタムエディターを使ってdiv要素にcontenteditable属性を設定して入力を受け付けるようにする
// エディタとして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を表示したり、セルに値を入力中に別のセルを選択した際に入力中の内容をセルに反映する。
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に変更する
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);
動作イメージ
Jspreadsheet CEのタッチ操作を改善するを合わせて使用するとタッチ操作でセル範囲の指定などが行える。
終わりに
(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のタッチ操作を改善すると一緒に使用できるようにコードを見直し