Handsontable中的cell editor

本文主要是对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()方法时都会更新。

属性类型描述
instanceHandsontable.Core此编辑器对象所属的Handsontable实例。在类构造函数中设置,在编辑器的整个生命周期中不可变。
rowNumber活动单元格行索引。
colNumber活动单元格的列索引。
propString与活动单元格关联的属性名(仅当数据源是对象数组时相关)。
TDHTMLTableCellNode活动单元的节点对象。
cellPropertiesObject表示活动单元格属性的对象。

如何创建自定义编辑器

  1. 如果只是想增强现有的编辑器,可以扩展它的类并只覆盖它的几个方法。

示例:扩展TextEditor,创建PasswordEditor,显示密码

  1. 也可以通过创建一个继承自BaseEditor的新编辑器类来从头构建一个新的编辑器。

示例:创建一个全新的SelectEditor,它使用

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允许用户从定义好的标签并将其添加到DOM上的函数。

有三个方法可以完成这一步,init(),prepare(),open()。 init()方法在创建编辑器类对象期间被调用。每个表实例最多只发生一次,因为一旦创建了对象,每次EditorManager请求这个编辑器类实例时都会重用它。 prepare()方法在用户选择指定单元格时会调用,指定单元格指的是editor设置为特定编辑器类的的单元格。因此,如果我们将SelectEditor设置为整个列的编辑器,那么选择该列中的任何单元格将调用SelectEditor的prepare()方法。换句话说,这个方法在表生命周期中可以被调用数百次,特别是在处理大数据时。另外,prepare()不应该显示编辑器(这是open()的工作)。显示编辑器是由用户事件(按ENTER、F2或双击单元格等)触发的,因此在调用prepare()和实际显示编辑器之间存在一段时间。尽管如此,应该尽可能快地完成prepare()执行的操作,以提供最佳的用户体验。 当需要显示编辑器时调用open()方法。在大多数情况下,该方法应该将CSS里display属性更改为block或执行类似的操作。用户希望在事件(按下适当的键或双击单元格)触发后立即显示编辑器,因此open()方法应该尽可能快地工作。 了解了所有这些,最合理的地方放置负责创建下拉选项,对应的下拉选项通过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包含一长串选项,那么创建

// 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;
}
  1. 实现这些函数: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,使其与相应单元格的大小匹配。这是一个可选步骤,但如果没有它,编辑器将根据浏览器的不同而具有不同的大小。限制被附加到表格容器的末尾,我们必须更改它的位置,以便它可以显示在正在编辑的单元格上方。同样,这是一个可选的步骤,但是将编辑器放在适当的单元格旁边似乎是非常合理的。 5. 覆盖默认的EditorManager行为,这样按向上(Arrow Up)和向下(Arrow Down)箭头键不会关闭编辑器,而是改变当前选择的值。 虽然我们不能直接访问EditorManager实例,但是仍然可以覆盖它的行为。在EditorManager开始处理键盘事件之前,它会触发beforeKeyDown钩子。如果任何侦听函数调用事件对象上的stopImmediatePropagation()方法,EditorManager将不再处理此事件。因此,我们所要做的就是注册一个beforeKeyDown监听器函数,该函数检查箭头向上或箭头向下是否被按下,如果是,停止事件传播并相应地改变

更新于: 2024年11月19日