Recently, independent selection and manipulation of tables were implemented in the rich text editor of Slate.js. Because the table has the cell merge operation, makes the selection calculation and operation function becomes more complex, so the relevant implementation is recorded. There’s not a lot of slate.js involved, so it doesn’t necessarily limit the technology stack. I hope I can provide you with an idea when meeting similar functional requirements.



The whole function content is relatively large, so it will be divided into the following three parts:

  1. Independent selection of cells and range calculation;
  2. Cell operation;
  3. Row and column operations.

This article is to explain the table itself independent selection implementation. It is mainly the realization process of the calculation of the cell data and the size of the selection range. The effect is as follows:

The premise condition

Table data includes: tableRow represents row table data; TableCell represents table cells. TableCell contains data content and data across rows (rowSpan) and columns (colSpan) (default: 1).

{
    "type": "table"."children": [{"type": "tableRow"."children": [{"type": "tableCell"."colSpan": 2."rowSpan": 1."children": [...]. }, {"type": "tableCell"."children": [...]. }]},]}Copy the code

The source table

Source table refers to a table whose cell data is the coordinate or coordinate range of the same table that does not span rows or columns (when cells are merged). The source table is the basis of subsequent selection and operation functions. The cell range of selection and operation cannot be accurately calculated due to the combination of cells across rows and columns. The related range calculation can be realized through the source table. In the following text, coordinate positions are described based on the upper left corner as the starting point of the table. For example, if the first cell in the table is {rowSpan: 3, colSpan:2}, then the corresponding cell in the table is [0, 0], while the cell in the source data is [[0, 0], [2, 1]].

const originTable = [
  	...,
    [
        [
            [
                1.0
            ],
            [
                1.1]]]... ]Copy the code

The source form can be calculated by the following steps:

  • Calculates the total number of columns in the source table. If cells in the table cross rows and columns at the same time, the sum of colSpan of cells in the next row is not the number of source table columns. There is no cross-row influence of other cells in the first row cell, so the total number of columns in the source table can be calculated through the first row cell.
const colNum = table.children[0].children.reduce(
    (value: number, cell: TableCellElement) = > {
      const { colSpan = 1 } = cell
      return colSpan + value
    },
    0.)Copy the code
  • Calculates the row index (row coordinates) in the source table for each row in the table.
    1. Calculate whether the 0 to colNum columns of the current row exist in the current source table according to the row index.
    2. If both exist, the row index is added to continue the calculation; If none exists, the current row index is returned, which is the source table index of the current row.
table.children.forEach((row: TableRowElement) = > {
    const originRow: (number | number[] [] [] []) =// Raw row data
    rowIndex = getRowOriginPosition(originTable, rowIndex, colNum)
    let colOriginIndex = 0. })/** auxiliary methods **/

// Calculates the row index
function getRowOriginPosition(
  originTable: (number | number[])[][][],
  rowIndex: number,
  colNum: number.) {
  let index = 0
  while (true) {
    // Initial table column index
    let colIndex = 0
    // Calculate whether the 0 to colNum columns of the current row exist in the current source table based on the row index
    while (colIndex < colNum) {
      const originCell = [rowIndex + index, colIndex]
      // If both exist, the row index is continued;
      // If there is none, the current row index is returned, i.e. the source table index of the current row
      if(! isContainPath(originTable, originCell))return originCell[0]
      colIndex++
    }
    // Perform the next line of calculation
    index++
  }
}


