起因
最近接到一个项目,需要基于 Fabric.js 实现一个表格,百度、Bing、谷歌搜索了一番,没有找到合适的,无法直接白嫖,那只能自己撸起袖子干。
原理分析
一个表格由一个个单元格组成,如下图。
从第二列开始,单元格的左边和前一列的单元右边重合; 从第二行开始,单元格的上边和前一行的单元格的底边重合; 那么一个障眼法的表格就出来了。
任务拆解
实现单元格
在 Fabric.js 内,单元格可以用一个 Group
包住 Rect
和 Textbox
来实现。其中单元格的边框由Rect
的边框实现,单元格里面文本由 Textbox
实现。当双击单元格时,使 Textbox
进入编辑模式。
// table.js
import { fabric } from 'fabric'
// 定义自定义的单元格类
fabric.TableCell = fabric.util.createClass(fabric.Group, {
type: 'tableCell',
text: null,
rect: null,
initialize: function (rectOptions, textOptions, text) {
this.rect = new fabric.Rect(rectOptions)
this.text = new fabric.Textbox(text, {
...textOptions,
selectable: false,
})
this.on('mousedblclick', () => {
this.selectable = false
this.text.selectable = true
this.canvas.setActiveObject(this.text)
this.text.enterEditing()
})
this.on('mousedown', () => {
// this.rect.set('fill', 'green')
// this.canvas.requestRenderAll()
})
this.text.on('editing:exited', () => {
this.text.selectable = false
this.selectable = true
})
this.callSuper('initialize', [this.rect, this.text], {
subTargetCheck: true,
objectCaching: false
})
},
toObject: function () {
return fabric.util.object.extend(this.callSuper('toObject'), {
text: this.text.get('text')
})
}
})
实现表格
一个表格由一个 Group
包裹 rowHeights.length
* columnWidths.length
个 TableCell
组成。
由于每一列的宽度会不一样,所以要算出下一列的 left
值;同理每一行的高度也会不一样,同样需要算出下一行所有单元格的 top
值
由于会有单元格合并的情况存在,所以用 ignore
记住哪些单元格是不用渲染的。
// table.js
fabric.Table = fabric.util.createClass(fabric.Group, {
type: 'table',
borderWidth: 4, // 单元格边框宽度
rowHeights: [], // 行高,每一行的高度可以自由配置
columnWidths: [], // 列宽,每一列的宽度可以自由配置
cells: [], // 每一个单元格。比较重要的字段有 row, col 分别表示行、列的索引,从 0 开始,rowSpan 代表单元格横跨的行数,colSpan 代表单元格横跨的列数,content 代表单元格内的文字
initialize: function (tableOptions) {
const { left, top, borderWidth, rowHeights, columnWidths, cells } =
tableOptions
const rectOptions = {
rx: 0,
stroke: '#000',
fill: 'transparent',
shadow: 0,
strokeWidth: +borderWidth,
strokeUniform: true
}
const textOptions = {
lineHeight: 1,
fontSize: 20,
stroke: '#000',
fill: '#000',
selectable: false,
textAlign: 'center',
editingBorderColor: '#FF9002'
}
const ignore = []
const cellCmps = []
let totalH = 0
for (let i = 0; i < rowHeights.length; i++) {
let totalW = 0
for (let j = 0; j < columnWidths.length; j++) {
let {
rowSpan = 0,
colSpan = 0,
content
} = cells.find((cell) => cell.row == i && cell.col == j) || {}
const height = rowHeights[i]
const width = columnWidths[j]
rowSpan = Math.min(rowHeights.length - 1, rowSpan)
colSpan = Math.min(columnWidths.length - 1, colSpan)
let w = width
let h = height
let rowSpanHeight = 0
let colSpanHeight = 0
//@TODO 以下 3 个 for 有很大的优化空间。
for (let rs = 1; rs <= rowSpan; rs++) {
rowSpanHeight += rowHeights[i + rs]
ignore.push(`${i + rs}:${j}`)
}
for (let rs = 1; rs <= colSpan; rs++) {
colSpanHeight += columnWidths[j + rs]
ignore.push(`${i}:${j + rs}`)
}
if (rowSpan > 0 && colSpan > 0) {
for (let r = 1; r <= rowSpan; r++) {
for (let c = 1; c <= colSpan; c++) {
ignore.push(`${i + r}:${j + c}`)
}
}
}
h += rowSpanHeight
w += colSpanHeight
if (ignore.includes(`${i}:${j}`)) {
totalW += width
continue
}
const cell = new fabric.TableCell(
{
...rectOptions,
left: totalW + 1,
top: totalH,
width: w,
height: h
},
{
...textOptions,
left: totalW + 1 + rectOptions.strokeWidth,
top: totalH + rectOptions.strokeWidth,
width: w,
height: h
},
content || `${i}-${j}`
)
cellCmps.push(cell)
totalW += width
}
totalH += rowHeights[i]
}
this.borderWidth = borderWidth
this.rowHeights = rowHeights
this.columnWidths = columnWidths
this.cells = cells
this.callSuper('initialize', cellCmps, {
subTargetCheck: true,
objectCaching: false,
left,
top
})
},
toObject: function () {
return fabric.util.object.extend(this.callSuper('toObject'), {
borderWidth: this.get('borderWidth'),
rowHeights: this.get('rowHeights'),
columnWidths: this.get('columnWidths'),
cells: this.get('cells')
})
}
})
fabric.Table.fromObject = function (o, callback) {
const options = {
left: o.left,
top: o.top,
rowHeights: o.rowHeights,
columnWidths: o.columnWidths,
borderWidth: o.borderWidth,
cells: o.cells,
}
const newTable = new fabric.Table(options)
callback(newTable)
}
Demo
import { fabric } from 'fabric'
class PcEditor {
canvas;
constructor(canvasId) {
this.init(canvasId);
}
/**
* 初始化画板
* @param {string} canvasId
*/
init(canvasId) {
const canvas = new fabric.Canvas(canvasId, {
stopContextMenu: true,
controlsAboveOverlay: true,
preserveObjectStacking: true,
altSelectionKey: "altKey",
});
this.canvas = canvas;
this.addTable()
}
addTable() {
const options = {
left: 20,
top: 20,
borderWidth: 4,
rowHeights: [80, 120],
columnWidths: [160, 100, 100],
cells: [
{
row: 0,
col: 0,
rowSpan: 0,
colSpan: 2,
content: "遥遥领先"
},
{
row: 0,
col: 1,
rowSpan: 0,
colSpan: 0,
content: ""
},
{
row: 0,
col: 2,
rowSpan: 0,
colSpan: 0,
content: ""
},
{
row: 1,
col: 0,
rowSpan: 0,
colSpan: 0,
content: ""
},
{
row: 1,
col: 1,
rowSpan: 0,
colSpan: 0,
content: ""
},
{
row: 1,
col: 2,
rowSpan: 0,
colSpan: 0,
content: ""
}
]
};
const canvas = this.canvas;
const center = canvas.getCenter();
const table = new fabric.Table(options);
table.set("left", center.left - table.width / 2);
table.set("top", center.top - table.height / 2);
canvas.add(table);
}
}
new PcEditor("canvasBox");