本文主要是对handsontable官网中cell editor的简单翻译,仅供参考。
原文地址:https://handsontable.com/docs/javascript-data-grid/cell-editor/
editor简述
cell editor指的是单元格编辑器函数。handsontable将展示单元格的值的过程与改变单元格的值的过程分开,Renderers(渲染器)主要责任是呈现数据,Editor(编辑器)主要是修改数据。因为Renderers只有一个简单的任务:获取单元格的实际值并以HTML代码的形式返回它的表示,所以它们可以是一个函数。然而,Editor需要做这些工作:处理用户的输入(鼠标、键盘事件),验证数据,并且根据验证结果进行不同的展示,如果将这些功能都放到同一个函数中显然不是很合理,所以在handsontable中编辑器是通过编辑器类(editor classes)来描述。
EditorManager
EditorManager是一个负责处理Handsontable中可用的所有编辑器的类。Handsontable通过EditorManager对象与编辑器交互。在第一次调用Handsontable()构造函数之后会运行init()方法,并在这个方法中实例化EditorManager对象。EditorManager对象的引用在Handsontable实例中是私有的,不能直接访问它。但是,有一些方法可以更改EditorManager的默认行为,稍后会详细介绍。
EditorManager的任务
EditorManager主要有以下四个任务:
- 为活动单元格选择合适的编辑器
- 准备编辑器
- 展示编辑器(基于用户行为)
- 关闭编辑器(基于用户行为)
以下,将详细解释每个任务。
为活动单元格选择合适的编辑器
首先解释活动单元格,当用户选择一个单元格时,当前单元格即为活动单元格(此时暂不讨论多选的情况,因为我暂时也不清楚,以免误导读者),EditorManager查找分配给该单元格的编辑器类,检查编辑器配置选项的值。可以全局(为表中的所有单元格)、或者每列(为列中的所有单元格),或单独为每个单元格定义编辑器配置选项。具体配置详情参考(https://handsontable.com/docs/javascript-data-grid/configuration-options/#cascading-configuration)。编辑器配置选项的值可以是指定字符串(代表某个特定编辑器的字符串,例如:'text', 'checkbox', 'autocomplete'……),也可以是一个编辑器类。通过这个值,EditorManager将获得一个被指定的编辑器类的实例,注意,每个编辑器类对象都是单个表中的一个单例,这意味着每个表只调用一次它的构造函数。如果一个页面上有3个表,每个表都有自己的编辑器类实例。
准备编辑器
当EditorManager获取到编辑器类实例(编辑器对象)后,会调用编辑器类实例的prepare()方法。prepare()方法设置与所选单元格相关的编辑器对象属性,但不显示编辑器。每次用户选择一个单元格时都会调用prepare()。在某些情况下,可以对同一个单元格多次调用它,而不更改选择。
展示编辑器
prepare()执行完之后,EditorManager等待触发单元格编辑的用户事件。相关的用户事件如下:
- 按下enter
- 按下shift + enter
- 双击单元格
- 按下 f2
以上任意事件被触发,EditorManager就会调用编辑器类实例的beginEditing()方法去展示编辑器。
关闭编辑器
打开编辑器时,EditorManager等待应该结束单元格编辑的用户事件被触发。相关的用户事件如下:
- 点击另一个单元格(保存更改)
- 按下enter(保存更改,并移动至下一个单元格)
- 按下enter + shift (保存更改,并移动至上一个单元格)
- 按下ctrl + enter或者alt + enter(在单元格内新增一行)
- 按下esc(放弃更改)
- 按下tab(保存更改,并向右或者向左移动(取决于表格布局))
- 按下shift + tab(保存更改,并向左或者向右移动(取决于表格布局))
- 按下Page Up或Page Down(保存更改,并向上或下移动一屏)
如果触发了这些事件中的任何一个,EditorManager将会调用编辑器的finishiting()方法,该方法应该尝试保存更改(除非按下了ESC键)并关闭编辑器。
覆盖EditorManager的默认行为
有时候我们可能希望更改导致编辑器打开或关闭的默认事件。例如,默认情况下,编辑器可能使用向上和向下箭头事件来执行一些操作(例如增加或减少单元格值),并且我们不希望在用户按下这些键时EditorManager关闭编辑器。这时候就可以通过beforeKeyDown钩子来处理这种情况,因为EditorManager在处理用户事件之前会运行beforeKeyDown钩子。如果为beforeKeyDown注册了一个监听器,那么对事件对象EditorManager的stopImmediatePropagation()调用将执行其默认操作(这句翻译感觉不是很准确,暂时参考有道词典)。更多信息参考下文。
BaseEditor
Handsontable.editors.BaseEditor是一个抽象类,所有编辑器类都应该继承于它。它实现了一些基本的编辑器方法,并声明了一些应该由每个编辑器类实现的方法。
公共方法
公共方法是由BaseEditor类实现的方法。它们包含了每个编辑器都应该具备的一些核心逻辑。大多数时候,我们应该尽量避免使用这些方法。但有的时候我们可能会希望重写部分常用方法,来创建一些比较复杂的编辑器,在这种情况下,您应该始终调用原始方法,然后再执行特定于您的编辑器的其他操作。
实例:
// CustomEditor 是一个类, 继承自 BaseEditor
class CustomEditor extends BaseEditor {
prepare(row, col, prop, td, originalValue, cellProperties) {
// 执行原始方法...
super.prepare(row, col, prop, td, originalValue, cellProperties);
// ...然后做一些特定于你的CustomEditor的东西
this.customEditorSpecificProperty = 'foo';
}
}
有下方这七个公共方法:
-
prepare(row: Number, col: Number, prop: Number|String, td: HTMLTableCellElement, originalValue: Mixed, cellProperties: Object):undefined
为给定单元格准备要显示的编辑器。设置大多数实例属性。返回undefined -
beginEditing(newInitialValue: Mixed, event: Mixed):undefined
设置编辑器值为newInitialValue。如果newInitialValue未定义,则编辑器值将设置为原始单元格值。内部调用open()方法。返回undefined -
finishEditing(restoreOriginalValue: 'Boolean' [optional], ctrlDown: Boolean [optional], callback: Function)
尝试结束单元格编辑。内部调用saveValue()和discardEditor()。如果restoreOriginalValue设置为true,则将单元格值设置为其原始值(编辑之前的值)。ctrlDown值作为第二个参数传递给saveValue()。
callback包含一个布尔参数,这个布尔参数的值取决于新的值是否有效,或者allowInvalid配置选项被设置为true,否则参数为false -
discardEditor(result: Boolean):undefined
单元格验证结束时调用。如果新值保存成功(result为true或allowInvalid属性为true),则调用close()方法,否则调用focus()方法并保持编辑器打开。 -
saveValue(value: Mixed, ctrlDown: Boolean):undefined
尝试将value保存为单元格的新值。在内部执行验证。如果ctrlDown设置为true,则新值将设置为所有选定的单元格。 -
isOpened():Boolean
如果编辑器打开则返回true,如果编辑器关闭则返回false。在调用open()之后,编辑器被认为是打开的。在调用close()方法后,编辑器被认为是关闭的。 -
extend():Function
返回Function—从当前类继承的类函数。返回类的原型方法可以被安全地覆盖,而不会有改变父类原型的危险。(这是一个与单元格编辑过程无关的实用方法。)
实例1:从BaseEditor继承并重写它的方法
const CustomEditor = Handsontable.editors.BaseEditor.prototype.extend();
// 这不会更改 BaseEditor.prototype.beginEditing()方法
CustomEditor.prototype.beginEditing = function() {};
实例2:从其他editor类继承
const CustomTextEditor = Handsontable.editors.TextEditor.prototype.extend();
// CustomTextEditor可以使用所有TextEditor中实现的方法。
// 你可以安全地覆盖任何方法而不影响原始的TextEditor。
Editor特定的方法
特定于编辑器的方法是在BaseEditor中没有实现的方法。每个编辑器类都必须实现这些方法。
-
init()
init()方法在创建编辑器类的新实例时调用。每个表实例只会调用一次,因为所有编辑器都在表实例中作为单例使用。init()方法主要用来创建展示界面(翻译可能有误)。其结果可以在编辑器的生命周期中重用。最常见的操作是创建编辑器的HTML结构。init()没有返回值。 -
getValue()
getValue()方法应返回当前编辑器值,即应保存为单元格新的值。 -
setValue(newValue: Mixed)
setValue()方法应将编辑器值设置为newValue。
示例:实现一个DateEditor,它通过显示日历来帮助选择日期。getValue()和setValue()方法可以这样工作:
class CalendarEditor extends TextEditor {
constructor(hotInstance) {
super(hotInstance);
}
getValue() {
// returns currently selected date, for example "2023/09/15"
return calendar.getDate();
}
setValue(newValue) {
// highlights given date on calendar
calendar.highlightDate(newValue);
}
}
-
open()
显示编辑器,不需要返回任何值。示例:
class CustomEditor extends TextEditor {
open() {
this.editorDiv.style.display = '';
}
}
-
close()
在更改单元格值后隐藏编辑器,不需要返回任何值。示例:
class CustomEditor extends TextEditor {
close() {
this.editorDiv.style.display = 'none';
}
}
-
focus()
聚焦编辑器,不需要返回任何值。当用户想要通过选择另一个单元格来关闭编辑器,并且编辑器中的值不生效(当allowInvalid为false)时,调用此方法。示例:
class CustomEditor extends TextEditor {
focus() {
this.editorInput.focus();
}
}
编辑器共有属性
下面提到的所有属性都可以通过this在编辑器实例中使用,例:this.instance。并且每次调用prepare()方法时都会更新。
属性 | 类型 | 描述 |
---|---|---|
instance | Handsontable.Core | 此编辑器对象所属的Handsontable实例。在类构造函数中设置,在编辑器的整个生命周期中不可变。 |
row | Number | 活动单元格行索引。 |
col | Number | 活动单元格的列索引。 |
prop | String | 与活动单元格关联的属性名(仅当数据源是对象数组时相关)。 |
TD | HTMLTableCellNode | 活动单元的节点对象。 |
cellProperties | Object | 表示活动单元格属性的对象。 |
如何创建自定义编辑器
如果只是想增强现有的编辑器,可以扩展它的类并只覆盖它的几个方法。
示例:扩展TextEditor,创建PasswordEditor,显示密码也可以通过创建一个继承自BaseEditor的新编辑器类来从头构建一个新的编辑器。
示例:创建一个全新的SelectEditor,它使用<select> list来改变cell的值。
创建PasswordEditor
TextEditor是Handsontable中最复杂的默认编辑器。它显示一个<textarea>,它会自动改变其大小以适应其内容。我们想创建一个PasswordEditor,它保留了所有这些功能,但显示<input type="password">字段而不是<textarea>。
我们需要创建一个继承自TextEditor新的编辑器类,然后覆盖它的一些方法,用input:password替换<textarea>。文本区域和密码输入具有相同的API,因此我们所要做的就是替换负责创建HTML元素的代码。TextEditor中的init()内部调用了createElements()方法,该方法创建<textarea>节点,并在编辑器初始化期间将其附加到DOM中。
具体代码如下:
import Handsontable from 'handsontable';
class PasswordEditor extends Handsontable.editors.TextEditor {
createElements() {
super.createElements();
this.TEXTAREA = this.hot.rootDocument.createElement('input');
this.TEXTAREA.setAttribute('type', 'password');
this.TEXTAREA.setAttribute('data-hot-input', true); // Makes the element recognizable by HOT as its own component's element.
this.textareaStyle = this.TEXTAREA.style;
this.textareaStyle.width = 0;
this.textareaStyle.height = 0;
this.TEXTAREA_PARENT.innerText = '';
this.TEXTAREA_PARENT.appendChild(this.TEXTAREA);
}
}
使用:
const container = document.querySelector('#container')
const hot = new Handsontable(container, {
columns: [
{
type: 'text'
},
{
editor: PasswordEditor
// If you want to use string 'password' instead of passing
// the actual editor class check out section "Registering editor"
}
]
});
创建SelectEditor
SelectEditor允许用户从定义好的<select>列表中选择值作为单元格的新值。另外,用户还可以使用ARROW_UP和ARROW_DOWN键更改当前选择的选项。具体步骤如下:
- 创建一个继承自Handsontable.editors.BaseEditor的新类。
const SelectEditor = Handsontable.editors.BaseEditor.prototype.extend();
- 添加创建<select>标签并将其添加到DOM上的函数。
有三个方法可以完成这一步,init(),prepare(),open()。
init()方法在创建编辑器类对象期间被调用。每个表实例最多只发生一次,因为一旦创建了对象,每次EditorManager请求这个编辑器类实例时都会重用它。
prepare()方法在用户选择指定单元格时会调用,指定单元格指的是editor设置为特定编辑器类的的单元格。因此,如果我们将SelectEditor设置为整个列的编辑器,那么选择该列中的任何单元格将调用SelectEditor的prepare()方法。换句话说,这个方法在表生命周期中可以被调用数百次,特别是在处理大数据时。另外,prepare()不应该显示编辑器(这是open()的工作)。显示编辑器是由用户事件(按ENTER、F2或双击单元格等)触发的,因此在调用prepare()和实际显示编辑器之间存在一段时间。尽管如此,应该尽可能快地完成prepare()执行的操作,以提供最佳的用户体验。
当需要显示编辑器时调用open()方法。在大多数情况下,该方法应该将CSS里display属性更改为block或执行类似的操作。用户希望在事件(按下适当的键或双击单元格)触发后立即显示编辑器,因此open()方法应该尽可能快地工作。
了解了所有这些,最合理的地方放置负责创建<select>输入的代码是在init()方法中的某个地方。DOM操作被认为是相当昂贵的(就资源消耗而言)操作,因此最好只执行一次,并在编辑器的整个生命周期中重用生成的HTML节点。
import Handsontable from 'handsontable';
class SelectEditor extends Handsontable.editors.BaseEditor {
/**
* Initializes editor instance, DOM Element and mount hooks.
*/
init() {
// Create detached node, add CSS class and make sure its not visible
this.select = this.hot.rootDocument.createElement('SELECT');
this.select.classList.add('htSelectEditor');
this.select.style.display = 'none';
// Attach node to DOM, by appending it to the container holding the table
this.hot.rootElement.appendChild(this.select);
}
}
.htSelectEditor {
/*
* This hack enables to change <select> dimensions in WebKit browsers
*/
-webkit-appearance: menulist-button !important;
position: absolute;
width: auto;
z-index: 300;
}
- 添加一个函数,用于填充<select>下拉选项,对应的下拉选项通过cell properties传入。
通过配置项传入下拉选项列表:
const container = document.querySelector('#container')
const hot = new Handsontable(container, {
columns: [
{
editor: SelectEditor,
selectOptions: ['option1', 'option2', 'option3']
}
]
});
在init()方法中填充列表不是很适合,因为init()方法只会执行一次,当多个列都使用SelectEditor编辑器,并且每个列的下拉选项都不同,甚至同一列中的两个单元格的下拉选项都不一致的时候,init()方法就不能实现我们的需求了。
我们只剩下两个地方prepare()和open()。后一种方法更容易实现,但正如我们前面所说,setValue()应该尽可能快地工作,如果selectOptions包含一长串选项,那么创建<option>节点并将它们附加到DOM可能会很耗时。因此,prepare()似乎是做这类工作更安全的地方。唯一要记住的是,当重写prepare()时,我们应该始终调用BaseEditor的原始方法。prepare()设置一些重要的属性,这些属性被其他编辑器方法使用。
// Create options in prepare() method
prepare(row, col, prop, td, originalValue, cellProperties) {
// Remember to invoke parent's method
super.prepare(row, col, prop, td, originalValue, cellProperties);
const selectOptions = this.cellProperties.selectOptions;
let options;
if (typeof selectOptions === 'function') {
options = this.prepareOptions(selectOptions(this.row, this.col, this.prop));
} else {
options = this.prepareOptions(selectOptions);
}
this.select.innerText = '';
Object.keys(options).forEach((key) => {
const optionElement = this.hot.rootDocument.createElement('OPTION');
optionElement.value = key;
optionElement.innerText = options[key];
this.select.appendChild(optionElement);
});
}
prepareOptions(optionsToPrepare) {
let preparedOptions = {};
if (Array.isArray(optionsToPrepare)) {
for (let i = 0, len = optionsToPrepare.length; i < len; i++) {
preparedOptions[optionsToPrepare[i]] = optionsToPrepare[i];
}
} else if (typeof optionsToPrepare === 'object') {
preparedOptions = optionsToPrepare;
}
return preparedOptions;
}
- 实现这些函数:getValue(),setValue(),open(),close(),focus()。
getValue() {
return this.select.value;
}
setValue(value) {
this.select.value = value;
}
open() {
const {
top,
start,
width,
height,
} = this.getEditedCellRect();
const selectStyle = this.select.style;
this._opened = true;
selectStyle.height = `${height}px`;
selectStyle.minWidth = `${width}px`;
selectStyle.top = `${top}px`;
selectStyle[this.hot.isRtl() ? 'right' : 'left'] = `${start}px`;
selectStyle.margin = '0px';
selectStyle.display = '';
}
focus() {
this.select.focus();
}
close() {
this._opened = false;
this.select.style.display = 'none';
}
getvalue()、setvalue()和close()的实现很简单,但是open()需要一些注释。首先,假设实现填充下拉列表的代码放在prepare()中。其次,在显示列表之前,我们设置其height和minWidth,使其与相应单元格的大小匹配。这是一个可选步骤,但如果没有它,编辑器将根据浏览器的不同而具有不同的大小。限制<select>的最大高度可能是一个好主意。最后,由于<select>被附加到表格容器的末尾,我们必须更改它的位置,以便它可以显示在正在编辑的单元格上方。同样,这是一个可选的步骤,但是将编辑器放在适当的单元格旁边似乎是非常合理的。
- 覆盖默认的EditorManager行为,这样按向上(Arrow Up)和向下(Arrow Down)箭头键不会关闭编辑器,而是改变当前选择的值。
虽然我们不能直接访问EditorManager实例,但是仍然可以覆盖它的行为。在EditorManager开始处理键盘事件之前,它会触发beforeKeyDown钩子。如果任何侦听函数调用事件对象上的stopImmediatePropagation()方法,EditorManager将不再处理此事件。因此,我们所要做的就是注册一个beforeKeyDown监听器函数,该函数检查箭头向上或箭头向下是否被按下,如果是,停止事件传播并相应地改变<select>列表中当前选择的值。
我们需要注意的是,侦听器应该只在打开编辑器时工作。我们希望保留其他编辑器的默认行为,以及没有打开编辑器时的默认行为。这就是为什么注册侦听器的最合理的位置是open()方法,close()方法应该包含将删除侦听器的代码。
onBeforeKeyDown() {
const previousOptionIndex = this.select.selectedIndex - 1;
const nextOptionIndex = this.select.selectedIndex + 1;
switch (event.keyCode) {
case 38: // Arrow Up
if (previousOptionIndex >= 0) {
this.select[previousOptionIndex].selected = true;
}
event.stopImmediatePropagation();
event.preventDefault();
break;
case 40: // Arrow Down
if (nextOptionIndex <= this.select.length - 1){
this.select[nextOptionIndex].selected=true;
}
event.stopImmediatePropagation();
event.preventDefault();
break;
default:
break;
}
}
open() {
this.addHook('beforeKeyDown', () => this.onBeforeKeyDown());
}
close() {
this.clearHooks();
}
活动编辑器是最近调用prepare()方法的编辑器。例如,如果选择一个单元格,其编辑器是Handsontable.TextEditor,那么getActiveEditor()将返回这个编辑器类的一个对象。如果选择编辑器为Handsontable.DateEditor的单元格,活动编辑器将会改变,现在getActiveEditor()将返回一个DateEditor类的对象。
- 注册编辑器。
创建编辑器时,最好是为其分配一个别名,该别名将引用这个特定的编辑器类。Handsontable默认定义了11个别名:
别名 | 类型 |
---|---|
autocomplete | Handsontable.editors.AutocompleteEditor |
base | Handsontable.editors.BaseEditor |
checkbox | Handsontable.editors.CheckboxEditor |
date | Handsontable.editors.DateEditor |
dropdown | Handsontable.editors.DropdownEditor |
handsontable | Handsontable.editors.HandsontableEditor |
numeric | Handsontable.editors.NumericEditor |
password | Handsontable.editors.PasswordEditor |
select | Handsontable.editors.SelectEditor |
text | Handsontable.editors.TextEditor |
time | Handsontable.editors.TimeEditor |
它为用户提供了一种方便的方式来定义在更改某些单元格的值时应该使用哪个编辑器。用户不需要知道哪个类负责显示编辑器,他甚至根本不需要知道有任何类。此外,您可以更改与别名关联的类,而无需更改定义表的代码。
要注册自己的别名,请使用Handsontable.editors.registerEditor()函数。它接受两个参数:
- editorName - 字符串:代表编辑器的别名
- editorClass - 类:编辑器类
Handsontable.editors.registerEditor('select', SelectEditor);
尽量不要选择Handsontable已经定义了的别名。如果您以已注册的名称注册编辑器,则目标类将被覆盖:
// 现在‘text’别名指向MyNewTextEditor
Handsontable.editors.registerEditor('text', MyNewTextEditor);
因此,除非您有意要覆盖现有别名,否则请尝试选择唯一的名称。一个好的做法是用一些自定义名称(例如您的GitHub用户名)前缀您的别名,以尽量减少名称冲突的可能性。如果要发布编辑器,这一点尤其重要,因为您永远不知道使用编辑器的用户是否注册了别名。
// 这样定义比较好
Handsontable.editors.registerEditor('my.select', SelectEditor);
优化编辑器代码
如果你打算发布你的编辑器,或者只是想保持你的代码整洁,有3个简单的步骤可以帮助你组织你的代码。
- 使用IIFE包裹代码
将代码放在模块中,以避免污染全局命名空间。你可以使用AMD、CommonJS或任何其他模块模式,但隔离代码的最简单方法是使用普通的立即调用函数表达式(IIFE)。
(Handsontable => {
const CustomEditor = Handsontable.editors.BaseEditor.prototype.extend();
// ...rest of the editor code
})(Handsontable);
将Handsontable作为参数传递是可选的(因为它是全局定义的),但使用尽可能少的全局对象是一个很好的实践,以使模块化和依赖管理更容易。
- 向专用命名空间添加编辑器
包含在IIFE中的代码不能从外部访问,除非它是故意暴露的。使用Handsontable.editors.registerEditor方法将编辑器注册到编辑器集合中,以使事情井井有条。通过这种方式,您可以在表定义期间使用编辑器,并且其他用户如果想扩展编辑器,可以轻松访问编辑器。
(Handsontable => {
const CustomEditor = Handsontable.editors.BaseEditor.prototype.extend();
// ...rest of the editor code
// And at the end
Handsontable.editors.registerEditor('custom', CustomEditor);
})(Handsontable);
可以这样使用:
const container = document.querySelector('#container');
const hot = new Handsontable(container, {
columns: [{
editor: Handsontable.editors.CustomEditor
}]
});
// 扩展
const AnotherEditor = Handsontable.editors.getEditor('custom').prototype.extend();
- 注册别名
最后一步是注册编辑器别名,这样用户就可以轻松地引用它,而不需要知道实际的类名。
(Handsontable => {
const CustomEditor = Handsontable.editors.BaseEditor.prototype.extend();
// ...rest of the editor code
// Put editor in dedicated namespace
Handsontable.editors.CustomEditor = CustomEditor;
// Register alias
Handsontable.editors.registerEditor('theBestEditor', CustomEditor);
})(Handsontable);
// 使用
const container = document.querySelector('#container')
const hot = new Handsontable(container, {
columns: [{
editor: 'theBestEditor'
}]
});