// Determine if coordinates exist in the source table
export function isContainPath(
  originTable: (number | number[])[][][],
  target: number[].) {
  const [x, y] = target
  for (const row of originTable) {
    for (const cell of row) {
      if (Array.isArray(cell[0]) {// There are cross row/column cells
        const xRange = [cell[0] [0], cell[1] [0]]
        const yRange = [cell[0] [1], cell[1] [1]]
        
        if (
          x >= xRange[0] &&
          x <= xRange[1] &&
          y >= yRange[0] &&
          y <= yRange[1])return true
      } else if (cell[0] === x && cell[1] === y) {
        // There is no merge cell direct judgment
        return true}}}return false
}
Copy the code
  • Calculates the source table cell data.
    1. For each row, starting from the first column, determine whether it already exists in the source table until the source table does not exist, and calculate the cell scope data based on rowSpan/colSpan.
    2. Calculates the source table cell data
// Source table row data column index
let colOriginIndex = 0
row.children.forEach((cell: TableCellElement) = > {
  const { rowSpan = 1, colSpan = 1 } = cell
	while (true) {
    // Each row starts from the first column and determines if it already exists in the source table,
    Calculate cell range data based on rowSpan/colSpan until no source table exists
		const target = [rowIndex, colOriginIndex]
    if(! isContainPath(originTable, target))break
      colOriginIndex++
  }
	
  // Calculates the source table cell data
  if (rowSpan === 1 && colSpan === 1) {
   	originRow.push([rowIndex, colOriginIndex])
  } else {
    // Merged cell data
    originRow.push([
      [rowIndex, colOriginIndex],
      [rowIndex + rowSpan - 1, colOriginIndex + colSpan - 1],
    ])
  }
  colOriginIndex += colSpan
})
Copy the code

Selection range calculation

  • Calculates the coordinate range data for the source table based on the start and end cells.
    1. According to the start and end cells, obtain the corresponding cell data in the corresponding source table;
    2. Gets the coordinate range of the start and end cells in the source table;
// Obtain the corresponding cell data in the corresponding source table according to the start and end cells
const originStart = getOriginPath(originTable, startPath)
const originEnd = getOriginPath(originTable, endPath)

// Get the range of coordinates for the start and end cells in the source table
const newRange: number= [] [] []if (Array.isArray(originStart[0&&])Array.isArray(originStart[1])) {
  newRange.push(originStart[0], originStart[1])}else {
  newRange.push(originStart as rangeType)
}
if (Array.isArray(originEnd[0&&])Array.isArray(originEnd[1])) {
  newRange.push(originEnd[0], originEnd[1])}else {
  newRange.push(originEnd as rangeType)
}
constrange = getRange(... (newRangeas rangeType[]))


/** auxiliary methods **/

// Calculate the cell range of the source table according to the cell coordinates
function getOriginPath(originTable: (number | number[])[][][], real: number[]) {
  return originTable[real[0]][real[1]]}// Calculate the maximum range
export function getRange(. args: rangeType[]) :tableRange {
  const xArr: number[] = []
  const yArr: number[] = []
  args.forEach((item) = > {
    xArr.push(item[0])
    yArr.push(item[1])})return {
    xRange: [Math.min(... xArr),Math.max(... xArr)],yRange: [Math.min(... yArr),Math.max(... yArr)], } }Copy the code
  • Gets the coordinate range data in the final source table. The coordinate range of the source table cell corresponding to a single coordinate may be outside the current coordinate range, so a calculation is required to expand the range.

In the following picture, the coordinate range of the start and end cells is:[0, 3], [2, 2]. But in the[[2,1], [2, 2]and[[0, 0], [1, 1]“, expands the selection and selects the entire table.

  1. According to the coordinates, the corresponding source table cells of each coordinate are calculated.
  2. And judge whether the cell range is in the current selection coordinate range;
  3. If not in the current range, need to expand the coordinate range data according to the cell data to calculate again.
function getOriginRange(
  originTable: (number | number[])[][][],
  xRange: rangeType,
  yRange: rangeType,
) {
  for (let x = xRange[0]; x <= xRange[1]; x++) {
    for (let y = yRange[0]; y <= yRange[1]; y++) {
      const path = [x, y]
      // Calculate the source table cells corresponding to each coordinate according to the coordinates
      const rangePath = getRangeByOrigin(originTable, path)
      if(rangePath ! == path) {// Return range data
        const range = getRange(
          [xRange[0], yRange[0]],
          [xRange[1], yRange[1]],
          ...(rangePath as rangeType[]),
        )
        // Determine whether the cell range is in the current selection coordinate range
        const isContain = isContainRange(range, { xRange, yRange })
        if(! isContain) {// If you are not in the current range, you need to expand the coordinate range data according to the cell data to calculate again
          return getOriginRange(originTable, range.xRange, range.yRange)
        }
      }
    }
  }
  return {
    xRange,
    yRange,
  }
}

/** auxiliary methods **/

// Get the cell containing this coordinate in the source table based on the coordinate data
function getRangeByOrigin(
  originTable: (number | number[])[][][],
  target: number[].) {
  const [x, y] = target
  for (const row of originTable) {
    for (const cell of row) {
      if (Array.isArray(cell[0]) {// Whether it is within the range
        const xRange = [cell[0] [0], cell[1] [0]]
        const yRange = [cell[0] [1], cell[1] [1]]
        if (
          x >= xRange[0] &&
          x <= xRange[1] &&
          y >= yRange[0] &&
          y <= yRange[1]) {return cell
        }
      } else if (cell[0] === x && cell[1] === y) {
        return target
      }
    }
  }
  return[]}Copy the code
  • From the coordinate range data of the source table, the corresponding cells of the table are calculated. Iterate over all coordinates in the coordinate range, get the corresponding cells and exclude duplicate cells.
function getRealRelativePaths(
  originTable: (number | number[])[][][],
  range: tableRange,
) {
  const realPaths: Path[] = []
  const { xRange, yRange } = range
  
  // Iterate over each row in the range to get the corresponding cell
  for (let x = xRange[0]; x <= xRange[1]; x++) {
    for (let y = yRange[0]; y <= yRange[1]; y++) {
      const path = getRealPathByPath(originTable, [x, y])
      if(path && ! isIncludePath(realPaths, path)) { realPaths.push(path) } } }return realPaths
}

/** auxiliary methods **/

// Calculate the cell according to the source coordinates
export function getRealPathByPath(
  originTable: (number | number[])[][][],
  path: Path,
) {
  const [x, y] = path

  for (const [rowKey, row] of originTable.entries()) {
    for (const [cellKey, cell] of row.entries()) {
      if (Array.isArray(cell[0]) {// Whether it is within the range
        const xRange = [cell[0] [0], cell[1] [0]]
        const yRange = [cell[0] [1], cell[1] [1]]
        if (
          x >= xRange[0] &&
          x <= xRange[1] &&
          y >= yRange[0] &&
          y <= yRange[1]) {return [rowKey, cellKey]
        }
      } else if (cell[0] === x && cell[1] === y) {
        return [rowKey, cellKey]
      }
    }
  }

  return [-1, -1]}Copy the code
  • Calculates the selection size. Directly fetching the upper left and lower right cells of the selection data will be inaccurate due to merging, so it is necessary to obtain the docking cells in the source table for selection calculation.
    1. Convert the selection range to source coordinates, and obtain the corresponding range data;
    2. Take the coordinates of the upper left corner and the lower right corner to obtain the corresponding cell;
    3. The selection size is calculated according to the corresponding cell DOM.
function selectionBound(editor: IYTEditor, selectPath: Path[]) {
  // Convert the selection range to source coordinate range data
  const tablePath = Path.parent(Path.parent(selectPath[0]))
  const [tableNode] = Editor.node(editor, tablePath)
  const originTable = getOriginTable(tableNode as TableElement)

  const originSelectPath: rangeType[] = []
  selectPath.forEach((cellPath) = > {
    const relativePath = Path.relative(cellPath, tablePath)
    const originRange = originTable[relativePath[0]][relativePath[1]]
    if (Array.isArray(originRange[0])) {
      originSelectPath.push(
        originRange[0] as rangeType,
        originRange[1] as rangeType,
      )
    } else {
      originSelectPath.push(originRange as rangeType)
    }
  })
  const{ xRange, yRange } = getRange(... originSelectPath)// Take the coordinates of the upper left and lower right corner to get the corresponding cells
  const ltRelativePath = [xRange[0], yRange[0]]
  const rbRelativePath = [xRange[1], yRange[1]]
  const ltPath = getRealPathByPath(originTable, ltRelativePath)
  const rbPath = getRealPathByPath(originTable, rbRelativePath)

  // Calculate the selection size according to the corresponding cell DOM
  const [startNode] = Editor.node(editor, [...tablePath, ...ltPath])
  const [endNode] = Editor.node(editor, [...tablePath, ...rbPath])

  const ltDom = ReactEditor.toDOMNode(editor, startNode)
  const rbDom = ReactEditor.toDOMNode(editor, endNode)

  const ltBound = ltDom.getBoundingClientRect()
  const rbBound = rbDom.getBoundingClientRect()
  return {
    x: ltDom.offsetLeft,
    y: ltDom.offsetTop,
    left: ltBound.left,
    top: ltBound.top,
    right: rbBound.right,
    bottom: rbBound.bottom,
  }
}
Copy the code

conclusion

This paper explains the implementation of table selection. Main is:

  1. Table selection and operation of the basic source table data calculation, all kinds of operations in the support of the source table can better calculate the operation position and scope;
  2. When calculating the size of a selection, consider expanding the current size of the selection in the case of table merging until the true size of the selection is calculated.