likes
comments
collection
share

slatejs编辑器表格---选区

作者站长头像
站长
· 阅读数 38

最近在 slate.js 的富文本编辑器中实现了 Table 的独立选区以及操作功能。由于表格存在单元格的合并操作,使得在选区计算和操作功能变得更加的复杂,所以对相关的实现进行了记录。当中,涉及到 slate.js 的内容不是很多,所以并不一定限制技术栈。在遇到类似功能需求时,希望能够为你提供出一种思路。slatejs编辑器表格---选区整个功能内容相对还是比较多,所以将分为以下3个部分讲解:

  1. 独立选区的单元格和范围计算;
  2. 单元格操作;
  3. 行列操作。

本文是对表格自身独立的选区实现进行讲解。主要是对选区包含的单元格数据和选区范围大小计算的实现过程。效果如下:slatejs编辑器表格---选区

前提条件

表格数据包括了:tableRow 表示行表格数据;tableCell 表示表格单元格。其中 tableCell 包括了数据内容和跨行(rowSpan)和跨列(colSpan)的数据(默认为 1)。

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

源表格

源表格指的是:表格的单元格数据是未跨行、跨列的相同表格对应的坐标或坐标范围(单元格存在合并情况时)。源表格是后续选区以及操作功能的基础,单元格存在跨行/跨列的合并,无法准确计算选区和操作的单元格范围,通过源表格能够实现相关范围计算。在后文中,都基于将左上角作为表格的起点进行坐标位置的描述。比如表格的首个单元格数据为{rowSpan: 3, colSpan:2}那么,对应的表格中单元格为[0, 0],而源数据中单元格为[[0, 0], [2, 1]]

const originTable = [
  	...,
    [
        [
            [
                1,
                0
            ],
            [
                1,
                1
            ]
        ],
    ]
    ...
]

源表格可通过以下步骤计算可得:

  • 计算源表格的总列数。表格的单元格同时存在跨行跨列情况,下一行单元格的 colSpan 之和不一定为源表格列数。在首行单元格没有其他单元格跨行影响,故可通过首行单元格计算可得源表格总列数。
const colNum = table.children[0].children.reduce(
    (value: number, cell: TableCellElement) => {
      const { colSpan = 1 } = cell
      return colSpan + value
    },
    0,
  )
  • 计算表格中每行对应的源表格中行索引(行坐标)。
    1. 根据行索引计算当前行的 0~colNum 列中是否存在在当前源表格中;
    2. 若都存在则行索引加继续计算;若有不存在,则返回当前行索引,即为当前行的源表格索引。
table.children.forEach((row: TableRowElement) => {
    const originRow: (number | number[])[][] = [] // 原始行数据
    rowIndex = getRowOriginPosition(originTable, rowIndex, colNum)
    let colOriginIndex = 0
		...
})

/** 辅助方法 **/

// 计算行索引
function getRowOriginPosition(
  originTable: (number | number[])[][][],
  rowIndex: number,
  colNum: number,
) {
  let index = 0
  while (true) {
    // 初始源表格列索引
    let colIndex = 0
    // 根据行索引计算当前行的 0~colNum 列中是否存在在当前源表格中
    while (colIndex < colNum) {
      const originCell = [rowIndex + index, colIndex]
      // 若都存在则行索引加继续计算;
      // 若有不存在,则返回当前行索引,即为当前行的源表格索引
      if (!isContainPath(originTable, originCell)) return originCell[0]
      colIndex++
    }
    // 进行下一行计算
    index++
  }
}


// 判断坐标是否存在于源表格中
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])) {
        // 存在跨行/跨列单元格
        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) {
        // 不存在合并单元格直接判断
        return true
      }
    }
  }
  return false
}
  • 计算源表格单元格数据。
    1. 每行从第一列开始,判断是否已经存在于源表格中,直到不存在源表格时根据 rowSpan/colSpan 计算单元范围数据(单元存在跨行情况,当前坐标可能已经在其他存在了,所以需要判断一次);
    2. 计算源表格单元格数据
// 源表格行数据列索引
let colOriginIndex = 0
row.children.forEach((cell: TableCellElement) => {
  const { rowSpan = 1, colSpan = 1 } = cell
	while (true) {
    // 每行从第一列开始,判断是否已经存在于源表格中,
    // 直到不存在源表格时根据 rowSpan/colSpan 计算单元范围数据
		const target = [rowIndex, colOriginIndex]
    if (!isContainPath(originTable, target)) break
      colOriginIndex++
  }
	
  // 计算源表格单元格数据
  if (rowSpan === 1 && colSpan === 1) {
   	originRow.push([rowIndex, colOriginIndex])
  } else {
    // 合并的单元格数据
    originRow.push([
      [rowIndex, colOriginIndex],
      [rowIndex + rowSpan - 1, colOriginIndex + colSpan - 1],
    ])
  }
  colOriginIndex += colSpan
})

