@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.
@@ -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;