@hprint/plugins 0.0.10 → 0.0.11-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +55 -55
- package/dist/index.mjs +10668 -10171
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/plugins/ActualContentLayoutPlugin.d.ts.map +1 -1
- package/dist/src/plugins/TablePlugin.d.ts +95 -0
- package/dist/src/plugins/TablePlugin.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +21 -11
- package/src/plugins/ActualContentLayoutPlugin.ts +11 -3
- package/src/plugins/TablePlugin.ts +872 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { fabric } from '@hprint/core';
|
|
2
|
+
import type { IEditor, IPluginTempl } from '@hprint/core';
|
|
3
|
+
import { convertSingle, formatOriginValues, getUnit } from '../utils/units';
|
|
4
|
+
|
|
5
|
+
export interface TableColumn {
|
|
6
|
+
title: string;
|
|
7
|
+
field: string;
|
|
8
|
+
width: number | `${number}%`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ITableItem {
|
|
12
|
+
type: 'table';
|
|
13
|
+
key: string;
|
|
14
|
+
title: string;
|
|
15
|
+
columns: TableColumn[];
|
|
16
|
+
data?: Array<Record<string, unknown>>;
|
|
17
|
+
headerHeight?: number;
|
|
18
|
+
rowHeight?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
heightAllocationMode?: 'stretch' | 'grow';
|
|
21
|
+
cellPaddingHorizontal?: number;
|
|
22
|
+
cellPaddingVertical?: number;
|
|
23
|
+
textOverflow?: 'wrap' | 'ellipsis';
|
|
24
|
+
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TableOptions {
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
headerHeight?: number;
|
|
31
|
+
rowHeight?: number;
|
|
32
|
+
heightAllocationMode?: 'stretch' | 'grow';
|
|
33
|
+
borderWidth?: number;
|
|
34
|
+
fontFamily?: string;
|
|
35
|
+
fontSize?: number;
|
|
36
|
+
fontWeight?: string;
|
|
37
|
+
fontStyle?: string;
|
|
38
|
+
underline?: boolean | string;
|
|
39
|
+
linethrough?: boolean | string;
|
|
40
|
+
textOverflow?: 'wrap' | 'ellipsis';
|
|
41
|
+
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
|
42
|
+
lineHeight?: number;
|
|
43
|
+
charSpacing?: number;
|
|
44
|
+
color?: string;
|
|
45
|
+
cellPadding?: number;
|
|
46
|
+
cellPaddingHorizontal?: number;
|
|
47
|
+
cellPaddingVertical?: number;
|
|
48
|
+
data?: Array<Record<string, unknown>>;
|
|
49
|
+
_field_?: string;
|
|
50
|
+
_renderRows?: Array<Record<string, unknown>>;
|
|
51
|
+
_clipContent?: boolean;
|
|
52
|
+
_actualContentLayout?: boolean;
|
|
53
|
+
[key: string]: any;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TableGroup = fabric.Group & {
|
|
57
|
+
extensionType?: string;
|
|
58
|
+
extension?: TableOptions & { columns: TableColumn[] };
|
|
59
|
+
_originSize?: Record<string, any>;
|
|
60
|
+
setExtension?: (fields: Record<string, any>) => Promise<void>;
|
|
61
|
+
setExtensionByUnit?: (fields: Record<string, any>) => Promise<void>;
|
|
62
|
+
setByUnit?: (field: string, value: any) => Promise<any>;
|
|
63
|
+
__tableModified?: () => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type IPlugin = Pick<
|
|
67
|
+
TablePlugin,
|
|
68
|
+
'createTable' | 'initTableEvents' | 'refreshTable'
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
declare module '@hprint/core' {
|
|
72
|
+
interface IEditor extends IPlugin {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let tableCellClipId = 0;
|
|
76
|
+
|
|
77
|
+
type TableCellTextboxInstance = fabric.Textbox & {
|
|
78
|
+
cellClipWidth: number;
|
|
79
|
+
cellClipHeight: number;
|
|
80
|
+
cellClipTopOffset: number;
|
|
81
|
+
cellClipOffsetY: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Keep the cell clipping behavior on the Fabric class itself instead of
|
|
85
|
+
// patching individual Textbox instances. History/loadFromJSON recreates child
|
|
86
|
+
// objects from their serialized type, so an instance-only renderer disappears
|
|
87
|
+
// after undo/redo and wrapped text can leak outside the cell.
|
|
88
|
+
const TableCellTextbox = fabric.util.createClass(fabric.Textbox, {
|
|
89
|
+
type: 'TableCellTextbox',
|
|
90
|
+
|
|
91
|
+
initialize(text: string, options: Record<string, any> = {}) {
|
|
92
|
+
this.callSuper('initialize', text, options);
|
|
93
|
+
this.cellClipWidth = Math.max(1, Number(options.cellClipWidth) || 1);
|
|
94
|
+
this.cellClipHeight = Math.max(1, Number(options.cellClipHeight) || 1);
|
|
95
|
+
this.cellClipTopOffset = Number(options.cellClipTopOffset) || 0;
|
|
96
|
+
this.cellClipOffsetY = Number(options.cellClipOffsetY) || 0;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
_render(ctx: CanvasRenderingContext2D) {
|
|
100
|
+
ctx.save();
|
|
101
|
+
try {
|
|
102
|
+
ctx.beginPath();
|
|
103
|
+
ctx.rect(
|
|
104
|
+
-this.cellClipWidth / 2,
|
|
105
|
+
-this.cellClipHeight / 2 + this.cellClipOffsetY,
|
|
106
|
+
this.cellClipWidth,
|
|
107
|
+
this.cellClipHeight
|
|
108
|
+
);
|
|
109
|
+
ctx.clip();
|
|
110
|
+
this.callSuper('_render', ctx);
|
|
111
|
+
} finally {
|
|
112
|
+
ctx.restore();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
toObject(propertiesToInclude?: string[]) {
|
|
117
|
+
return this.callSuper('toObject', [
|
|
118
|
+
...(propertiesToInclude || []),
|
|
119
|
+
'cellClipWidth',
|
|
120
|
+
'cellClipHeight',
|
|
121
|
+
'cellClipTopOffset',
|
|
122
|
+
'cellClipOffsetY',
|
|
123
|
+
]);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
toSVG(reviver?: (markup: string) => string) {
|
|
127
|
+
const markup = this.callSuper('toSVG', reviver);
|
|
128
|
+
const clipId = `hprint-table-cell-${++tableCellClipId}`;
|
|
129
|
+
const clipLeft = Number(this.left) || 0;
|
|
130
|
+
const clipTop =
|
|
131
|
+
(Number(this.top) || 0) + Number(this.cellClipTopOffset || 0);
|
|
132
|
+
return `<defs><clipPath id="${clipId}" clipPathUnits="userSpaceOnUse"><rect x="${clipLeft}" y="${clipTop}" width="${this.cellClipWidth}" height="${this.cellClipHeight}" /></clipPath></defs><g clip-path="url(#${clipId})">${markup}</g>`;
|
|
133
|
+
},
|
|
134
|
+
}) as any;
|
|
135
|
+
|
|
136
|
+
(fabric as any).TableCellTextbox = TableCellTextbox;
|
|
137
|
+
(fabric as any).TableCellTextbox.fromObject = (
|
|
138
|
+
object: Record<string, any>,
|
|
139
|
+
callback?: (instance: TableCellTextboxInstance) => void
|
|
140
|
+
) => {
|
|
141
|
+
const instance = new TableCellTextbox(
|
|
142
|
+
String(object.text ?? ''),
|
|
143
|
+
object
|
|
144
|
+
) as TableCellTextboxInstance;
|
|
145
|
+
callback?.(instance);
|
|
146
|
+
return instance;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const DEFAULT_OPTIONS: Required<
|
|
150
|
+
Pick<
|
|
151
|
+
TableOptions,
|
|
152
|
+
| 'width'
|
|
153
|
+
| 'height'
|
|
154
|
+
| 'headerHeight'
|
|
155
|
+
| 'rowHeight'
|
|
156
|
+
| 'heightAllocationMode'
|
|
157
|
+
| 'borderWidth'
|
|
158
|
+
| 'fontFamily'
|
|
159
|
+
| 'fontSize'
|
|
160
|
+
| 'fontWeight'
|
|
161
|
+
| 'fontStyle'
|
|
162
|
+
| 'underline'
|
|
163
|
+
| 'linethrough'
|
|
164
|
+
| 'textOverflow'
|
|
165
|
+
| 'textAlign'
|
|
166
|
+
| 'lineHeight'
|
|
167
|
+
| 'charSpacing'
|
|
168
|
+
| 'color'
|
|
169
|
+
| 'cellPadding'
|
|
170
|
+
| 'cellPaddingHorizontal'
|
|
171
|
+
| 'cellPaddingVertical'
|
|
172
|
+
>
|
|
173
|
+
> = {
|
|
174
|
+
width: 40,
|
|
175
|
+
height: 12,
|
|
176
|
+
headerHeight: 6,
|
|
177
|
+
rowHeight: 6,
|
|
178
|
+
heightAllocationMode: 'stretch',
|
|
179
|
+
borderWidth: 0.2,
|
|
180
|
+
fontFamily: 'Microsoft YaHei',
|
|
181
|
+
fontSize: 3,
|
|
182
|
+
fontWeight: 'normal',
|
|
183
|
+
fontStyle: 'normal',
|
|
184
|
+
underline: false,
|
|
185
|
+
linethrough: false,
|
|
186
|
+
textOverflow: 'wrap',
|
|
187
|
+
textAlign: 'left',
|
|
188
|
+
lineHeight: 1.5,
|
|
189
|
+
charSpacing: 0,
|
|
190
|
+
color: '#000000',
|
|
191
|
+
cellPadding: 1,
|
|
192
|
+
cellPaddingHorizontal: 1,
|
|
193
|
+
cellPaddingVertical: 1,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export function normalizeTableColumns(columns: TableColumn[]) {
|
|
197
|
+
if (!Array.isArray(columns) || columns.length === 0) {
|
|
198
|
+
throw new Error('表格至少需要配置一列');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const normalized = columns.map((column, index) => {
|
|
202
|
+
if (!column || !String(column.field || '').trim()) {
|
|
203
|
+
throw new Error(`表格第 ${index + 1} 列缺少 field`);
|
|
204
|
+
}
|
|
205
|
+
const percent = typeof column.width === 'string';
|
|
206
|
+
let weight: number;
|
|
207
|
+
if (percent) {
|
|
208
|
+
const raw = String(column.width).trim();
|
|
209
|
+
if (!/^\d+(?:\.\d+)?%$/.test(raw)) {
|
|
210
|
+
throw new Error(`表格第 ${index + 1} 列的百分比宽度无效`);
|
|
211
|
+
}
|
|
212
|
+
weight = Number.parseFloat(raw.slice(0, -1));
|
|
213
|
+
} else {
|
|
214
|
+
weight = Number(column.width);
|
|
215
|
+
}
|
|
216
|
+
if (!Number.isFinite(weight) || weight <= 0) {
|
|
217
|
+
throw new Error(`表格第 ${index + 1} 列的宽度必须大于 0`);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
...column,
|
|
221
|
+
title: String(column.title ?? ''),
|
|
222
|
+
field: String(column.field),
|
|
223
|
+
width: column.width,
|
|
224
|
+
weight,
|
|
225
|
+
widthType: percent ? 'percent' : 'number',
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const widthTypes = new Set(normalized.map((column) => column.widthType));
|
|
230
|
+
if (widthTypes.size > 1) {
|
|
231
|
+
throw new Error('表格列宽不能混用数值和百分比');
|
|
232
|
+
}
|
|
233
|
+
const total = normalized.reduce((sum, column) => sum + column.weight, 0);
|
|
234
|
+
return normalized.map(({ weight, widthType, ...column }) => ({
|
|
235
|
+
...column,
|
|
236
|
+
ratio: weight / total,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
class TablePlugin implements IPluginTempl {
|
|
241
|
+
static pluginName = 'TablePlugin';
|
|
242
|
+
static apis = ['createTable', 'initTableEvents', 'refreshTable'];
|
|
243
|
+
|
|
244
|
+
constructor(
|
|
245
|
+
public canvas: fabric.Canvas,
|
|
246
|
+
public editor: IEditor
|
|
247
|
+
) {}
|
|
248
|
+
|
|
249
|
+
async hookTransform(object: any) {
|
|
250
|
+
if (object.extensionType !== 'table') return;
|
|
251
|
+
const left = object.left;
|
|
252
|
+
const top = object.top;
|
|
253
|
+
const sourceExtension = { ...(object.extension || {}) };
|
|
254
|
+
if (!Number.isFinite(Number(sourceExtension.height))) {
|
|
255
|
+
const unit = getUnit(this.editor);
|
|
256
|
+
const originHeight = Number(object._originSize?.[unit]?.height);
|
|
257
|
+
sourceExtension.height = Number.isFinite(originHeight)
|
|
258
|
+
? originHeight
|
|
259
|
+
: unit === 'px'
|
|
260
|
+
? Math.abs(Number(object.height || 1) * Number(object.scaleY || 1))
|
|
261
|
+
: this.editor.getSizeByUnit(
|
|
262
|
+
Math.abs(Number(object.height || 1) * Number(object.scaleY || 1))
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const extension = this.normalizeOptions(sourceExtension);
|
|
266
|
+
const group = this.buildGroup(extension);
|
|
267
|
+
this.updateOriginSize(group, extension);
|
|
268
|
+
const transformed = group.toObject(
|
|
269
|
+
this.editor.getExtensionKey?.() || []
|
|
270
|
+
);
|
|
271
|
+
Object.assign(object, transformed, {
|
|
272
|
+
left,
|
|
273
|
+
top,
|
|
274
|
+
extensionType: 'table',
|
|
275
|
+
extension,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async hookTransformObjectEnd(...args: unknown[]) {
|
|
280
|
+
const { originObject, fabricObject } = args[0] as {
|
|
281
|
+
originObject: any;
|
|
282
|
+
fabricObject: TableGroup;
|
|
283
|
+
};
|
|
284
|
+
if (originObject.extensionType === 'table') {
|
|
285
|
+
this.initTableEvents(fabricObject);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
createTable(
|
|
290
|
+
columns: TableColumn[],
|
|
291
|
+
options: TableOptions = {}
|
|
292
|
+
): TableGroup {
|
|
293
|
+
const extension = this.normalizeOptions({ ...options, columns });
|
|
294
|
+
const group = this.buildGroup(extension);
|
|
295
|
+
group.set({ extensionType: 'table', extension } as any);
|
|
296
|
+
this.updateOriginSize(group, extension);
|
|
297
|
+
this.initTableEvents(group);
|
|
298
|
+
return group;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
initTableEvents(group: TableGroup) {
|
|
302
|
+
group.setExtension = async (fields: Record<string, any>) => {
|
|
303
|
+
const extension = this.normalizeOptions({
|
|
304
|
+
...(group.get('extension') || {}),
|
|
305
|
+
...(fields || {}),
|
|
306
|
+
});
|
|
307
|
+
group.set('extension' as any, extension as any);
|
|
308
|
+
await this.refreshTable(group);
|
|
309
|
+
};
|
|
310
|
+
group.setExtensionByUnit = group.setExtension;
|
|
311
|
+
|
|
312
|
+
this.editor.addSetAndSyncByUnit?.(group);
|
|
313
|
+
const originalSetByUnit = group.setByUnit?.bind(group);
|
|
314
|
+
if (originalSetByUnit) {
|
|
315
|
+
group.setByUnit = async (field: string, value: any) => {
|
|
316
|
+
if (field === 'width' || field === 'height') {
|
|
317
|
+
const extension = this.normalizeOptions({
|
|
318
|
+
...(group.get('extension') || {}),
|
|
319
|
+
[field]: Number(value),
|
|
320
|
+
});
|
|
321
|
+
group.set('extension' as any, extension as any);
|
|
322
|
+
await this.refreshTable(group);
|
|
323
|
+
return group;
|
|
324
|
+
}
|
|
325
|
+
return originalSetByUnit(field, value);
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (group.__tableModified) {
|
|
330
|
+
group.off('modified', group.__tableModified);
|
|
331
|
+
}
|
|
332
|
+
group.__tableModified = () => {
|
|
333
|
+
const scaleX = Math.abs(group.scaleX || 1);
|
|
334
|
+
const scaleY = Math.abs(group.scaleY || 1);
|
|
335
|
+
if (scaleX === 1 && scaleY === 1) return;
|
|
336
|
+
const extension = this.normalizeOptions(
|
|
337
|
+
group.get('extension') || {}
|
|
338
|
+
);
|
|
339
|
+
const scaledHeight = Math.max(
|
|
340
|
+
1,
|
|
341
|
+
Number(group.height || 1) * scaleY
|
|
342
|
+
);
|
|
343
|
+
group.set({ scaleX: 1, scaleY: 1 });
|
|
344
|
+
group.set(
|
|
345
|
+
'extension' as any,
|
|
346
|
+
{
|
|
347
|
+
...extension,
|
|
348
|
+
width: extension.width * scaleX,
|
|
349
|
+
height:
|
|
350
|
+
getUnit(this.editor) === 'px'
|
|
351
|
+
? scaledHeight
|
|
352
|
+
: this.editor.getSizeByUnit(scaledHeight),
|
|
353
|
+
} as any
|
|
354
|
+
);
|
|
355
|
+
void this.refreshTable(group);
|
|
356
|
+
};
|
|
357
|
+
group.on('modified', group.__tableModified);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async refreshTable(group: TableGroup) {
|
|
361
|
+
const extension = this.normalizeOptions(group.get('extension') || {});
|
|
362
|
+
const left = group.left;
|
|
363
|
+
const top = group.top;
|
|
364
|
+
const replacement = this.buildGroup(extension);
|
|
365
|
+
const children = replacement.getObjects();
|
|
366
|
+
|
|
367
|
+
// A scaled Fabric group can retain its previous bitmap cache even after
|
|
368
|
+
// scaleX/scaleY are normalized. Drop it before replacing the children so
|
|
369
|
+
// text is rasterized again at the new table dimensions.
|
|
370
|
+
(group as any)._removeCacheCanvas?.();
|
|
371
|
+
|
|
372
|
+
(group as any)._objects = children;
|
|
373
|
+
children.forEach((child) => {
|
|
374
|
+
child.group = group;
|
|
375
|
+
child.set({ dirty: true, noScaleCache: false } as any);
|
|
376
|
+
});
|
|
377
|
+
(replacement as any)._objects = [];
|
|
378
|
+
|
|
379
|
+
group.set({
|
|
380
|
+
left,
|
|
381
|
+
top,
|
|
382
|
+
width: replacement.width,
|
|
383
|
+
height: replacement.height,
|
|
384
|
+
scaleX: 1,
|
|
385
|
+
scaleY: 1,
|
|
386
|
+
clipPath: undefined,
|
|
387
|
+
objectCaching: false,
|
|
388
|
+
noScaleCache: false,
|
|
389
|
+
dirty: true,
|
|
390
|
+
});
|
|
391
|
+
group.set('extension' as any, extension as any);
|
|
392
|
+
this.updateOriginSize(group, extension);
|
|
393
|
+
group.setCoords();
|
|
394
|
+
this.canvas.requestRenderAll();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private normalizeOptions(
|
|
398
|
+
options: TableOptions & { columns?: TableColumn[] }
|
|
399
|
+
) {
|
|
400
|
+
const columns = (options.columns || []).map((column) => ({
|
|
401
|
+
...column,
|
|
402
|
+
}));
|
|
403
|
+
normalizeTableColumns(columns);
|
|
404
|
+
const cellPadding = this.nonNegativeNumber(
|
|
405
|
+
options.cellPadding,
|
|
406
|
+
DEFAULT_OPTIONS.cellPadding
|
|
407
|
+
);
|
|
408
|
+
return {
|
|
409
|
+
...DEFAULT_OPTIONS,
|
|
410
|
+
...options,
|
|
411
|
+
width: this.positiveNumber(options.width, DEFAULT_OPTIONS.width),
|
|
412
|
+
height: Math.max(
|
|
413
|
+
this.positiveNumber(options.headerHeight, DEFAULT_OPTIONS.headerHeight),
|
|
414
|
+
this.positiveNumber(options.height, DEFAULT_OPTIONS.height)
|
|
415
|
+
),
|
|
416
|
+
headerHeight: this.positiveNumber(
|
|
417
|
+
options.headerHeight,
|
|
418
|
+
DEFAULT_OPTIONS.headerHeight
|
|
419
|
+
),
|
|
420
|
+
rowHeight: this.positiveNumber(
|
|
421
|
+
options.rowHeight,
|
|
422
|
+
DEFAULT_OPTIONS.rowHeight
|
|
423
|
+
),
|
|
424
|
+
borderWidth: this.nonNegativeNumber(
|
|
425
|
+
options.borderWidth,
|
|
426
|
+
DEFAULT_OPTIONS.borderWidth
|
|
427
|
+
),
|
|
428
|
+
cellPadding,
|
|
429
|
+
cellPaddingHorizontal: this.nonNegativeNumber(
|
|
430
|
+
options.cellPaddingHorizontal,
|
|
431
|
+
cellPadding
|
|
432
|
+
),
|
|
433
|
+
cellPaddingVertical: this.nonNegativeNumber(
|
|
434
|
+
options.cellPaddingVertical,
|
|
435
|
+
cellPadding
|
|
436
|
+
),
|
|
437
|
+
fontSize: this.positiveNumber(
|
|
438
|
+
options.fontSize,
|
|
439
|
+
DEFAULT_OPTIONS.fontSize
|
|
440
|
+
),
|
|
441
|
+
lineHeight: this.positiveNumber(
|
|
442
|
+
options.lineHeight,
|
|
443
|
+
DEFAULT_OPTIONS.lineHeight
|
|
444
|
+
),
|
|
445
|
+
charSpacing: this.nonNegativeNumber(
|
|
446
|
+
options.charSpacing,
|
|
447
|
+
DEFAULT_OPTIONS.charSpacing
|
|
448
|
+
),
|
|
449
|
+
textOverflow:
|
|
450
|
+
options.textOverflow === 'ellipsis' ? 'ellipsis' : 'wrap',
|
|
451
|
+
heightAllocationMode:
|
|
452
|
+
options.heightAllocationMode === 'grow' ? 'grow' : 'stretch',
|
|
453
|
+
columns,
|
|
454
|
+
} as Required<
|
|
455
|
+
Pick<
|
|
456
|
+
TableOptions,
|
|
457
|
+
| 'width'
|
|
458
|
+
| 'height'
|
|
459
|
+
| 'headerHeight'
|
|
460
|
+
| 'rowHeight'
|
|
461
|
+
| 'heightAllocationMode'
|
|
462
|
+
| 'borderWidth'
|
|
463
|
+
| 'cellPadding'
|
|
464
|
+
| 'fontSize'
|
|
465
|
+
| 'lineHeight'
|
|
466
|
+
| 'charSpacing'
|
|
467
|
+
| 'textOverflow'
|
|
468
|
+
| 'cellPaddingHorizontal'
|
|
469
|
+
| 'cellPaddingVertical'
|
|
470
|
+
>
|
|
471
|
+
> &
|
|
472
|
+
TableOptions & { columns: TableColumn[] };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private positiveNumber(value: unknown, fallback: number) {
|
|
476
|
+
const number = Number(value);
|
|
477
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private nonNegativeNumber(value: unknown, fallback: number) {
|
|
481
|
+
const number = Number(value);
|
|
482
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private toPx(value: number | undefined) {
|
|
486
|
+
return convertSingle(Number(value) || 0, getUnit(this.editor));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private buildGroup(
|
|
490
|
+
extension: TableOptions & { columns: TableColumn[] }
|
|
491
|
+
): TableGroup {
|
|
492
|
+
const normalizedColumns = normalizeTableColumns(extension.columns);
|
|
493
|
+
const width = Math.max(1, this.toPx(extension.width));
|
|
494
|
+
const headerHeight = Math.max(1, this.toPx(extension.headerHeight));
|
|
495
|
+
const designHeight = Math.max(headerHeight, this.toPx(extension.height));
|
|
496
|
+
const minimumRowHeight = Math.max(1, this.toPx(extension.rowHeight));
|
|
497
|
+
const borderWidth = Math.max(0, this.toPx(extension.borderWidth));
|
|
498
|
+
const rows = Array.isArray(extension._renderRows)
|
|
499
|
+
? extension._renderRows
|
|
500
|
+
: [];
|
|
501
|
+
const growByContent = extension.heightAllocationMode === 'grow';
|
|
502
|
+
const verticalPadding = growByContent
|
|
503
|
+
? Math.max(
|
|
504
|
+
0,
|
|
505
|
+
this.toPx(
|
|
506
|
+
extension.cellPaddingVertical ?? extension.cellPadding
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
: 0;
|
|
510
|
+
|
|
511
|
+
const columnBounds: Array<{ left: number; width: number }> = [];
|
|
512
|
+
let left = 0;
|
|
513
|
+
normalizedColumns.forEach((column, index) => {
|
|
514
|
+
const columnWidth =
|
|
515
|
+
index === normalizedColumns.length - 1
|
|
516
|
+
? width - left
|
|
517
|
+
: width * column.ratio;
|
|
518
|
+
columnBounds.push({ left, width: columnWidth });
|
|
519
|
+
left += columnWidth;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const rowHeights = growByContent
|
|
523
|
+
? rows.map((row) =>
|
|
524
|
+
Math.max(
|
|
525
|
+
minimumRowHeight,
|
|
526
|
+
...columnBounds.map((column, columnIndex) => {
|
|
527
|
+
const field = extension.columns[columnIndex].field;
|
|
528
|
+
return (
|
|
529
|
+
this.measureCellTextHeight(
|
|
530
|
+
row?.[field],
|
|
531
|
+
column.width,
|
|
532
|
+
extension
|
|
533
|
+
) +
|
|
534
|
+
verticalPadding * 2
|
|
535
|
+
);
|
|
536
|
+
})
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
: rows.map(() =>
|
|
540
|
+
Math.max(1, (designHeight - headerHeight) / rows.length)
|
|
541
|
+
);
|
|
542
|
+
const naturalHeight =
|
|
543
|
+
headerHeight + rowHeights.reduce((sum, height) => sum + height, 0);
|
|
544
|
+
const visibleHeight = !rows.length
|
|
545
|
+
? headerHeight
|
|
546
|
+
: growByContent
|
|
547
|
+
? extension._actualContentLayout
|
|
548
|
+
? naturalHeight
|
|
549
|
+
: Math.min(naturalHeight, designHeight)
|
|
550
|
+
: designHeight;
|
|
551
|
+
const groupHeight = Math.max(1, visibleHeight);
|
|
552
|
+
const objects: fabric.Object[] = [];
|
|
553
|
+
|
|
554
|
+
const boundary = new fabric.Rect({
|
|
555
|
+
left: 0,
|
|
556
|
+
top: 0,
|
|
557
|
+
width,
|
|
558
|
+
height: groupHeight,
|
|
559
|
+
fill: 'rgba(0,0,0,0)',
|
|
560
|
+
strokeWidth: 0,
|
|
561
|
+
selectable: false,
|
|
562
|
+
evented: false,
|
|
563
|
+
});
|
|
564
|
+
objects.push(boundary);
|
|
565
|
+
|
|
566
|
+
columnBounds.forEach((column, index) => {
|
|
567
|
+
objects.push(
|
|
568
|
+
this.createCellText(
|
|
569
|
+
extension.columns[index].title,
|
|
570
|
+
column.left,
|
|
571
|
+
0,
|
|
572
|
+
column.width,
|
|
573
|
+
headerHeight,
|
|
574
|
+
extension,
|
|
575
|
+
Math.min(headerHeight, visibleHeight),
|
|
576
|
+
verticalPadding
|
|
577
|
+
)
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
let rowTop = headerHeight;
|
|
581
|
+
const renderedRowTops: number[] = [];
|
|
582
|
+
rows.forEach((row, rowIndex) => {
|
|
583
|
+
const rowHeight = rowHeights[rowIndex];
|
|
584
|
+
if (rowTop >= visibleHeight) return;
|
|
585
|
+
const visibleRowHeight = Math.min(
|
|
586
|
+
rowHeight,
|
|
587
|
+
visibleHeight - rowTop
|
|
588
|
+
);
|
|
589
|
+
renderedRowTops.push(rowTop);
|
|
590
|
+
columnBounds.forEach((column, columnIndex) => {
|
|
591
|
+
const field = extension.columns[columnIndex].field;
|
|
592
|
+
const value = row?.[field];
|
|
593
|
+
objects.push(
|
|
594
|
+
this.createCellText(
|
|
595
|
+
value == null ? '' : String(value),
|
|
596
|
+
column.left,
|
|
597
|
+
rowTop,
|
|
598
|
+
column.width,
|
|
599
|
+
rowHeight,
|
|
600
|
+
extension,
|
|
601
|
+
visibleRowHeight,
|
|
602
|
+
verticalPadding
|
|
603
|
+
)
|
|
604
|
+
);
|
|
605
|
+
});
|
|
606
|
+
rowTop += rowHeight;
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const renderedBorderWidth = Math.min(borderWidth, width, visibleHeight);
|
|
610
|
+
const lineOptions = {
|
|
611
|
+
stroke: '#000000',
|
|
612
|
+
strokeWidth: renderedBorderWidth,
|
|
613
|
+
selectable: false,
|
|
614
|
+
evented: false,
|
|
615
|
+
objectCaching: false,
|
|
616
|
+
};
|
|
617
|
+
const edgeOffset = renderedBorderWidth / 2;
|
|
618
|
+
objects.push(
|
|
619
|
+
new fabric.Rect({
|
|
620
|
+
// Fabric positions a stroked Rect by its outer bounds. Its
|
|
621
|
+
// stroke is already included in left/top, so adding half the
|
|
622
|
+
// stroke here shifts the visible table down and right from the
|
|
623
|
+
// group's controls.
|
|
624
|
+
left: 0,
|
|
625
|
+
top: 0,
|
|
626
|
+
width: Math.max(0, width - renderedBorderWidth),
|
|
627
|
+
height: Math.max(0, visibleHeight - renderedBorderWidth),
|
|
628
|
+
fill: 'rgba(0,0,0,0)',
|
|
629
|
+
...lineOptions,
|
|
630
|
+
})
|
|
631
|
+
);
|
|
632
|
+
const horizontalLines = renderedRowTops.filter(
|
|
633
|
+
(top) => top < visibleHeight
|
|
634
|
+
);
|
|
635
|
+
horizontalLines.forEach((top) => {
|
|
636
|
+
objects.push(
|
|
637
|
+
new fabric.Line(
|
|
638
|
+
[edgeOffset, top, width - edgeOffset, top],
|
|
639
|
+
lineOptions
|
|
640
|
+
)
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
const verticalLines = columnBounds
|
|
644
|
+
.slice(1)
|
|
645
|
+
.map((column) => column.left);
|
|
646
|
+
verticalLines.forEach((columnLeft) => {
|
|
647
|
+
objects.push(
|
|
648
|
+
new fabric.Line(
|
|
649
|
+
[
|
|
650
|
+
columnLeft,
|
|
651
|
+
edgeOffset,
|
|
652
|
+
columnLeft,
|
|
653
|
+
visibleHeight - edgeOffset,
|
|
654
|
+
],
|
|
655
|
+
lineOptions
|
|
656
|
+
)
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// Let only the transparent boundary establish the group coordinate
|
|
661
|
+
// system. A Textbox may be taller than its cell before it is visually
|
|
662
|
+
// clipped; including that raw textbox in Fabric's initial bounds would
|
|
663
|
+
// move the group origin and make the controls drift vertically from the
|
|
664
|
+
// visible table.
|
|
665
|
+
const contentObjects = objects.slice(1);
|
|
666
|
+
const group = new fabric.Group([boundary], {
|
|
667
|
+
width,
|
|
668
|
+
height: groupHeight,
|
|
669
|
+
subTargetCheck: false,
|
|
670
|
+
// Only the rows that fit the design height are constructed in clip
|
|
671
|
+
// mode, so a group-level clip/cache is unnecessary. Rendering the
|
|
672
|
+
// group directly keeps text sharp while and after resizing.
|
|
673
|
+
objectCaching: false,
|
|
674
|
+
noScaleCache: false,
|
|
675
|
+
}) as TableGroup;
|
|
676
|
+
|
|
677
|
+
contentObjects.forEach((object) => {
|
|
678
|
+
object.set({
|
|
679
|
+
left: (object.left || 0) - width / 2,
|
|
680
|
+
top: (object.top || 0) - groupHeight / 2,
|
|
681
|
+
});
|
|
682
|
+
object.group = group;
|
|
683
|
+
});
|
|
684
|
+
(group as any)._objects.push(...contentObjects);
|
|
685
|
+
|
|
686
|
+
group.set({ width, height: groupHeight });
|
|
687
|
+
group.setCoords();
|
|
688
|
+
return group;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private createCellText(
|
|
692
|
+
text: unknown,
|
|
693
|
+
left: number,
|
|
694
|
+
top: number,
|
|
695
|
+
width: number,
|
|
696
|
+
height: number,
|
|
697
|
+
extension: TableOptions,
|
|
698
|
+
visibleHeight = height,
|
|
699
|
+
verticalPaddingOverride?: number
|
|
700
|
+
) {
|
|
701
|
+
const horizontalPadding = Math.max(
|
|
702
|
+
0,
|
|
703
|
+
this.toPx(extension.cellPaddingHorizontal ?? extension.cellPadding)
|
|
704
|
+
);
|
|
705
|
+
const verticalPadding =
|
|
706
|
+
verticalPaddingOverride ??
|
|
707
|
+
Math.max(
|
|
708
|
+
0,
|
|
709
|
+
this.toPx(
|
|
710
|
+
extension.cellPaddingVertical ?? extension.cellPadding
|
|
711
|
+
)
|
|
712
|
+
);
|
|
713
|
+
const fontSize = Math.max(1, this.toPx(extension.fontSize));
|
|
714
|
+
const charSpacingPx = Math.max(0, this.toPx(extension.charSpacing));
|
|
715
|
+
const textWidth = Math.max(1, width - horizontalPadding * 2);
|
|
716
|
+
const clipTop = Math.min(height, verticalPadding);
|
|
717
|
+
const clipBottom = Math.max(
|
|
718
|
+
clipTop,
|
|
719
|
+
Math.min(height - verticalPadding, visibleHeight)
|
|
720
|
+
);
|
|
721
|
+
const textHeight = Math.max(0.01, clipBottom - clipTop);
|
|
722
|
+
const textStyle = {
|
|
723
|
+
fontFamily: extension.fontFamily,
|
|
724
|
+
fontSize,
|
|
725
|
+
fontWeight: extension.fontWeight as any,
|
|
726
|
+
fontStyle: extension.fontStyle as any,
|
|
727
|
+
charSpacing: (charSpacingPx / fontSize) * 1000,
|
|
728
|
+
};
|
|
729
|
+
const textValue =
|
|
730
|
+
extension.textOverflow === 'ellipsis'
|
|
731
|
+
? this.ellipsizeCellText(
|
|
732
|
+
String(text ?? ''),
|
|
733
|
+
textWidth,
|
|
734
|
+
textStyle
|
|
735
|
+
)
|
|
736
|
+
: String(text ?? '');
|
|
737
|
+
const textObject = new TableCellTextbox(textValue, {
|
|
738
|
+
left: left + horizontalPadding,
|
|
739
|
+
top,
|
|
740
|
+
width: textWidth,
|
|
741
|
+
cellClipWidth: textWidth,
|
|
742
|
+
cellClipHeight: textHeight,
|
|
743
|
+
cellClipOffsetY:
|
|
744
|
+
(clipTop + clipBottom) / 2 - height / 2,
|
|
745
|
+
...textStyle,
|
|
746
|
+
underline: Boolean(extension.underline),
|
|
747
|
+
linethrough: Boolean(extension.linethrough),
|
|
748
|
+
fill: extension.color,
|
|
749
|
+
textAlign: extension.textAlign,
|
|
750
|
+
lineHeight: Number(extension.lineHeight) || 1,
|
|
751
|
+
splitByGrapheme: true,
|
|
752
|
+
selectable: false,
|
|
753
|
+
evented: false,
|
|
754
|
+
// A Fabric clipPath forces this textbox through an off-screen
|
|
755
|
+
// bitmap cache. That bitmap is scaled with the table while a
|
|
756
|
+
// corner control is dragged, which leaves the text blurred.
|
|
757
|
+
// Render the glyphs directly and clip on the live canvas instead.
|
|
758
|
+
objectCaching: false,
|
|
759
|
+
noScaleCache: false,
|
|
760
|
+
});
|
|
761
|
+
textObject.initDimensions();
|
|
762
|
+
const clipTopOffset =
|
|
763
|
+
clipTop - (height - textObject.getScaledHeight()) / 2;
|
|
764
|
+
|
|
765
|
+
textObject.set({
|
|
766
|
+
top: top + (height - textObject.getScaledHeight()) / 2,
|
|
767
|
+
cellClipTopOffset: clipTopOffset,
|
|
768
|
+
});
|
|
769
|
+
textObject.setCoords();
|
|
770
|
+
return textObject;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private measureCellTextHeight(
|
|
774
|
+
text: unknown,
|
|
775
|
+
width: number,
|
|
776
|
+
extension: TableOptions
|
|
777
|
+
) {
|
|
778
|
+
const horizontalPadding = Math.max(
|
|
779
|
+
0,
|
|
780
|
+
this.toPx(extension.cellPaddingHorizontal ?? extension.cellPadding)
|
|
781
|
+
);
|
|
782
|
+
const fontSize = Math.max(1, this.toPx(extension.fontSize));
|
|
783
|
+
const charSpacingPx = Math.max(0, this.toPx(extension.charSpacing));
|
|
784
|
+
const textWidth = Math.max(1, width - horizontalPadding * 2);
|
|
785
|
+
const textStyle = {
|
|
786
|
+
fontFamily: extension.fontFamily,
|
|
787
|
+
fontSize,
|
|
788
|
+
fontWeight: extension.fontWeight as any,
|
|
789
|
+
fontStyle: extension.fontStyle as any,
|
|
790
|
+
charSpacing: (charSpacingPx / fontSize) * 1000,
|
|
791
|
+
};
|
|
792
|
+
const value =
|
|
793
|
+
extension.textOverflow === 'ellipsis'
|
|
794
|
+
? this.ellipsizeCellText(String(text ?? ''), textWidth, textStyle)
|
|
795
|
+
: String(text ?? '');
|
|
796
|
+
const measurement = new fabric.Textbox(value, {
|
|
797
|
+
width: textWidth,
|
|
798
|
+
...textStyle,
|
|
799
|
+
lineHeight: Number(extension.lineHeight) || 1,
|
|
800
|
+
splitByGrapheme: true,
|
|
801
|
+
objectCaching: false,
|
|
802
|
+
});
|
|
803
|
+
measurement.initDimensions();
|
|
804
|
+
return Math.max(1, measurement.getScaledHeight());
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private ellipsizeCellText(
|
|
808
|
+
value: string,
|
|
809
|
+
maxWidth: number,
|
|
810
|
+
textStyle: {
|
|
811
|
+
fontFamily?: string;
|
|
812
|
+
fontSize: number;
|
|
813
|
+
fontWeight?: any;
|
|
814
|
+
fontStyle?: any;
|
|
815
|
+
charSpacing: number;
|
|
816
|
+
}
|
|
817
|
+
) {
|
|
818
|
+
const text = value.replace(/[\r\n]+/g, ' ');
|
|
819
|
+
if (!text) return '';
|
|
820
|
+
|
|
821
|
+
const measure = (content: string) => {
|
|
822
|
+
const measurement = new fabric.Text(content, {
|
|
823
|
+
...textStyle,
|
|
824
|
+
objectCaching: false,
|
|
825
|
+
});
|
|
826
|
+
measurement.initDimensions();
|
|
827
|
+
return measurement.getScaledWidth();
|
|
828
|
+
};
|
|
829
|
+
if (measure(text) <= maxWidth) return text;
|
|
830
|
+
|
|
831
|
+
const ellipsis = '…';
|
|
832
|
+
const graphemes = Array.from(text);
|
|
833
|
+
let low = 0;
|
|
834
|
+
let high = graphemes.length;
|
|
835
|
+
while (low < high) {
|
|
836
|
+
const middle = Math.ceil((low + high) / 2);
|
|
837
|
+
const candidate = `${graphemes.slice(0, middle).join('')}${ellipsis}`;
|
|
838
|
+
if (measure(candidate) <= maxWidth) {
|
|
839
|
+
low = middle;
|
|
840
|
+
} else {
|
|
841
|
+
high = middle - 1;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return `${graphemes.slice(0, low).join('')}${ellipsis}`;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private updateOriginSize(
|
|
848
|
+
group: TableGroup,
|
|
849
|
+
extension: TableOptions & { columns: TableColumn[] }
|
|
850
|
+
) {
|
|
851
|
+
const unit = getUnit(this.editor);
|
|
852
|
+
const origin = group._originSize || {};
|
|
853
|
+
group._originSize = {
|
|
854
|
+
...origin,
|
|
855
|
+
[unit]: formatOriginValues(
|
|
856
|
+
{
|
|
857
|
+
...(origin[unit] || {}),
|
|
858
|
+
width: extension.width,
|
|
859
|
+
height:
|
|
860
|
+
unit === 'px'
|
|
861
|
+
? group.height
|
|
862
|
+
: this.editor.getSizeByUnit(group.height || 1),
|
|
863
|
+
},
|
|
864
|
+
(this.editor as any).getPrecision?.()
|
|
865
|
+
),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
destroy() {}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export default TablePlugin;
|