From 26250faaa6fbbbdd39a4d5c28364df9d944596bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=9C=E7=81=B0?= Date: Tue, 9 Jul 2024 10:53:15 +0800 Subject: [PATCH] feat: Table add merge / split cell --- .../src/modules/image/render-elem.tsx | 38 ++- .../init-default-config/config/hoverbar.ts | 3 + packages/table-module/package.json | 3 +- packages/table-module/src/assets/index.less | 75 +++++- packages/table-module/src/constants/svg.ts | 9 + packages/table-module/src/locale/en.ts | 2 + packages/table-module/src/locale/zh-CN.ts | 2 + .../table-module/src/module/column-resize.ts | 249 ++++++++++++++++++ .../table-module/src/module/custom-types.ts | 11 + packages/table-module/src/module/index.ts | 4 + .../table-module/src/module/menu/DeleteCol.ts | 91 +++++-- .../table-module/src/module/menu/DeleteRow.ts | 66 ++++- .../table-module/src/module/menu/InsertCol.ts | 89 +++++-- .../table-module/src/module/menu/InsertRow.ts | 68 ++++- .../table-module/src/module/menu/MergeCell.ts | 126 +++++++++ .../table-module/src/module/menu/SplitCell.ts | 148 +++++++++++ .../table-module/src/module/menu/index.ts | 17 ++ packages/table-module/src/module/plugin.ts | 7 + .../src/module/render-elem/render-cell.tsx | 117 ++------ .../src/module/render-elem/render-table.tsx | 119 ++++++++- .../table-module/src/module/table-cursor.ts | 90 +++++++ packages/table-module/src/module/weak-maps.ts | 8 + .../table-module/src/module/with-selection.ts | 120 +++++++++ packages/table-module/src/utils/has-common.ts | 26 ++ packages/table-module/src/utils/index.ts | 5 + packages/table-module/src/utils/is-of-type.ts | 18 ++ packages/table-module/src/utils/matrices.ts | 88 +++++++ packages/table-module/src/utils/options.ts | 29 ++ packages/table-module/src/utils/point.ts | 17 ++ packages/table-module/src/utils/types.ts | 22 ++ 30 files changed, 1499 insertions(+), 168 deletions(-) create mode 100644 packages/table-module/src/module/column-resize.ts create mode 100644 packages/table-module/src/module/menu/MergeCell.ts create mode 100644 packages/table-module/src/module/menu/SplitCell.ts create mode 100644 packages/table-module/src/module/table-cursor.ts create mode 100644 packages/table-module/src/module/weak-maps.ts create mode 100644 packages/table-module/src/module/with-selection.ts create mode 100644 packages/table-module/src/utils/has-common.ts create mode 100644 packages/table-module/src/utils/index.ts create mode 100644 packages/table-module/src/utils/is-of-type.ts create mode 100644 packages/table-module/src/utils/matrices.ts create mode 100644 packages/table-module/src/utils/options.ts create mode 100644 packages/table-module/src/utils/point.ts create mode 100644 packages/table-module/src/utils/types.ts diff --git a/packages/basic-modules/src/modules/image/render-elem.tsx b/packages/basic-modules/src/modules/image/render-elem.tsx index 82a2758b8..a801f4347 100644 --- a/packages/basic-modules/src/modules/image/render-elem.tsx +++ b/packages/basic-modules/src/modules/image/render-elem.tsx @@ -33,7 +33,8 @@ function renderContainer( const style: any = {} if (width) style.width = width - if (height) style.height = height + /** 不强制设置高度 */ + // if (height) style.height = height const containerId = genContainerId(editor, elemNode) @@ -60,6 +61,7 @@ function renderResizeContainer( let originalX = 0 let originalWith = 0 let originalHeight = 0 + let maxWidth = 0 // 最大宽度 let revers = false // 是否反转。如向右拖拽 right-top 需增加宽度(非反转),但向右拖拽 left-top 则需要减少宽度(反转) let $container: Dom7Array | null = null @@ -72,11 +74,12 @@ function renderResizeContainer( /** * 初始化。监听事件,记录原始数据 */ - function init(clientX: number) { + function init(clientX: number, parentNodeWidth: number) { $container = getContainerElem() // 记录当前 x 坐标值 originalX = clientX + maxWidth = parentNodeWidth // 记录 img 原始宽高 const $img = $container.find('img') @@ -104,12 +107,17 @@ function renderResizeContainer( const newWidth = originalWith + gap const newHeight = originalHeight * (newWidth / originalWith) // 根据 width ,按比例计算 height + /** + * 图片有左右3px margin + */ + if (newWidth > maxWidth - 6) return // 超过最大宽度,不处理 + // 实时修改 img 宽高 -【注意】这里只修改 DOM ,mouseup 时再统一不修改 node if ($container == null) return if (newWidth <= 15 || newHeight <= 15) return // 最小就是 15px $container.css('width', `${newWidth}px`) - $container.css('height', `${newHeight}px`) + // $container.css('height', `${newHeight}px`) }, 100) function onMouseup(e: Event) { @@ -118,14 +126,14 @@ function renderResizeContainer( if ($container == null) return const newWidth = $container.width().toFixed(2) - const newHeight = $container.height().toFixed(2) + // const newHeight = $container.height().toFixed(2) // 修改 node const props: Partial = { style: { ...(elemNode as ImageElement).style, width: `${newWidth}px`, - height: `${newHeight}px`, + // height: `${newHeight}px`, }, } Transforms.setNodes(editor, props, { at: DomEditor.findPath(editor, elemNode) }) @@ -136,7 +144,7 @@ function renderResizeContainer( const style: any = {} if (width) style.width = width - if (height) style.height = height + // if (height) style.height = height // style.boxShadow = '0 0 0 1px #B4D5FF' // 自定义 selected 样式,因为有拖拽触手 return ( @@ -157,7 +165,21 @@ function renderResizeContainer( if ($target.hasClass('left-top') || $target.hasClass('left-bottom')) { revers = true // 反转。向右拖拽,减少宽度 } - init(e.clientX) // 初始化 + + // 获取 image 父容器宽度 + const parentNode = DomEditor.getParentNode(editor, elemNode) + if (parentNode == null) return + const parentNodeDom = DomEditor.toDOMNode(editor, parentNode) + const rect = parentNodeDom.getBoundingClientRect() + // 获取元素的计算样式 + const style = window.getComputedStyle(parentNodeDom) + // 获取左右 padding 和 border 的宽度 + const paddingLeft = parseFloat(style.paddingLeft) + const paddingRight = parseFloat(style.paddingRight) + const borderLeft = parseFloat(style.borderLeftWidth) + const borderRight = parseFloat(style.borderRightWidth) + + init(e.clientX, rect.width - paddingLeft - paddingRight - borderLeft - borderRight) // 初始化 }, }} > @@ -177,7 +199,7 @@ function renderImage(elemNode: SlateElement, children: VNode[] | null, editor: I const { width = '', height = '' } = style const selected = DomEditor.isNodeSelected(editor, elemNode) // 图片是否选中 - const imageStyle: any = {} + const imageStyle: any = { maxWidth: '100%' } if (width) imageStyle.width = '100%' if (height) imageStyle.height = '100%' diff --git a/packages/editor/src/init-default-config/config/hoverbar.ts b/packages/editor/src/init-default-config/config/hoverbar.ts index 80dc2abe5..21cf4dc19 100644 --- a/packages/editor/src/init-default-config/config/hoverbar.ts +++ b/packages/editor/src/init-default-config/config/hoverbar.ts @@ -32,6 +32,9 @@ const COMMON_HOVERBAR_KEYS = { 'insertTableCol', 'deleteTableCol', 'deleteTable', + /** 注册单元格合并 拆分 */ + 'mergeTableCell', + 'splitTableCell', ], }, divider: { diff --git a/packages/table-module/package.json b/packages/table-module/package.json index 4ba0d4c7c..079552149 100644 --- a/packages/table-module/package.json +++ b/packages/table-module/package.json @@ -42,10 +42,11 @@ "peerDependencies": { "@wangeditor-next/core": "1.x", "dom7": "^3.0.0", + "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "lodash.throttle": "^4.1.1", "nanoid": "^3.2.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" } -} +} \ No newline at end of file diff --git a/packages/table-module/src/assets/index.less b/packages/table-module/src/assets/index.less index 38690d8b9..1c68dc054 100644 --- a/packages/table-module/src/assets/index.less +++ b/packages/table-module/src/assets/index.less @@ -8,23 +8,84 @@ padding: 10px; border-radius: 5px; margin-top: 10px; + position: relative; } table { border-collapse: collapse; + table-layout: fixed; - td,th { + td, + th { border: 1px solid @textarea-border-color; padding: 3px 5px; min-width: 30px; text-align: left; line-height: 1.5; + /* 强制换行,table column 宽度必须拖动增大 */ + overflow: hidden; + overflow-wrap: break-word; + word-break: break-all; + white-space: pre-wrap; } + th { background-color: @textarea-slight-bg-color; text-align: center; font-weight: bold; } + + /* 选区拖影 */ + td.w-e-selected, + th.w-e-selected { + background-color: rgba(20, 86, 240, 0.18); + } + } + + /* 干掉 Chrome 默认选区样式*/ + table.table-selection-none *::selection { + background: none; + } + + /* 拖动 Style */ + .column-resizer { + position: absolute; + display: flex; + top: 10px; + left: 11px; + width: 0; + height: 0; + z-index: 1; + + .column-resizer-item { + position: relative; + } + } + + .resizer-line-hotzone { + cursor: col-resize; + position: absolute; + width: 10px; + right: -3px; + visibility: hidden; + opacity: 0; + transition: opacity .2s ease, visibility .2s ease; + + .resizer-line { + height: 100%; + width: 2px; + margin-left: 5px; + background: rgba(20, 86, 240, 0.8); + user-select: none; + } + } + + .resizer-line-hotzone.visible { + visibility: visible; + } + + .resizer-line-hotzone.highlight { + opacity: 1; } } @@ -35,6 +96,15 @@ table { border-collapse: collapse; + table-layout: fixed; + } + + th, + td { + overflow: hidden; + overflow-wrap: break-word; + word-break: break-all; + white-space: pre-wrap; } td { @@ -44,7 +114,8 @@ height: 15px; cursor: pointer; } + td.active { background-color: @toolbar-active-bg-color; } -} +} \ No newline at end of file diff --git a/packages/table-module/src/constants/svg.ts b/packages/table-module/src/constants/svg.ts index 7a9e2191a..9957f1575 100644 --- a/packages/table-module/src/constants/svg.ts +++ b/packages/table-module/src/constants/svg.ts @@ -40,3 +40,12 @@ export const TABLE_HEADER_SVG = // 宽度 export const FULL_WIDTH_SVG = '' + +// 合并单元格 +export const MERGE_CELL_SVG = + '' + +// 拆分单元格 +export const SPLIT_CELL_SVG = + '' + diff --git a/packages/table-module/src/locale/en.ts b/packages/table-module/src/locale/en.ts index bfa7a06e6..708c5afd3 100644 --- a/packages/table-module/src/locale/en.ts +++ b/packages/table-module/src/locale/en.ts @@ -13,5 +13,7 @@ export default { insertRow: 'Insert row', insertTable: 'Insert table', header: 'Header', + mergeCell: 'merge cell', + splitCell: 'solit cell', }, } diff --git a/packages/table-module/src/locale/zh-CN.ts b/packages/table-module/src/locale/zh-CN.ts index c88ddfa8b..485904868 100644 --- a/packages/table-module/src/locale/zh-CN.ts +++ b/packages/table-module/src/locale/zh-CN.ts @@ -13,5 +13,7 @@ export default { insertRow: '插入行', insertTable: '插入表格', header: '表头', + mergeCell: '合并单元格', + splitCell: '拆分单元格', }, } diff --git a/packages/table-module/src/module/column-resize.ts b/packages/table-module/src/module/column-resize.ts new file mode 100644 index 000000000..84950db67 --- /dev/null +++ b/packages/table-module/src/module/column-resize.ts @@ -0,0 +1,249 @@ +import throttle from 'lodash.throttle' +import { Element as SlateElement, Transforms, Editor } from 'slate' +import { DomEditor, IDomEditor } from '@wangeditor/core' +import { TableElement } from './custom-types' +import { isOfType } from '../utils' +import $ from '../utils/dom' + +/*** + * 计算 cell border 距离 table 左侧距离 + */ +function getCumulativeWidths(columnWidths: number[]) { + let cumulativeWidths: number[] = [] + let totalWidth = 0 + + for (let width of columnWidths) { + totalWidth += width + cumulativeWidths.push(totalWidth) + } + + return cumulativeWidths +} + +/*** + * 用于计算拖动 cell 时,cell 宽度变化的比例 + */ +export function getColumnWidthRatios(columnWidths: number[]) { + let columnWidthsRatio: number[] = [] + let totalWidth = columnWidths.reduce((a, b) => a + b, 0) + + for (let width of columnWidths) { + columnWidthsRatio.push(width / totalWidth) + } + + return columnWidthsRatio +} + +/** + * 监听 table 内部变化,如新增行、列,删除行列等操作,引起的高度变化。 + * ResizeObserver 需要即时释放,以免引起内存泄露 + */ +let resizeObserver: ResizeObserver | null = null +export function observerTableResize(editor: IDomEditor, elm: Node | undefined) { + if (elm instanceof HTMLElement) { + const table = elm.querySelector('table') + if (table) { + resizeObserver = new ResizeObserver(([{ contentRect }]) => { + // 当非拖动引起的宽度变化,需要调整 columnWidths + Transforms.setNodes( + editor, + { + scrollWidth: contentRect.width, + height: contentRect.height, + } as TableElement, + { mode: 'highest' } + ) + }) + resizeObserver.observe(table) + } + } +} + +export function unObserveTableResize() { + if (resizeObserver) { + resizeObserver?.disconnect() + resizeObserver = null + } +} + +// 是否为光标选区行为 +let isSelectionOperation = false +// 拖拽列宽相关信息 +let isMouseDownForResize = false +let clientXWhenMouseDown = 0 +let cellWidthWhenMouseDown = 0 +let editorWhenMouseDown: IDomEditor | null = null +const $window = $(window) +$window.on('mousedown', onMouseDown) + +function onMouseDown(event: Event) { + const elem = event.target as HTMLElement + // 判断是否为光标选区行为,对列宽变更行为进行过滤 + // console.log('onMouseDown', elem) + if (elem.closest('[data-block-type="table-cell"]')) { + isSelectionOperation = true + } else if (elem.tagName == 'DIV' && elem.closest('.column-resizer-item')) { + if (editorWhenMouseDown == null) return + + const [[elemNode]] = Editor.nodes(editorWhenMouseDown, { + match: isOfType(editorWhenMouseDown, 'table'), + }) + const { + width: tableWidth = 'auto', + columnWidths = [], + resizingIndex = -1, + } = elemNode as TableElement + /** + * table width 为 100% 模式时,因无法增加Table宽度,不触发 列宽变更行为 + * 如需变更,到底哪个列增宽度,哪个列减去宽度?? + */ + if (tableWidth == '100%') return + + // 记录必要信息 + isMouseDownForResize = true + const { clientX } = event as MouseEvent + clientXWhenMouseDown = clientX + cellWidthWhenMouseDown = columnWidths[resizingIndex] + document.body.style.cursor = 'col-resize' + event.preventDefault() + } + + $window.on('mousemove', onMouseMove) + $window.on('mouseup', onMouseUp) +} + +const onMouseMove = throttle(function (event: Event) { + if (!isMouseDownForResize) return + if (editorWhenMouseDown == null) return + event.preventDefault() + + const { clientX } = event as MouseEvent + let newWith = cellWidthWhenMouseDown + (clientX - clientXWhenMouseDown) // 计算新宽度 + newWith = Math.floor(newWith * 100) / 100 // 保留小数点后两位 + if (newWith < 30) newWith = 30 // 最小宽度 + + const [[elemNode]] = Editor.nodes(editorWhenMouseDown, { + match: isOfType(editorWhenMouseDown, 'table'), + }) + const { columnWidths = [], resizingIndex = -1, scrollWidth = 0 } = elemNode as TableElement + + /** + * 判断拖动引起的宽度是否最大化了 + * 如果最大化了,则需要调整列的宽度 + * + * 0.5 很微妙 + */ + const cumulativeTotalWidth = columnWidths.reduce((a, b) => a + b, 0) + const remainWidth = cumulativeTotalWidth - columnWidths[resizingIndex] + if (cumulativeTotalWidth > scrollWidth && remainWidth + newWith > scrollWidth) { + newWith = scrollWidth - remainWidth + 0.5 + } + + const adjustColumnWidths = [...columnWidths].map(width => Math.floor(width)) + adjustColumnWidths[resizingIndex] = newWith + + // 这是宽度 + Transforms.setNodes(editorWhenMouseDown, { columnWidths: adjustColumnWidths } as TableElement, { + mode: 'highest', + }) +}, 100) + +function onMouseUp(event: Event) { + isSelectionOperation = false + isMouseDownForResize = false + editorWhenMouseDown = null + document.body.style.cursor = '' + + // 解绑事件 + $window.off('mousemove', onMouseMove) + $window.off('mouseup', onMouseUp) +} +/** + * 鼠标移动时,判断在哪个 Cell border 上 + * Class 先 visible 后 highlight @跟随飞书 + * 避免光标选区功能收到干扰 + */ +export function handleCellBorderVisible(editor: IDomEditor, elemNode: SlateElement, e: MouseEvent) { + if (editor.isDisabled()) return + if (isSelectionOperation || isMouseDownForResize) return + + const { + width: tableWidth = 'auto', + columnWidths = [], + isHoverCellBorder, + resizingIndex, + } = elemNode as TableElement + + /** + * table width 为 100% 模式时,因无法增加Table宽度,不触发 列宽变更行为 + * 如需变更,到底哪个列增宽度,哪个列减去宽度?? + */ + if (tableWidth == '100%') return + + // Cell Border 宽度为 10px + const { clientX, target } = e + // 当单元格合并的时候,鼠标在 cell 中间,则不显示 cell border + if (target instanceof HTMLElement) { + const rect = target.getBoundingClientRect() + + if (clientX > rect.x + 5 && clientX < rect.x + rect.width - 5) { + if (isHoverCellBorder) { + Transforms.setNodes( + editor, + { isHoverCellBorder: false, resizingIndex: -1 } as TableElement, + { mode: 'highest' } + ) + } + return + } + } + if (target instanceof HTMLElement) { + const parent = target.closest('.table') + if (parent) { + const { clientX } = e + const rect = parent.getBoundingClientRect() + let cumulativeWidths = getCumulativeWidths(columnWidths) + + // 鼠标移动时,计算当前鼠标位置,判断在哪个 Cell border 上 + for (let i = 0; i < cumulativeWidths.length; i++) { + if ( + clientX - rect.x >= cumulativeWidths[i] - 5 && + clientX - rect.x < cumulativeWidths[i] + 5 + ) { + // 节流,防止多次引起Transforms.setNodes重绘 + if (resizingIndex == i) return + Transforms.setNodes( + editor, + { isHoverCellBorder: true, resizingIndex: i } as TableElement, + { mode: 'highest' } + ) + return + } + } + } + } + + // 鼠标移出时,重置 + if (isHoverCellBorder == true) { + Transforms.setNodes(editor, { isHoverCellBorder: false, resizingIndex: -1 } as TableElement, { + mode: 'highest', + }) + } +} + +/** + * 设置 class highlight + * 将 render-cell.tsx 拖动功能迁移至 div.column-resize + */ +export function handleCellBorderHighlight(editor: IDomEditor, e: MouseEvent) { + if (e.type === 'mouseenter') { + Transforms.setNodes(editor, { isResizing: true } as TableElement, { mode: 'highest' }) + } else { + Transforms.setNodes(editor, { isResizing: false } as TableElement, { mode: 'highest' }) + } +} + +export function handleCellBorderMouseDown(editor: IDomEditor, elemNode: SlateElement) { + if (isMouseDownForResize) return // 此时正在修改列宽 + editorWhenMouseDown = editor +} diff --git a/packages/table-module/src/module/custom-types.ts b/packages/table-module/src/module/custom-types.ts index 33e73ff91..11360a218 100644 --- a/packages/table-module/src/module/custom-types.ts +++ b/packages/table-module/src/module/custom-types.ts @@ -14,6 +14,9 @@ export type TableCellElement = { rowSpan?: number width?: string // 只作用于第一行(尚未考虑单元格合并!) children: Text[] + + /** 用于设置单元格的 display 属性 */ + hidden?: boolean } export type TableRowElement = { @@ -25,4 +28,12 @@ export type TableElement = { type: 'table' width: string children: TableRowElement[] + + /** resize bar */ + scrollWidth?: number + height?: number // 用于设置 resize-bar 高度 + resizingIndex?: number // 用于标记 resize-bar index + isResizing?: boolean | null // 用于设置 index resize-bar 的 highlight 属性 + isHoverCellBorder?: boolean // 用于设置 index resize-bar 的 visible 属性 + columnWidths?: number[] } diff --git a/packages/table-module/src/module/index.ts b/packages/table-module/src/module/index.ts index 84a25b823..9691b7c9e 100644 --- a/packages/table-module/src/module/index.ts +++ b/packages/table-module/src/module/index.ts @@ -18,6 +18,8 @@ import { deleteTableColConf, tableHeaderMenuConf, tableFullWidthMenuConf, + mergeTableCellConf, + splitTableCellConf, } from './menu/index' const table: Partial = { @@ -34,6 +36,8 @@ const table: Partial = { deleteTableColConf, tableHeaderMenuConf, tableFullWidthMenuConf, + mergeTableCellConf, + splitTableCellConf, ], editorPlugin: withTable, } diff --git a/packages/table-module/src/module/menu/DeleteCol.ts b/packages/table-module/src/module/menu/DeleteCol.ts index b907e9723..e1def4780 100644 --- a/packages/table-module/src/module/menu/DeleteCol.ts +++ b/packages/table-module/src/module/menu/DeleteCol.ts @@ -3,10 +3,11 @@ * @author wangfupeng */ -import isEqual from 'lodash.isequal' -import { Editor, Element, Transforms, Range, Node } from 'slate' +import { Editor, Transforms, Range, Node, Path } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor-next/core' import { DEL_COL_SVG } from '../../constants/svg' +import { filledMatrix } from '../../utils' +import { TableCellElement, TableElement } from '../custom-types' class DeleteCol implements IButtonMenu { readonly title = t('tableModule.deleteCol') @@ -58,25 +59,79 @@ class DeleteCol implements IButtonMenu { const tableNode = DomEditor.getParentNode(editor, rowNode) if (tableNode == null) return - // 遍历所有 rows ,挨个删除 cell - const rows = tableNode.children || [] - rows.forEach(row => { - if (!Element.isElement(row)) return - - const cells = row.children || [] - // 遍历一个 row 的所有 cells - cells.forEach((cell: Node) => { - const path = DomEditor.findPath(editor, cell) - if ( - path.length === selectedCellPath.length && - isEqual(path.slice(-1), selectedCellPath.slice(-1)) // 俩数组,最后一位相同 - ) { - // 如果当前 td 的 path 和选中 td 的 path ,最后一位相同,说明是同一列 - // 删除当前的 cell - Transforms.removeNodes(editor, { at: path }) + const matrix = filledMatrix(editor) + let tdIndex = 0 + out: for (let x = 0; x < matrix.length; x++) { + for (let y = 0; y < matrix[x].length; y++) { + const [[, path]] = matrix[x][y] + + if (Path.equals(selectedCellPath, path)) { + tdIndex = y + break out + } + } + } + + Editor.withoutNormalizing(editor, () => { + for (let x = 0; x < matrix.length; x++) { + const [[{ hidden }], { rtl, ltr }] = matrix[x][tdIndex] + + if (rtl > 1 || ltr > 1) { + // 找到显示中 colSpan 节点 + const [[{ rowSpan = 1, colSpan = 1 }, path]] = matrix[x][tdIndex - (rtl - 1)] + + if (hidden) { + Transforms.setNodes( + editor, + { + rowSpan, + colSpan: Math.max(colSpan - 1, 1), + }, + { at: path } + ) + } else { + const [[, rightPath]] = matrix[x][tdIndex + 1] + Transforms.setNodes( + editor, + { + rowSpan, + colSpan: colSpan - 1, + hidden: false, + }, + { at: rightPath } + ) + // 移动单元格 文本、图片等元素 + for (const [, childPath] of Node.children(editor, path, { reverse: true })) { + Transforms.moveNodes(editor, { + to: [...rightPath, 0], + at: childPath, + }) + } + } } + } + + // 挨个删除 cell + for (let x = 0; x < matrix.length; x++) { + const [[, path]] = matrix[x][tdIndex] + Transforms.removeNodes(editor, { at: path }) + } + + // 需要调整 columnWidths + const [tableEntry] = Editor.nodes(editor, { + match: n => DomEditor.checkNodeType(n, 'table'), + universal: true, + }) + const [elemNode, tablePath] = tableEntry + const { columnWidths = [] } = elemNode as TableElement + const adjustColumnWidths = [...columnWidths] + adjustColumnWidths.splice(tdIndex, 1) + + Transforms.setNodes(editor, { columnWidths: adjustColumnWidths } as TableElement, { + at: tablePath, }) }) + } } diff --git a/packages/table-module/src/module/menu/DeleteRow.ts b/packages/table-module/src/module/menu/DeleteRow.ts index 250c8a5af..029e418be 100644 --- a/packages/table-module/src/module/menu/DeleteRow.ts +++ b/packages/table-module/src/module/menu/DeleteRow.ts @@ -3,9 +3,11 @@ * @author wangfupeng */ -import { Editor, Transforms, Range } from 'slate' +import { Editor, Transforms, Range, Path, Node } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor-next/core' import { DEL_ROW_SVG } from '../../constants/svg' +import { filledMatrix } from '../../utils' +import { TableCellElement } from '../custom-types' class DeleteRow implements IButtonMenu { readonly title = t('tableModule.deleteRow') @@ -53,7 +55,67 @@ class DeleteRow implements IButtonMenu { } // row > 1 行,则删掉这一行 - Transforms.removeNodes(editor, { at: rowPath }) + const [cellEntry] = Editor.nodes(editor, { + match: n => DomEditor.checkNodeType(n, 'table-cell'), + universal: true, + }) + const [, cellPath] = cellEntry + const matrix = filledMatrix(editor) + let trIndex = 0 + outer: for (let x = 0; x < matrix.length; x++) { + for (let y = 0; y < matrix[x].length; y++) { + const [[, path]] = matrix[x][y] + if (!Path.equals(cellPath, path)) { + continue + } + trIndex = x + break outer + } + } + + Editor.withoutNormalizing(editor, () => { + for (let y = 0; y < matrix[trIndex].length; y++) { + const [[{ hidden }], { ttb, btt }] = matrix[trIndex][y] + + // 寻找跨行行为 + if (ttb > 1 || btt > 1) { + // 找到显示中 rowSpan 节点 + const [[{ rowSpan = 1, colSpan = 1 }, path]] = matrix[trIndex - (ttb - 1)][y] + // 如果当前选中节点为隐藏节点,则向上寻找处理 rowSpan 逻辑 + if (hidden) { + Transforms.setNodes( + editor, + { + rowSpan: Math.max(rowSpan - 1, 1), + colSpan, + }, + { at: path } + ) + } else { + const [[, belowPath]] = matrix[trIndex + 1][y] + Transforms.setNodes( + editor, + { + rowSpan: rowSpan - 1, + colSpan, + hidden: false, + }, + { at: belowPath } + ) + + // 移动单元格 文本、图片等元素 + for (const [, childPath] of Node.children(editor, path, { reverse: true })) { + Transforms.moveNodes(editor, { + to: [...belowPath, 0], + at: childPath, + }) + } + } + } + } + + Transforms.removeNodes(editor, { at: rowPath }) + }) } } diff --git a/packages/table-module/src/module/menu/InsertCol.ts b/packages/table-module/src/module/menu/InsertCol.ts index 98781da69..25d3f7fa0 100644 --- a/packages/table-module/src/module/menu/InsertCol.ts +++ b/packages/table-module/src/module/menu/InsertCol.ts @@ -3,12 +3,13 @@ * @author wangfupeng */ -import isEqual from 'lodash.isequal' -import { Editor, Element, Transforms, Range, Node } from 'slate' +import { Editor, Transforms, Range, Node, Path } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor-next/core' import { ADD_COL_SVG } from '../../constants/svg' import { TableCellElement, TableElement } from '../custom-types' import { isTableWithHeader } from '../helpers' +import { isTableWithHeader } from '../helpers' +import { filledMatrix } from '../../utils' class InsertCol implements IButtonMenu { readonly title = t('tableModule.insertCol') @@ -52,29 +53,75 @@ class InsertCol implements IButtonMenu { const tableNode = DomEditor.getParentNode(editor, rowNode) as TableElement if (tableNode == null) return - // 遍历所有 rows ,挨个添加 cell - const rows = tableNode.children || [] - rows.forEach((row, rowIndex) => { - if (!Element.isElement(row)) return - - const cells = row.children || [] - // 遍历一个 row 的所有 cells - cells.forEach((cell: Node) => { - const path = DomEditor.findPath(editor, cell) - if ( - path.length === selectedCellPath.length && - isEqual(path.slice(-1), selectedCellPath.slice(-1)) // 俩数组,最后一位相同 - ) { - // 如果当前 td 的 path 和选中 td 的 path ,最后一位相同,说明是同一列 - // 则在其后插入一个 cell - const newCell: TableCellElement = { type: 'table-cell', children: [{ text: '' }] } - if (rowIndex === 0 && isTableWithHeader(tableNode)) { - newCell.isHeader = true + const matrix = filledMatrix(editor) + let tdIndex = 0 + out: for (let x = 0; x < matrix.length; x++) { + for (let y = 0; y < matrix[x].length; y++) { + const [[, path]] = matrix[x][y] + + if (Path.equals(selectedCellPath, path)) { + tdIndex = y + break out + } + } + } + + Editor.withoutNormalizing(editor, () => { + const exitMerge: number[] = [] + + for (let x = 0; x < matrix.length; x++) { + const [, { ltr, rtl }] = matrix[x][tdIndex] + + // 向左找到 1 元素为止 + if (ltr > 1 || rtl > 1) { + if (rtl == 1) continue + + const [[element, path]] = matrix[x][tdIndex - (rtl - 1)] + const colSpan = element.colSpan || 1 + + exitMerge.push(x) + if (!element.hidden) { + Transforms.setNodes( + editor, + { + colSpan: colSpan + 1, + }, + { at: path } + ) } - Transforms.insertNodes(editor, newCell, { at: path }) } + } + + // 遍历所有 rows ,挨个添加 cell + for (let x = 0; x < matrix.length; x++) { + const newCell: TableCellElement = { + type: 'table-cell', + hidden: exitMerge.includes(x), + children: [{ text: '' }], + } + if (x === 0 && isTableWithHeader(tableNode)) { + newCell.isHeader = true + } + const [[, insertPath]] = matrix[x][tdIndex] + Transforms.insertNodes(editor, newCell, { at: insertPath }) + } + + // 需要调整 columnWidths + const [tableEntry] = Editor.nodes(editor, { + match: n => DomEditor.checkNodeType(n, 'table'), + universal: true, + }) + const [elemNode, tablePath] = tableEntry + const { columnWidths = [] } = elemNode as TableElement + const adjustColumnWidths = [...columnWidths] + adjustColumnWidths.splice(tdIndex, 0, 60) + + Transforms.setNodes(editor, { columnWidths: adjustColumnWidths } as TableElement, { + at: tablePath, }) }) + + } } diff --git a/packages/table-module/src/module/menu/InsertRow.ts b/packages/table-module/src/module/menu/InsertRow.ts index 1f92a1a71..25ee2154e 100644 --- a/packages/table-module/src/module/menu/InsertRow.ts +++ b/packages/table-module/src/module/menu/InsertRow.ts @@ -7,6 +7,7 @@ import { Editor, Transforms, Range, Path } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor-next/core' import { ADD_ROW_SVG } from '../../constants/svg' import { TableRowElement, TableCellElement } from '../custom-types' +import { filledMatrix } from '../../utils' class InsertRow implements IButtonMenu { readonly title = t('tableModule.insertRow') @@ -50,20 +51,65 @@ class InsertRow implements IButtonMenu { const cellsLength = rowNode?.children.length || 0 if (cellsLength === 0) return - // 拼接新的 row - const newRow: TableRowElement = { type: 'table-row', children: [] } - for (let i = 0; i < cellsLength; i++) { - const cell: TableCellElement = { - type: 'table-cell', - children: [{ text: '' }], + const matrix = filledMatrix(editor) + // 向下插入行为,先找到 + // 当前选区所在的 tr 索引 + let trIndex = 0 + outer: for (let x = 0; x < matrix.length; x++) { + for (let y = 0; y < matrix[x].length; y++) { + const [[, path]] = matrix[x][y] + if (!Path.equals(cellPath, path)) { + continue + } + trIndex = x + break outer } - newRow.children.push(cell) } - // 插入 row - const rowPath = Path.parent(cellPath) // 获取 tr 的 path - const newRowPath = Path.next(rowPath) - Transforms.insertNodes(editor, newRow, { at: newRowPath }) + Editor.withoutNormalizing(editor, () => { + // 向下添加 tr 索引 + const destIndex = trIndex + 1 + const isWithinBounds = destIndex >= 0 && destIndex < matrix.length + const exitMerge: number[] = [] + + for (let y = 0; isWithinBounds && y < matrix[trIndex].length; y++) { + const [, { ttb, btt }] = matrix[trIndex][y] + + // 向上找到 1 元素为止 + if (ttb > 1 || btt > 1) { + if (btt == 1) continue + const [[element, path]] = matrix[trIndex - (ttb - 1)][y] + const rowSpan = element.rowSpan || 1 + + exitMerge.push(y) + if (!element.hidden) { + Transforms.setNodes( + editor, + { + rowSpan: rowSpan + 1, + }, + { at: path } + ) + } + } + } + + // 拼接新的 row + const newRow: TableRowElement = { type: 'table-row', children: [] } + for (let i = 0; i < cellsLength; i++) { + const cell: TableCellElement = { + type: 'table-cell', + hidden: exitMerge.includes(i), + children: [{ text: '' }], + } + newRow.children.push(cell) + } + + // 插入 row + const rowPath = Path.parent(cellPath) // 获取 tr 的 path + const newRowPath = Path.next(rowPath) + Transforms.insertNodes(editor, newRow, { at: newRowPath }) + }) } } diff --git a/packages/table-module/src/module/menu/MergeCell.ts b/packages/table-module/src/module/menu/MergeCell.ts new file mode 100644 index 000000000..d4cc01631 --- /dev/null +++ b/packages/table-module/src/module/menu/MergeCell.ts @@ -0,0 +1,126 @@ +import { Editor, Path, Transforms, Node } from 'slate' +import { IButtonMenu, IDomEditor, t } from '@wangeditor/core' +import { MERGE_CELL_SVG } from '../../constants/svg' +import { EDITOR_TO_SELECTION } from '../weak-maps' +import { TableCursor } from '../table-cursor' +import { hasCommon, filledMatrix, isOfType, CellElement } from '../../utils' +import { TableCellElement } from '../custom-types' + +class MergeCell implements IButtonMenu { + readonly title = t('tableModule.mergeCell') + readonly iconSvg = MERGE_CELL_SVG + readonly tag = 'button' + + getValue(editor: IDomEditor): string | boolean { + // 无需获取 val + return '' + } + + isActive(editor: IDomEditor): boolean { + // 无需 active + return false + } + + isDisabled(editor: IDomEditor): boolean { + return !this.canMerge(editor) + } + + exec(editor: IDomEditor, value: string | boolean) { + if (this.isDisabled(editor)) return + + this.merge(editor) + // 释放选区 + TableCursor.unselect(editor) + } + + /** + * Checks if the current selection can be merged. Merging is not possible when any of the following conditions are met: + * - The selection is empty. + * - The selection is not within the same "thead", "tbody," or "tfoot" section. + * @returns {boolean} `true` if the selection can be merged, otherwise `false`. + */ + canMerge(editor: Editor): boolean { + const matrix = EDITOR_TO_SELECTION.get(editor) + + // cannot merge when selection is empty + if (!matrix || !matrix.length) { + return false + } + + // prettier-ignore + const [[, lastPath]] = matrix[matrix.length - 1][matrix[matrix.length - 1].length - 1]; + const [[, firstPath]] = matrix[0][0] + + // cannot merge when selection is not in common section + if (!hasCommon(editor, [firstPath, lastPath], 'table')) { + return false + } + + return true + } + + /** + * Merges the selected cells in the table. + * @returns void + */ + merge(editor: Editor): void { + if (!this.canMerge(editor)) { + return + } + + const selection = EDITOR_TO_SELECTION.get(editor) + + if (!selection || !selection.length) { + return + } + + const [[, basePath]] = selection[0][0] + const [[, lastPath]] = Node.children(editor, basePath, { reverse: true }) + + const matrix = filledMatrix(editor, { at: basePath }) + + Editor.withoutNormalizing(editor, () => { + let rowSpan = 0 + let colSpan = 0 + for (let x = selection.length - 1; x >= 0; x--, rowSpan++) { + colSpan = 0 + for (let y = selection[x].length - 1; y >= 0; y--, colSpan++) { + const [[, path], { ttb }] = selection[x][y] + + // skip first cell and "fake" cells which belong to a cell with a `rowspan` + if (Path.equals(basePath, path) || ttb > 1) { + continue + } + + // prettier-ignore + for (const [, childPath] of Node.children(editor, path, { reverse: true })) { + Transforms.moveNodes(editor, { + to: Path.next(lastPath), + at: childPath, + }); + } + + const [[, trPath]] = Editor.nodes(editor, { + match: isOfType(editor, 'tr'), + at: path, + }) + + const [, sibling] = Node.children(editor, trPath) + + if (sibling) { + /** + * 删除节点调整成隐藏节点 + * 隐藏节点不会影响 table 布局 + */ + Transforms.setNodes(editor, { hidden: true } as TableCellElement, { at: path }) + continue + } + } + } + + Transforms.setNodes(editor, { rowSpan, colSpan }, { at: basePath }) + }) + } +} + +export default MergeCell diff --git a/packages/table-module/src/module/menu/SplitCell.ts b/packages/table-module/src/module/menu/SplitCell.ts new file mode 100644 index 000000000..03ee863d9 --- /dev/null +++ b/packages/table-module/src/module/menu/SplitCell.ts @@ -0,0 +1,148 @@ +import { Editor, Path, Transforms } from 'slate' +import { IButtonMenu, IDomEditor, t } from '@wangeditor/core' +import { SPLIT_CELL_SVG } from '../../constants/svg' +import { EDITOR_TO_SELECTION } from '../weak-maps' +import { filledMatrix, isOfType, CellElement } from '../../utils' +// import { DEFAULT_WITH_TABLE_OPTIONS } from "../../utils/options"; + +class SplitCell implements IButtonMenu { + readonly title = t('tableModule.splitCell') + readonly iconSvg = SPLIT_CELL_SVG + readonly tag = 'button' + + getValue(editor: IDomEditor): string | boolean { + // 无需获取 val + return '' + } + + isActive(editor: IDomEditor): boolean { + // 无需 active + return false + } + + isDisabled(editor: IDomEditor): boolean { + const [td] = Editor.nodes(editor, { + match: isOfType(editor, 'td'), + }) + + const [{ rowSpan = 1, colSpan = 1 }] = td as CellElement[] + + if (rowSpan > 1 || colSpan > 1) { + return false + } + + return true + } + + exec(editor: IDomEditor, value: string | boolean) { + if (this.isDisabled(editor)) return + + this.split(editor) + } + + /** + * Splits either the cell at the current selection or a specified location. If a range + * selection is present, all cells within the range will be split. + * @param {Location} [options.at] - Splits the cell at the specified location. If no + * location is specified it will split the cell at the current selection + * @param {boolean} [options.all] - If true, splits all cells in the table + * @returns void + */ + split(editor: Editor, options: { at?: Location; all?: boolean } = {}): void { + const [table, td] = Editor.nodes(editor, { + match: isOfType(editor, 'table', 'th', 'td'), + // @ts-ignore + at: options.at, + }) + + if (!table || !td) { + return + } + + const selection = EDITOR_TO_SELECTION.get(editor) || [] + // @ts-ignore + const matrix = filledMatrix(editor, { at: options.at }) + + // const { blocks } = DEFAULT_WITH_TABLE_OPTIONS; + + Editor.withoutNormalizing(editor, () => { + for (let x = matrix.length - 1; x >= 0; x--) { + for (let y = matrix[x].length - 1; y >= 0; y--) { + const [[, path], context] = matrix[x][y] + const { ltr: colSpan, rtl, btt: rowSpan, ttb } = context + + if (rtl > 1) { + // get to the start of the colspan + y -= rtl - 2 + continue + } + + if (ttb > 1) { + continue + } + + if (rowSpan === 1 && colSpan === 1) { + continue + } + + let found = !!options.all + + if (selection.length) { + outer: for (let i = 0; !options.all && i < selection.length; i++) { + for (let j = 0; j < selection[i].length; j++) { + const [[, tdPath]] = selection[i][j] + + if (Path.equals(tdPath, path)) { + found = true + break outer + } + } + } + } else { + const [, tdPath] = td + if (Path.equals(tdPath, path)) { + found = true + } + } + + if (!found) { + continue + } + + const [[section]] = Editor.nodes(editor, { + match: isOfType(editor, 'table'), + at: path, + }) + + out: for (let r = 1; r < rowSpan; r++) { + for (let i = y; i >= 0; i--) { + const [[, path], { ttb }] = matrix[x + r][i] + + if (ttb == 1) { + continue + } + + for (let c = 0; c < colSpan; c++) { + let [[, nextPath]] = matrix[x + r][i + c] + + Transforms.unsetNodes(editor, ['hidden', 'colSpan', 'rowSpan'], { at: nextPath }) + } + continue out + } + + } + + for (let c = 1; c < colSpan; c++) { + let [[, nextPath]] = matrix[x][y + c] + + Transforms.unsetNodes(editor, ['hidden', 'colSpan', 'rowSpan'], { at: nextPath }) + } + + Transforms.setNodes(editor, { rowSpan: 1, colSpan: 1 }, { at: path }) + } + } + }) + } +} + +export default SplitCell diff --git a/packages/table-module/src/module/menu/index.ts b/packages/table-module/src/module/menu/index.ts index 63a71236e..661cd7032 100644 --- a/packages/table-module/src/module/menu/index.ts +++ b/packages/table-module/src/module/menu/index.ts @@ -11,6 +11,8 @@ import InsertCol from './InsertCol' import DeleteCol from './DeleteCol' import TableHander from './TableHeader' import FullWidth from './FullWidth' +import MergeCell from './MergeCell' +import SplitCell from './SplitCell' export const insertTableMenuConf = { key: 'insertTable', @@ -67,3 +69,18 @@ export const tableFullWidthMenuConf = { return new FullWidth() }, } + +/** Meger / Split conf */ +export const mergeTableCellConf = { + key: 'mergeTableCell', + factory() { + return new MergeCell() + }, +} + +export const splitTableCellConf = { + key: 'splitTableCell', + factory() { + return new SplitCell() + }, +} diff --git a/packages/table-module/src/module/plugin.ts b/packages/table-module/src/module/plugin.ts index c54fce55b..874f96f8f 100644 --- a/packages/table-module/src/module/plugin.ts +++ b/packages/table-module/src/module/plugin.ts @@ -16,6 +16,7 @@ import { Path, } from 'slate' import { IDomEditor, DomEditor } from '@wangeditor-next/core' +import { withSelection } from './with-selection' // table cell 内部的删除处理 function deleteHandler(newEditor: IDomEditor): boolean { @@ -221,6 +222,12 @@ function withTable(editor: T): T { newEditor.select(newSelection) // 选中 table-cell 内部的全部文字 } + + /** + * 光标选区行为新增 + */ + withSelection(newEditor) + // 可继续修改其他 newEditor API ... // 返回 editor ,重要! diff --git a/packages/table-module/src/module/render-elem/render-cell.tsx b/packages/table-module/src/module/render-elem/render-cell.tsx index be0b3aa7e..b8a3d6ba5 100644 --- a/packages/table-module/src/module/render-elem/render-cell.tsx +++ b/packages/table-module/src/module/render-elem/render-cell.tsx @@ -3,73 +3,13 @@ * @author wangfupeng */ -import throttle from 'lodash.throttle' -import { Element as SlateElement, Transforms, Location } from 'slate' + +import { Element as SlateElement } from 'slate' import { jsx, VNode } from 'snabbdom' -import { IDomEditor, DomEditor } from '@wangeditor-next/core' +import { IDomEditor } from '@wangeditor-next/core' import { TableCellElement } from '../custom-types' import { isCellInFirstRow } from '../helpers' -import $ from '../../utils/dom' - -// 拖拽列宽相关信息 -let isMouseDownForResize = false -let clientXWhenMouseDown = 0 -let cellWidthWhenMouseDown = 0 -let cellPathWhenMouseDown: Location | null = null -let editorWhenMouseDown: IDomEditor | null = null -const $body = $('body') - -function onMouseDown(event: Event) { - const elem = event.target as HTMLElement - if (elem.tagName !== 'TH' && elem.tagName !== 'TD') return - - if (elem.style.cursor !== 'col-resize') return - elem.style.cursor = 'auto' - - event.preventDefault() - - // 记录必要信息 - isMouseDownForResize = true - const { clientX } = event as MouseEvent - clientXWhenMouseDown = clientX - const { width } = elem.getBoundingClientRect() - cellWidthWhenMouseDown = width - - // 绑定事件 - $body.on('mousemove', onMouseMove) - $body.on('mouseup', onMouseUp) -} -$body.on('mousedown', onMouseDown) // 绑定事件 - -function onMouseUp(event: Event) { - isMouseDownForResize = false - editorWhenMouseDown = null - cellPathWhenMouseDown = null - - // 解绑事件 - $body.off('mousemove', onMouseMove) - $body.off('mouseup', onMouseUp) -} - -const onMouseMove = throttle(function (event: Event) { - if (!isMouseDownForResize) return - if (editorWhenMouseDown == null || cellPathWhenMouseDown == null) return - event.preventDefault() - - const { clientX } = event as MouseEvent - let newWith = cellWidthWhenMouseDown + (clientX - clientXWhenMouseDown) // 计算新宽度 - newWith = Math.floor(newWith * 100) / 100 // 保留小数点后两位 - if (newWith < 30) newWith = 30 // 最小宽度 - - // 这是宽度 - Transforms.setNodes( - editorWhenMouseDown, - { width: newWith.toString() }, - { - at: cellPathWhenMouseDown, - } - ) -}, 100) +import { TableCursor } from '../table-cursor' function renderTableCell( cellNode: SlateElement, @@ -77,12 +17,23 @@ function renderTableCell( editor: IDomEditor ): VNode { const isFirstRow = isCellInFirstRow(editor, cellNode as TableCellElement) - const { colSpan = 1, rowSpan = 1, isHeader = false } = cellNode as TableCellElement + const { colSpan = 1, rowSpan = 1, isHeader = false, hidden = false, } = cellNode as TableCellElement + const selected = TableCursor.isSelected(editor, cellNode) + // ------------------ 不是第一行,直接渲染 ------------------ if (!isFirstRow) { return ( - + {children} ) @@ -95,33 +46,15 @@ function renderTableCell( left + width - 5 && clientX < left + width // X 轴,是否接近 cell 右侧? - const matchY = clientY > top && clientY < top + height // Y 轴,是否在 cell 之内 - // X Y 轴都接近,则修改鼠标样式 - if (matchX && matchY) { - elem.style.cursor = 'col-resize' - editorWhenMouseDown = editor - cellPathWhenMouseDown = DomEditor.findPath(editor, cellNode) - } else { - if (!isMouseDownForResize) { - elem.style.cursor = 'auto' - editorWhenMouseDown = null - cellPathWhenMouseDown = null - } - } - }, 100), - }} + /** + * 1. 添加一个方便寻址的 block-type + * 2. 选区颜色 + * 3. 合并单元格时,判断隐藏 + */ + data-block-type="table-cell" + className={selected ? 'w-e-selected' : ''} + style={{ display: hidden ? 'none' : '' }} > {children} diff --git a/packages/table-module/src/module/render-elem/render-table.tsx b/packages/table-module/src/module/render-elem/render-table.tsx index 19d0ab503..f22c1a88f 100644 --- a/packages/table-module/src/module/render-elem/render-table.tsx +++ b/packages/table-module/src/module/render-elem/render-table.tsx @@ -3,11 +3,20 @@ * @author wangfupeng */ +import debounce from 'lodash.debounce' import { Editor, Element as SlateElement, Range, Point, Path } from 'slate' -import { jsx, VNode } from 'snabbdom' +import { h, jsx, VNode } from 'snabbdom' import { IDomEditor, DomEditor } from '@wangeditor-next/core' import { TableElement } from '../custom-types' -import { getFirstRowCells } from '../helpers' +import { TableCursor } from '../table-cursor' +import { + observerTableResize, + unObserveTableResize, + handleCellBorderVisible, + handleCellBorderHighlight, + handleCellBorderMouseDown, + getColumnWidthRatios, +} from '../column-resize' /** * 计算 table 是否可编辑。如果选区跨域 table 和外部内容,删除,会导致 table 结构打乱。所以,有时要让 table 不可编辑 @@ -46,13 +55,23 @@ function renderTable(elemNode: SlateElement, children: VNode[] | null, editor: I const editable = getContentEditable(editor, elemNode) // 宽度 - const { width = 'auto' } = elemNode as TableElement - - // 是否选中 + const { + width: tableWidth = 'auto', + height, + columnWidths = [], + scrollWidth = 0, + isHoverCellBorder, + resizingIndex, + isResizing, + } = elemNode as TableElement + + // 光标是否选中 const selected = DomEditor.isNodeSelected(editor, elemNode) + // 光标是否有选区 + const [isSelecting] = TableCursor.selection(editor) + // 列宽之间比值 + const columnWidthRatios = getColumnWidthRatios(columnWidths) - // 第一行的 cells ,以计算列宽 - const firstRowCells = getFirstRowCells(elemNode as TableElement) const vnode = (
- +
a + b, 0) + 'px') }} + on={{ + mousemove: debounce((e: MouseEvent) => handleCellBorderVisible(editor, elemNode, e), 25), + }} + > - {firstRowCells.map(cell => { - const { width = 'auto' } = cell - return - })} + { + /** + * 剔除 firstRowCells,因单元格合并 表头 th,会计算错误。 + * 使用 columnWidth 数组长度代表列数 + * 拖动行为及变量设置均参考 飞书 + */ + columnWidths.map(width => { + return + })} {children}
+ +
+ {columnWidths.map((width, index) => { + let minWidth = width + /** + * table width 为 100% 模式时 + * columnWidths 表示的是比例 + * 1. 需要计算出真实的宽度 + */ + if (tableWidth == '100%') { + minWidth = columnWidthRatios[index] * scrollWidth + } + + return ( +
+
handleCellBorderHighlight(editor, e), + mouseleave: (e: MouseEvent) => handleCellBorderHighlight(editor, e), + mousedown: (e: MouseEvent) => handleCellBorderMouseDown(editor, elemNode), + }} + > +
+
+
+ ) + })} +
+
) - return vnode + + /** + * 移出直接返回 vnode + * 添加 ObserverResize 监听行为 + * 监听 table 内部变化,更新 table resize-bar 高度 + */ + const containerVnode = h( + 'div', + { + hook: { + insert: ({ elm }: VNode) => observerTableResize(editor, elm), + destroy: () => { + unObserveTableResize() + }, + }, + }, + vnode + ) + return containerVnode } export default renderTable diff --git a/packages/table-module/src/module/table-cursor.ts b/packages/table-module/src/module/table-cursor.ts new file mode 100644 index 000000000..1e111d622 --- /dev/null +++ b/packages/table-module/src/module/table-cursor.ts @@ -0,0 +1,90 @@ +import { + Editor, + Element, + Location, + Node, + NodeEntry, + Operation, + Path, + Point, + Range, + Transforms, +} from 'slate' +import { EDITOR_TO_SELECTION, EDITOR_TO_SELECTION_SET } from './weak-maps' +import { isOfType } from '../utils' + +export const TableCursor = { + /** @returns {boolean} `true` if the selection is inside a table, otherwise `false`. */ + isInTable(editor: Editor, options: { at?: Location } = {}): boolean { + const [table] = Editor.nodes(editor, { + match: isOfType(editor, 'table'), + at: options.at, + }) + + return !!table + }, + /** + * Retrieves a matrix representing the selected cells within a table. + * @returns {NodeEntry[][]} A matrix containing the selected cells. + */ + *selection(editor: Editor): Generator { + const matrix = EDITOR_TO_SELECTION.get(editor) + for (let x = 0; matrix && x < matrix.length; x++) { + const cells: NodeEntry[] = [] + for (let y = 0; y < matrix[x].length; y++) { + const [entry, { ltr: colSpan, ttb }] = matrix[x][y] + + ttb === 1 && cells.push(entry) + + y += colSpan - 1 + } + + yield cells + } + }, + /** Clears the selection from the table */ + unselect(editor: Editor): void { + // const matrix = EDITOR_TO_SELECTION.get(editor); + + // if (!matrix?.length) { + // return; + // } + + // for (let x = 0; x < matrix.length; x++) { + // for (let y = 0; y < matrix[x].length; y++) { + // const [[, path], { ltr: colSpan, ttb }] = matrix[x][y]; + // y += colSpan - 1; + + // if (ttb > 1) { + // continue; + // } + + // // no-op since the paths are the same + // const noop: Operation = { + // type: "move_node", + // newPath: path, + // path: path, + // }; + // Transforms.transform(editor, noop); + // } + // } + + EDITOR_TO_SELECTION_SET.delete(editor) + EDITOR_TO_SELECTION.delete(editor) + // 清除选区 + document.getSelection()?.removeAllRanges() + }, + /** + * Checks whether a given cell is part of the current table selection. + * @returns {boolean} - Returns true if the cell is selected, otherwise false. + */ + isSelected(editor: Editor, element: T): boolean { + const selectedElements = EDITOR_TO_SELECTION_SET.get(editor) + + if (!selectedElements) { + return false + } + + return selectedElements.has(element) + }, +} diff --git a/packages/table-module/src/module/weak-maps.ts b/packages/table-module/src/module/weak-maps.ts new file mode 100644 index 000000000..1f2b47108 --- /dev/null +++ b/packages/table-module/src/module/weak-maps.ts @@ -0,0 +1,8 @@ +import { Editor, Element } from 'slate' +import { NodeEntryWithContext } from '../utils' + +/** Weak reference between the `Editor` and the selected elements */ +export const EDITOR_TO_SELECTION = new WeakMap() + +/** Weak reference between the `Editor` and a set of the selected elements */ +export const EDITOR_TO_SELECTION_SET = new WeakMap>() diff --git a/packages/table-module/src/module/with-selection.ts b/packages/table-module/src/module/with-selection.ts new file mode 100644 index 000000000..c6ccf3858 --- /dev/null +++ b/packages/table-module/src/module/with-selection.ts @@ -0,0 +1,120 @@ +import { Editor, Element, Operation, Path, Range } from 'slate' +import { TableCursor } from './table-cursor' +import { EDITOR_TO_SELECTION, EDITOR_TO_SELECTION_SET } from './weak-maps' +import { Point, filledMatrix, hasCommon, isOfType, NodeEntryWithContext } from '../utils' + +export function withSelection(editor: T) { + const { apply } = editor + + editor.apply = (op: Operation): void => { + if (!Operation.isSelectionOperation(op) || !op.newProperties) { + // TableCursor.unselect(editor); + // 仿飞书效果,拖动单元格宽度时,选区不消失 + return apply(op) + } + + const selection = { + ...editor.selection, + ...op.newProperties, + } + + if (!Range.isRange(selection)) { + TableCursor.unselect(editor) + return apply(op) + } + + const [fromEntry] = Editor.nodes(editor, { + match: isOfType(editor, 'th', 'td'), + at: Range.start(selection), + }) + + const [toEntry] = Editor.nodes(editor, { + match: isOfType(editor, 'th', 'td'), + at: Range.end(selection), + }) + + if (!fromEntry || !toEntry) { + TableCursor.unselect(editor) + return apply(op) + } + + const [, fromPath] = fromEntry + const [, toPath] = toEntry + + if (Path.equals(fromPath, toPath) || !hasCommon(editor, [fromPath, toPath], 'table')) { + TableCursor.unselect(editor) + return apply(op) + } + + // TODO: perf: could be improved by passing a Span [fromPath, toPath] + const filled = filledMatrix(editor, { at: fromPath }) + + // find initial bounds + const from = Point.valueOf(0, 0) + const to = Point.valueOf(0, 0) + outer: for (let x = 0; x < filled.length; x++) { + for (let y = 0; y < filled[x].length; y++) { + const [[, path]] = filled[x][y] + + if (Path.equals(fromPath, path)) { + from.x = x + from.y = y + } + + if (Path.equals(toPath, path)) { + to.x = x + to.y = y + break outer + } + } + } + + let start = Point.valueOf(Math.min(from.x, to.x), Math.min(from.y, to.y)) + let end = Point.valueOf(Math.max(from.x, to.x), Math.max(from.y, to.y)) + + // expand the selection based on rowspan and colspan + for (;;) { + const nextStart = Point.valueOf(start.x, start.y) + const nextEnd = Point.valueOf(end.x, end.y) + + for (let x = nextStart.x; x <= nextEnd.x; x++) { + for (let y = nextStart.y; y <= nextEnd.y; y++) { + const [, { rtl, ltr, btt, ttb }] = filled[x][y] + + nextStart.x = Math.min(nextStart.x, x - (ttb - 1)) + nextStart.y = Math.min(nextStart.y, y - (rtl - 1)) + + nextEnd.x = Math.max(nextEnd.x, x + (btt - 1)) + nextEnd.y = Math.max(nextEnd.y, y + (ltr - 1)) + } + } + + if (Point.equals(start, nextStart) && Point.equals(end, nextEnd)) { + break + } + + start = nextStart + end = nextEnd + } + + const selected: NodeEntryWithContext[][] = [] + const selectedSet = new WeakSet() + + for (let x = start.x; x <= end.x; x++) { + const cells: NodeEntryWithContext[] = [] + for (let y = start.y; y <= end.y; y++) { + const [[element]] = filled[x][y] + selectedSet.add(element) + cells.push(filled[x][y]) + } + selected.push(cells) + } + + EDITOR_TO_SELECTION.set(editor, selected) + EDITOR_TO_SELECTION_SET.set(editor, selectedSet) + + apply(op) + } + + return editor +} diff --git a/packages/table-module/src/utils/has-common.ts b/packages/table-module/src/utils/has-common.ts new file mode 100644 index 000000000..d7a793612 --- /dev/null +++ b/packages/table-module/src/utils/has-common.ts @@ -0,0 +1,26 @@ +import { Editor, Node, Span } from 'slate' +import { WithTableOptions } from './options' +import { isOfType } from './is-of-type' + +/** + * Determines whether two paths belong to the same types by checking + * if they share a common ancestor node of type table + */ +export function hasCommon( + editor: Editor, + [path, another]: Span, + ...types: Array +) { + const [node, commonPath] = Node.common(editor, path, another) + + if (isOfType(editor, ...types)(node, commonPath)) { + return true + } + + // Warning: returns the common ancestor but will return `undefined` if the + // `commonPath` is equal to the specified types path + return !!Editor.above(editor, { + match: isOfType(editor, ...types), + at: commonPath, + }) +} diff --git a/packages/table-module/src/utils/index.ts b/packages/table-module/src/utils/index.ts new file mode 100644 index 000000000..471ac8cdf --- /dev/null +++ b/packages/table-module/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from './has-common' +export * from './is-of-type' +export * from './matrices' +export * from './point' +export * from './types' diff --git a/packages/table-module/src/utils/is-of-type.ts b/packages/table-module/src/utils/is-of-type.ts new file mode 100644 index 000000000..bd47d6b59 --- /dev/null +++ b/packages/table-module/src/utils/is-of-type.ts @@ -0,0 +1,18 @@ +import { Editor, Element, Node, NodeMatch } from 'slate' +import { WithType } from './types' +import { WithTableOptions, DEFAULT_WITH_TABLE_OPTIONS } from './options' + +export function isElement(node: Node): node is WithType { + return !Editor.isEditor(node) && Element.isElement(node) && 'type' in node +} + +/** @returns a `NodeMatch` function which is used to match the elements of a specific `type`. */ +export function isOfType>( + editor: Editor, + ...types: Array +): NodeMatch { + const options = DEFAULT_WITH_TABLE_OPTIONS, + elementTypes = types.map(type => options?.blocks?.[type]) + + return (node: Node): boolean => isElement(node) && elementTypes.includes(node.type as any) +} diff --git a/packages/table-module/src/utils/matrices.ts b/packages/table-module/src/utils/matrices.ts new file mode 100644 index 000000000..efacfc0f0 --- /dev/null +++ b/packages/table-module/src/utils/matrices.ts @@ -0,0 +1,88 @@ +import { Editor, Location, NodeEntry } from 'slate' +import { NodeEntryWithContext, CellElement } from './types' +import { isOfType } from './is-of-type' + +/** Generates a matrix for each table section (`thead`, `tbody`, `tfoot`) */ +export function* matrices( + editor: Editor, + options: { at?: Location } = {} +): Generator[][]> { + const [table] = Editor.nodes(editor, { + match: isOfType(editor, 'table'), + at: options.at, + }) + + if (!table) { + return [] + } + + const [, tablePath] = table + + for (const [, path] of Editor.nodes(editor, { + // match: isOfType(editor, "thead", "tbody", "tfoot"), + match: isOfType(editor, 'table'), + at: tablePath, + })) { + const matrix: NodeEntry[][] = [] + + for (const [, trPath] of Editor.nodes(editor, { + match: isOfType(editor, 'tr'), + at: path, + })) { + matrix.push([ + ...Editor.nodes(editor, { + match: isOfType(editor, 'th', 'td'), + at: trPath, + }), + ]) + } + + yield matrix + } +} + +export function filledMatrix( + editor: Editor, + options: { at?: Location } = {} +): NodeEntryWithContext[][] { + const filled: NodeEntryWithContext[][] = [] + + // Expand each section separately to avoid sections collapsing into each other. + for (const matrix of matrices(editor, { at: options.at })) { + const filledSection: NodeEntryWithContext[][] = [] + + for (let x = 0; x < matrix.length; x++) { + if (!filledSection[x]) { + filledSection[x] = [] + } + + for (let y = 0; y < matrix[x].length; y++) { + const [{ rowSpan = 1, colSpan = 1 }] = matrix[x][y] + + for (let c = 0, occupied = 0; c < colSpan + occupied; c++) { + for (let r = 0; r < rowSpan; r++) { + if (!filledSection[x + r]) { + filledSection[x + r] = [] + } + if (filledSection[x + r][y + c]) { + continue + } + filledSection[x + r][y + c] = [ + matrix[x + r][y + c], + { + rtl: c - occupied + 1, + ltr: colSpan - c + occupied, + ttb: r + 1, + btt: rowSpan - r, + }, + ] + } + } + } + } + + filled.push(...filledSection) + } + + return filled +} diff --git a/packages/table-module/src/utils/options.ts b/packages/table-module/src/utils/options.ts new file mode 100644 index 000000000..ca640cac0 --- /dev/null +++ b/packages/table-module/src/utils/options.ts @@ -0,0 +1,29 @@ +import { CustomTypes, ExtendedType } from 'slate' + +type ElementType = ExtendedType<'Element', CustomTypes>['type'] + +export interface WithTableOptions { + blocks: { + td: ElementType + th: ElementType + content: ElementType + tr: ElementType + table: ElementType + tbody: ElementType + tfoot: ElementType + thead: ElementType + } +} + +export const DEFAULT_WITH_TABLE_OPTIONS = { + blocks: { + td: 'table-cell', + th: 'table-cell', + content: 'paragraph', + tr: 'table-row', + table: 'table', + tbody: 'table-body', + // tfoot: "table-footer", + // thead: "table-head", + }, +} diff --git a/packages/table-module/src/utils/point.ts b/packages/table-module/src/utils/point.ts new file mode 100644 index 000000000..c80af781b --- /dev/null +++ b/packages/table-module/src/utils/point.ts @@ -0,0 +1,17 @@ +export class Point { + public x: number + public y: number + + constructor(x: number, y: number) { + this.x = x + this.y = y + } + + public static valueOf(x: number, y: number): Point { + return new this(x, y) + } + + public static equals(point: Point, another: Point): boolean { + return point.x === another.x && point.y === another.y + } +} diff --git a/packages/table-module/src/utils/types.ts b/packages/table-module/src/utils/types.ts new file mode 100644 index 000000000..c890b5b1e --- /dev/null +++ b/packages/table-module/src/utils/types.ts @@ -0,0 +1,22 @@ +import { Element, NodeEntry } from 'slate' + +export type CellElement = WithType< + { rowSpan?: number; colSpan?: number; hidden?: boolean } & Element +> + +/** Extends an element with the "type" property */ +export type WithType = T & Record<'type', unknown> + +export type NodeEntryWithContext = [ + NodeEntry, + { + rtl: number // right-to-left (colspan) + ltr: number // left-to-right (colspan) + ttb: number // top-to-bottom (rowspan) + btt: number // bottom-to-top (rowspan) + } +] + +export type SelectionMode = 'start' | 'end' | 'all' + +export type Edge = 'start' | 'end' | 'top' | 'bottom'