选区范围计算

  • 根据起止的单元格,计算出源表格的坐标范围数据。
    1. 根据起止单元格,获取对应源表格中对应单元格数据;
    2. 获取源表格中起止单元格的坐标范围;
// 根据起止单元格,获取对应源表格中对应单元格数据
const originStart = getOriginPath(originTable, startPath)
const originEnd = getOriginPath(originTable, endPath)

// 获取源表格中起止单元格的坐标范围
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)
}
const range = getRange(...(newRange as rangeType[]))


/** 辅助方法 **/

// 根据单元格坐标,计算出源表格单元格范围
function getOriginPath(originTable: (number | number[])[][][], real: number[]) {
  return originTable[real[0]][real[1]]
}  
// 计算最大范围
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)],
  }
}
  • 获取最终源表格中的坐标范围数据。单个坐标对应的源表格单元格坐标范围可能超出当前坐标范围,所以需要计算扩大范围。

以下图为例,起止单元格的坐标范围为:[0, 3],[2, 2]。但是在[ [2,1], [2, 2] ][ [0, 0], [1, 1] ]时,会扩大选区范围,最终选择了整个表格。slatejs编辑器表格---选区

  1. 根据坐标,计算每个坐标对应的源表格单元格;
  2. 并判断是否单元格范围是否在当前选区坐标范围之中;
  3. 若不在当前范围之中时,需要根据单元格数据扩大坐标范围数据再次进行计算。
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]
      // 根据坐标,计算每个坐标对应的源表格单元格
      const rangePath = getRangeByOrigin(originTable, path)
      if (rangePath !== path) {
        // 返回范围数据
        const range = getRange(
          [xRange[0], yRange[0]],
          [xRange[1], yRange[1]],
          ...(rangePath as rangeType[]),
        )
        // 并判断是否单元格范围是否在当前选区坐标范围之中
        const isContain = isContainRange(range, { xRange, yRange })
        if (!isContain) {
          // 若不在当前范围之中时,需要根据单元格数据扩大坐标范围数据再次进行计算
          return getOriginRange(originTable, range.xRange, range.yRange)
        }
      }
    }
  }
  return {
    xRange,
    yRange,
  }
}

/** 辅助方法 **/

// 根据坐标数据获取源表格中包含此坐标的单元格
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])) {
        // 是否在范围内
        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 []
}
  • 通过源表格的坐标范围数据,计算表格对应单元格。遍历坐标范围内的所有坐标,获取相应的单元格并排除重复单元格。
function getRealRelativePaths(
  originTable: (number | number[])[][][],
  range: tableRange,
) {
  const realPaths: Path[] = []
  const { xRange, yRange } = range
  
  // 遍历范围内每个行列,获取相应的单元格
  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
}

/** 辅助方法 **/

// 根据源坐标,计算单元格
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])) {
        // 是否在范围内
        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]
}
  • 计算选区大小。直接取选区数据的左上和右下单元格会因为存在合并情况而不准确,所以需要在源表格中获取对接单元格,从而进行选区计算。
    1. 将选区范围转换为源坐标,并获取相应的范围数据;
    2. 取左上角和右下角坐标,获取相应的单元格;
    3. 根据对应单元格 DOM 计算出选区大小。
function selectionBound(editor: IYTEditor, selectPath: Path[]) {
  // 将选区范围转换为源坐标范围数据
  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)
	
  // 取左上角和右下角坐标,获取相应的单元格
  const ltRelativePath = [xRange[0], yRange[0]]
  const rbRelativePath = [xRange[1], yRange[1]]
  const ltPath = getRealPathByPath(originTable, ltRelativePath)
  const rbPath = getRealPathByPath(originTable, rbRelativePath)

  // 根据对应单元格 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,
  }
}

总结

本文对表格选区的实现进行了讲解说明。主要是:

  1. 表格选区和操作的基础源表格数据计算,各种操作在源表格的支撑下能够更好的进行计算操作位置和范围;
  2. 选区范围计算时,要考虑表格存在合并的情况时扩大选区的当前范围,直到计算出真正的选区范围。
转载自:https://juejin.cn/post/7077766418841731108
评论
请登录